From b38c849726b72f083bff59aec09d095236025ce5 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:38:38 +0100 Subject: [PATCH 1/5] =?UTF-8?q?Portal:=20Procurement=20surface=20=E2=80=94?= =?UTF-8?q?=20layout=20rework=20+=20stateful=20mock=20backend=20(#6785)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> Co-authored-by: Connor Yoh --- .../public/locales/en-US/translation.toml | 73 +++ frontend/portal/src/ViewRouter.tsx | 2 + frontend/portal/src/api/procurement.ts | 94 ++++ frontend/portal/src/components/Sidebar.tsx | 11 +- frontend/portal/src/components/icons.tsx | 10 + .../procurement/ActionModal.stories.tsx | 51 ++ .../components/procurement/ActionModal.tsx | 166 ++++++ .../procurement/DealJourney.stories.tsx | 30 ++ .../components/procurement/DealJourney.tsx | 97 ++++ .../components/procurement/DocRow.stories.tsx | 62 +++ .../src/components/procurement/DocRow.tsx | 89 ++++ .../procurement/DocumentLedger.stories.tsx | 30 ++ .../components/procurement/DocumentLedger.tsx | 158 ++++++ .../procurement/LockedState.stories.tsx | 18 + .../components/procurement/LockedState.tsx | 36 ++ .../procurement/StageStepper.stories.tsx | 20 + .../components/procurement/StageStepper.tsx | 50 ++ .../src/components/procurement/format.ts | 37 ++ frontend/portal/src/contexts/ViewContext.tsx | 3 + frontend/portal/src/mocks/handlers/index.ts | 3 + .../portal/src/mocks/handlers/procurement.ts | 90 ++++ frontend/portal/src/mocks/procurement.ts | 480 ++++++++++++++++++ .../src/mocks/procurementMachine.test.ts | 86 ++++ .../portal/src/mocks/procurementMachine.ts | 114 +++++ frontend/portal/src/views/Procurement.css | 453 +++++++++++++++++ frontend/portal/src/views/Procurement.tsx | 107 ++++ frontend/shared/components/Collapsible.css | 63 +++ .../shared/components/Collapsible.stories.tsx | 63 +++ frontend/shared/components/Collapsible.tsx | 65 +++ frontend/shared/components/index.ts | 1 + 30 files changed, 2561 insertions(+), 1 deletion(-) create mode 100644 frontend/portal/src/api/procurement.ts create mode 100644 frontend/portal/src/components/procurement/ActionModal.stories.tsx create mode 100644 frontend/portal/src/components/procurement/ActionModal.tsx create mode 100644 frontend/portal/src/components/procurement/DealJourney.stories.tsx create mode 100644 frontend/portal/src/components/procurement/DealJourney.tsx create mode 100644 frontend/portal/src/components/procurement/DocRow.stories.tsx create mode 100644 frontend/portal/src/components/procurement/DocRow.tsx create mode 100644 frontend/portal/src/components/procurement/DocumentLedger.stories.tsx create mode 100644 frontend/portal/src/components/procurement/DocumentLedger.tsx create mode 100644 frontend/portal/src/components/procurement/LockedState.stories.tsx create mode 100644 frontend/portal/src/components/procurement/LockedState.tsx create mode 100644 frontend/portal/src/components/procurement/StageStepper.stories.tsx create mode 100644 frontend/portal/src/components/procurement/StageStepper.tsx create mode 100644 frontend/portal/src/components/procurement/format.ts create mode 100644 frontend/portal/src/mocks/handlers/procurement.ts create mode 100644 frontend/portal/src/mocks/procurement.ts create mode 100644 frontend/portal/src/mocks/procurementMachine.test.ts create mode 100644 frontend/portal/src/mocks/procurementMachine.ts create mode 100644 frontend/portal/src/views/Procurement.css create mode 100644 frontend/portal/src/views/Procurement.tsx create mode 100644 frontend/shared/components/Collapsible.css create mode 100644 frontend/shared/components/Collapsible.stories.tsx create mode 100644 frontend/shared/components/Collapsible.tsx diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index 4e90ab71f1..fb85a21a27 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -28,6 +28,7 @@ components = "Components" infrastructure = "Infrastructure" usage = "Usage & Billing" docs = "Developer Docs" +procurement = "Procurement" settings = "Settings" [shell.header] @@ -1806,3 +1807,75 @@ body = "Your Stirling account session has expired. Sign in again to view billing loadWallet = "Couldn't load wallet" openStripePortal = "Couldn't open Stripe portal" walletUnavailable = "Wallet unavailable: {{status}} {{statusText}}" + +[procurement] +title = "Procurement" +subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place." +enterpriseBadge = "Enterprise" + +[procurement.journey] +eyebrow = "Your rollout" +title = "From trial to live, one guided path" +subtitle = "Your solutions engineer is on every step. One next action at a time; the full checklist is below." +engineerLabel = "Your solutions engineer" +trialTitle = "Enterprise trial" +daysLeft_one = "{{count}} day left" +daysLeft_other = "{{count}} days left" +live = "You're live on Stirling Enterprise" +nextStep = "Next step: {{action}}" + +[procurement.docs] +title = "Documents" +subtitle = "Everything you need at each step of the journey, surfaced as the deal moves through it." +here = "You're here" +done = "Done" +count_one = "{{count}} doc" +count_other = "{{count}} docs" +supportingTitle = "Supporting your evaluation" +supportingSubtitle = "SOC 2, security reviews, tax forms and more, ready when your security or procurement team asks. Some carry a one-time fee." +show = "Show" +hide = "Hide" +optional = "Optional" +paidAddon = "Paid add-on" +upcoming = "Upcoming" + +[procurement.status] +available = "Available" +action = "Action needed" +pending = "Pending" +request = "On request" +complete = "Complete" + +[procurement.action] +download = "Download" +sign = "Review & sign" +pay = "Pay now" +upload = "Upload" +request = "Request" + +[procurement.modal] +cancel = "Cancel" +chooseFile = "Choose file" +noFile = "No file selected" +signTitle = "Review and sign your agreement" +signBody = "Opens the Stirling Enterprise Agreement for e-signature: one signature covers the MSA, order form, EULA and DPA. We countersign automatically and you advance to payment." +signCta = "Open for signature" +payTitle = "Confirm payment" +payBody = "Pay your committed contract by card or bank transfer through Stripe. Your workspace provisions as soon as payment clears." +payCta = "Continue to Stripe" +uploadTitle = "Upload your purchase order" +uploadBody = "Send us your PO and we invoice against it on your terms. Drag in the PDF or pick a file below." +uploadCta = "Upload purchase order" +requestTitle = "Request this document" +requestBodyPaid = "This is a paid add-on. Confirm and your solutions engineer will scope it and send the paperwork." +requestBodyFree = "We generate this on demand. Confirm and your solutions engineer will send it across shortly." +requestCta = "Request" +downloadTitle = "Download" +downloadBody = "Your download will begin shortly." +downloadCta = "Download" + +[procurement.locked] +eyebrow = "Enterprise only" +title = "The procurement track opens with Enterprise" +description = "Trial keys, committed-volume quotes, the one-signature agreement, payment, and your document ledger all live here once you start an enterprise evaluation." +talkToSales = "Talk to sales" diff --git a/frontend/portal/src/ViewRouter.tsx b/frontend/portal/src/ViewRouter.tsx index f56aa08bff..3ad54cd1bb 100644 --- a/frontend/portal/src/ViewRouter.tsx +++ b/frontend/portal/src/ViewRouter.tsx @@ -11,6 +11,7 @@ import { EditorAdmin } from "@portal/views/EditorAdmin"; import { Infrastructure } from "@portal/views/Infrastructure"; import { Usage } from "@portal/views/Usage"; import { DeveloperDocs } from "@portal/views/DeveloperDocs"; +import { Procurement } from "@portal/views/Procurement"; import { VIEW_PATHS } from "@portal/contexts/ViewContext"; export function ViewRouter() { @@ -27,6 +28,7 @@ export function ViewRouter() { } /> } /> } /> + } /> } /> {/* Account-link is now a Settings panel; redirect legacy bookmarks home. */} } /> diff --git a/frontend/portal/src/api/procurement.ts b/frontend/portal/src/api/procurement.ts new file mode 100644 index 0000000000..3a2b9fe0f2 --- /dev/null +++ b/frontend/portal/src/api/procurement.ts @@ -0,0 +1,94 @@ +import { apiClient } from "@portal/api/http"; +import type { Tier } from "@portal/contexts/TierContext"; +import type { + DealStage, + DocAction, + ProcurementResponse, +} from "@portal/mocks/procurement"; + +export type { + Deal, + DealStage, + DocAction, + DocStatus, + JourneyStep, + LedgerDoc, + LedgerGroup, + ProcurementResponse, + QuoteInfo, + SolutionsEngineer, + SupportingCategory, + SupportingGroup, + TrialInfo, +} from "@portal/mocks/procurement"; +export { JOURNEY } from "@portal/mocks/procurement"; + +/** GET /v1/procurement?tier=…, the deal, journey, ledger and supporting pool. */ +export async function fetchProcurement( + tier: Tier, +): Promise { + return apiClient.local.json( + `/v1/procurement?tier=${encodeURIComponent(tier)}`, + ); +} + +/* + * Commercial actions. Each mutates the deal server-side and returns the updated + * ProcurementResponse, the new canonical state, which the view applies so the + * journey progresses. The MSW layer answers these today; a real backend honours + * the same contracts unchanged. + */ + +/** Advance the deal to the next stage (the journey's primary CTA). */ +export async function advanceStage( + fromStage: DealStage, +): Promise { + return apiClient.local.json("/v1/procurement/advance", { + method: "POST", + body: { fromStage }, + }); +} + +/** Sign the Stirling Enterprise Agreement (MSA + order form + EULA + DPA). */ +export async function signAgreement( + docId: string, +): Promise { + // A real backend opens an e-signature envelope and completes on callback; + // here it completes immediately and advances the deal. + return apiClient.local.json("/v1/procurement/sign", { + method: "POST", + body: { docId }, + }); +} + +/** Pay the contract online (card / bank transfer via Stripe). */ +export async function payOnline(): Promise { + return apiClient.local.json("/v1/procurement/pay", { + method: "POST", + }); +} + +/** Upload a purchase order to invoice against (an alternate payment path). */ +export async function uploadPurchaseOrder( + file: File, +): Promise { + // A real backend takes the PO as multipart; the mock only needs the name. + return apiClient.local.json( + "/v1/procurement/purchase-order", + { + method: "POST", + body: { fileName: file.name }, + }, + ); +} + +/** Request a document that is generated on demand (some carry a one-off fee). */ +export async function requestDocument( + docId: string, + action: DocAction, +): Promise { + return apiClient.local.json( + `/v1/procurement/documents/${encodeURIComponent(docId)}/request`, + { method: "POST", body: { action } }, + ); +} diff --git a/frontend/portal/src/components/Sidebar.tsx b/frontend/portal/src/components/Sidebar.tsx index a94c780e9d..ecfa0e7900 100644 --- a/frontend/portal/src/components/Sidebar.tsx +++ b/frontend/portal/src/components/Sidebar.tsx @@ -22,6 +22,7 @@ import { UsageIcon, LinkIcon, DocsIcon, + ProcurementIcon, SettingsIcon, ChevronDownIcon, } from "@portal/components/icons"; @@ -135,8 +136,16 @@ export function Sidebar() { const { activeView, setActiveView } = useView(); const { theme } = useTheme(); const { openSettings } = useUI(); + const { tier } = useTier(); const { t } = useTranslation(); + // Procurement is the enterprise buyer's commercial journey — surfaced only to + // enterprise tenants (it has no free/pro equivalent). + const platformGroup: NavEntry[] = + tier === "enterprise" + ? [{ id: "procurement", icon: }, ...GROUP_PLATFORM] + : GROUP_PLATFORM; + function renderGroup(entries: NavEntry[]) { return entries.map((entry) => (
- {renderGroup(GROUP_PLATFORM)} + {renderGroup(platformGroup)}
diff --git a/frontend/portal/src/components/icons.tsx b/frontend/portal/src/components/icons.tsx index 84d66c9cb3..51a8f0693c 100644 --- a/frontend/portal/src/components/icons.tsx +++ b/frontend/portal/src/components/icons.tsx @@ -244,6 +244,16 @@ export function AgentBuilderIcon(props: IconProps) { ); } +export function ProcurementIcon(props: IconProps) { + return ( + + + + + + ); +} + export function LinkIcon(props: IconProps) { return ( diff --git a/frontend/portal/src/components/procurement/ActionModal.stories.tsx b/frontend/portal/src/components/procurement/ActionModal.stories.tsx new file mode 100644 index 0000000000..ea767202d1 --- /dev/null +++ b/frontend/portal/src/components/procurement/ActionModal.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ActionModal } from "@portal/components/procurement/ActionModal"; +import type { LedgerDoc } from "@portal/api/procurement"; +import "@portal/views/Procurement.css"; + +const meta: Meta = { + title: "Portal/Procurement/ActionModal", + component: ActionModal, + parameters: { layout: "fullscreen" }, + args: { onClose: () => {}, onDone: () => {} }, +}; +export default meta; +type Story = StoryObj; + +const sign: LedgerDoc = { + id: "d1", + name: "Stirling Enterprise Agreement", + sub: "One signature: MSA + order form + EULA + DPA.", + status: "action", + action: "sign", +}; + +const pay: LedgerDoc = { + id: "d2", + name: "Pay online", + sub: "Card or bank transfer via Stripe.", + status: "pending", + action: "pay", +}; + +const upload: LedgerDoc = { + id: "d3", + name: "Purchase order", + sub: "Upload it and we invoice against it.", + status: "request", + action: "upload", +}; + +const requestPaid: LedgerDoc = { + id: "d4", + name: "Custom security review", + sub: "Dedicated session with our security team.", + status: "request", + action: "request", + fee: 5_000, +}; + +export const Sign: Story = { args: { doc: sign } }; +export const Pay: Story = { args: { doc: pay } }; +export const UploadPO: Story = { args: { doc: upload } }; +export const RequestPaid: Story = { args: { doc: requestPaid } }; diff --git a/frontend/portal/src/components/procurement/ActionModal.tsx b/frontend/portal/src/components/procurement/ActionModal.tsx new file mode 100644 index 0000000000..0026a93684 --- /dev/null +++ b/frontend/portal/src/components/procurement/ActionModal.tsx @@ -0,0 +1,166 @@ +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { Button, Modal } from "@shared/components"; +import type { LedgerDoc, ProcurementResponse } from "@portal/api/procurement"; +import { + payOnline, + requestDocument, + signAgreement, + uploadPurchaseOrder, +} from "@portal/api/procurement"; +import { USD } from "@portal/components/procurement/format"; + +interface ActionCopy { + title: string; + subtitle: string; + body: string; + cta: string; +} + +/** Per-action confirmation copy, with the fee folded into the CTA when present. */ +function actionCopy(doc: LedgerDoc, t: TFunction): ActionCopy { + const fee = doc.fee !== undefined ? ` · ${USD.format(doc.fee)}` : ""; + switch (doc.action) { + case "sign": + return { + title: t("procurement.modal.signTitle"), + subtitle: doc.name, + body: t("procurement.modal.signBody"), + cta: t("procurement.modal.signCta"), + }; + case "pay": + return { + title: t("procurement.modal.payTitle"), + subtitle: doc.name, + body: t("procurement.modal.payBody"), + cta: t("procurement.modal.payCta"), + }; + case "upload": + return { + title: t("procurement.modal.uploadTitle"), + subtitle: doc.name, + body: t("procurement.modal.uploadBody"), + cta: t("procurement.modal.uploadCta"), + }; + case "request": + return { + title: t("procurement.modal.requestTitle"), + subtitle: doc.name, + body: doc.fee + ? t("procurement.modal.requestBodyPaid") + : t("procurement.modal.requestBodyFree"), + cta: `${t("procurement.modal.requestCta")}${fee}`, + }; + default: + return { + title: t("procurement.modal.downloadTitle"), + subtitle: doc.name, + body: t("procurement.modal.downloadBody"), + cta: t("procurement.modal.downloadCta"), + }; + } +} + +/** + * Confirmation modal for a document's gating action. Owns its in-flight state; + * on success it hands the updated deal back to the caller via `onDone` so the + * journey re-renders. Downloads are client-side and just close the modal. + */ +export function ActionModal({ + doc, + onClose, + onDone, +}: { + doc: LedgerDoc | null; + onClose: () => void; + onDone: (next: ProcurementResponse) => void; +}) { + const { t } = useTranslation(); + const [submitting, setSubmitting] = useState(false); + const fileRef = useRef(null); + const [file, setFile] = useState(null); + + if (!doc) return null; + const copy = actionCopy(doc, t); + const needsFile = doc.action === "upload"; + + async function submit() { + if (!doc) return; + setSubmitting(true); + try { + let next: ProcurementResponse | null = null; + switch (doc.action) { + case "sign": + next = await signAgreement(doc.id); + break; + case "pay": + next = await payOnline(); + break; + case "upload": + if (file) next = await uploadPurchaseOrder(file); + break; + case "request": + next = await requestDocument(doc.id, doc.action); + break; + default: + // download is client-side; no state change. + break; + } + setFile(null); + if (next) onDone(next); + else onClose(); + } finally { + setSubmitting(false); + } + } + + return ( + + + +
+ } + > +

{copy.body}

+ {needsFile && ( +
+ setFile(e.target.files?.[0] ?? null)} + /> + + + {file ? file.name : t("procurement.modal.noFile")} + +
+ )} + + ); +} diff --git a/frontend/portal/src/components/procurement/DealJourney.stories.tsx b/frontend/portal/src/components/procurement/DealJourney.stories.tsx new file mode 100644 index 0000000000..a59386dc3a --- /dev/null +++ b/frontend/portal/src/components/procurement/DealJourney.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DealJourney } from "@portal/components/procurement/DealJourney"; +import { buildProcurement } from "@portal/mocks/procurement"; +import type { Deal } from "@portal/api/procurement"; +import "@portal/views/Procurement.css"; + +const data = buildProcurement("enterprise"); +const deal = data.deal as Deal; + +const meta: Meta = { + title: "Portal/Procurement/DealJourney", + component: DealJourney, + parameters: { layout: "padded" }, + args: { deal, journey: data.journey, onAdvance: () => {} }, +}; +export default meta; +type Story = StoryObj; + +// Mid-journey at the Agreement stage, the seeded deal state. +export const Default: Story = {}; + +// Evaluating: the trial strip shows runway + key; next step builds the quote. +export const AtTrial: Story = { + args: { deal: { ...deal, currentStage: "trial" } }, +}; + +// Terminal stage, provisioning, no further CTA. +export const Live: Story = { + args: { deal: { ...deal, currentStage: "active" } }, +}; diff --git a/frontend/portal/src/components/procurement/DealJourney.tsx b/frontend/portal/src/components/procurement/DealJourney.tsx new file mode 100644 index 0000000000..837b9034a9 --- /dev/null +++ b/frontend/portal/src/components/procurement/DealJourney.tsx @@ -0,0 +1,97 @@ +import { useTranslation } from "react-i18next"; +import { Button, Card } from "@shared/components"; +import type { Deal, DealStage, JourneyStep } from "@portal/api/procurement"; +import { StageStepper } from "@portal/components/procurement/StageStepper"; + +/** + * The deal's commercial journey in one card: who's guiding it (the solutions + * engineer), where it sits (the stage stepper), trial runway while evaluating, + * and the single next action that advances the deal. Mirrors "one next action + * at a time"; the full per-stage checklist lives in the Documents card. + */ +export function DealJourney({ + deal, + journey, + onAdvance, + advancing = false, +}: { + deal: Deal; + journey: JourneyStep[]; + onAdvance: (stage: DealStage) => void; + advancing?: boolean; +}) { + const { t } = useTranslation(); + const { engineer, trial, currentStage } = deal; + const currentStep = journey.find((s) => s.stage === currentStage); + const isTerminal = + journey.length > 0 && journey[journey.length - 1].stage === currentStage; + + return ( + +
+
+ + {t("procurement.journey.eyebrow")} + +

+ {t("procurement.journey.title")} +

+

+ {t("procurement.journey.subtitle")} +

+
+
+ + {t("procurement.journey.engineerLabel")} + + {engineer.name} + + {engineer.email} + +
+
+ +
+ +
+ + {currentStage === "trial" && ( +
+ + {t("procurement.journey.trialTitle")} + + + {t("procurement.journey.daysLeft", { count: trial.daysLeft })} + + {trial.key} +
+ )} + +
+
+ + + {isTerminal + ? t("procurement.journey.live") + : t("procurement.journey.nextStep", { + action: currentStep?.gatingAction ?? "", + })} + +
+ {!isTerminal && currentStep && ( + + )} +
+
+ ); +} diff --git a/frontend/portal/src/components/procurement/DocRow.stories.tsx b/frontend/portal/src/components/procurement/DocRow.stories.tsx new file mode 100644 index 0000000000..695b8c6941 --- /dev/null +++ b/frontend/portal/src/components/procurement/DocRow.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DocRow } from "@portal/components/procurement/DocRow"; +import type { LedgerDoc } from "@portal/api/procurement"; +import "@portal/views/Procurement.css"; + +const meta: Meta = { + title: "Portal/Procurement/DocRow", + component: DocRow, + parameters: { layout: "padded" }, + args: { onAction: () => {} }, +}; +export default meta; +type Story = StoryObj; + +const sign: LedgerDoc = { + id: "d1", + name: "Stirling Enterprise Agreement", + sub: "One signature: MSA + order form + EULA + DPA.", + status: "action", + action: "sign", +}; + +const download: LedgerDoc = { + id: "d2", + name: "SOC 2 Type II report", + sub: "Independent audit of our security controls.", + status: "available", + action: "download", +}; + +const paidAddon: LedgerDoc = { + id: "d3", + name: "Onboarding & training", + sub: "Guided rollout and live training for your team.", + status: "request", + action: "request", + optional: true, + fee: 7_500, +}; + +const done: LedgerDoc = { + id: "d4", + name: "Formal quote", + sub: "Committed-volume pricing, term and line items.", + status: "complete", + action: "download", +}; + +// Deal-advancing action, filled purple CTA. +export const SignAction: Story = { args: { doc: sign } }; + +// Quiet outline action for a ready download. +export const Download: Story = { args: { doc: download } }; + +// Optional paid add-on, chips flag it and the fee folds into the CTA. +export const PaidAddon: Story = { args: { doc: paidAddon } }; + +// Completed paperwork keeps a record but offers no further action. +export const Complete: Story = { args: { doc: done } }; + +// A row in a future, not-yet-reached stage, dimmed, marked "Upcoming", inert. +export const Locked: Story = { args: { doc: sign, locked: true } }; diff --git a/frontend/portal/src/components/procurement/DocRow.tsx b/frontend/portal/src/components/procurement/DocRow.tsx new file mode 100644 index 0000000000..1655c3744e --- /dev/null +++ b/frontend/portal/src/components/procurement/DocRow.tsx @@ -0,0 +1,89 @@ +import { useTranslation } from "react-i18next"; +import { Button, Chip, StatusBadge } from "@shared/components"; +import type { LedgerDoc } from "@portal/api/procurement"; +import { + ACTION_LABEL_KEY, + STATUS_LABEL_KEY, + STATUS_TONE, + USD, +} from "@portal/components/procurement/format"; + +/** Maps a document's action to the button accent + variant. */ +function buttonStyle(doc: LedgerDoc): { + variant: "gradient" | "outline"; + accent: "purple" | "blue"; +} { + // The agreement signature and online payment are the deal-advancing actions; + // give them the filled accent CTA. Everything else is a quieter outline. + if (doc.action === "sign" || doc.action === "pay") { + return { variant: "gradient", accent: "purple" }; + } + return { variant: "outline", accent: "blue" }; +} + +/** + * A single document in the ledger or supporting pool: name + sub-line on the + * left, status badge and action button on the right. Optional/fee-bearing docs + * carry a chip so the buyer sees a paid add-on before clicking. `locked` is for + * rows in a future, not-yet-reached stage: dimmed, marked "Upcoming", inert. + */ +export function DocRow({ + doc, + onAction, + locked = false, +}: { + doc: LedgerDoc; + onAction: (doc: LedgerDoc) => void; + locked?: boolean; +}) { + const { t } = useTranslation(); + const { variant, accent } = buttonStyle(doc); + // Locked (future-stage), in-progress (pending) and completed paperwork all + // offer no action; only "available", "action" and "request" docs do. + const actionable = + !locked && doc.status !== "complete" && doc.status !== "pending"; + const label = t(ACTION_LABEL_KEY[doc.action]); + const actionLabel = + doc.fee !== undefined ? `${label} · ${USD.format(doc.fee)}` : label; + + return ( +
+
+
+ {doc.name} + {doc.optional && ( + + {t("procurement.docs.optional")} + + )} + {doc.fee !== undefined && ( + + {t("procurement.docs.paidAddon")} + + )} +
+

{doc.sub}

+
+
+ + {locked + ? t("procurement.docs.upcoming") + : t(STATUS_LABEL_KEY[doc.status])} + + {actionable && ( + + )} +
+
+ ); +} diff --git a/frontend/portal/src/components/procurement/DocumentLedger.stories.tsx b/frontend/portal/src/components/procurement/DocumentLedger.stories.tsx new file mode 100644 index 0000000000..5de6692999 --- /dev/null +++ b/frontend/portal/src/components/procurement/DocumentLedger.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DocumentLedger } from "@portal/components/procurement/DocumentLedger"; +import { buildProcurement } from "@portal/mocks/procurement"; +import "@portal/views/Procurement.css"; + +const data = buildProcurement("enterprise"); + +const meta: Meta = { + title: "Portal/Procurement/DocumentLedger", + component: DocumentLedger, + parameters: { layout: "padded" }, + args: { + groups: data.ledger, + supporting: data.supporting, + journey: data.journey, + currentStage: data.deal?.currentStage ?? "trial", + onAction: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +// Mid-journey: the Agreement stage is open, earlier stages read as done, later +// stages are locked previews, and the supporting pool sits collapsed below. +export const Default: Story = {}; + +// Day one: only the Trial stage has been reached; everything ahead is locked. +export const AtTrial: Story = { + args: { currentStage: "trial" }, +}; diff --git a/frontend/portal/src/components/procurement/DocumentLedger.tsx b/frontend/portal/src/components/procurement/DocumentLedger.tsx new file mode 100644 index 0000000000..5ef9bbc30f --- /dev/null +++ b/frontend/portal/src/components/procurement/DocumentLedger.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Card, Chip, Collapsible } from "@shared/components"; +import type { + DealStage, + JourneyStep, + LedgerDoc, + LedgerGroup, + SupportingGroup, +} from "@portal/api/procurement"; +import { DocRow } from "@portal/components/procurement/DocRow"; + +/** + * The "Documents" card: every artifact the deal needs, as a stage accordion + * that mirrors the journey. Only the current stage is open by default; earlier + * stages read as done, later stages are locked previews. A collapsed-by-default + * "Supporting your evaluation" pool holds the stage-agnostic paperwork. + */ +export function DocumentLedger({ + groups, + supporting, + journey, + currentStage, + onAction, +}: { + groups: LedgerGroup[]; + supporting: SupportingGroup[]; + journey: JourneyStep[]; + currentStage: DealStage; + onAction: (doc: LedgerDoc) => void; +}) { + const { t } = useTranslation(); + const order = journey.map((s) => s.stage); + const curIdx = order.indexOf(currentStage); + // Follow the deal: the stage you're in opens first; any other stage can be + // peeked. null collapses them all. Advancing moves the open section along. + const [openStage, setOpenStage] = useState(currentStage); + const [supportingOpen, setSupportingOpen] = useState(false); + useEffect(() => setOpenStage(currentStage), [currentStage]); + + return ( + +
+

+ {t("procurement.docs.title")} +

+

+ {t("procurement.docs.subtitle")} +

+
+ +
+ {groups.map((group) => { + const idx = order.indexOf(group.stage); + const done = idx < curIdx; + const cur = group.stage === currentStage; + const locked = idx > curIdx; + const blurb = journey.find((s) => s.stage === group.stage)?.blurb; + const open = openStage === group.stage; + const count = group.docs.length; + + return ( + setOpenStage(open ? null : group.stage)} + header={ + <> + + + {group.label} + + {blurb && ( + · {blurb} + )} + {cur && ( + + {t("procurement.docs.here")} + + )} + {done && ( + + {t("procurement.docs.done")} + + )} + + } + aside={ + + {t("procurement.docs.count", { count })} + + } + > +
+ {group.docs.map((doc) => ( + + ))} +
+
+ ); + })} + + {supporting.length > 0 && ( + setSupportingOpen((o) => !o)} + header={ + + + {t("procurement.docs.supportingTitle")} + + + {t("procurement.docs.supportingSubtitle")} + + + } + aside={ + + {supportingOpen + ? t("procurement.docs.hide") + : t("procurement.docs.show")} + + } + > +
+ {supporting.map((group) => ( +
+
{group.label}
+
+ {group.docs.map((doc) => ( + + ))} +
+
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/frontend/portal/src/components/procurement/LockedState.stories.tsx b/frontend/portal/src/components/procurement/LockedState.stories.tsx new file mode 100644 index 0000000000..e88c810801 --- /dev/null +++ b/frontend/portal/src/components/procurement/LockedState.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LockedState } from "@portal/components/procurement/LockedState"; +import { JOURNEY } from "@portal/mocks/procurement"; +import "@portal/views/Procurement.css"; + +const meta: Meta = { + title: "Portal/Procurement/LockedState", + component: LockedState, + parameters: { layout: "padded" }, + args: { onTalkToSales: () => {} }, +}; +export default meta; +type Story = StoryObj; + +// Shown to free/pro buyers, the journey preview behind the upgrade prompt. +export const Default: Story = { + args: { journey: JOURNEY }, +}; diff --git a/frontend/portal/src/components/procurement/LockedState.tsx b/frontend/portal/src/components/procurement/LockedState.tsx new file mode 100644 index 0000000000..72a5e8b129 --- /dev/null +++ b/frontend/portal/src/components/procurement/LockedState.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from "react-i18next"; +import { Button, Card, EmptyState } from "@shared/components"; +import type { JourneyStep } from "@portal/api/procurement"; +import { StageStepper } from "@portal/components/procurement/StageStepper"; + +/** + * Enterprise-only gate for free/pro buyers. Shows the journey as a greyed + * preview behind an upgrade prompt so the buyer understands what the + * commercial track looks like before they talk to sales. + */ +export function LockedState({ + journey, + onTalkToSales, +}: { + journey: JourneyStep[]; + onTalkToSales: () => void; +}) { + const { t } = useTranslation(); + return ( +
+ + {t("procurement.locked.talkToSales")} + + } + /> + + + +
+ ); +} diff --git a/frontend/portal/src/components/procurement/StageStepper.stories.tsx b/frontend/portal/src/components/procurement/StageStepper.stories.tsx new file mode 100644 index 0000000000..a27b0b90aa --- /dev/null +++ b/frontend/portal/src/components/procurement/StageStepper.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { StageStepper } from "@portal/components/procurement/StageStepper"; +import { JOURNEY } from "@portal/mocks/procurement"; +import "@portal/views/Procurement.css"; + +const meta: Meta = { + title: "Portal/Procurement/StageStepper", + component: StageStepper, + parameters: { layout: "padded" }, + args: { journey: JOURNEY }, +}; +export default meta; +type Story = StoryObj; + +export const AtAgreement: Story = { args: { currentStage: "security" } }; + +export const AtTrial: Story = { args: { currentStage: "trial" } }; + +// Greyed preview for the free/pro upgrade gate, no stage is current. +export const Locked: Story = { args: { currentStage: "trial", locked: true } }; diff --git a/frontend/portal/src/components/procurement/StageStepper.tsx b/frontend/portal/src/components/procurement/StageStepper.tsx new file mode 100644 index 0000000000..1981078459 --- /dev/null +++ b/frontend/portal/src/components/procurement/StageStepper.tsx @@ -0,0 +1,50 @@ +import { Fragment } from "react"; +import type { DealStage, JourneyStep } from "@portal/api/procurement"; + +/** Status of a step relative to the deal's current stage. */ +type StepState = "complete" | "current" | "upcoming"; + +/** + * The five-stage commercial journey as a horizontal band of labelled dots with + * connectors between them. Purely presentational, the gating action lives in + * the journey card's next-step row, not on the dots. `locked` greys the whole + * band for the free/pro upgrade preview, where no stage is current. + */ +export function StageStepper({ + journey, + currentStage, + locked = false, +}: { + journey: JourneyStep[]; + currentStage: DealStage; + locked?: boolean; +}) { + const order = journey.map((s) => s.stage); + const curIdx = locked ? -1 : order.indexOf(currentStage); + + return ( +
+ {journey.map((step, i) => { + const state: StepState = + i < curIdx ? "complete" : i === curIdx ? "current" : "upcoming"; + return ( + + {i > 0 && ( + + )} +
+ + {step.label} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/portal/src/components/procurement/format.ts b/frontend/portal/src/components/procurement/format.ts new file mode 100644 index 0000000000..c40d7921aa --- /dev/null +++ b/frontend/portal/src/components/procurement/format.ts @@ -0,0 +1,37 @@ +/** Shared formatting + status/action mappings for the Procurement surface. */ + +import type { StatusTone } from "@shared/components"; +import type { DocAction, DocStatus } from "@portal/api/procurement"; + +export const USD = new Intl.NumberFormat(undefined, { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, +}); + +/** Document status → badge tone. Action items lean amber to pull the eye. */ +export const STATUS_TONE: Record = { + available: "success", + action: "warning", + pending: "info", + request: "neutral", + complete: "neutral", +}; + +/** Document status → translation key for the short badge label. */ +export const STATUS_LABEL_KEY: Record = { + available: "procurement.status.available", + action: "procurement.status.action", + pending: "procurement.status.pending", + request: "procurement.status.request", + complete: "procurement.status.complete", +}; + +/** Action → translation key for the button label (the fee, when present, is appended by the caller). */ +export const ACTION_LABEL_KEY: Record = { + download: "procurement.action.download", + sign: "procurement.action.sign", + pay: "procurement.action.pay", + upload: "procurement.action.upload", + request: "procurement.action.request", +}; diff --git a/frontend/portal/src/contexts/ViewContext.tsx b/frontend/portal/src/contexts/ViewContext.tsx index a8eff0fee4..01c3de9b54 100644 --- a/frontend/portal/src/contexts/ViewContext.tsx +++ b/frontend/portal/src/contexts/ViewContext.tsx @@ -14,6 +14,7 @@ export type ViewId = | "infrastructure" | "usage" | "docs" + | "procurement" | "settings"; export const VIEW_LABELS: Record = { @@ -29,6 +30,7 @@ export const VIEW_LABELS: Record = { infrastructure: "Infrastructure", usage: "Usage & Billing", docs: "Developer Docs", + procurement: "Procurement", settings: "Settings", }; @@ -45,6 +47,7 @@ export const VIEW_PATHS: Record = { infrastructure: "/infrastructure", usage: "/usage", docs: "/docs", + procurement: "/procurement", settings: "/settings", }; diff --git a/frontend/portal/src/mocks/handlers/index.ts b/frontend/portal/src/mocks/handlers/index.ts index 130d1a7b35..8c9d1cad91 100644 --- a/frontend/portal/src/mocks/handlers/index.ts +++ b/frontend/portal/src/mocks/handlers/index.ts @@ -7,6 +7,7 @@ import { searchHandlers } from "@portal/mocks/handlers/search"; import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines"; import { sourcesHandlers } from "@portal/mocks/handlers/sources"; import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure"; +import { procurementHandlers } from "@portal/mocks/handlers/procurement"; import { docsHandlers } from "@portal/mocks/handlers/docs"; import { settingsHandlers } from "@portal/mocks/handlers/settings"; import { usersHandlers } from "@portal/mocks/handlers/users"; @@ -28,6 +29,7 @@ export const handlers = [ ...sourcesHandlers, ...infrastructureHandlers, ...docsHandlers, + ...procurementHandlers, ...settingsHandlers, ...usersHandlers, ...agentsHandlers, @@ -39,3 +41,4 @@ export const handlers = [ ]; export { resetNotificationsStore } from "@portal/mocks/handlers/notifications"; +export { resetProcurementStore } from "@portal/mocks/handlers/procurement"; diff --git a/frontend/portal/src/mocks/handlers/procurement.ts b/frontend/portal/src/mocks/handlers/procurement.ts new file mode 100644 index 0000000000..e0e6f497c2 --- /dev/null +++ b/frontend/portal/src/mocks/handlers/procurement.ts @@ -0,0 +1,90 @@ +import { http, HttpResponse, delay } from "msw"; +import type { Tier } from "@portal/contexts/TierContext"; +import { + JOURNEY, + buildProcurement, + seedEnterpriseDeal, + type DealStage, + type ProcurementResponse, +} from "@portal/mocks/procurement"; +import { + advanceDeal, + payDeal, + requestDoc, + signDoc, + uploadPurchaseOrder, +} from "@portal/mocks/procurementMachine"; + +/** + * Stateful mock of the commercial backend. The write endpoints drive the + * procurement state machine over one in-memory enterprise deal, so the journey + * genuinely progresses within a session, advancing a stage flips the gating + * paperwork, unlocks the next stage's documents, and the GET reflects every + * prior write. Swapping in the real backend is just deleting these handlers; + * the api/ contracts and the state-machine semantics stay. + */ +let store = seedEnterpriseDeal(); + +/** Reseed the deal to its starting (mid-journey) state, used by tests + replay. */ +export function resetProcurementStore() { + store = seedEnterpriseDeal(); +} + +function snapshot(): ProcurementResponse { + return { + tier: "enterprise", + unlocked: true, + deal: store.deal, + journey: JOURNEY, + ledger: store.ledger, + supporting: store.supporting, + }; +} + +export const procurementHandlers = [ + http.get("/v1/procurement", async ({ request }) => { + await delay(120); + const tier = (new URL(request.url).searchParams.get("tier") ?? + "pro") as Tier; + // Only the enterprise tenant has a live deal; others get the locked payload. + if (tier !== "enterprise") return HttpResponse.json(buildProcurement(tier)); + return HttpResponse.json(snapshot()); + }), + + // POST /v1/procurement/advance, the journey's primary "next step". + http.post("/v1/procurement/advance", async ({ request }) => { + await delay(140); + const { fromStage } = (await request.json()) as { fromStage: DealStage }; + advanceDeal(store, fromStage); + return HttpResponse.json(snapshot()); + }), + + // POST /v1/procurement/sign, e-sign the agreement, then advance. + http.post("/v1/procurement/sign", async ({ request }) => { + await delay(160); + const { docId } = (await request.json()) as { docId: string }; + signDoc(store, docId); + return HttpResponse.json(snapshot()); + }), + + // POST /v1/procurement/pay, confirm online payment, then advance. + http.post("/v1/procurement/pay", async () => { + await delay(160); + payDeal(store); + return HttpResponse.json(snapshot()); + }), + + // POST /v1/procurement/purchase-order, upload a PO (an alternate pay path). + http.post("/v1/procurement/purchase-order", async () => { + await delay(160); + uploadPurchaseOrder(store); + return HttpResponse.json(snapshot()); + }), + + // POST /v1/procurement/documents/:docId/request, queue an on-demand doc. + http.post("/v1/procurement/documents/:docId/request", async ({ params }) => { + await delay(140); + requestDoc(store, String(params.docId)); + return HttpResponse.json(snapshot()); + }), +]; diff --git a/frontend/portal/src/mocks/procurement.ts b/frontend/portal/src/mocks/procurement.ts new file mode 100644 index 0000000000..4faeefa491 --- /dev/null +++ b/frontend/portal/src/mocks/procurement.ts @@ -0,0 +1,480 @@ +/** + * Procurement fixtures and the types api/procurement.ts shares with them. + * api/procurement.ts imports the types; the MSW handlers in + * mocks/handlers/procurement.ts serve this fixture data over the intercepted + * httpJson() calls. Components never reach into this module directly. + * + * Procurement models the enterprise commercial journey, trial → quote → + * agreement → payment → implementation, plus the paperwork ledger that rides + * alongside it. The journey is enterprise-only; free/pro tiers receive a + * minimal locked payload the view renders as an upgrade prompt. + * + * Once a real commercial backend exists the MSW handlers stop being registered + * and these fixtures can be deleted (or kept as test seeds). + */ + +import type { Tier } from "@portal/contexts/TierContext"; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Journey stages */ +/* ──────────────────────────────────────────────────────────────────────── */ + +/** + * The five-stage enterprise journey. The id is the contract value the backend + * advances; the labels below are the buyer-facing stage names (Agreement and + * Payment read more plainly than the internal `security` / `procurement`). + */ +export type DealStage = + | "trial" + | "quote" + | "security" + | "procurement" + | "active"; + +export interface JourneyStep { + stage: DealStage; + /** Buyer-facing stage name. */ + label: string; + /** One-line description of what happens at this stage. */ + blurb: string; + /** + * Label for the single action that advances this stage. The current stage + * surfaces its gating action; `active` is terminal (provisioning). + */ + gatingAction: string; +} + +/** Ordered journey definition, the stepper renders this verbatim. */ +export const JOURNEY: JourneyStep[] = [ + { + stage: "trial", + label: "Trial", + blurb: "Evaluate Stirling against your documents and workflows.", + gatingAction: "Build your quote", + }, + { + stage: "quote", + label: "Quote", + blurb: "Review committed-volume pricing and contract term.", + gatingAction: "Accept your quote", + }, + { + stage: "security", + label: "Agreement", + blurb: "One signature covers MSA, order form, EULA and DPA.", + gatingAction: "Review and sign your agreement", + }, + { + stage: "procurement", + label: "Payment", + blurb: "Pay by card, bank transfer, or against a purchase order.", + gatingAction: "Confirm payment", + }, + { + stage: "active", + label: "Implementation", + blurb: "Provision your workspace and run the go-live playbook.", + gatingAction: "Provisioning your workspace", + }, +]; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Deal header */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export interface SolutionsEngineer { + name: string; + title: string; + email: string; +} + +export interface TrialInfo { + /** License key seeded for the evaluation. */ + key: string; + /** ISO date the trial began. */ + startedOn: string; + /** ISO date the trial expires. */ + endsOn: string; + /** Whole days remaining (derived in the fixture for a stable demo number). */ + daysLeft: number; + extensionsUsed: number; + maxExtensions: number; +} + +export interface QuoteInfo { + number: string; + /** Annual contract value, in USD. */ + amount: number; + /** Contract term, e.g. "12 months". */ + term: string; + /** ISO date the quote expires. */ + validUntil: string; +} + +export interface Deal { + company: string; + currentStage: DealStage; + engineer: SolutionsEngineer; + trial: TrialInfo; + quote: QuoteInfo; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Document ledger + supporting pool */ +/* ──────────────────────────────────────────────────────────────────────── */ + +/** + * Lifecycle of a single document. + * available: ready to grab now (download/sign/pay/upload as the action says) + * action: waiting on the buyer to act (the gating paperwork of a stage) + * pending: issued, awaiting the other side / a system step + * request: not generated yet; the buyer asks for it (some carry a fee) + * complete: done, kept for the record + */ +export type DocStatus = + | "available" + | "action" + | "pending" + | "request" + | "complete"; + +/** What pressing the document's button does. */ +export type DocAction = "download" | "sign" | "pay" | "upload" | "request"; + +export interface LedgerDoc { + id: string; + name: string; + /** Sub-line describing what the document is / what it covers. */ + sub: string; + status: DocStatus; + action: DocAction; + /** Buyer-skippable paperwork (e.g. paid onboarding). */ + optional?: boolean; + /** One-off fee in USD when the document/service is a paid add-on. */ + fee?: number; +} + +/** Document ledger grouped by the journey stage the paperwork belongs to. */ +export interface LedgerGroup { + stage: DealStage; + /** Buyer-facing stage name (matches JourneyStep.label). */ + label: string; + docs: LedgerDoc[]; +} + +/** Categories the stage-agnostic supporting pool is grouped under. */ +export type SupportingCategory = + | "security" + | "legal" + | "corporate" + | "procurement"; + +export interface SupportingGroup { + category: SupportingCategory; + label: string; + docs: LedgerDoc[]; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Full procurement payload */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export interface ProcurementResponse { + tier: Tier; + /** True only for enterprise, gates the whole journey + ledger. */ + unlocked: boolean; + /** Present only when unlocked. */ + deal: Deal | null; + journey: JourneyStep[]; + ledger: LedgerGroup[]; + supporting: SupportingGroup[]; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Fixtures */ +/* ──────────────────────────────────────────────────────────────────────── */ + +const ENTERPRISE_DEAL: Deal = { + company: "Northwind Logistics", + // The buyer has accepted the quote and is at the agreement signature, the + // mid-journey state shows the most surface area (completed + active + ahead). + currentStage: "security", + engineer: { + name: "Priya Raman", + title: "Senior Solutions Engineer", + email: "priya.raman@stirlingpdf.com", + }, + trial: { + key: "TRIAL-NWND-7F3A-2C9E", + startedOn: daysFromNow(-23), + endsOn: daysFromNow(7), + daysLeft: 7, + extensionsUsed: 1, + maxExtensions: 2, + }, + quote: { + number: "Q-2026-0488", + amount: 84_000, + term: "12 months", + validUntil: daysFromNow(14), + }, +}; + +/** Document ledger for the live enterprise deal, grouped by stage. */ +const ENTERPRISE_LEDGER: LedgerGroup[] = [ + { + stage: "trial", + label: "Trial", + docs: [ + { + id: "doc-trial-quickstart", + name: "Trial quick-start guide", + sub: "Stand up the evaluation environment in under an hour.", + status: "available", + action: "download", + }, + { + id: "doc-trial-handout", + name: "Evaluator handout", + sub: "Share Stirling's capabilities with your evaluation team.", + status: "available", + action: "download", + }, + ], + }, + { + stage: "quote", + label: "Quote", + docs: [ + { + id: "doc-quote-formal", + name: "Formal quote", + sub: "Committed-volume pricing, term and line items, Q-2026-0488.", + status: "complete", + action: "download", + }, + ], + }, + { + stage: "security", + label: "Agreement", + docs: [ + { + id: "doc-agreement-enterprise", + name: "Stirling Enterprise Agreement", + sub: "One signature: MSA + order form + EULA + DPA.", + status: "action", + action: "sign", + }, + ], + }, + { + stage: "procurement", + label: "Payment", + docs: [ + { + id: "doc-pay-online", + name: "Pay online", + sub: "Card or bank transfer via Stripe.", + status: "pending", + action: "pay", + }, + { + id: "doc-pay-wire", + name: "Bank transfer instructions", + sub: "Wire details, plus RIB for EU buyers.", + status: "pending", + action: "download", + }, + { + id: "doc-pay-po", + name: "Purchase order", + sub: "Upload it and we invoice against it.", + status: "request", + action: "upload", + }, + ], + }, + { + stage: "active", + label: "Implementation", + docs: [ + { + id: "doc-active-playbook", + name: "Go-live playbook", + sub: "Cut-over steps, rollback plan, and success checks.", + status: "pending", + action: "download", + }, + { + id: "doc-active-admin", + name: "Administrator setup guide", + sub: "SSO, regions, audit export and seat provisioning.", + status: "pending", + action: "download", + }, + { + id: "doc-active-onboarding", + name: "Onboarding & training", + sub: "Guided rollout and live training for your team.", + status: "request", + action: "request", + optional: true, + fee: 7_500, + }, + ], + }, +]; + +/** Stage-agnostic supporting documents, grouped by category. */ +const ENTERPRISE_SUPPORTING: SupportingGroup[] = [ + { + category: "security", + label: "Security", + docs: [ + { + id: "sup-soc2", + name: "SOC 2 Type II report", + sub: "Independent audit of our security controls.", + status: "available", + action: "download", + }, + { + id: "sup-caiq", + name: "Security questionnaire (CAIQ)", + sub: "Pre-filled Consensus Assessments Initiative Questionnaire.", + status: "available", + action: "download", + }, + { + id: "sup-pentest", + name: "Penetration test summary", + sub: "Latest third-party penetration test results.", + status: "available", + action: "download", + }, + { + id: "sup-custom-review", + name: "Custom security review", + sub: "Dedicated session with our security team for your assessment.", + status: "request", + action: "request", + fee: 5_000, + }, + ], + }, + { + category: "legal", + label: "Legal", + docs: [ + { + id: "sup-baa", + name: "Business Associate Agreement (HIPAA)", + sub: "Required when processing protected health information.", + status: "request", + action: "request", + fee: 2_500, + }, + ], + }, + { + category: "corporate", + label: "Corporate", + docs: [ + { + id: "sup-w9", + name: "IRS Form W-9", + sub: "Our taxpayer identification for your records.", + status: "available", + action: "download", + }, + { + id: "sup-incorporation", + name: "Certificate of Incorporation", + sub: "Proof of our legal entity registration.", + status: "available", + action: "download", + }, + { + id: "sup-insurance", + name: "Certificate of Insurance", + sub: "Liability and cyber insurance coverage evidence.", + status: "available", + action: "download", + }, + ], + }, + { + category: "procurement", + label: "Procurement", + docs: [ + { + id: "sup-vendor-onboarding", + name: "Vendor onboarding form", + sub: "We fill out your procurement-portal forms for you.", + status: "request", + action: "request", + fee: 1_500, + }, + ], + }, +]; + +/** Structured deep clone of plain fixture data (no functions / class instances). */ +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +/** + * A fresh, mutable copy of the live enterprise deal, deal header, stage ledger + * and supporting pool. The MSW layer seeds its in-memory store from this so the + * write handlers can advance the journey within a session without mutating the + * shared fixtures. The journey definition (JOURNEY) is immutable and shared. + */ +export function seedEnterpriseDeal(): { + deal: Deal; + ledger: LedgerGroup[]; + supporting: SupportingGroup[]; +} { + return { + deal: clone(ENTERPRISE_DEAL), + ledger: clone(ENTERPRISE_LEDGER), + supporting: clone(ENTERPRISE_SUPPORTING), + }; +} + +/** + * Builds the procurement payload for a tier. Enterprise gets the full live + * deal; free/pro get a minimal locked payload the view renders as an + * "enterprise-only" upgrade state. + */ +export function buildProcurement(tier: Tier): ProcurementResponse { + if (tier !== "enterprise") { + // Locked tiers still receive the journey definition so the view can render + // a greyed preview of the steps behind the upgrade prompt. + return { + tier, + unlocked: false, + deal: null, + journey: JOURNEY, + ledger: [], + supporting: [], + }; + } + + return { + tier, + unlocked: true, + deal: ENTERPRISE_DEAL, + journey: JOURNEY, + ledger: ENTERPRISE_LEDGER, + supporting: ENTERPRISE_SUPPORTING, + }; +} + +/** ISO date (YYYY-MM-DD) `n` days from today; negative for the past. */ +function daysFromNow(n: number): string { + const d = new Date(); + d.setDate(d.getDate() + n); + return d.toISOString().slice(0, 10); +} diff --git a/frontend/portal/src/mocks/procurementMachine.test.ts b/frontend/portal/src/mocks/procurementMachine.test.ts new file mode 100644 index 0000000000..6bf913b89c --- /dev/null +++ b/frontend/portal/src/mocks/procurementMachine.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { seedEnterpriseDeal } from "@portal/mocks/procurement"; +import { + advanceDeal, + payDeal, + requestDoc, + signDoc, + uploadPurchaseOrder, + type ProcurementStore, +} from "@portal/mocks/procurementMachine"; + +let store: ProcurementStore; +beforeEach(() => { + store = seedEnterpriseDeal(); +}); + +function status(s: ProcurementStore, id: string): string | undefined { + return [ + ...s.ledger.flatMap((g) => g.docs), + ...s.supporting.flatMap((g) => g.docs), + ].find((d) => d.id === id)?.status; +} + +describe("seed", () => { + it("starts mid-journey at the agreement, awaiting signature", () => { + expect(store.deal.currentStage).toBe("security"); + expect(status(store, "doc-agreement-enterprise")).toBe("action"); + }); + + it("is an independent copy each call (writes never touch the fixture)", () => { + advanceDeal(store, "security"); + const fresh = seedEnterpriseDeal(); + expect(fresh.deal.currentStage).toBe("security"); + }); +}); + +describe("advanceDeal", () => { + it("completes the stage's gating doc and unlocks the next stage", () => { + advanceDeal(store, "security"); + expect(store.deal.currentStage).toBe("procurement"); + expect(status(store, "doc-agreement-enterprise")).toBe("complete"); + // Payment paperwork becomes actionable / downloadable on entry. + expect(status(store, "doc-pay-online")).toBe("action"); + expect(status(store, "doc-pay-wire")).toBe("available"); + }); + + it("ignores a stale stage so a double-click can't skip ahead", () => { + advanceDeal(store, "security"); // → procurement + advanceDeal(store, "security"); // stale, no-op + expect(store.deal.currentStage).toBe("procurement"); + }); + + it("is a no-op at the terminal stage", () => { + advanceDeal(store, "security"); // → procurement + advanceDeal(store, "procurement"); // → active + expect(store.deal.currentStage).toBe("active"); + advanceDeal(store, "active"); // terminal + expect(store.deal.currentStage).toBe("active"); + }); +}); + +describe("document actions", () => { + it("signing completes the agreement and advances the deal", () => { + signDoc(store, "doc-agreement-enterprise"); + expect(status(store, "doc-agreement-enterprise")).toBe("complete"); + expect(store.deal.currentStage).toBe("procurement"); + }); + + it("paying clears payment and advances to implementation", () => { + advanceDeal(store, "security"); // → procurement + payDeal(store); + expect(store.deal.currentStage).toBe("active"); + expect(status(store, "doc-pay-online")).toBe("complete"); + }); + + it("uploading a PO is an alternate payment path that also advances", () => { + advanceDeal(store, "security"); // → procurement + uploadPurchaseOrder(store); + expect(store.deal.currentStage).toBe("active"); + }); + + it("requesting an on-demand document moves it to pending", () => { + requestDoc(store, "sup-custom-review"); + expect(status(store, "sup-custom-review")).toBe("pending"); + }); +}); diff --git a/frontend/portal/src/mocks/procurementMachine.ts b/frontend/portal/src/mocks/procurementMachine.ts new file mode 100644 index 0000000000..275b511b51 --- /dev/null +++ b/frontend/portal/src/mocks/procurementMachine.ts @@ -0,0 +1,114 @@ +/** + * The procurement deal's state machine, the exact server-side semantics the + * mock backend enforces, as pure functions over a {@link ProcurementStore}. + * + * Kept separate from the MSW handler (which just owns one in-memory store and + * wires these to endpoints) so the rules are unit-testable without a network, + * and so a real backend has a precise spec to mirror. Every function mutates + * the store in place, there's a single instance per session. + */ + +import { + JOURNEY, + type Deal, + type DealStage, + type DocAction, + type DocStatus, + type LedgerDoc, + type LedgerGroup, + type SupportingGroup, +} from "@portal/mocks/procurement"; + +export interface ProcurementStore { + deal: Deal; + ledger: LedgerGroup[]; + supporting: SupportingGroup[]; +} + +/** Document actions that gate a stage, completing them moves the deal forward. */ +const GATING: DocAction[] = ["sign", "pay", "upload"]; + +function nextStage(stage: DealStage): DealStage | null { + const i = JOURNEY.findIndex((s) => s.stage === stage); + return i >= 0 && i < JOURNEY.length - 1 ? JOURNEY[i + 1].stage : null; +} + +function allDocs(store: ProcurementStore): LedgerDoc[] { + return [ + ...store.ledger.flatMap((g) => g.docs), + ...store.supporting.flatMap((g) => g.docs), + ]; +} + +function setDocStatus(store: ProcurementStore, id: string, status: DocStatus) { + const doc = allDocs(store).find((d) => d.id === id); + if (doc) doc.status = status; +} + +function stageOfDoc( + store: ProcurementStore, + id: string, +): DealStage | undefined { + return store.ledger.find((g) => g.docs.some((d) => d.id === id))?.stage; +} + +/** + * Advance the deal one stage. Completing a stage marks its outstanding gating + * docs done; entering the next stage promotes that stage's paperwork from + * "pending" to actionable (gating → action needed, downloads → available). + * No-ops on a stale `from` so a double-click can't skip a stage. + */ +export function advanceDeal(store: ProcurementStore, from: DealStage) { + if (store.deal.currentStage !== from) return; + const to = nextStage(from); + if (!to) return; + + store.ledger + .find((g) => g.stage === from) + ?.docs.forEach((d) => { + if (d.status === "action") d.status = "complete"; + }); + + store.deal.currentStage = to; + + store.ledger + .find((g) => g.stage === to) + ?.docs.forEach((d) => { + if (d.status !== "pending") return; + if (GATING.includes(d.action)) d.status = "action"; + else if (d.action === "download") d.status = "available"; + }); +} + +/** Complete the current stage's doc matching an action, then advance past it. */ +function completeAndAdvance(store: ProcurementStore, action: DocAction) { + const stage = store.deal.currentStage; + store.ledger + .find((g) => g.stage === stage) + ?.docs.forEach((d) => { + if (d.action === action) d.status = "complete"; + }); + advanceDeal(store, stage); +} + +/** Sign the agreement: complete the doc and advance out of its stage. */ +export function signDoc(store: ProcurementStore, docId: string) { + setDocStatus(store, docId, "complete"); + const stage = stageOfDoc(store, docId); + if (stage && stage === store.deal.currentStage) advanceDeal(store, stage); +} + +/** Confirm online payment, then advance to implementation. */ +export function payDeal(store: ProcurementStore) { + completeAndAdvance(store, "pay"); +} + +/** Upload a purchase order (an alternate payment path), then advance. */ +export function uploadPurchaseOrder(store: ProcurementStore) { + completeAndAdvance(store, "upload"); +} + +/** Queue an on-demand document, it moves to "pending" until generated. */ +export function requestDoc(store: ProcurementStore, docId: string) { + setDocStatus(store, docId, "pending"); +} diff --git a/frontend/portal/src/views/Procurement.css b/frontend/portal/src/views/Procurement.css new file mode 100644 index 0000000000..90da816a53 --- /dev/null +++ b/frontend/portal/src/views/Procurement.css @@ -0,0 +1,453 @@ +.portal-proc { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + max-width: 84rem; + margin: 0 auto; +} + +/* Page header */ +.portal-proc__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.portal-proc__title { + margin: 0; + font-size: 1.375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-proc__subtitle { + margin: 0.25rem 0 0; + font-size: 0.8125rem; + color: var(--color-text-4); +} + +/* Eyebrow label shared by the journey header + SE block */ +.portal-proc__eyebrow { + display: block; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-5); + margin-bottom: 0.25rem; +} + +/* ── Journey card ─────────────────────────────────────────────────────── */ +/* Stacked, border-divided sections: header / stepper / trial / next step. */ +.portal-proc__journey-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--color-border-light); + flex-wrap: wrap; +} + +.portal-proc__journey-title { + margin: 0; + font-size: 1.0625rem; + font-weight: 700; + color: var(--color-text-1); +} + +.portal-proc__journey-sub { + margin: 0.25rem 0 0; + max-width: 36rem; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--color-text-4); +} + +.portal-proc__se { + display: flex; + flex-direction: column; + text-align: right; + flex-shrink: 0; +} + +.portal-proc__se .portal-proc__eyebrow { + margin-bottom: 0.25rem; +} + +.portal-proc__se-name { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-proc__se-email { + font-size: 0.75rem; + color: var(--color-blue); + text-decoration: none; +} + +.portal-proc__se-email:hover { + text-decoration: underline; +} + +/* Stepper band */ +.portal-proc__journey-stepper { + padding: 1.125rem 1.5rem; + border-bottom: 1px solid var(--color-border-light); +} + +.portal-proc__steps { + display: flex; + align-items: flex-start; +} + +.portal-proc__steps--locked { + opacity: 0.55; + filter: grayscale(0.4); + pointer-events: none; +} + +.portal-proc__step { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + min-width: 4rem; +} + +.portal-proc__step-dot { + width: 0.875rem; + height: 0.875rem; + border-radius: 50%; + background: var(--color-border-input); +} + +.portal-proc__step--complete .portal-proc__step-dot { + background: var(--color-green); +} + +.portal-proc__step--current .portal-proc__step-dot { + background: var(--color-purple); + box-shadow: 0 0 0 4px var(--color-purple-light); +} + +.portal-proc__step-label { + font-size: 0.6875rem; + font-weight: 500; + text-align: center; + white-space: nowrap; + color: var(--color-text-5); +} + +.portal-proc__step--complete .portal-proc__step-label { + color: var(--color-text-4); +} + +.portal-proc__step--current .portal-proc__step-label { + font-weight: 700; + color: var(--color-text-1); +} + +/* Connector aligns with the 0.875rem dots: (14px − 2px) / 2 = 6px down */ +.portal-proc__step-line { + flex: 1; + height: 2px; + margin: 0.375rem 0.375rem 0; + background: var(--color-border-light); +} + +.portal-proc__step-line[data-filled="true"] { + background: var(--color-green); +} + +/* Trial status strip (shown while evaluating) */ +.portal-proc__trial { + display: flex; + align-items: center; + gap: 0.625rem; + flex-wrap: wrap; + padding: 0.75rem 1.5rem; + border-bottom: 1px solid var(--color-border-light); + background: var(--color-bg-code); +} + +.portal-proc__trial-title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-proc__trial-dim { + font-size: 0.78125rem; + color: var(--color-text-5); +} + +.portal-proc__trial-key { + font-family: + ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-size: 0.6875rem; + color: var(--color-text-4); + background: var(--color-surface); + border: 1px solid var(--color-border-input); + border-radius: 0.375rem; + padding: 0.1875rem 0.5rem; +} + +/* Next-step row: one primary action at a time */ +.portal-proc__next { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + padding: 1rem 1.5rem; +} + +.portal-proc__next-label { + display: flex; + align-items: center; + gap: 0.625rem; + font-size: 0.84375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-proc__next-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--color-amber); + flex-shrink: 0; +} + +.portal-proc__next-dot[data-live="true"] { + background: var(--color-green); +} + +/* ── Documents card ───────────────────────────────────────────────────── */ +.portal-proc__docs-head { + padding: 1.125rem 1.5rem; + border-bottom: 1px solid var(--color-border-light); +} + +.portal-proc__docs-title { + margin: 0; + font-size: 0.9375rem; + font-weight: 700; + color: var(--color-text-1); +} + +.portal-proc__docs-sub { + margin: 0.25rem 0 0; + font-size: 0.78125rem; + color: var(--color-text-5); +} + +.portal-proc__docs-body { + padding: 0.375rem 1.5rem 1.125rem; +} + +/* Accordion spacing — the disclosure chrome itself lives in shared Collapsible + (.sui-collapsible); here we only space the stacked sections. */ +.portal-proc__docs-body .sui-collapsible { + margin-top: 0.875rem; +} + +/* Stage header bits */ +.portal-proc__stage-dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + flex-shrink: 0; +} + +.portal-proc__stage-dot[data-state="done"] { + background: var(--color-green); +} + +.portal-proc__stage-dot[data-state="current"] { + background: var(--color-purple); +} + +.portal-proc__stage-dot[data-state="upcoming"] { + background: var(--color-border-input); +} + +.portal-proc__stage-label { + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--color-text-4); +} + +.portal-proc__stage-label[data-current] { + color: var(--color-text-1); +} + +.portal-proc__stage-hint { + font-size: 0.71875rem; + color: var(--color-text-5); +} + +.portal-proc__stage-count { + font-size: 0.6875rem; + color: var(--color-text-5); +} + +/* Document lists inside the accordion */ +.portal-proc__doc-list { + border-top: 1px solid var(--color-border-light); +} + +.portal-proc__doc-list--boxed { + border: 1px solid var(--color-border-light); + border-radius: 0.5rem; + overflow: hidden; +} + +/* Supporting section — extra separation from the stage accordion above it. + Scoped to match the general .sui-collapsible spacing rule's specificity. */ +.portal-proc__docs-body .portal-proc__supporting-acc { + margin-top: 1.5rem; +} + +.portal-proc__supporting-head { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.portal-proc__supporting-sub { + font-size: 0.71875rem; + font-weight: 400; + line-height: 1.45; + color: var(--color-text-5); +} + +.portal-proc__acc-toggle-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-blue); +} + +.portal-proc__supporting-groups { + display: flex; + flex-direction: column; + gap: 1rem; + border-top: 1px solid var(--color-border-light); + padding: 0.875rem; +} + +.portal-proc__group-label { + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-5); + margin-bottom: 0.5rem; +} + +/* ── Document rows (ledger + supporting) ──────────────────────────────── */ +.portal-proc__doc { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 0.875rem; + border-bottom: 1px solid var(--color-border-light); +} + +.portal-proc__doc:last-child { + border-bottom: none; +} + +.portal-proc__doc[data-locked] { + opacity: 0.6; +} + +.portal-proc__doc-text { + min-width: 0; +} + +.portal-proc__doc-name-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.portal-proc__doc-name { + font-size: 0.84375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-proc__doc-sub { + margin: 0.0625rem 0 0; + font-size: 0.71875rem; + line-height: 1.4; + color: var(--color-text-5); +} + +.portal-proc__doc-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; +} + +/* ── Locked state ─────────────────────────────────────────────────────── */ +.portal-proc__locked { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +/* ── Action modal ─────────────────────────────────────────────────────── */ +.portal-proc__modal-body { + margin: 0 0 1rem; + font-size: 0.875rem; + line-height: 1.55; + color: var(--color-text-2); +} + +.portal-proc__modal-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.625rem; +} + +.portal-proc__upload { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.portal-proc__upload-input { + display: none; +} + +.portal-proc__upload-name { + font-size: 0.75rem; + color: var(--color-text-4); +} + +@media (max-width: 48rem) { + .portal-proc__journey-head { + flex-direction: column; + } + + .portal-proc__se { + text-align: left; + } + + .portal-proc__steps { + overflow-x: auto; + } +} diff --git a/frontend/portal/src/views/Procurement.tsx b/frontend/portal/src/views/Procurement.tsx new file mode 100644 index 0000000000..f41bbe52d4 --- /dev/null +++ b/frontend/portal/src/views/Procurement.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Card, Skeleton, StatusBadge } from "@shared/components"; +import { useTier } from "@portal/contexts/TierContext"; +import { useAsync } from "@portal/hooks/useAsync"; +import { + advanceStage, + fetchProcurement, + type DealStage, + type LedgerDoc, + type ProcurementResponse, +} from "@portal/api/procurement"; +import { DealJourney } from "@portal/components/procurement/DealJourney"; +import { DocumentLedger } from "@portal/components/procurement/DocumentLedger"; +import { ActionModal } from "@portal/components/procurement/ActionModal"; +import { LockedState } from "@portal/components/procurement/LockedState"; +import "@portal/views/Procurement.css"; + +/** + * Procurement: the enterprise commercial journey (trial to live) plus the + * document ledger. Enterprise-only: free/pro buyers see a locked upgrade state. + */ +export function Procurement() { + const { t } = useTranslation(); + const { tier } = useTier(); + const [activeDoc, setActiveDoc] = useState(null); + const [advancing, setAdvancing] = useState(false); + + const state = useAsync( + () => fetchProcurement(tier), + [tier], + ); + + // Write actions return the new canonical state; we hold it here so the + // journey reflects every action immediately. Cleared when the tier (and so + // the deal) changes, falling back to whatever the GET loaded. + const [applied, setApplied] = useState(null); + useEffect(() => setApplied(null), [tier]); + const data = applied ?? (state.loading ? null : state.data); + + async function onAdvance(stage: DealStage) { + setAdvancing(true); + try { + setApplied(await advanceStage(stage)); + } finally { + setAdvancing(false); + } + } + + return ( +
+
+
+

{t("procurement.title")}

+

{t("procurement.subtitle")}

+
+ + {t("procurement.enterpriseBadge")} + +
+ + {state.loading && ( + + + + + )} + + {data && !data.unlocked && ( + { + // TODO(backend): POST /v1/procurement/sales-contact, for now this + // is the sidebar's upgrade path; hand off to the account team. + }} + /> + )} + + {data && data.unlocked && data.deal && ( + <> + + + + )} + + setActiveDoc(null)} + onDone={(next) => { + setApplied(next); + setActiveDoc(null); + }} + /> +
+ ); +} diff --git a/frontend/shared/components/Collapsible.css b/frontend/shared/components/Collapsible.css new file mode 100644 index 0000000000..95b53886a5 --- /dev/null +++ b/frontend/shared/components/Collapsible.css @@ -0,0 +1,63 @@ +.sui-collapsible { + border: 1px solid var(--color-border-light); + border-radius: 0.5625rem; + overflow: hidden; +} + +.sui-collapsible__head { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.75rem 0.875rem; + background: var(--color-surface); + border: none; + cursor: pointer; + text-align: left; + transition: background-color 0.15s ease; +} + +.sui-collapsible__head:hover { + background: var(--color-bg-hover); +} + +.sui-collapsible__head-main { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; + flex-wrap: wrap; +} + +.sui-collapsible__head-end { + display: inline-flex; + align-items: center; + gap: 0.625rem; + margin-left: auto; + flex-shrink: 0; +} + +.sui-collapsible__chevron { + color: var(--color-text-5); + transition: transform 0.2s ease; +} + +.sui-collapsible__chevron[data-open="true"] { + transform: rotate(180deg); +} + +/* Grid-rows disclosure: 0fr → 1fr, no magic max-height */ +.sui-collapsible__body { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.sui-collapsible__body[data-open="true"] { + grid-template-rows: 1fr; +} + +.sui-collapsible__body-inner { + overflow: hidden; +} diff --git a/frontend/shared/components/Collapsible.stories.tsx b/frontend/shared/components/Collapsible.stories.tsx new file mode 100644 index 0000000000..79e71cbe51 --- /dev/null +++ b/frontend/shared/components/Collapsible.stories.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Collapsible } from "@shared/components/Collapsible"; + +const meta: Meta = { + title: "Components/Collapsible", + component: Collapsible, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +// open/onToggle are a controlled pair, so the stories own the state. +export const Default: Story = { + render: () => { + const [open, setOpen] = useState(false); + return ( +
+ setOpen((o) => !o)} + header={Section title} + aside={3 items} + > +
+ Body content revealed when the section is open. +
+
+
+ ); + }, +}; + +// Stacked instances form an accordion; each section toggles independently. +export const Accordion: Story = { + render: () => { + const [open, setOpen] = useState(0); + const sections = ["Trial", "Quote", "Agreement"]; + return ( +
+ {sections.map((label, i) => ( + setOpen(open === i ? null : i)} + header={{label}} + > +
+ {label} details. +
+
+ ))} +
+ ); + }, +}; diff --git a/frontend/shared/components/Collapsible.tsx b/frontend/shared/components/Collapsible.tsx new file mode 100644 index 0000000000..d4768e9d1e --- /dev/null +++ b/frontend/shared/components/Collapsible.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from "react"; +import "@shared/components/Collapsible.css"; + +export interface CollapsibleProps { + /** Whether the section is expanded. Controlled — pair with `onToggle`. */ + open: boolean; + onToggle: () => void; + /** Header content (left/main side); the chevron is appended automatically. */ + header: ReactNode; + /** Right-aligned header content shown before the chevron (a count, a label). */ + aside?: ReactNode; + children: ReactNode; + className?: string; +} + +/** + * A single bordered disclosure section: an always-visible header that toggles, + * and a body that animates open/closed via a grid-rows transition (no magic + * max-height). Stack several to build an accordion. Header content is fully + * caller-supplied, so it suits both terse and rich (icon + chips + count) rows. + */ +export function Collapsible({ + open, + onToggle, + header, + aside, + children, + className, +}: CollapsibleProps) { + return ( +
+ +
+
{children}
+
+
+ ); +} diff --git a/frontend/shared/components/index.ts b/frontend/shared/components/index.ts index fd2846a7cc..0416614d97 100644 --- a/frontend/shared/components/index.ts +++ b/frontend/shared/components/index.ts @@ -28,6 +28,7 @@ export * from "@shared/components/Banner"; export * from "@shared/components/Toast"; // Compound +export * from "@shared/components/Collapsible"; export * from "@shared/components/Tabs"; export * from "@shared/components/Dropdown"; export * from "@shared/components/Drawer"; From c22ecc6c096e75fe53aa0d92e85dbc965dd1a917 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 1 Jul 2026 11:42:35 +0100 Subject: [PATCH 2/5] Add counts to sources page (#6819) --- app/proprietary/build.gradle | 4 + .../policy/engine/PolicyRunner.java | 13 +- .../proprietary/policy/source/DocStats.java | 15 ++ .../source/InProcessSourceDocCounter.java | 64 ++++++++ .../policy/source/JpaSourceDocCounter.java | 154 ++++++++++++++++++ .../policy/source/SourceController.java | 14 ++ .../policy/source/SourceDayDocSum.java | 7 + .../policy/source/SourceDocCountEntity.java | 62 +++++++ .../policy/source/SourceDocCountId.java | 36 ++++ .../source/SourceDocCountRepository.java | 69 ++++++++ .../policy/source/SourceDocCounter.java | 28 ++++ .../policy/source/SourceDocSum.java | 6 + .../policy/source/SourceDocTotalEntity.java | 59 +++++++ .../source/SourceDocTotalRepository.java | 34 ++++ .../policy/source/SourceDocWindows.java | 61 +++++++ .../policy/source/SourceOverviewService.java | 21 ++- .../proprietary/policy/source/SourceView.java | 8 +- .../policy/engine/PolicyRunnerTest.java | 8 +- .../source/InProcessSourceDocCounterTest.java | 62 +++++++ .../source/JpaSourceDocCounterDbTest.java | 93 +++++++++++ .../policy/source/SourceControllerTest.java | 7 +- .../source/SourceOverviewServiceTest.java | 23 ++- .../saas/V23__policy_source_doc_counts.sql | 26 +++ .../public/locales/en-US/translation.toml | 6 +- frontend/portal/src/api/sources.ts | 17 +- .../sources/SourceDetailCard.stories.tsx | 12 +- .../components/sources/SourceDetailCard.tsx | 4 +- .../sources/SourceDetailPanel.stories.tsx | 17 +- .../components/sources/SourceDetailPanel.tsx | 45 ++++- .../sources/SourcesTable.stories.tsx | 12 +- .../src/components/sources/Sparkline.test.tsx | 48 ++++++ .../src/components/sources/Sparkline.tsx | 53 ++++++ frontend/portal/src/mocks/handlers/sources.ts | 34 +++- .../portal/src/mocks/sampleDailySeries.ts | 10 ++ frontend/portal/src/views/Pipelines.test.tsx | 4 +- frontend/portal/src/views/Sources.css | 7 + frontend/portal/src/views/Sources.test.tsx | 12 +- frontend/portal/src/views/Sources.tsx | 17 ++ 38 files changed, 1134 insertions(+), 38 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/DocStats.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/InProcessSourceDocCounter.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDayDocSum.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountEntity.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountId.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCounter.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocSum.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalEntity.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocWindows.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/source/InProcessSourceDocCounterTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceDocCounterDbTest.java create mode 100644 app/saas/src/main/resources/db/migration/saas/V23__policy_source_doc_counts.sql create mode 100644 frontend/portal/src/components/sources/Sparkline.test.tsx create mode 100644 frontend/portal/src/components/sources/Sparkline.tsx create mode 100644 frontend/portal/src/mocks/sampleDailySeries.ts diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 7fb12de5ce..fb21ed0d0c 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -80,6 +80,10 @@ dependencies { implementation "software.amazon.awssdk:s3:${awsSdkVersion}" implementation "software.amazon.awssdk:url-connection-client:${awsSdkVersion}" + // @DataJpaTest slice (Boot 4 ships test slices as separate starters, like webmvc-test at the + // root) so policy.source repositories can be exercised against embedded H2. + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' + // Testcontainers: real MinIO/LocalStack (S3) and Valkey for integration tests in CI without // manually-started instances. Tests skip cleanly when Docker is unavailable. testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}" diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index 819b6cae02..fa2a190e8f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -21,6 +21,7 @@ import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.PolicyRunStatus; import stirling.software.proprietary.policy.progress.PolicyProgressListener; import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceDocCounter; import stirling.software.proprietary.policy.source.SourceStore; /** @@ -37,6 +38,7 @@ public class PolicyRunner { private final PolicyEngine policyEngine; private final List inputSources; private final SourceStore sourceStore; + private final SourceDocCounter docCounter; /** * Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so @@ -65,7 +67,7 @@ public class PolicyRunner { policy.id()); continue; } - runIds.addAll(pullAndRun(policy, source.toInputSpec())); + runIds.addAll(pullAndRun(policy, sourceId, source.toInputSpec())); } return runIds; } @@ -82,7 +84,11 @@ public class PolicyRunner { return policyEngine.submit(definition, inputs, listener); } - private List pullAndRun(Policy policy, InputSpec spec) { + /** + * Resolves the source and starts a run per unit; records how many documents the source fed and + * returns the ids of the runs started. + */ + private List pullAndRun(Policy policy, String sourceId, InputSpec spec) { InputSource source = sourceFor(spec); if (source == null) { log.warn( @@ -103,9 +109,12 @@ public class PolicyRunner { return List.of(); } List runIds = new ArrayList<>(); + long docsFed = 0; for (ResolvedInput unit : work) { runIds.add(startRun(policy, unit.inputs(), unit.onComplete())); + docsFed += unit.inputs().primary().size(); } + docCounter.record(sourceId, docsFed); return runIds; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/DocStats.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/DocStats.java new file mode 100644 index 0000000000..3f01275bd0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/DocStats.java @@ -0,0 +1,15 @@ +package stirling.software.proprietary.policy.source; + +/** + * Per-source document throughput for the overview row: how many documents a source has fed into + * runs in total and over the trailing 24-hour and 30-day windows. Counts documents fed + * (picked up by a run), so a snapshot-mode source that re-reads the same files each run counts them + * per run. + */ +public record DocStats(long total, long last24h, long last30d) { + + /** Number of trailing daily buckets in a source's daily series. */ + public static final int DAYS = 30; + + public static final DocStats ZERO = new DocStats(0, 0, 0); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/InProcessSourceDocCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/InProcessSourceDocCounter.java new file mode 100644 index 0000000000..4f05dcca6e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/InProcessSourceDocCounter.java @@ -0,0 +1,64 @@ +package stirling.software.proprietary.policy.source; + +import java.time.Instant; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * In-memory {@link SourceDocCounter} for tests and any future no-database mode. Holds hourly + * buckets per source; the clock is injectable so window boundaries can be exercised + * deterministically. {@link JpaSourceDocCounter} is the runtime bean. + */ +public class InProcessSourceDocCounter implements SourceDocCounter { + + private final Supplier clock; + private final Map> bucketsBySource = new ConcurrentHashMap<>(); + + public InProcessSourceDocCounter() { + this(Instant::now); + } + + public InProcessSourceDocCounter(Supplier clock) { + this.clock = clock; + } + + @Override + public void record(String sourceId, long docs) { + if (docs <= 0) { + return; + } + bucketsBySource + .computeIfAbsent(sourceId, key -> new ConcurrentHashMap<>()) + .merge(currentHour(), docs, Long::sum); + } + + @Override + public Map statsFor(Collection sourceIds) { + long now = currentHour(); + Map stats = new HashMap<>(); + for (String id : sourceIds) { + Map buckets = bucketsBySource.getOrDefault(id, Map.of()); + long total = buckets.values().stream().mapToLong(Long::longValue).sum(); + long last24h = + SourceDocWindows.sumSince(buckets, now - (SourceDocWindows.HOURS_IN_24H - 1)); + long last30d = SourceDocWindows.sumSince(buckets, SourceDocWindows.firstDayHour(now)); + stats.put(id, new DocStats(total, last24h, last30d)); + } + return stats; + } + + @Override + public List dailySeriesFor(String sourceId) { + long now = currentHour(); + Map buckets = bucketsBySource.getOrDefault(sourceId, Map.of()); + return SourceDocWindows.series(SourceDocWindows.byDay(buckets), now / 24); + } + + private long currentHour() { + return clock.get().getEpochSecond() / 3600; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java new file mode 100644 index 0000000000..af67953d77 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java @@ -0,0 +1,154 @@ +package stirling.software.proprietary.policy.source; + +import java.time.Instant; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.IntSupplier; +import java.util.function.Supplier; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +/** + * Durable {@link SourceDocCounter}; the runtime bean. {@code record} keeps two things in step: an + * hourly bucket ({@link SourceDocCountEntity}) that feeds the rolling 24h / 30d / daily-series + * windows, and a denormalized lifetime total ({@link SourceDocTotalEntity}) read directly for the + * all-time figure. The lifetime counter means the overview never scans a source's whole bucket + * history, and lets {@link #pruneOldBuckets()} retire buckets past the 30-day window so the hourly + * table stays bounded (~one row per source per active hour, for at most 30 days). + */ +@Service +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class JpaSourceDocCounter implements SourceDocCounter { + + private final SourceDocCountRepository countRepository; + private final SourceDocTotalRepository totalRepository; + private final Supplier clock; + + @Autowired + public JpaSourceDocCounter( + SourceDocCountRepository countRepository, SourceDocTotalRepository totalRepository) { + this(countRepository, totalRepository, Instant::now); + } + + // Clock seam so tests can pin "now"; the runtime bean uses the wall clock above. + JpaSourceDocCounter( + SourceDocCountRepository countRepository, + SourceDocTotalRepository totalRepository, + Supplier clock) { + this.countRepository = countRepository; + this.totalRepository = totalRepository; + this.clock = clock; + } + + @Override + public void record(String sourceId, long docs) { + if (docs <= 0) { + return; + } + long bucketHour = currentHour(); + upsert( + () -> totalRepository.increment(sourceId, docs), + () -> totalRepository.saveAndFlush(new SourceDocTotalEntity(sourceId, docs))); + upsert( + () -> countRepository.increment(sourceId, bucketHour, docs), + () -> + countRepository.saveAndFlush( + new SourceDocCountEntity(sourceId, bucketHour, docs))); + } + + /** + * Add {@code docs} to a per-source running total: increment the existing row, else insert a new + * one. The insert is flushed now (in its own transaction, since {@code record} is not + * {@code @Transactional}) so a concurrent run's winning insert surfaces as a constraint + * violation we retry as an increment, rather than as a silent {@code merge} overwrite or a + * later doomed commit. + */ + private static void upsert(IntSupplier increment, Runnable insert) { + if (increment.getAsInt() > 0) { + return; + } + try { + insert.run(); + } catch (DataIntegrityViolationException concurrentInsert) { + increment.getAsInt(); + } + } + + @Override + public Map statsFor(Collection sourceIds) { + if (sourceIds.isEmpty()) { + return Map.of(); + } + long now = currentHour(); + Map totals = sums(totalRepository.totalsFor(sourceIds)); + Map last24h = + sums( + countRepository.sumBySourceSince( + sourceIds, now - (SourceDocWindows.HOURS_IN_24H - 1))); + Map last30d = + sums( + countRepository.sumBySourceSince( + sourceIds, SourceDocWindows.firstDayHour(now))); + + Map stats = new HashMap<>(); + for (String id : sourceIds) { + stats.put( + id, + new DocStats( + totals.getOrDefault(id, 0L), + last24h.getOrDefault(id, 0L), + last30d.getOrDefault(id, 0L))); + } + return stats; + } + + @Override + public List dailySeriesFor(String sourceId) { + long now = currentHour(); + Collection ids = List.of(sourceId); + Map dailyCounts = + dailyBySource( + countRepository.dailyCountsSince( + ids, SourceDocWindows.firstDayHour(now))) + .getOrDefault(sourceId, Map.of()); + return SourceDocWindows.series(dailyCounts, now / 24); + } + + /** + * Retire hourly buckets older than the 30-day window; the lifetime total is held separately, so + * nothing reported is lost. Daily is ample - the read queries already ignore older buckets, so + * this is purely storage hygiene. + */ + @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) + public void pruneOldBuckets() { + countRepository.deleteOlderThan(SourceDocWindows.firstDayHour(currentHour())); + } + + private long currentHour() { + return clock.get().getEpochSecond() / 3600; + } + + private static Map sums(List rows) { + Map map = new HashMap<>(); + for (SourceDocSum row : rows) { + map.put(row.sourceId(), row.count() == null ? 0L : row.count()); + } + return map; + } + + private static Map> dailyBySource(List rows) { + Map> bySource = new HashMap<>(); + for (SourceDayDocSum row : rows) { + bySource.computeIfAbsent(row.sourceId(), key -> new HashMap<>()) + .merge(row.day(), row.docs() == null ? 0L : row.docs(), Long::sum); + } + return bySource; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java index 5755eb332f..27d60703de 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java @@ -74,6 +74,20 @@ public class SourceController { .orElseGet(() -> ResponseEntity.notFound().build()); } + @GetMapping("/{sourceId}/document-counts") + @Operation( + summary = "Daily document counts for a source", + description = + "The trailing 30-day per-day document series (oldest first) for the source's" + + " sparkline.") + public ResponseEntity> documentCounts(@PathVariable String sourceId) { + return sourceStore + .get(sourceId) + .filter(sourceAccessGuard::canAccess) + .map(source -> ResponseEntity.ok(overviewService.dailySeries(source.id()))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) @Operation( summary = "Create or update a source", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDayDocSum.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDayDocSum.java new file mode 100644 index 0000000000..923040ac62 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDayDocSum.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.policy.source; + +/** + * A {@code (sourceId, epoch-day, summed document count)} row from the daily-aggregate query. The + * day is {@code floor(bucketHour / 24)}, i.e. hours-since-epoch collapsed to days-since-epoch. + */ +public record SourceDayDocSum(String sourceId, Long day, Long docs) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountEntity.java new file mode 100644 index 0000000000..59fb5d954b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountEntity.java @@ -0,0 +1,62 @@ +package stirling.software.proprietary.policy.source; + +import java.io.Serializable; + +import org.springframework.data.domain.Persistable; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * One hour's document tally for a source: {@code bucketHour} is the hours-since-epoch the documents + * were fed, {@code docCount} the running total for that hour. Rolling-window totals are summed from + * these buckets. {@code sourceId} is a plain value, not a foreign key, matching the rest of the + * subsystem so it stays decoupled from the security entities. + */ +@Entity +@Table(name = "policy_source_doc_counts") +@IdClass(SourceDocCountId.class) +@NoArgsConstructor +@Getter +@Setter +public class SourceDocCountEntity implements Serializable, Persistable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "source_id") + private String sourceId; + + @Id + @Column(name = "bucket_hour") + private long bucketHour; + + @Column(name = "doc_count") + private long docCount; + + public SourceDocCountEntity(String sourceId, long bucketHour, long docCount) { + this.sourceId = sourceId; + this.bucketHour = bucketHour; + this.docCount = docCount; + } + + @Override + @Transient + public SourceDocCountId getId() { + return new SourceDocCountId(sourceId, bucketHour); + } + + @Override + @Transient + public boolean isNew() { + return true; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountId.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountId.java new file mode 100644 index 0000000000..549902901d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountId.java @@ -0,0 +1,36 @@ +package stirling.software.proprietary.policy.source; + +import java.io.Serializable; +import java.util.Objects; + +/** Composite key for {@link SourceDocCountEntity}: one row per source per hour bucket. */ +public class SourceDocCountId implements Serializable { + + private static final long serialVersionUID = 1L; + + private String sourceId; + private long bucketHour; + + public SourceDocCountId() {} + + public SourceDocCountId(String sourceId, long bucketHour) { + this.sourceId = sourceId; + this.bucketHour = bucketHour; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof SourceDocCountId other)) { + return false; + } + return bucketHour == other.bucketHour && Objects.equals(sourceId, other.sourceId); + } + + @Override + public int hashCode() { + return Objects.hash(sourceId, bucketHour); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountRepository.java new file mode 100644 index 0000000000..53a6965e28 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCountRepository.java @@ -0,0 +1,69 @@ +package stirling.software.proprietary.policy.source; + +import java.util.Collection; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public interface SourceDocCountRepository + extends JpaRepository { + + /** + * Add to an existing bucket; returns the number of rows updated (0 when the bucket is new). + * Transactional per call so {@code JpaSourceDocCounter.record} can run it (and the retry after + * a concurrent insert) without an enclosing transaction. + */ + @Modifying + @Transactional + @Query( + "update SourceDocCountEntity e set e.docCount = e.docCount + :docs" + + " where e.sourceId = :sourceId and e.bucketHour = :bucketHour") + int increment( + @Param("sourceId") String sourceId, + @Param("bucketHour") long bucketHour, + @Param("docs") long docs); + + /** + * Delete hourly buckets older than {@code floor} (hours-since-epoch). Nothing reads buckets + * before the 30-day window ({@code SourceDocWindows.firstDayHour}); the lifetime total lives in + * {@code policy_source_doc_totals}, so retiring old buckets keeps the table bounded without + * losing any reported figure. + */ + @Modifying + @Transactional + @Query("delete from SourceDocCountEntity e where e.bucketHour < :floor") + int deleteOlderThan(@Param("floor") long floor); + + /** + * Document total per source restricted to buckets at or after {@code since} (the 24h window). + */ + @Query( + "select new stirling.software.proprietary.policy.source.SourceDocSum(" + + "e.sourceId, sum(e.docCount))" + + " from SourceDocCountEntity e" + + " where e.sourceId in :ids and e.bucketHour >= :since" + + " group by e.sourceId") + List sumBySourceSince( + @Param("ids") Collection ids, @Param("since") long since); + + /** + * Per-source, per-day document totals for buckets at or after {@code since}, summed in the + * database so the overview reads ~one row per source per active day instead of per active hour. + * The day is {@code cast(floor(bucketHour / 24.0) as long)}: {@code 24.0} forces decimal + * division and the cast pins the result to a whole day on every dialect. + */ + @Query( + "select new stirling.software.proprietary.policy.source.SourceDayDocSum(" + + "e.sourceId, cast(floor(e.bucketHour / 24.0) as long), sum(e.docCount))" + + " from SourceDocCountEntity e" + + " where e.sourceId in :ids and e.bucketHour >= :since" + + " group by cast(floor(e.bucketHour / 24.0) as long), e.sourceId") + List dailyCountsSince( + @Param("ids") Collection ids, @Param("since") long since); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCounter.java new file mode 100644 index 0000000000..08eb1c277f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocCounter.java @@ -0,0 +1,28 @@ +package stirling.software.proprietary.policy.source; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * Records and reports how many documents each source feeds into runs. Counting is bucketed by hour + * so the overview can report rolling totals ({@link DocStats}) cheaply; {@link JpaSourceDocCounter} + * is the runtime bean and {@link InProcessSourceDocCounter} backs tests. + */ +public interface SourceDocCounter { + + /** Record that {@code docs} documents were fed from {@code sourceId} at the current time. */ + void record(String sourceId, long docs); + + /** + * Document totals for each given source; a source with no recorded docs maps to {@link + * DocStats#ZERO}. + */ + Map statsFor(Collection sourceIds); + + /** + * The trailing {@link DocStats#DAYS}-day daily document series for one source, oldest first, + * for the detail-panel sparkline. + */ + List dailySeriesFor(String sourceId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocSum.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocSum.java new file mode 100644 index 0000000000..8324397401 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocSum.java @@ -0,0 +1,6 @@ +package stirling.software.proprietary.policy.source; + +/** + * A {@code (sourceId, summed document count)} row, populated by the doc-count aggregate queries. + */ +public record SourceDocSum(String sourceId, Long count) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalEntity.java new file mode 100644 index 0000000000..afff11d43a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalEntity.java @@ -0,0 +1,59 @@ +package stirling.software.proprietary.policy.source; + +import java.io.Serializable; + +import org.springframework.data.domain.Persistable; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A source's lifetime document total, denormalized so the overview reads the all-time count in one + * row instead of scanning the source's whole hourly-bucket history, and so {@link + * SourceDocCountEntity} buckets can be pruned to the rolling 30-day window without losing it. + * + *

Like {@link SourceDocCountEntity}, implements {@link Persistable} reporting {@code isNew() == + * true} so a new source's first {@code save} {@code persist}s (a raw INSERT) and a concurrent + * insert surfaces as a constraint violation the counter retries as an increment, rather than {@code + * merge} silently overwriting it. + */ +@Entity +@Table(name = "policy_source_doc_totals") +@NoArgsConstructor +@Getter +@Setter +public class SourceDocTotalEntity implements Serializable, Persistable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "source_id") + private String sourceId; + + @Column(name = "doc_total") + private long docTotal; + + public SourceDocTotalEntity(String sourceId, long docTotal) { + this.sourceId = sourceId; + this.docTotal = docTotal; + } + + @Override + @Transient + public String getId() { + return sourceId; + } + + @Override + @Transient + public boolean isNew() { + return true; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalRepository.java new file mode 100644 index 0000000000..6542e98787 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocTotalRepository.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.policy.source; + +import java.util.Collection; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public interface SourceDocTotalRepository extends JpaRepository { + + /** + * Add to a source's lifetime total; returns the number of rows updated (0 when the source has + * no total row yet). Transactional per call so {@code JpaSourceDocCounter.record} can run it + * (and the retry after a concurrent insert) without an enclosing transaction. + */ + @Modifying + @Transactional + @Query( + "update SourceDocTotalEntity e set e.docTotal = e.docTotal + :docs" + + " where e.sourceId = :sourceId") + int increment(@Param("sourceId") String sourceId, @Param("docs") long docs); + + /** Lifetime totals for the given sources, as {@code (sourceId, total)} rows. */ + @Query( + "select new stirling.software.proprietary.policy.source.SourceDocSum(" + + "e.sourceId, e.docTotal)" + + " from SourceDocTotalEntity e where e.sourceId in :ids") + List totalsFor(@Param("ids") Collection ids); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocWindows.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocWindows.java new file mode 100644 index 0000000000..d52de7024b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceDocWindows.java @@ -0,0 +1,61 @@ +package stirling.software.proprietary.policy.source; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Shared rolling-window math over a source's document buckets, so the JPA and in-memory counters + * agree on the window boundaries and the daily series. Both define "last 30 days" as the buckets at + * or after {@link #firstDayHour}, and build the series from per-day counts with {@link #series} + * (the JPA counter aggregates by day in SQL; the in-memory counter groups its hourly buckets with + * {@link #byDay}). + */ +final class SourceDocWindows { + + static final long HOURS_IN_24H = 24; + + private SourceDocWindows() {} + + /** + * The hours-since-epoch at the start of the oldest day in the 30-day window, given the current + * hour bucket. Both the "last 30 days" total and the daily series are measured from here, so + * the KPI and the sparkline always cover the same buckets. + */ + static long firstDayHour(long nowHour) { + return ((nowHour / 24) - (DocStats.DAYS - 1)) * 24; + } + + /** + * Build the {@link DocStats#DAYS}-day daily series (oldest first) from per-day document counts + * (keyed by epoch-day, i.e. hours-since-epoch / 24). {@code currentDay} is today's epoch-day; + * the series runs back {@code DAYS} days from it. + */ + static List series(Map dailyCounts, long currentDay) { + long firstDay = currentDay - (DocStats.DAYS - 1); + long[] daily = new long[DocStats.DAYS]; + for (Map.Entry day : dailyCounts.entrySet()) { + int dayIndex = (int) (day.getKey() - firstDay); + if (dayIndex >= 0 && dayIndex < DocStats.DAYS) { + daily[dayIndex] += day.getValue(); + } + } + return Arrays.stream(daily).boxed().toList(); + } + + /** Collapse hourly buckets (keyed by hours-since-epoch) into per-day counts (keyed by day). */ + static Map byDay(Map hourlyCounts) { + Map dailyCounts = new HashMap<>(); + hourlyCounts.forEach((hour, count) -> dailyCounts.merge(hour / 24, count, Long::sum)); + return dailyCounts; + } + + /** Sum hourly buckets at or after {@code since} (hours-since-epoch). */ + static long sumSince(Map hourlyCounts, long since) { + return hourlyCounts.entrySet().stream() + .filter(entry -> entry.getKey() >= since) + .mapToLong(Map.Entry::getValue) + .sum(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java index b9bc7c1cb2..6e5f92ce44 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java @@ -30,12 +30,15 @@ public class SourceOverviewService { private final PolicyStore policyStore; private final SourceAccessGuard sourceAccessGuard; private final PolicyAccessGuard policyAccessGuard; + private final SourceDocCounter docCounter; public SourcesResponse overview() { List sources = sourceAccessGuard.visibleFrom(sourceStore); List policies = policyAccessGuard.visibleFrom(policyStore); Map> referencesBySource = referencesBySource(policies); + Map docStats = + docCounter.statsFor(sources.stream().map(Source::id).toList()); List views = sources.stream() @@ -44,7 +47,8 @@ public class SourceOverviewService { toView( source, referencesBySource.getOrDefault( - source.id(), List.of()))) + source.id(), List.of()), + docStats.getOrDefault(source.id(), DocStats.ZERO))) .sorted( Comparator.comparingInt(SourceView::referenceCount) .reversed() @@ -54,6 +58,14 @@ public class SourceOverviewService { return new SourcesResponse(buildKpis(views), views); } + /** + * The 30-day daily document series for one source (oldest first), for the expanded row's + * sparkline. + */ + public List dailySeries(String sourceId) { + return docCounter.dailySeriesFor(sourceId); + } + /** Policies referencing each source id, across the caller's visible policies. */ private static Map> referencesBySource(List policies) { Map> bySource = new HashMap<>(); @@ -65,7 +77,8 @@ public class SourceOverviewService { return bySource; } - private static SourceView toView(Source source, List referencingPolicies) { + private static SourceView toView( + Source source, List referencingPolicies, DocStats docs) { List refs = referencingPolicies.stream() .map(policy -> new SourceView.PolicyRef(policy.id(), policy.name())) @@ -78,7 +91,9 @@ public class SourceOverviewService { refs.size(), refs, configRows(source), - null); + docs.total(), + docs.last24h(), + docs.last30d()); } /** A disabled (paused) source reads as "disabled"; an unreferenced one reads as "unused". */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java index bb1fdbf9d0..6c6a9c4c6d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java @@ -4,8 +4,8 @@ import java.util.List; /** * One row in the Sources overview: a persisted input connection shown exactly once, with how many - * policies reference it (and which). {@code docsTotal} is {@code null} - per-source document volume - * is not tracked yet; the field is reserved so a later doc-accounting pass is additive. + * policies reference it (and which) and how many documents it has fed into runs ({@code docsTotal} + * lifetime plus the trailing 24-hour and 30-day windows). */ public record SourceView( String id, @@ -15,7 +15,9 @@ public record SourceView( int referenceCount, List referencingPolicies, List config, - Long docsTotal) { + long docsTotal, + long docs24h, + long docs30d) { /** A policy that references this source. */ public record PolicyRef(String id, String name) {} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java index cff169581b..b1ab05f8ba 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java @@ -33,6 +33,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.PolicyRunStatus; import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.InProcessSourceDocCounter; import stirling.software.proprietary.policy.source.InProcessSourceStore; import stirling.software.proprietary.policy.source.Source; import stirling.software.proprietary.policy.source.SourceStore; @@ -53,7 +54,12 @@ class PolicyRunnerTest { @BeforeEach void setUp() { - runner = new PolicyRunner(policyEngine, List.of(folderSource), sourceStore); + runner = + new PolicyRunner( + policyEngine, + List.of(folderSource), + sourceStore, + new InProcessSourceDocCounter()); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/InProcessSourceDocCounterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/InProcessSourceDocCounterTest.java new file mode 100644 index 0000000000..b598b44c80 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/InProcessSourceDocCounterTest.java @@ -0,0 +1,62 @@ +package stirling.software.proprietary.policy.source; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; + +/** + * Tests the rolling-window aggregation: documents recorded at different times roll into the total, + * the 24-hour / 30-day windows, and the daily series correctly as the clock advances. + */ +class InProcessSourceDocCounterTest { + + private static InProcessSourceDocCounter seededCounter(AtomicReference clock) { + InProcessSourceDocCounter counter = new InProcessSourceDocCounter(clock::get); + // 40 days ago: outside the 30-day window. + clock.set(Instant.parse("2026-05-21T12:00:00Z")); + counter.record("s", 100); + // 10 days ago: inside 30 days, outside 24 hours. + clock.set(Instant.parse("2026-06-20T12:00:00Z")); + counter.record("s", 50); + // 2 hours ago: inside both windows. + clock.set(Instant.parse("2026-06-30T10:00:00Z")); + counter.record("s", 7); + // Query as of "now". + clock.set(Instant.parse("2026-06-30T12:00:00Z")); + return counter; + } + + @Test + void rollsCountsIntoTotalAnd24hAnd30dWindows() { + AtomicReference clock = new AtomicReference<>(); + DocStats stats = seededCounter(clock).statsFor(List.of("s")).get("s"); + + assertEquals(157, stats.total()); + assertEquals(7, stats.last24h()); + assertEquals(57, stats.last30d()); + } + + @Test + void buildsTheDailySeriesOldestFirst() { + AtomicReference clock = new AtomicReference<>(); + // Today (index 29) and 10 days ago (index 19) only; 40 days ago is outside the window. + List series = seededCounter(clock).dailySeriesFor("s"); + + assertEquals(30, series.size()); + assertEquals(7L, series.get(29)); + assertEquals(50L, series.get(19)); + assertEquals(57L, series.stream().mapToLong(Long::longValue).sum()); + } + + @Test + void aSourceWithNoRecordedDocsIsZero() { + InProcessSourceDocCounter counter = new InProcessSourceDocCounter(); + assertEquals(DocStats.ZERO, counter.statsFor(List.of("unknown")).get("unknown")); + assertEquals(Collections.nCopies(DocStats.DAYS, 0L), counter.dailySeriesFor("unknown")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceDocCounterDbTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceDocCounterDbTest.java new file mode 100644 index 0000000000..4df0ab0ced --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceDocCounterDbTest.java @@ -0,0 +1,93 @@ +package stirling.software.proprietary.policy.source; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; + +/** + * Exercises {@link JpaSourceDocCounter} against a real (H2) database so the daily-aggregate query + * ({@code cast(floor(bucketHour / 24.0) as long)} grouping) and the record upsert are actually run, + * not just asserted against a mock. The unit-level window math lives in {@link + * InProcessSourceDocCounterTest}. + */ +@DataJpaTest +class JpaSourceDocCounterDbTest { + + // Pinned "now" so seeding and statsFor agree regardless of when the test runs (no hour-tick + // flake at the boundary of a wall-clock hour). + private static final Instant NOW = Instant.parse("2026-06-30T12:00:00Z"); + private static final long NOW_HOUR = NOW.getEpochSecond() / 3600; + + @Autowired private SourceDocCountRepository repository; + @Autowired private SourceDocTotalRepository totalRepository; + + private JpaSourceDocCounter counter() { + return new JpaSourceDocCounter(repository, totalRepository, () -> NOW); + } + + @Test + void recordIncrementsBothTheHourlyBucketAndTheLifetimeTotal() { + JpaSourceDocCounter counter = counter(); + counter.record("s", 5); + counter.record("s", 3); + + DocStats stats = counter.statsFor(List.of("s")).get("s"); + assertEquals(8, stats.total()); // from the denormalized lifetime row + assertEquals(8, stats.last24h()); + assertEquals(8, stats.last30d()); + assertEquals(8L, counter.dailySeriesFor("s").get(DocStats.DAYS - 1)); + } + + @Test + void statsBucketDocsByDayAndWindowFromTheDatabase() { + // Seed buckets directly at controlled hours: today, 10 days ago, 40 days ago, plus the + // lifetime row (record() would write it, but here we seed history directly). + repository.saveAndFlush(new SourceDocCountEntity("s", NOW_HOUR, 7)); + repository.saveAndFlush(new SourceDocCountEntity("s", NOW_HOUR - 24L * 10, 50)); + repository.saveAndFlush(new SourceDocCountEntity("s", NOW_HOUR - 24L * 40, 100)); + totalRepository.saveAndFlush(new SourceDocTotalEntity("s", 157)); + + DocStats stats = counter().statsFor(List.of("s")).get("s"); + assertEquals(157, stats.total()); // lifetime, including the out-of-window 40-days-ago docs + assertEquals(7, stats.last24h()); + assertEquals(57, stats.last30d()); + + // The daily series (fetched separately, per source) covers the same 30-day window. + List series = counter().dailySeriesFor("s"); + assertEquals(DocStats.DAYS, series.size()); + assertEquals(7L, series.get(DocStats.DAYS - 1)); // today + assertEquals(50L, series.get(DocStats.DAYS - 11)); // 10 days ago + assertEquals(57L, series.stream().mapToLong(Long::longValue).sum()); + } + + @Test + void pruneRetiresOutOfWindowBucketsButKeepsTheLifetimeTotal() { + repository.saveAndFlush(new SourceDocCountEntity("s", NOW_HOUR, 7)); // today + repository.saveAndFlush(new SourceDocCountEntity("s", NOW_HOUR - 24L * 40, 100)); // 40d ago + totalRepository.saveAndFlush(new SourceDocTotalEntity("s", 107)); + + counter().pruneOldBuckets(); + + assertEquals(1, repository.count()); // only the in-window bucket remains + DocStats stats = counter().statsFor(List.of("s")).get("s"); + assertEquals(107, stats.total()); // lifetime survives pruning + assertEquals(7, stats.last24h()); + assertEquals(7, stats.last30d()); + } + + @Test + void aSourceWithNoRecordedDocsIsZero() { + assertEquals(DocStats.ZERO, counter().statsFor(List.of("unknown")).get("unknown")); + } + + @SpringBootConfiguration + @AutoConfigurationPackage + static class TestApp {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java index c01df84a55..d1d62b7dbe 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java @@ -50,7 +50,12 @@ class SourceControllerTest { SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority); PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority); SourceOverviewService overviewService = - new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard); + new SourceOverviewService( + sourceStore, + policyStore, + sourceGuard, + policyGuard, + new InProcessSourceDocCounter()); triggerManager = mock(PolicyTriggerManager.class); // A permissive input source so config validation passes and save can be exercised. InputSource folderInput = mock(InputSource.class); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java index 986fd8c636..275f076db3 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java @@ -1,7 +1,6 @@ package stirling.software.proprietary.policy.source; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -31,6 +30,7 @@ class SourceOverviewServiceTest { private final SourceStore sourceStore = new InProcessSourceStore(); private final PolicyStore policyStore = new InProcessPolicyStore(); + private final SourceDocCounter docCounter = new InProcessSourceDocCounter(); private SourceOverviewService service; @BeforeEach @@ -41,7 +41,9 @@ class SourceOverviewServiceTest { PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class); SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority); PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority); - service = new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard); + service = + new SourceOverviewService( + sourceStore, policyStore, sourceGuard, policyGuard, docCounter); } @Test @@ -111,7 +113,8 @@ class SourceOverviewServiceTest { SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority); PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority); SourceOverviewService scoped = - new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard); + new SourceOverviewService( + sourceStore, policyStore, sourceGuard, policyGuard, docCounter); Source ours = teamSource("Ours", "/ours", 1L); teamSource("Theirs", "/theirs", 2L); @@ -128,9 +131,19 @@ class SourceOverviewServiceTest { } @Test - void documentVolumeIsNotTrackedYet() { + void documentCountsReflectRecordedDocs() { Source a = source("A", "/a"); - assertNull(find(service.overview(), a.id()).docsTotal()); + Source b = source("B", "/b"); + docCounter.record(a.id(), 5); + docCounter.record(a.id(), 3); + + SourceView av = find(service.overview(), a.id()); + assertEquals(8, av.docsTotal()); + assertEquals(8, av.docs24h()); + assertEquals(8, av.docs30d()); + + // A source with no recorded documents reads as zero, not null. + assertEquals(0, find(service.overview(), b.id()).docsTotal()); } private Source source(String name, String directory) { diff --git a/app/saas/src/main/resources/db/migration/saas/V23__policy_source_doc_counts.sql b/app/saas/src/main/resources/db/migration/saas/V23__policy_source_doc_counts.sql new file mode 100644 index 0000000000..7dcbaf1c0e --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V23__policy_source_doc_counts.sql @@ -0,0 +1,26 @@ +-- Per-source document throughput, in two tables: +-- +-- policy_source_doc_counts one row per source per hour bucket (hours-since-epoch), holding how +-- many documents that source fed into runs in that hour. Feeds the +-- rolling last-24h / last-30d windows and the 30-day daily series, and +-- is pruned to that window so it stays bounded. +-- policy_source_doc_totals a denormalized lifetime total per source, incremented alongside the +-- hourly bucket, so the overview reads the all-time figure in one row +-- instead of scanning a source's whole bucket history - and so the +-- hourly buckets can be pruned without losing it. +-- +-- Gated by policies.enabled like the rest of the subsystem; Hibernate ddl-auto would also create +-- these, but the migration keeps the schema explicit for the Flyway-managed deployments. + +CREATE TABLE IF NOT EXISTS policy_source_doc_counts ( + source_id VARCHAR(255) NOT NULL, + bucket_hour BIGINT NOT NULL, + doc_count BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (source_id, bucket_hour) +); + +CREATE TABLE IF NOT EXISTS policy_source_doc_totals ( + source_id VARCHAR(255) NOT NULL, + doc_total BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (source_id) +); diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index fb85a21a27..b32f19c303 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -330,7 +330,11 @@ subtitle = "{{type}} · {{status}}" closeAriaLabel = "Close detail" usedBy = "Used by" notReferenced = "Not referenced by any policy, so it's safe to delete." -docsUntracked = "Per-source document volume isn't tracked yet." +documents = "Documents" +docsTotal = "Total seen" +docs24h = "Last 24h" +docs30d = "Last 30 days" +docsTrend = "Documents over the last 30 days" edit = "Edit" pause = "Pause" resume = "Resume" diff --git a/frontend/portal/src/api/sources.ts b/frontend/portal/src/api/sources.ts index 9f9839d7a1..9a66aeef2d 100644 --- a/frontend/portal/src/api/sources.ts +++ b/frontend/portal/src/api/sources.ts @@ -28,8 +28,10 @@ export interface SourceView { referenceCount: number; referencingPolicies: SourcePolicyRef[]; config: SourceDetailRow[]; - /** Per-source document volume: not tracked yet (always null for now). */ - docsTotal: number | null; + /** Documents this source has fed into runs: lifetime, plus trailing 24h / 30d windows. */ + docsTotal: number; + docs24h: number; + docs30d: number; } export interface SourceKpi { @@ -69,6 +71,17 @@ export async function fetchSource(id: string): Promise { ); } +/** + * GET /api/v1/sources/{id}/document-counts: the trailing 30-day daily document + * series (oldest first) for the source's sparkline. Fetched only for the expanded + * row, so the overview list stays lightweight. + */ +export async function fetchSourceDocCounts(id: string): Promise { + return apiClient.local.json( + `/api/v1/sources/${encodeURIComponent(id)}/document-counts`, + ); +} + /** POST /api/v1/sources: create (blank id) or update (matched id) a source. */ export async function createSource(source: Source): Promise { return apiClient.local.json("/api/v1/sources", { diff --git a/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx b/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx index 0f9ddee521..118c084d14 100644 --- a/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx +++ b/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx @@ -1,6 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { SourceView } from "@portal/api/sources"; import { SourceDetailCard } from "@portal/components/sources/SourceDetailCard"; +import { sampleDailySeries } from "@portal/mocks/sampleDailySeries"; + +const SAMPLE_SERIES = sampleDailySeries(330); const IN_USE: SourceView = { id: "src-claims", @@ -16,7 +19,9 @@ const IN_USE: SourceView = { { label: "Directory", value: "/data/claims-intake" }, { label: "Mode", value: "consume" }, ], - docsTotal: null, + docsTotal: 45230, + docs24h: 312, + docs30d: 9870, }; const ORPHANED: SourceView = { @@ -27,7 +32,9 @@ const ORPHANED: SourceView = { referenceCount: 0, referencingPolicies: [], config: [{ label: "Directory", value: "/data/archive" }], - docsTotal: null, + docsTotal: 45230, + docs24h: 312, + docs30d: 9870, }; const meta: Meta = { @@ -35,6 +42,7 @@ const meta: Meta = { component: SourceDetailCard, parameters: { layout: "padded" }, args: { + docSeries: SAMPLE_SERIES, onClose: () => {}, onEdit: () => {}, onTogglePause: () => {}, diff --git a/frontend/portal/src/components/sources/SourceDetailCard.tsx b/frontend/portal/src/components/sources/SourceDetailCard.tsx index 6169116c0f..b32f1ec36b 100644 --- a/frontend/portal/src/components/sources/SourceDetailCard.tsx +++ b/frontend/portal/src/components/sources/SourceDetailCard.tsx @@ -7,6 +7,7 @@ import "@portal/views/Sources.css"; interface SourceDetailCardProps { source: SourceView; + docSeries: number[]; onClose: () => void; onEdit: (source: SourceView) => void; onTogglePause: (source: SourceView) => void; @@ -18,6 +19,7 @@ interface SourceDetailCardProps { /** Expanded detail for the selected source row, with edit/pause/delete actions. */ export function SourceDetailCard({ source, + docSeries, onClose, onEdit, onTogglePause, @@ -55,7 +57,7 @@ export function SourceDetailCard({ - +

); } diff --git a/frontend/portal/src/components/sources/SourcesTable.stories.tsx b/frontend/portal/src/components/sources/SourcesTable.stories.tsx index 30f1e5bc5f..0a556ca63b 100644 --- a/frontend/portal/src/components/sources/SourcesTable.stories.tsx +++ b/frontend/portal/src/components/sources/SourcesTable.stories.tsx @@ -17,7 +17,9 @@ const SOURCES: SourceView[] = [ { label: "Directory", value: "/data/claims-intake" }, { label: "Mode", value: "consume" }, ], - docsTotal: null, + docsTotal: 45230, + docs24h: 312, + docs30d: 9870, }, { id: "src-archive", @@ -27,7 +29,9 @@ const SOURCES: SourceView[] = [ referenceCount: 0, referencingPolicies: [], config: [{ label: "Directory", value: "/data/archive" }], - docsTotal: null, + docsTotal: 1180, + docs24h: 0, + docs30d: 0, }, { id: "src-legacy", @@ -37,7 +41,9 @@ const SOURCES: SourceView[] = [ referenceCount: 0, referencingPolicies: [], config: [{ label: "Directory", value: "/mnt/legacy" }], - docsTotal: null, + docsTotal: 48600, + docs24h: 0, + docs30d: 0, }, ]; diff --git a/frontend/portal/src/components/sources/Sparkline.test.tsx b/frontend/portal/src/components/sources/Sparkline.test.tsx new file mode 100644 index 0000000000..45db5eca44 --- /dev/null +++ b/frontend/portal/src/components/sources/Sparkline.test.tsx @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import { Sparkline } from "@portal/components/sources/Sparkline"; + +function pointsOf(container: HTMLElement): string[] { + const poly = container.querySelector("polyline"); + return (poly?.getAttribute("points") ?? "") + .trim() + .split(/\s+/) + .filter(Boolean); +} + +describe("Sparkline", () => { + it("draws one point per value", () => { + const { container } = render(); + expect(pointsOf(container)).toHaveLength(5); + }); + + it("renders nothing for an empty series", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeNull(); + }); + + it("renders a flat finite line for an all-zero series (no divide-by-zero)", () => { + const { container } = render(); + const ys = pointsOf(container).map((p) => Number(p.split(",")[1])); + expect(ys).toHaveLength(4); + expect(ys.every(Number.isFinite)).toBe(true); + // All equal: a zero series is a single horizontal line, not NaN-laden. + expect(new Set(ys).size).toBe(1); + }); + + it("puts the peak value at the top of the band", () => { + const { container } = render(); + const ys = pointsOf(container).map((p) => Number(p.split(",")[1])); + // y grows downward in SVG, so the larger value (10) sits at the smaller y. + expect(ys[1]).toBeLessThan(ys[0]); + }); + + it("exposes its aria-label", () => { + const { container } = render( + , + ); + expect(container.querySelector("svg")?.getAttribute("aria-label")).toBe( + "Docs trend", + ); + }); +}); diff --git a/frontend/portal/src/components/sources/Sparkline.tsx b/frontend/portal/src/components/sources/Sparkline.tsx new file mode 100644 index 0000000000..7877d21fd4 --- /dev/null +++ b/frontend/portal/src/components/sources/Sparkline.tsx @@ -0,0 +1,53 @@ +import "@portal/views/Sources.css"; + +interface SparklineProps { + /** Series values, oldest first. */ + data: number[]; + /** Drawing height in px; the width fills the container. */ + height?: number; + ariaLabel?: string; +} + +/** + * A tiny dependency-free trend line: normalises {@code data} to its own peak and draws a single + * polyline. The viewBox is fixed while the rendered width fills the container (the stroke stays + * crisp via non-scaling-stroke), so it adapts to any column without distorting the line weight. + */ +export function Sparkline({ data, height = 36, ariaLabel }: SparklineProps) { + if (data.length === 0) { + return null; + } + const width = 240; + const pad = 3; + const max = Math.max(...data, 1); + const stepX = data.length > 1 ? width / (data.length - 1) : 0; + const points = data + .map((value, i) => { + const x = i * stepX; + const y = pad + (1 - value / max) * (height - 2 * pad); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); + + return ( + + + + ); +} diff --git a/frontend/portal/src/mocks/handlers/sources.ts b/frontend/portal/src/mocks/handlers/sources.ts index 90418caf56..4839c17720 100644 --- a/frontend/portal/src/mocks/handlers/sources.ts +++ b/frontend/portal/src/mocks/handlers/sources.ts @@ -7,6 +7,7 @@ import type { SourceView, SourcesResponse, } from "@portal/api/sources"; +import { sampleDailySeries } from "@portal/mocks/sampleDailySeries"; /** * Stateful mock for the Sources surface so the portal works fully offline with @@ -66,6 +67,27 @@ const references: Record = { "src-contracts": [{ id: "pol_contract", name: "Contract Review" }], }; +/** Per-source document throughput, mirroring the backend's docsTotal / 24h / 30d. */ +const docCounts: Record< + string, + { total: number; last24h: number; last30d: number } +> = { + "src-claims": { total: 45230, last24h: 312, last30d: 9870 }, + "src-contracts": { total: 12840, last24h: 96, last30d: 2310 }, + "src-archive": { total: 1180, last24h: 0, last30d: 0 }, + "src-legacy": { total: 48600, last24h: 0, last30d: 0 }, +}; + +function docsFor(id: string): { + total: number; + last24h: number; + last30d: number; + daily: number[]; +} { + const counts = docCounts[id] ?? { total: 0, last24h: 0, last30d: 0 }; + return { ...counts, daily: sampleDailySeries(counts.last30d / 30) }; +} + let store: StoredSource[] = seedSources(); let idCounter = 0; @@ -97,6 +119,7 @@ function toSourceView( source: StoredSource, refs: SourcePolicyRef[], ): SourceView { + const docs = docsFor(source.id); return { id: source.id, name: source.name, @@ -105,7 +128,9 @@ function toSourceView( referenceCount: refs.length, referencingPolicies: refs, config: configRows(source.options), - docsTotal: null, + docsTotal: docs.total, + docs24h: docs.last24h, + docs30d: docs.last30d, }; } @@ -142,6 +167,13 @@ export const sourcesHandlers = [ return HttpResponse.json(source); }), + http.get("/api/v1/sources/:id/document-counts", async ({ params }) => { + await delay(120); + const source = store.find((s) => s.id === params.id); + if (!source) return new HttpResponse(null, { status: 404 }); + return HttpResponse.json(docsFor(source.id).daily); + }), + http.post("/api/v1/sources", async ({ request }) => { await delay(120); const incoming = (await request.json()) as Source; diff --git a/frontend/portal/src/mocks/sampleDailySeries.ts b/frontend/portal/src/mocks/sampleDailySeries.ts new file mode 100644 index 0000000000..20b5c3e716 --- /dev/null +++ b/frontend/portal/src/mocks/sampleDailySeries.ts @@ -0,0 +1,10 @@ +/** + * A gentle, deterministic 30-point daily series averaging ~`avg`/day, shaped by a sine wave so mock + * and Storybook sparklines have something to draw. Shared so the mock handler and the source stories + * stay in sync instead of each carrying their own copy of the formula. + */ +export function sampleDailySeries(avg: number): number[] { + return Array.from({ length: 30 }, (_, i) => + Math.round(avg * (0.5 + Math.abs(Math.sin((i + 1) / 3)))), + ); +} diff --git a/frontend/portal/src/views/Pipelines.test.tsx b/frontend/portal/src/views/Pipelines.test.tsx index afcad9be07..579181b1ce 100644 --- a/frontend/portal/src/views/Pipelines.test.tsx +++ b/frontend/portal/src/views/Pipelines.test.tsx @@ -93,7 +93,9 @@ const SOURCES: SourcesResponse = { referenceCount: 1, referencingPolicies: [], config: [], - docsTotal: null, + docsTotal: 0, + docs24h: 0, + docs30d: 0, }, ], }; diff --git a/frontend/portal/src/views/Sources.css b/frontend/portal/src/views/Sources.css index 7c5a594c0b..d265a17135 100644 --- a/frontend/portal/src/views/Sources.css +++ b/frontend/portal/src/views/Sources.css @@ -238,6 +238,13 @@ font-weight: 600; } +.portal-sparkline { + display: block; + width: 100%; + margin-top: 0.5rem; + color: var(--color-blue); +} + .portal-sources__chips { display: flex; flex-wrap: wrap; diff --git a/frontend/portal/src/views/Sources.test.tsx b/frontend/portal/src/views/Sources.test.tsx index 98ce7175e4..191e30bbd8 100644 --- a/frontend/portal/src/views/Sources.test.tsx +++ b/frontend/portal/src/views/Sources.test.tsx @@ -16,11 +16,13 @@ vi.mock("react-i18next", () => ({ const fetchSources = vi.fn(); const fetchSource = vi.fn(); +const fetchSourceDocCounts = vi.fn(); const createSource = vi.fn(); const deleteSource = vi.fn(); vi.mock("@portal/api/sources", () => ({ fetchSources: () => fetchSources(), fetchSource: (id: string) => fetchSource(id), + fetchSourceDocCounts: (id: string) => fetchSourceDocCounts(id), createSource: (source: unknown) => createSource(source), deleteSource: (id: string) => deleteSource(id), })); @@ -43,7 +45,9 @@ const RESPONSE: SourcesResponse = { { id: "pol-2", name: "Classification" }, ], config: [{ label: "Directory", value: "/data/incoming" }], - docsTotal: null, + docsTotal: 1240, + docs24h: 18, + docs30d: 540, }, { id: "src-orphan", @@ -53,7 +57,9 @@ const RESPONSE: SourcesResponse = { referenceCount: 0, referencingPolicies: [], config: [{ label: "Directory", value: "/tmp/scratch" }], - docsTotal: null, + docsTotal: 1240, + docs24h: 18, + docs30d: 540, }, ], }; @@ -70,6 +76,8 @@ describe("Sources view", () => { beforeEach(() => { fetchSources.mockReset(); fetchSource.mockReset(); + fetchSourceDocCounts.mockReset(); + fetchSourceDocCounts.mockResolvedValue([]); createSource.mockReset(); deleteSource.mockReset(); }); diff --git a/frontend/portal/src/views/Sources.tsx b/frontend/portal/src/views/Sources.tsx index 230dc3abb7..7df5f1680e 100644 --- a/frontend/portal/src/views/Sources.tsx +++ b/frontend/portal/src/views/Sources.tsx @@ -14,6 +14,7 @@ import { createSource, deleteSource, fetchSource, + fetchSourceDocCounts, fetchSources, type Source, type SourcesResponse, @@ -49,6 +50,21 @@ export function Sources() { const sources = data?.sources ?? []; const expanded = sources.find((s) => s.id === expandedId) ?? null; + // The 30-day sparkline series lives off the list endpoint; fetch it for the one + // expanded row only (empty while collapsed, so no request fires). + const docSeriesState = useAsync<{ id: string; series: number[] }>( + () => + expandedId + ? fetchSourceDocCounts(expandedId).then((series) => ({ + id: expandedId, + series, + })) + : Promise.resolve({ id: "", series: [] }), + [expandedId], + ); + const docSeries = + docSeriesState.data?.id === expandedId ? docSeriesState.data.series : []; + function openCreate() { setEditingSource(null); setWizardOpen(true); @@ -166,6 +182,7 @@ export function Sources() { {expanded && ( setExpandedId(null)} onEdit={openEdit} onTogglePause={togglePause} From 9d3701a585cd3d59b484937248d4c2e0ee4efd4f Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:40:27 +0100 Subject: [PATCH 3/5] Fix rearrange-pages DUPLICATE producing shared page nodes (pypdf cyclic-references CI break) (#6851) --- .../api/RearrangePagesPDFController.java | 16 ++++++++-- .../api/RearrangePagesPDFControllerTest.java | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java index 6dd7aacd79..12e3a15b89 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java @@ -3,9 +3,12 @@ package stirling.software.SPDF.controller.api; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Set; +import org.apache.pdfbox.cos.COSDictionary; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageTree; @@ -261,10 +264,19 @@ public class RearrangePagesPDFController { log.info("newPageOrder = {}", newPageOrder); log.info("totalPages = {}", totalPages); - // Snapshot the desired pages before mutating the source document's page tree. + // Snapshot desired pages before mutating the tree; clone repeats (e.g. DUPLICATE) + // so each slot is a distinct node, not one PDPage under multiple /Kids. List newPages = new ArrayList<>(newPageOrder.size()); + Set seenIndices = new HashSet<>(); for (Integer idx : newPageOrder) { - newPages.add(document.getPage(idx)); + PDPage page = document.getPage(idx); + if (!seenIndices.add(idx)) { + // Duplicate index: distinct page node sharing content/resources. + COSDictionary clonedDict = new COSDictionary(); + clonedDict.addAll(page.getCOSObject()); + page = new PDPage(clonedDict); + } + newPages.add(page); } // Rearrange in-place on the source document rather than copying pages into a diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/RearrangePagesPDFControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/RearrangePagesPDFControllerTest.java index a225c3fc52..14b1d9892a 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/RearrangePagesPDFControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/RearrangePagesPDFControllerTest.java @@ -9,6 +9,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import org.apache.pdfbox.Loader; @@ -302,6 +303,11 @@ class RearrangePagesPDFControllerTest { assertNotNull(response); // 2 pages * 3 duplicates = 6 final pages assertEquals(6, realDoc.getNumberOfPages()); + // Each duplicate must be a distinct page node in the saved output; a shared + // node under multiple /Kids is an invalid tree readers reject as cyclic. + List savedPages = reloadAndSnapshot(response); + assertEquals(6, savedPages.size()); + assertEquals(6, new HashSet<>(savedPages).size()); } } @@ -323,4 +329,29 @@ class RearrangePagesPDFControllerTest { assertEquals(4, realDoc.getNumberOfPages()); } } + + @Test + void testRearrangePages_SideStitchBooklet_RepeatedPaddingPagesAreDistinctNodes() + throws IOException { + MockMultipartFile file = createMockPdf(); + RearrangePagesRequest request = new RearrangePagesRequest(); + request.setFileInput(file); + request.setPageNumbers(""); + request.setCustomMode("SIDE_STITCH_BOOKLET_SORT"); + + // 6 pages is not a multiple of 4, so booklet padding repeats the last page index + // several times; each repeat must be a distinct page node, not one shared node. + try (PDDocument realDoc = buildRealPdf(6)) { + when(pdfDocumentFactory.load(file)).thenReturn(realDoc); + + ResponseEntity response = controller.rearrangePages(request); + + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertEquals(8, realDoc.getNumberOfPages()); + List savedPages = reloadAndSnapshot(response); + assertEquals(8, savedPages.size()); + assertEquals(8, new HashSet<>(savedPages).size()); + } + } } From 467f3a86c4a3bbe603ac070b4e2cb2ae521cb053 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:42:26 +0100 Subject: [PATCH 4/5] Portal policies (#6852) --- Taskfile.yml | 27 ++ .../components/policies/usePolicyAutoRun.ts | 36 +- .../src/proprietary/hooks/usePolicies.ts | 3 + .../src/proprietary/services/policyBackend.ts | 1 + .../src/proprietary/services/policyExport.ts | 1 + .../editor/src/proprietary/types/policies.ts | 3 + .../public/locales/en-US/translation.toml | 32 +- frontend/portal/src/api/http.ts | 8 +- frontend/portal/src/api/policies.ts | 237 +++++++++-- .../policies/PolicyCategoryCard.tsx | 99 ++--- .../policies/PolicyDetailPanel.stories.tsx | 38 ++ .../components/policies/PolicyDetailPanel.tsx | 280 ++++++++----- .../components/policies/PolicySetupWizard.tsx | 351 +++++++++------- .../src/components/policies/storyFixtures.ts | 53 ++- .../src/components/sources/sourceTypes.ts | 1 + .../portal/src/mocks/handlers/policies.ts | 162 ++------ frontend/portal/src/mocks/policies.ts | 388 +++++------------- frontend/portal/src/views/Policies.css | 296 ++++++------- frontend/portal/src/views/Policies.tsx | 120 +++--- frontend/portal/vitest.config.ts | 9 +- frontend/shared/policies/codec.test.ts | 121 ++++++ frontend/shared/policies/codec.ts | 77 ++++ frontend/shared/policies/runs.test.ts | 150 +++++++ frontend/shared/policies/runs.ts | 65 +++ frontend/shared/policies/types.ts | 112 +++++ scripts/find-free-port.ps1 | 22 +- 26 files changed, 1704 insertions(+), 988 deletions(-) create mode 100644 frontend/shared/policies/codec.test.ts create mode 100644 frontend/shared/policies/codec.ts create mode 100644 frontend/shared/policies/runs.test.ts create mode 100644 frontend/shared/policies/runs.ts create mode 100644 frontend/shared/policies/types.ts diff --git a/Taskfile.yml b/Taskfile.yml index f04967740f..34c7c8bc28 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -95,6 +95,7 @@ tasks: vars: PORT: '{{.PORTAL_PORT}}' BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + MOCKS: 'false' OPEN: "true" dev:portal:all: @@ -117,6 +118,32 @@ tasks: BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' # Point the portal's "Editor" app switcher at the editor we spawn here. EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/' + MOCKS: 'false' + OPEN: "true" + - task: frontend:dev + vars: + PORT: '{{.EDITOR_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + + dev:portal:all:saas: + desc: "Start SaaS backend + developer portal + editor concurrently on free ports" + vars: + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}' + BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}' + EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}' + deps: + - task: backend:dev:saas + vars: + PORT: '{{.BACKEND_PORT}}' + POLICIES_ENABLED: "true" + - task: frontend:dev:portal + vars: + PORT: '{{.PORTAL_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/' + MOCKS: 'false' OPEN: "true" - task: frontend:dev vars: diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 19f88c9304..188b404d23 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -212,8 +212,12 @@ export function usePolicyAutoRun(): void { s.configured && s.status === "active" && s.backendId && - // Only auto-run on upload when the policy is set to run on upload - // (export-triggered policies enforce at export time instead). + // Only enforce in the editor when the policy includes "editor" as a source. + // runOn is an editor-specific parameter: "upload" fires here, "export" fires + // at export time via policyExport. Non-editor sources have their own triggers. + (!s.sources || + s.sources.length === 0 || + s.sources.includes("editor")) && (s.runOn ?? "upload") === "upload", ); for (const [categoryId, s] of active) { @@ -275,6 +279,7 @@ export function usePolicyAutoRun(): void { // input file it ran on (needs that input's stub, still in the workspace). const outputMode = policies[run.categoryId]?.outputMode ?? "new_version"; const outputName = policies[run.categoryId]?.outputName ?? ""; + const outputNamePosition = policies[run.categoryId]?.outputNamePosition; const parentStub = fileStubs.find((s) => (s.id as string) === run.fileId); void importOutputs(run, { addFiles, @@ -282,6 +287,7 @@ export function usePolicyAutoRun(): void { bumpRevision, outputMode, outputName, + outputNamePosition, parentStub, }).finally(() => importing.current.delete(run.runId)); } @@ -314,9 +320,11 @@ interface ImportContext { bumpRevision: () => void; /** "new_file" adds the output as a separate file; "new_version" versions the input. */ outputMode: "new_file" | "new_version"; - /** Rename rule. Empty → keep the input's filename; set → use the policy's - * renamed output (applied server-side per the name-position setting). */ + /** Rename rule. Empty → keep the input's filename. */ outputName: string; + /** Where the rename is applied: before ("prefix") or after ("suffix") the + * base filename. Defaults to "suffix" when absent. */ + outputNamePosition?: "prefix" | "suffix" | "auto-number"; /** The input file's stub — required to version it; absent if it's been removed. */ parentStub: StirlingFileStub | undefined; } @@ -327,6 +335,20 @@ interface ImportContext { * don't, adopt it so the poll/import effects pick it up. Server-excluded ad-hoc runs and runs we * can't map to a configured category are skipped. */ +function applyOutputName( + inputFileName: string, + outputName: string, + position: "prefix" | "suffix" | "auto-number", +): string { + const dot = inputFileName.lastIndexOf("."); + const base = dot > 0 ? inputFileName.slice(0, dot) : inputFileName; + const ext = dot > 0 ? inputFileName.slice(dot) : ""; + if (position === "suffix") return `${base}_${outputName}${ext}`; + if (position === "prefix") return `${outputName}_${base}${ext}`; + // auto-number requires dedup state not available here — fall back to suffix. + return `${base}_${outputName}${ext}`; +} + async function reconcileServerRuns( policies: PoliciesByCategory, ): Promise { @@ -406,7 +428,11 @@ async function importOutputs( // rule the backend's auto-suffixed name (e.g. "_watermarked_sanitized") would // otherwise rename every output. const targetName = ctx.outputName - ? undefined // use the run's per-output (renamed) name below + ? applyOutputName( + run.fileName, + ctx.outputName, + ctx.outputNamePosition ?? "suffix", + ) : run.fileName; const settled = await Promise.allSettled( pending.map(async (out) => { diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index 292cb9d0bc..356afa3164 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -134,6 +134,7 @@ export function usePolicies() { reviewerEmail: result.reviewerEmail, outputMode: result.folder.outputMode, outputName: result.folder.outputName, + outputNamePosition: result.folder.outputNamePosition, runOn: result.folder.runOn, }); }, @@ -170,6 +171,7 @@ export function usePolicies() { reviewerEmail: result.reviewerEmail, outputMode: result.folder.outputMode, outputName: result.folder.outputName, + outputNamePosition: result.folder.outputNamePosition, runOn: result.folder.runOn, }); }, @@ -234,6 +236,7 @@ export function usePolicies() { reviewerEmail: result.reviewerEmail, outputMode: result.folder.outputMode, outputName: result.folder.outputName, + outputNamePosition: result.folder.outputNamePosition, runOn: result.folder.runOn, }); }, diff --git a/frontend/editor/src/proprietary/services/policyBackend.ts b/frontend/editor/src/proprietary/services/policyBackend.ts index 5b3097e023..e1ee2400f4 100644 --- a/frontend/editor/src/proprietary/services/policyBackend.ts +++ b/frontend/editor/src/proprietary/services/policyBackend.ts @@ -53,6 +53,7 @@ export function decodedToState( fieldValues: decoded.fieldValues, outputMode: decoded.folder.outputMode, outputName: decoded.folder.outputName, + outputNamePosition: decoded.folder.outputNamePosition, runOn: decoded.folder.runOn, folderId: localFolderId, backendId: decoded.id, diff --git a/frontend/editor/src/proprietary/services/policyExport.ts b/frontend/editor/src/proprietary/services/policyExport.ts index 92b14712dd..6294c914ff 100644 --- a/frontend/editor/src/proprietary/services/policyExport.ts +++ b/frontend/editor/src/proprietary/services/policyExport.ts @@ -70,6 +70,7 @@ function activeExportPolicies(): ExportPolicy[] { s.configured && s.status === "active" && s.backendId && + (s.sources.length === 0 || s.sources.includes("editor")) && s.runOn === "export", ) .map(([id, s]) => ({ diff --git a/frontend/editor/src/proprietary/types/policies.ts b/frontend/editor/src/proprietary/types/policies.ts index c157f4759c..b0285a4d22 100644 --- a/frontend/editor/src/proprietary/types/policies.ts +++ b/frontend/editor/src/proprietary/types/policies.ts @@ -131,6 +131,9 @@ export interface PolicyState { * input's filename; when set, it's applied as a prefix/suffix per the policy's * name-position setting. */ outputName?: string; + /** Whether the rename rule is applied before ("prefix") or after ("suffix") + * the base filename, or as an auto-incrementing number. */ + outputNamePosition?: "prefix" | "suffix" | "auto-number"; /** When the policy runs: on "upload" or before "export". Defaults to "upload". */ runOn?: "upload" | "export"; /** diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index b32f19c303..2de3f0369d 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -379,6 +379,9 @@ label = "Read mode" consume = "Consume: process each file once" snapshot = "Snapshot: re-read the folder every run" +[sources.types.editor] +label = "Editor" + [sources.types.unknown] label = "Source" @@ -696,6 +699,11 @@ description = "{{file}} is signed and ready to transfer. It activates one instan title = "Policies" subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original." +[policies.offline] +title = "Backend unavailable" +description = "Your policies are saved and will appear once the connection is restored." +retry = "Retry" + [policies.status] active = "Active" paused = "Paused" @@ -724,15 +732,17 @@ description = "Across active policies" [policies.card] comingSoon = "Coming soon" notSetUp = "Not set up" -setUp = "Set up →" [policies.detail] -title = "{{category}} policy" -meta = "Runs on {{event}} · output {{output}}" outputAsNewFile = "as a new file" outputAsNewVersion = "as a new version" enforces = "Enforces" -enforceNote = "{{scope}} · originals stay untouched, the enforced version is saved alongside." +sources = "Sources" +onEveryUpload = "On every upload" +onEveryExport = "On every export" +showMore = "Show more" +showLess = "Show less" +retry = "Retry" recentActivity = "Recent activity" [policies.detail.actions] @@ -746,10 +756,6 @@ editSettings = "Edit settings" title = "No activity yet" description = "Documents will appear here once this policy runs." -[policies.detail.scoped] -title = "Scoped" -description = "Limited to: {{types}}" - [policies.wizard.title] edit = "Edit {{category}} policy" setUp = "Set up {{category}} policy" @@ -778,6 +784,9 @@ heading = "Settings" [policies.wizard.sources] heading = "Sources" +loading = "Loading sources…" +emptyTitle = "No sources available" +emptyDescription = "Connect a source on the Sources page first, then attach it to a policy here." [policies.wizard.docTypes] heading = "Document types" @@ -809,9 +818,10 @@ suffix = "Suffix" autoNumber = "Auto-number" placeholder = "Text to add (optional)" -[policies.wizard.output.reviewerEmail] -label = "Reviewer email" -helper = "Low-confidence enforcements are routed here for review." +[policies.wizard.output.retries] +heading = "Retries" +maxLabel = "Max retries" +delayLabel = "Retry delay (min)" [users.summary] members = "Members" diff --git a/frontend/portal/src/api/http.ts b/frontend/portal/src/api/http.ts index de8d89babc..a37df3ce81 100644 --- a/frontend/portal/src/api/http.ts +++ b/frontend/portal/src/api/http.ts @@ -39,7 +39,7 @@ * 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 { getStoredToken } from "@shared/auth"; +import { clearStoredToken, getStoredToken } from "@shared/auth"; import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient"; import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; @@ -156,6 +156,12 @@ async function localJson( 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")); + } return unwrap(res); } diff --git a/frontend/portal/src/api/policies.ts b/frontend/portal/src/api/policies.ts index fba5eda1eb..70eccd5cfb 100644 --- a/frontend/portal/src/api/policies.ts +++ b/frontend/portal/src/api/policies.ts @@ -1,72 +1,173 @@ -import { apiClient } from "@portal/api/http"; -import type { PoliciesResponse, Policy } from "@portal/mocks/policies"; - /** - * Policies service layer — the backend contract. + * Policies service layer. * - * Unlike every other portal surface (which use the mock `/v1/...` base), this - * one calls the REAL Stirling policy API base `/api/v1/policies` so it is - * genuinely plug-and-play: drop MSW and these exact calls hit the live backend - * (PolicyController). The list response is the portal's catalogue shape; the - * single-policy / create / delete / run calls match the backend records. + * The portal calls the real Stirling policy API (`/api/v1/policies`). MSW + * intercepts these calls in dev/Storybook; dropping MSW is enough to hit the + * live backend — no call-site changes needed. + * + * `fetchPolicies()` assembles the decorated catalogue client-side from the + * backend's flat `WirePolicy[]` + `PolicyRunView[]`, mirroring the same + * approach the editor uses for its own catalogue view. */ +import { apiClient } from "@portal/api/http"; +import { fromWirePolicy, toWirePolicy } from "@shared/policies/codec"; +import { runsToActivity, runsToStats } from "@shared/policies/runs"; +import type { PolicyDecodedState, WirePolicy } from "@shared/policies/types"; +import { + POLICY_CATEGORIES, + POLICY_CONFIG, + type CatalogueEntry, + type DecoratedPolicy, + type PoliciesResponse, + type PoliciesSummary, + type PolicySetupResult, + type PolicyState, + type PolicyStatus, +} from "@portal/mocks/policies"; +import type { PolicyRunView } from "@shared/policies/types"; + export type { CatalogueEntry, DecoratedPolicy, - InputSpec, - OutputSpec, - PipelineStep, PoliciesResponse, PoliciesSummary, - Policy, - PolicyActivityItem, PolicyCategory, PolicyConfigDef, + PolicyDecodedState, PolicyField, PolicyFieldType, PolicyRowStatus, + PolicyRunView, PolicySetupResult, - PolicySource, PolicyState, PolicyStats, + PolicyActivityItem, PolicyStatus, - TriggerConfig, + WirePolicy, + WireOutputOptions, + WireOutputSpec, } from "@portal/mocks/policies"; export { ENDPOINT_LABELS, POLICY_CATEGORIES, POLICY_CONFIG, POLICY_DOC_TYPES, - POLICY_SOURCES, TOOL_ENDPOINTS, humanizeEndpoint, } from "@portal/mocks/policies"; -/** GET /api/v1/policies — the catalogue + every configured policy. */ +// Re-export the wire step type under the legacy name components depend on. +export type { WirePipelineStep as PipelineStep } from "@shared/policies/types"; + +// ── Client-side catalogue assembly ─────────────────────────────────────────── + +function decoratePolicy( + decoded: PolicyDecodedState, + runs: PolicyRunView[], + isDefault: boolean, +): DecoratedPolicy | null { + const category = POLICY_CATEGORIES.find((c) => c.id === decoded.categoryId); + const config = POLICY_CONFIG[decoded.categoryId]; + if (!category || !config) return null; + + const policyRuns = runs.filter((r) => r.policyId === decoded.id); + const status: PolicyStatus = decoded.enabled ? "active" : "paused"; + const state: PolicyState = { + configured: true, + status, + sources: decoded.sources, + scopeTypes: decoded.scopeTypes, + reviewerEmail: decoded.reviewerEmail, + fieldValues: decoded.fieldValues, + outputMode: decoded.outputMode, + outputName: decoded.outputName, + outputNamePosition: decoded.outputNamePosition, + runOn: decoded.runOn, + maxRetries: decoded.maxRetries, + retryDelayMinutes: decoded.retryDelayMinutes, + backendId: decoded.id, + isDefault, + }; + + return { + category, + config, + state, + steps: decoded.steps, + stats: runsToStats(policyRuns), + activity: runsToActivity(policyRuns), + }; +} + +/** GET /api/v1/policies + GET /api/v1/policies/runs → assembled catalogue. */ export async function fetchPolicies(): Promise { - return apiClient.local.json("/api/v1/policies"); + const [wirePolicies, runs] = await Promise.all([ + apiClient.local.json("/api/v1/policies"), + apiClient.local + .json("/api/v1/policies/runs") + .catch(() => [] as PolicyRunView[]), + ]); + + const decodedByCategory = new Map< + string, + { decoded: PolicyDecodedState; isDefault: boolean } + >(); + for (const wire of wirePolicies) { + const decoded = fromWirePolicy(wire); + if (decoded.categoryId) { + decodedByCategory.set(decoded.categoryId, { decoded, isDefault: false }); + } + } + + const catalogue: CatalogueEntry[] = POLICY_CATEGORIES.map((category) => { + const entry = decodedByCategory.get(category.id); + const policy = entry + ? decoratePolicy(entry.decoded, runs, entry.isDefault) + : null; + return { category, config: POLICY_CONFIG[category.id], policy }; + }); + + const active = wirePolicies.filter((p) => p.enabled).length; + const paused = wirePolicies.filter((p) => !p.enabled).length; + const enabledPolicyIds = new Set( + wirePolicies.filter((p) => p.enabled).map((p) => p.id), + ); + const docsEnforced = runs.filter( + (r) => + r.status === "COMPLETED" && + r.policyId != null && + enabledPolicyIds.has(r.policyId), + ).length; + const summary: PoliciesSummary = { + active, + paused, + categories: POLICY_CATEGORIES.length, + docsEnforced, + }; + + return { summary, catalogue }; } /** GET /api/v1/policies/{id} — one stored policy's raw record. */ -export async function fetchPolicy(id: string): Promise { - return apiClient.local.json( +export async function fetchPolicy(id: string): Promise { + return apiClient.local.json( `/api/v1/policies/${encodeURIComponent(id)}`, ); } /** - * POST /api/v1/policies — create (blank id) or update (matched id). The backend - * assigns owner + team server-side and returns the stored policy with its id. + * POST /api/v1/policies — create (blank id) or update (matched id). The + * backend stamps owner + teamId server-side and returns the stored record. */ -export async function savePolicy(policy: Policy): Promise { - return apiClient.local.json("/api/v1/policies", { +export async function savePolicy(wire: WirePolicy): Promise { + return apiClient.local.json("/api/v1/policies", { method: "POST", - body: policy, + body: wire, }); } -/** DELETE /api/v1/policies/{id} — remove a stored policy. */ +/** DELETE /api/v1/policies/{id} */ export async function deletePolicy(id: string): Promise { await apiClient.local.json( `/api/v1/policies/${encodeURIComponent(id)}`, @@ -76,22 +177,80 @@ export async function deletePolicy(id: string): Promise { ); } -/** The async run acknowledgement: a run id to poll for status. */ -export interface PolicyRunResponse { - status: boolean; - /** The run id (poll GET /api/v1/policies/run/{id} for status). */ - fileId: string | null; - message: string | null; +// ── Wire-build helpers (so Policies.tsx doesn't need codec knowledge) ──────── + +const DEFAULT_RETRIES = 3; +const DEFAULT_RETRY_DELAY = 5; + +// Catalogue policy bodies carry categoryId at the top level so the pipelines +// mock handler can discriminate them from raw pipeline saves on the shared +// POST /api/v1/policies endpoint. The real backend ignores unknown fields. +type CatalogueWireBody = WirePolicy & { categoryId: string }; + +/** Build a wire policy from a setup wizard result. */ +export function buildWireFromSetup( + entry: CatalogueEntry, + result: PolicySetupResult, + enabled = true, +): CatalogueWireBody { + return { + categoryId: entry.category.id, + ...toWirePolicy({ + id: entry.policy?.state.backendId ?? "", + name: `${entry.category.label} Policy`, + enabled, + categoryId: entry.category.id, + sources: result.sources, + scopeTypes: result.scopeTypes, + reviewerEmail: result.reviewerEmail, + fieldValues: result.fieldValues, + runOn: result.runOn, + outputMode: result.outputMode, + outputName: result.outputName, + outputNamePosition: result.outputNamePosition, + maxRetries: result.maxRetries, + retryDelayMinutes: result.retryDelayMinutes, + steps: result.steps, + }), + }; +} + +/** Build a wire policy from an existing decorated policy (e.g. for pause/resume). */ +export function buildWireFromState( + entry: CatalogueEntry, + policy: DecoratedPolicy, + enabled: boolean, +): CatalogueWireBody { + const s = policy.state; + return { + categoryId: entry.category.id, + ...toWirePolicy({ + id: s.backendId ?? "", + name: `${entry.category.label} Policy`, + enabled, + categoryId: entry.category.id, + sources: s.sources, + scopeTypes: s.scopeTypes, + reviewerEmail: s.reviewerEmail, + fieldValues: s.fieldValues, + runOn: s.runOn ?? "upload", + outputMode: s.outputMode ?? "new_version", + outputName: s.outputName ?? "", + outputNamePosition: s.outputNamePosition ?? "suffix", + maxRetries: s.maxRetries ?? DEFAULT_RETRIES, + retryDelayMinutes: s.retryDelayMinutes ?? DEFAULT_RETRY_DELAY, + steps: policy.steps, + }), + }; } /** - * POST /api/v1/policies/{id}/run — run a stored policy now. The real endpoint - * is multipart (the documents to process); the portal has no files to attach, - * so this triggers the policy on whatever the backend has queued and returns a - * run id. Runs regardless of the policy's enabled flag. + * POST /api/v1/policies/{id}/run — trigger a stored policy immediately. The + * real endpoint is multipart; the portal sends no files, relying on whatever + * the backend has queued for this policy. */ -export async function runPolicy(id: string): Promise { - return apiClient.local.json( +export async function runPolicy(id: string): Promise<{ runId: string }> { + return apiClient.local.json<{ runId: string }>( `/api/v1/policies/${encodeURIComponent(id)}/run`, { method: "POST" }, ); diff --git a/frontend/portal/src/components/policies/PolicyCategoryCard.tsx b/frontend/portal/src/components/policies/PolicyCategoryCard.tsx index fe999a1dfb..33466830d0 100644 --- a/frontend/portal/src/components/policies/PolicyCategoryCard.tsx +++ b/frontend/portal/src/components/policies/PolicyCategoryCard.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { Card, Chip, StatusBadge, StatTile } from "@shared/components"; +import { Card, Chip, StatusBadge } from "@shared/components"; import type { CatalogueEntry } from "@portal/api/policies"; import { policyIcon } from "@portal/components/policies/policyIcons"; import "@portal/views/Policies.css"; @@ -9,17 +9,13 @@ interface PolicyCategoryCardProps { onOpen: (entry: CatalogueEntry) => void; } -/** - * One card per policy category. Configured categories show the live status + - * stats and open the detail panel; unconfigured ones show the summary + a - * "Set up" affordance; coming-soon categories render locked and inert. - */ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) { const { t } = useTranslation(); const { category, config, policy } = entry; const comingSoon = category.comingSoon === true; const openable = !comingSoon; const status = policy?.state.status; + const enforces = config.rules.join(" · "); return ( -
- - {policyIcon(category.icon)} - -
-

{category.label}

- {category.desc} -
- {comingSoon ? ( - - {t("policies.card.comingSoon")} - - ) : policy ? ( + + {policyIcon(category.icon)} + + +
+

{category.label}

+ {enforces && ( + {enforces} + )} +
+ + {comingSoon ? ( + + {t("policies.card.comingSoon")} + + ) : policy ? ( +
+ + + {policy.stats.enforced.toLocaleString()} + + + {t("policies.stats.docsEnforced")} + + + + + {policy.stats.dataProcessed} + + + {t("policies.stats.dataProcessed")} + + - ) : ( - - {t("policies.card.notSetUp")} - - )} -
- -

{config.summary}

- - {policy ? ( -
- - - -
+ ) : ( -
-
- {config.rules.slice(0, 3).map((rule) => ( - - {rule} - - ))} -
- {!comingSoon && ( - - {t("policies.card.setUp")} - - )} -
+ + {t("policies.card.notSetUp")} + )}
); diff --git a/frontend/portal/src/components/policies/PolicyDetailPanel.stories.tsx b/frontend/portal/src/components/policies/PolicyDetailPanel.stories.tsx index 215c319ba4..541ea558b0 100644 --- a/frontend/portal/src/components/policies/PolicyDetailPanel.stories.tsx +++ b/frontend/portal/src/components/policies/PolicyDetailPanel.stories.tsx @@ -12,6 +12,7 @@ const meta: Meta = { onRun: () => {}, onTogglePause: () => {}, onDelete: () => {}, + onRetry: () => {}, }, }; export default meta; @@ -43,3 +44,40 @@ export const CustomNoActivity: Story = { }, }, }; + +/** Flagged activity items — shows retry button and error expansion. */ +export const WithFlaggedItems: Story = { + args: { + policy: { + ...decorateForStory("security"), + state: { ...decorateForStory("security").state, isDefault: false }, + activity: [ + { + doc: "Q4-Report.pdf", + action: "Low-confidence match — routed for review", + time: "2h ago", + status: "flagged", + }, + { + doc: "Contract-2026.pdf", + action: + "Enforcement failed: timeout after 30s — step 2/3 (redact) did not complete within the allowed window. Check the document for unusual formatting or large embedded images.", + time: "4h ago", + status: "flagged", + }, + { + doc: "Invoice-March.pdf", + action: "Enforced successfully", + time: "6h ago", + status: "enforced", + }, + { + doc: "HR-Policy-v3.pdf", + action: "Processing…", + time: "just now", + status: "processing", + }, + ], + }, + }, +}; diff --git a/frontend/portal/src/components/policies/PolicyDetailPanel.tsx b/frontend/portal/src/components/policies/PolicyDetailPanel.tsx index cdf4399bd9..db4fd5bdf7 100644 --- a/frontend/portal/src/components/policies/PolicyDetailPanel.tsx +++ b/frontend/portal/src/components/policies/PolicyDetailPanel.tsx @@ -1,42 +1,102 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { - Banner, Button, Card, - Chip, EmptyState, Modal, StatTile, StatusBadge, } from "@shared/components"; -import { humanizeEndpoint, type DecoratedPolicy } from "@portal/api/policies"; -import { policyIcon } from "@portal/components/policies/policyIcons"; +import { + humanizeEndpoint, + type DecoratedPolicy, + type PolicyActivityItem, +} from "@portal/api/policies"; import "@portal/views/Policies.css"; interface PolicyDetailPanelProps { - /** The configured policy being viewed, or null when closed. */ policy: DecoratedPolicy | null; - /** Whether a lifecycle action (run/pause/delete) is in flight. */ busy?: boolean; onClose: () => void; onEdit: () => void; - onRun: () => void; + onRun?: () => void; onTogglePause: () => void; onDelete: () => void; + onRetry?: (item: PolicyActivityItem) => void; } -const ACTIVITY_TONE = { - enforced: "success", - flagged: "warning", - processing: "info", -} as const; +function CheckIcon() { + return ( + + + + ); +} + +function WarnIcon() { + return ( + + + + ); +} + +function SpinIcon() { + return ( + + + + ); +} + +function ActivityError({ message }: { message: string }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const needsToggle = message.length > 80 || message.includes("\n"); + if (!needsToggle) return <>{message}; + return ( + + + {message} + + + + ); +} -/** - * Narrative view for a configured policy: the enforced tool chain, recent - * activity, summary stats, and the lifecycle actions (run now, pause/resume, - * delete). Built-in (default) policies hide Delete — they're configurable but - * not deletable, matching the backend. - */ export function PolicyDetailPanel({ policy, busy = false, @@ -45,31 +105,36 @@ export function PolicyDetailPanel({ onRun, onTogglePause, onDelete, + onRetry, }: PolicyDetailPanelProps) { const { t } = useTranslation(); if (!policy) return null; const { category, config, state, steps, stats, activity } = policy; const isPaused = state.status === "paused"; const canDelete = state.isDefault !== true; - const enforceItems = steps.length > 0 ? steps.map((s) => s.operation) : []; + + const enforceItems = steps.length > 0 ? steps.map((s) => s.operation) : null; + const hasEditorSource = state.sources.includes("editor"); + const trigger = + state.runOn === "export" + ? t("policies.detail.onEveryExport") + : t("policies.detail.onEveryUpload"); + const outputLabel = + state.outputMode === "new_file" + ? t("policies.detail.outputAsNewFile") + : t("policies.detail.outputAsNewVersion"); + + function sourceLabel(id: string) { + if (id === "editor") return t("sources.types.editor.label"); + return id; + } return ( - - {policyIcon(category.icon)} - - {t("policies.detail.title", { category: category.label })} - - } - subtitle={config.summary} + title={category.label} footer={
{canDelete && ( @@ -84,15 +149,17 @@ export function PolicyDetailPanel({ {t("policies.detail.actions.delete")} )} - + {onRun && ( + + )} + )}
))} @@ -209,16 +311,6 @@ export function PolicyDetailPanel({ value={stats.activeFor} /> - - {state.scopeTypes.length > 0 && ( - - )}
); } diff --git a/frontend/portal/src/components/policies/PolicySetupWizard.tsx b/frontend/portal/src/components/policies/PolicySetupWizard.tsx index 133100f9a3..316f906e83 100644 --- a/frontend/portal/src/components/policies/PolicySetupWizard.tsx +++ b/frontend/portal/src/components/policies/PolicySetupWizard.tsx @@ -14,14 +14,16 @@ import { } from "@shared/components"; import { POLICY_DOC_TYPES, - POLICY_SOURCES, humanizeEndpoint, type CatalogueEntry, type PipelineStep, type PolicySetupResult, } from "@portal/api/policies"; +import { fetchSources } from "@portal/api/sources"; +import { useAsync } from "@portal/hooks/useAsync"; import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow"; import { policyIcon } from "@portal/components/policies/policyIcons"; +import { sourceTypeMeta } from "@portal/components/sources/sourceTypes"; import "@portal/views/Policies.css"; interface PolicySetupWizardProps { @@ -59,15 +61,27 @@ function resolveFieldValues( * round-trips); otherwise the category preset's default chain. Each preset step * starts enabled — the user toggles tools off in the workflow. */ +// Temporary: tracks which tools start disabled until the tool registry lands in +// the portal and can drive this via registry metadata or a defaultEnabled flag. +const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]); + function seedTools(entry: CatalogueEntry): ToolState[] { - const source = entry.policy?.steps?.length - ? entry.policy.steps - : entry.config.defaultOperations; - return source.map((s) => ({ - operation: s.operation, - enabled: true, - parameters: s.parameters, - })); + const savedSteps = entry.policy?.steps ?? []; + const savedByOp = new Map(savedSteps.map((s) => [s.operation, s])); + // Always use defaultOperations as the canonical list so tools added after a + // policy was first saved still appear when editing. + return entry.config.defaultOperations.map((s) => { + const saved = savedByOp.get(s.operation); + return { + operation: s.operation, + enabled: saved + ? true + : savedSteps.length > 0 + ? false + : !DISABLED_BY_DEFAULT.has(s.operation), + parameters: saved?.parameters ?? s.parameters, + }; + }); } /** @@ -112,27 +126,50 @@ function PolicySetupWizardBody({ resolveFieldValues(entry), ); const [sources, setSources] = useState( - policy?.state.sources.length ? policy.state.sources : ["editor"], + policy?.state.sources ?? ["editor"], ); + + const sourcesAsync = useAsync(() => fetchSources(), []); + const availableSources = useMemo(() => { + const backendSources = (sourcesAsync.data?.sources ?? []).filter( + (s) => s.status !== "disabled", + ); + const editorSource = { + id: "editor", + name: t("sources.types.editor.label"), + type: "editor", + status: "active" as const, + referenceCount: 0, + referencingPolicies: [], + config: [], + docsTotal: null, + }; + return [editorSource, ...backendSources]; + }, [sourcesAsync.data, t]); const [scopeNarrow, setScopeNarrow] = useState( (policy?.state.scopeTypes.length ?? 0) > 0, ); const [scopeTypes, setScopeTypes] = useState( policy?.state.scopeTypes ?? [], ); - const [reviewerEmail, setReviewerEmail] = useState( - policy?.state.reviewerEmail ?? "you@acme.com", - ); + // TODO: replace with user-picker backed by GET /api/v1/user/users (UserSummary[]). + // Store username (which is the email in Spring Security) as reviewerEmail. + // See UserSelector.tsx in the editor for the grouping/display pattern. + const [reviewerEmail] = useState(policy?.state.reviewerEmail ?? ""); const [outputMode, setOutputMode] = useState<"new_file" | "new_version">( policy?.state.outputMode ?? "new_version", ); const [outputName, setOutputName] = useState(policy?.state.outputName ?? ""); const [outputNamePosition, setOutputNamePosition] = useState< "prefix" | "suffix" | "auto-number" - >("suffix"); + >(policy?.state.outputNamePosition ?? "suffix"); const [runOn, setRunOn] = useState<"upload" | "export">( policy?.state.runOn ?? "upload", ); + const [maxRetries, setMaxRetries] = useState(policy?.state.maxRetries ?? 3); + const [retryDelayMinutes, setRetryDelayMinutes] = useState( + policy?.state.retryDelayMinutes ?? 5, + ); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -180,6 +217,8 @@ function PolicySetupWizardBody({ outputName: outputName.trim(), outputNamePosition, runOn, + maxRetries, + retryDelayMinutes, steps, }); } catch { @@ -197,10 +236,7 @@ function PolicySetupWizardBody({ width="lg" title={ - + {policyIcon(category.icon)} {isEdit @@ -272,9 +308,7 @@ function PolicySetupWizardBody({ {humanizeEndpoint(tl.operation)} - - {tl.operation} - +
- {POLICY_SOURCES.map((src) => ( - - ))} + + )) + )}

@@ -392,107 +438,130 @@ function PolicySetupWizardBody({ {t("policies.wizard.output.heading")}

- - { - const mode = e.target.value as "new_file" | "new_version"; - setOutputMode(mode); - // Auto-number only applies to separate new files. - if ( - mode === "new_version" && - outputNamePosition === "auto-number" - ) { - setOutputNamePosition("suffix"); - } - }} - options={[ - { - value: "new_version", - label: t("policies.wizard.output.outputAs.newVersion"), - }, - { - value: "new_file", - label: t("policies.wizard.output.outputAs.newFile"), - }, - ]} - /> - - -
- + + { + const mode = e.target.value as "new_file" | "new_version"; + setOutputMode(mode); + // Auto-number only applies to separate new files. + if ( + mode === "new_version" && + outputNamePosition === "auto-number" + ) { + setOutputNamePosition("suffix"); + } + }} + options={[ + { + value: "new_version", + label: t("policies.wizard.output.outputAs.newVersion"), + }, + { + value: "new_file", + label: t("policies.wizard.output.outputAs.newFile"), + }, + ]} + /> + + +
+ setOutputName(e.target.value)} + /> + )} +
+
+ + )} + {/* TODO: reviewer user-picker goes here */} +

+ {t("policies.wizard.output.retries.heading")} +

+ setReviewerEmail(e.target.value)} + type="number" + value={String(maxRetries)} + onChange={(e) => + setMaxRetries(Math.max(0, Number(e.target.value) || 0)) + } + /> + + + + setRetryDelayMinutes(Math.max(0, Number(e.target.value) || 0)) + } />
diff --git a/frontend/portal/src/components/policies/storyFixtures.ts b/frontend/portal/src/components/policies/storyFixtures.ts index ec1c3ddffe..ddcdf46000 100644 --- a/frontend/portal/src/components/policies/storyFixtures.ts +++ b/frontend/portal/src/components/policies/storyFixtures.ts @@ -3,38 +3,53 @@ * policy built straight from the catalogue + seed data, so stories render the * same shapes the MSW handlers serve without standing up the whole API. */ +import { fromWirePolicy } from "@shared/policies/codec"; +import { runsToActivity, runsToStats } from "@shared/policies/runs"; import { POLICY_CATEGORIES, POLICY_CONFIG, - seedRuntime, + seedPolicies, + seedPolicyRuns, type DecoratedPolicy, + type PolicyState, } from "@portal/mocks/policies"; export { POLICY_CATEGORIES, POLICY_CONFIG }; -/** A decorated, active policy for a category, mirroring the handler's decorate(). */ +/** A decorated, active policy for a category, mirroring fetchPolicies() assembly. */ export function decorateForStory(categoryId: string): DecoratedPolicy { const category = POLICY_CATEGORIES.find((c) => c.id === categoryId)!; const config = POLICY_CONFIG[categoryId]; - const rt = seedRuntime().pol_security_default; + + // Use the seeded security policy for any category (story only needs the shape). + const wire = seedPolicies()[0]; + const decoded = fromWirePolicy(wire); + const allRuns = seedPolicyRuns(); + const policyRuns = allRuns.filter((r) => r.policyId === wire.id); + + const state: PolicyState = { + configured: true, + status: decoded.enabled ? "active" : "paused", + sources: decoded.sources, + scopeTypes: decoded.scopeTypes, + reviewerEmail: decoded.reviewerEmail, + fieldValues: decoded.fieldValues, + outputMode: decoded.outputMode, + outputName: decoded.outputName, + outputNamePosition: decoded.outputNamePosition, + runOn: decoded.runOn, + maxRetries: decoded.maxRetries, + retryDelayMinutes: decoded.retryDelayMinutes, + backendId: wire.id, + isDefault: true, + }; + return { category, config, - state: { - configured: true, - status: "active", - sources: ["editor"], - scopeTypes: [], - reviewerEmail: rt.reviewerEmail, - fieldValues: {}, - outputMode: "new_version", - outputName: "", - runOn: "upload", - backendId: "pol_story", - isDefault: true, - }, - steps: config.defaultOperations, - stats: rt.stats, - activity: rt.activity, + state, + steps: decoded.steps, + stats: runsToStats(policyRuns), + activity: runsToActivity(policyRuns), }; } diff --git a/frontend/portal/src/components/sources/sourceTypes.ts b/frontend/portal/src/components/sources/sourceTypes.ts index dd4b61470b..6b5c41ddeb 100644 --- a/frontend/portal/src/components/sources/sourceTypes.ts +++ b/frontend/portal/src/components/sources/sourceTypes.ts @@ -17,6 +17,7 @@ export interface SourceTypeMeta { const SOURCE_TYPE_META: Record = { folder: { labelKey: "sources.types.folder.label", icon: "⛁", tone: "blue" }, + editor: { labelKey: "sources.types.editor.label", icon: "✏", tone: "green" }, }; const UNKNOWN_TYPE_META: SourceTypeMeta = { diff --git a/frontend/portal/src/mocks/handlers/policies.ts b/frontend/portal/src/mocks/handlers/policies.ts index 521fcaf8d9..7a722d73e0 100644 --- a/frontend/portal/src/mocks/handlers/policies.ts +++ b/frontend/portal/src/mocks/handlers/policies.ts @@ -1,39 +1,35 @@ import { http, HttpResponse, delay } from "msw"; import { - POLICY_CATEGORIES, - POLICY_CONFIG, seedPolicies, - seedRuntime, - emptyRuntime, - type CatalogueEntry, - type DecoratedPolicy, - type PoliciesResponse, - type PoliciesSummary, - type Policy, - type PolicyRowStatus, - type PolicyRuntime, - type PolicyState, + seedPolicyRuns, + type WirePolicy, } from "@portal/mocks/policies"; +import type { PolicyRunView } from "@shared/policies/types"; /** * The portal exercises the REAL policy API base — `/api/v1/policies`, NOT the * portal's usual `/v1/...` — so this surface is plug-and-play against the live - * backend (drop MSW and the same calls hit Stirling). These handlers mutate an - * in-memory store, so create/delete/run behave like a real backend within a - * session (see the notifications handler for the same stateful pattern). + * backend (drop MSW and the same calls hit Stirling). + * + * These handlers speak the backend's actual wire contract: + * - GET /api/v1/policies → WirePolicy[] + * - GET /api/v1/policies/runs → PolicyRunView[] + * - POST /api/v1/policies → WirePolicy (create / update) + * - DELETE /api/v1/policies/:id → 204 + * + * The decorated catalogue (summary, category grouping, stats) is assembled + * client-side in api/policies.ts#fetchPolicies(), mirroring the real backend. */ -/** Configured policies, keyed by backend id (the source of truth). */ -let store: Policy[] = seedPolicies(); -/** Runtime extras the wire record doesn't carry (scope, stats, activity). */ -let runtime: Record = seedRuntime(); +let store: WirePolicy[] = seedPolicies(); +let runs: PolicyRunView[] = seedPolicyRuns(); export function resetPoliciesStore( - seed?: Policy[], - seedRt?: Record, + seed?: WirePolicy[], + seedRuns?: PolicyRunView[], ): void { store = seed ? [...seed] : seedPolicies(); - runtime = seedRt ? { ...seedRt } : seedRuntime(); + runs = seedRuns ? [...seedRuns] : seedPolicyRuns(); } let idCounter = 0; @@ -42,78 +38,21 @@ function nextId(categoryId: string): string { return `pol_${categoryId}_${Date.now().toString(36)}_${idCounter}`; } -/** Derive the display status from the wire `enabled` flag. */ -function rowStatus(policy: Policy): PolicyRowStatus { - return policy.enabled ? "active" : "paused"; -} - -/** Build the decorated runtime view the catalogue/detail consumes. */ -function decorate(policy: Policy): DecoratedPolicy | null { - const category = POLICY_CATEGORIES.find((c) => c.id === policy.categoryId); - const config = POLICY_CONFIG[policy.categoryId]; - if (!category || !config) return null; - const rt = runtime[policy.id] ?? emptyRuntime(); - const status = rowStatus(policy); - const state: PolicyState = { - configured: true, - status: status === "paused" ? "paused" : "active", - sources: policy.sources.map((s) => s.source), - scopeTypes: rt.scopeTypes, - reviewerEmail: rt.reviewerEmail, - fieldValues: rt.fieldValues, - outputMode: policy.output.mode, - outputName: policy.output.name, - runOn: policy.trigger?.event ?? "upload", - backendId: policy.id, - isDefault: rt.isDefault, - }; - return { - category, - config, - state, - steps: policy.steps, - stats: rt.stats, - activity: rt.activity, - }; -} - -/** The full catalogue response: every category, each with its policy (or null). */ -function buildResponse(): PoliciesResponse { - const byCategory = new Map(); - for (const p of store) byCategory.set(p.categoryId, p); - - const catalogue: CatalogueEntry[] = POLICY_CATEGORIES.map((category) => { - const policy = byCategory.get(category.id); - return { - category, - config: POLICY_CONFIG[category.id], - policy: policy ? decorate(policy) : null, - }; - }); - - const active = store.filter((p) => p.enabled).length; - const paused = store.filter((p) => !p.enabled).length; - const docsEnforced = store - .filter((p) => p.enabled) - .reduce((sum, p) => sum + (runtime[p.id]?.stats.enforced ?? 0), 0); - const summary: PoliciesSummary = { - active, - paused, - categories: POLICY_CATEGORIES.length, - docsEnforced, - }; - - return { summary, catalogue }; +function categoryId(wire: WirePolicy): string { + return (wire.output?.options?.categoryId as string | undefined) ?? ""; } export const policiesHandlers = [ - // List — the catalogue (categories + configs + configured policies). http.get("/api/v1/policies", async () => { await delay(120); - return HttpResponse.json(buildResponse()); + return HttpResponse.json(store); + }), + + http.get("/api/v1/policies/runs", async () => { + await delay(120); + return HttpResponse.json(runs); }), - // Get one stored policy by id (the raw wire record). http.get("/api/v1/policies/:id", async ({ params }) => { await delay(120); const policy = store.find((p) => p.id === params.id); @@ -121,17 +60,17 @@ export const policiesHandlers = [ return HttpResponse.json(policy); }), - // Create or update — a blank id is assigned (create) or matched (update). - // One policy per category: a create for a category that already has one - // replaces it, matching the editor's "one policy per category, ever". + // Create or update — one policy per category: a create for a category that + // already has one replaces it, matching the editor's contract. http.post("/api/v1/policies", async ({ request }) => { await delay(120); - const incoming = (await request.json()) as Policy; + const incoming = (await request.json()) as WirePolicy; + const catId = categoryId(incoming); const existing = incoming.id ? store.find((p) => p.id === incoming.id) - : store.find((p) => p.categoryId === incoming.categoryId); - const id = existing?.id ?? nextId(incoming.categoryId); - const saved: Policy = { + : store.find((p) => categoryId(p) === catId); + const id = existing?.id ?? nextId(catId); + const saved: WirePolicy = { ...incoming, id, owner: existing?.owner ?? "you@acme.com", @@ -139,45 +78,16 @@ export const policiesHandlers = [ store = existing ? store.map((p) => (p.id === id ? saved : p)) : [...store, saved]; - // Seed runtime for a brand-new policy so the detail panel has somewhere to - // read from; an update keeps whatever runtime it already had. - if (!runtime[id]) runtime[id] = emptyRuntime(); return HttpResponse.json(saved); }), - // Delete a stored policy by id. http.delete("/api/v1/policies/:id", async ({ params }) => { await delay(120); const id = String(params.id); - const existed = store.some((p) => p.id === id); - if (!existed) return new HttpResponse(null, { status: 404 }); + if (!store.some((p) => p.id === id)) + return new HttpResponse(null, { status: 404 }); store = store.filter((p) => p.id !== id); - delete runtime[id]; + runs = runs.filter((r) => r.policyId !== id); return new HttpResponse(null, { status: 204 }); }), - - // Run a stored policy now. The real endpoint is multipart (files) and returns - // a run id; the portal has no files, so the mock just acknowledges with a run - // id and nudges the activity feed so the run is visible. - http.post("/api/v1/policies/:id/run", async ({ params }) => { - await delay(120); - const id = String(params.id); - const policy = store.find((p) => p.id === id); - if (!policy) return new HttpResponse(null, { status: 404 }); - const rt = runtime[id] ?? emptyRuntime(); - runtime[id] = { - ...rt, - activity: [ - { - doc: "manual-run.pdf", - action: "Enforcing…", - time: "just now", - status: "processing", - }, - ...rt.activity, - ], - }; - const runId = `run_${Date.now().toString(36)}`; - return HttpResponse.json({ status: true, fileId: runId, message: null }); - }), ]; diff --git a/frontend/portal/src/mocks/policies.ts b/frontend/portal/src/mocks/policies.ts index 76339e5acd..27d138f297 100644 --- a/frontend/portal/src/mocks/policies.ts +++ b/frontend/portal/src/mocks/policies.ts @@ -1,200 +1,84 @@ /** * Policies fixtures and the canonical TS model the portal shares with them. * - * The model mirrors the editor + backend policy contract so the portal's - * "set up a policy" flow is plug-and-play against the real `/api/v1/policies` - * API. A policy is a stored automation: an ordered chain of tool steps (each - * step's `operation` is a Stirling endpoint path) plus an output destination, - * fired automatically by a trigger (editor upload/export) over a set of - * sources. The catalogue groups policies by category, each category carrying a - * `PolicyConfigDef` (summary, rules, fields, default tool chain) the setup flow - * builds from. + * Wire types (`WirePolicy`, `WirePipelineStep`) come from the shared codec + * layer and match the backend record exactly. Catalogue and UI types + * (`PolicyCategory`, `PolicyConfigDef`, `PolicyState`, …) are portal-only: + * the backend has no "category" concept — `categoryId` rides in + * `output.options`. The catalogue assembles client-side in `api/policies.ts` + * from the decoded wire records + these static definitions. * - * The wire types (`Policy`, `PipelineStep`) match the backend records exactly; - * the catalogue + decorated state shapes (`PolicyConfigDef`, `PolicyField`, - * `PolicyState`, …) are lifted from the editor's `types/policies.ts`, with - * ReactNode icons replaced by string icon keys (the portal renders its own). - * - * api/policies.ts re-exports these types; the MSW handlers serve the fixture - * data over intercepted apiClient.local.json() calls. Components never reach in here. + * api/policies.ts re-exports everything; components never reach in here. */ -/* ──────────────────────────────────────────────────────────────────────── */ -/* Backend wire model — matches Policy.java / PipelineStep.java exactly */ -/* ──────────────────────────────────────────────────────────────────────── */ +import type { WirePipelineStep, WirePolicy } from "@shared/policies/types"; +import type { PolicyRunView } from "@shared/policies/types"; -/** - * A single tool invocation in a policy's pipeline. `operation` is a Stirling - * endpoint path (e.g. `/api/v1/security/auto-redact`); `parameters` are the - * scalar form fields that endpoint accepts. `fileParameters` binds a tool's - * named file field to an asset key in a run's supporting-file store. - */ -export interface PipelineStep { - operation: string; - parameters: Record; - fileParameters?: Record; -} - -/** When a policy fires automatically. A null trigger means manual-only. */ -export interface TriggerConfig { - /** The editor event the policy runs on. */ - event: "upload" | "export"; -} - -/** Where a policy's documents come from (a connected source). */ -export interface InputSpec { - /** Source id from {@link POLICY_SOURCES}. */ - source: string; -} - -/** How a run's result is delivered. */ -export interface OutputSpec { - /** A separate new file, or a new version of the input the policy ran on. */ - mode: "new_file" | "new_version"; - /** Rename rule for the output; empty keeps the input filename. */ - name: string; - namePosition: "prefix" | "suffix" | "auto-number"; -} - -/** - * The stored policy record — the exact JSON body the backend returns from - * `GET /api/v1/policies` and accepts on `POST /api/v1/policies`. The portal - * decorates this with catalogue + runtime data for display (see {@link decorate}). - */ -export interface Policy { - /** Blank on create; the backend assigns one and returns it. */ - id: string; - name: string; - /** Server-assigned owner; the client never forges it. */ - owner?: string; - /** Whether the trigger fires automatically. Pausing flips this. */ - enabled: boolean; - trigger: TriggerConfig | null; - sources: InputSpec[]; - steps: PipelineStep[]; - output: OutputSpec; - /** The category this policy belongs to. Drives catalogue grouping. */ - categoryId: string; -} +export type { + PolicyActivityItem, + PolicyDecodedState, + PolicyRunStatus, + PolicyRunView, + PolicyStats, + WireOutputOptions, + WireOutputSpec, + WirePipelineStep, + WirePolicy, +} from "@shared/policies/types"; /* ──────────────────────────────────────────────────────────────────────── */ -/* Catalogue model — lifted from editor types/policies.ts */ +/* Catalogue model — portal-specific (lifted from editor types/policies.ts) */ /* ──────────────────────────────────────────────────────────────────────── */ -export type PolicyStatus = "default" | "active" | "paused"; +export type PolicyStatus = "active" | "paused"; -/** Derived display status for a card/detail. */ export type PolicyRowStatus = "active" | "paused" | "setup"; -/** A configurable field within a policy's settings. */ export type PolicyFieldType = "toggle" | "select" | "chips" | "text"; export interface PolicyField { label: string; key: string; type: PolicyFieldType; - /** Default value: boolean (toggle), string (select/text), string[] (chips). */ value: boolean | string | string[]; - /** Options for select/chips. */ options?: string[]; } -/** - * Static definition of a policy category. The editor's `icon: ReactNode` is - * replaced by a string `icon` key the portal resolves to its own glyph. - */ export interface PolicyCategory { id: string; label: string; - /** Icon key the portal renders (not a component — the portal owns glyphs). */ icon: string; - /** Visual tone for the category's icon chip. */ tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red"; - /** Long description shown in the setup flow. */ desc: string; - /** Drives the "Set up Classification" affordance (doc-type narrowing). */ providesClassification?: boolean; - /** Locked "Coming soon" — can't be opened or configured. */ comingSoon?: boolean; } -/** The narrative + field configuration backing a category. */ export interface PolicyConfigDef { - /** One-line summary of what the policy enforces. */ summary: string; - /** Pipeline-like rule chips shown in the "Enforces" section. */ rules: string[]; - /** Human label for the scope this policy applies to. */ scopeLabel: string; - /** Editable policy-level settings fields. */ fields: PolicyField[]; - /** - * The preset pipeline a new policy is seeded with — the real, editable tool - * steps (each `operation` is a Stirling endpoint path, matching the backend's - * PipelineStep). The setup flow starts from these. - */ - defaultOperations: PipelineStep[]; + defaultOperations: WirePipelineStep[]; } -/** A source a policy can run over (setup "Sources" step). */ -export interface PolicySource { - id: string; - label: string; - desc: string; - /** Icon key the portal renders. */ - icon: string; -} - -/** Three-up summary stats shown at the foot of a configured policy's detail. */ -export interface PolicyStats { - /** Documents enforced. */ - enforced: number; - /** Human-formatted data-processed figure, e.g. "2.3 GB". */ - dataProcessed: string; - /** Human-formatted active-for figure, e.g. "12d", or "—" when idle. */ - activeFor: string; -} - -/** An entry in a policy's recent-activity feed. */ -export interface PolicyActivityItem { - /** Document the policy acted on. */ - doc: string; - /** What the policy did, e.g. "Redacted 4 PII matches • 2 pages". */ - action: string; - /** Relative timestamp, e.g. "2h ago". */ - time: string; - /** "enforced" (clean), "flagged" (needs review), "processing" (running). */ - status: "enforced" | "flagged" | "processing"; -} - -/** - * The collected settings the setup flow gathers and the detail panel reads — - * the editor's `PolicyState`, minus the local-cache bookkeeping (folderId etc). - */ export interface PolicyState { configured: boolean; status: PolicyStatus; - /** Selected sources (ids from {@link POLICY_SOURCES}). */ sources: string[]; - /** When non-empty, narrows the policy to these document types. */ scopeTypes: string[]; - /** Email low-confidence enforcements are routed to. */ reviewerEmail: string; - /** Saved field values, keyed by field key (overrides the definition default). */ fieldValues: Record; - /** How a run's output is delivered. Defaults to "new_version". */ outputMode?: "new_file" | "new_version"; - /** Rename rule for the output. Empty keeps the input filename. */ outputName?: string; - /** When the policy runs. Defaults to "upload". */ + outputNamePosition?: "prefix" | "suffix" | "auto-number"; runOn?: "upload" | "export"; - /** Backend record id once persisted; used to update/delete/run it. */ + maxRetries?: number; + retryDelayMinutes?: number; backendId?: string; - /** A shipped catalogue policy (configurable but not deletable). */ isDefault?: boolean; } -/** What the setup flow hands back on submit — collected settings + built steps. */ export interface PolicySetupResult { fieldValues: Record; sources: string[]; @@ -204,57 +88,40 @@ export interface PolicySetupResult { outputName: string; outputNamePosition: "prefix" | "suffix" | "auto-number"; runOn: "upload" | "export"; - /** The configured tool chain as backend pipeline steps. */ - steps: PipelineStep[]; + maxRetries: number; + retryDelayMinutes: number; + steps: WirePipelineStep[]; } -/** - * A configured policy as the catalogue view consumes it: the wire record plus - * the catalogue's category/config and derived runtime data. The handlers build - * this from the in-memory store + fixtures. - */ export interface DecoratedPolicy { category: PolicyCategory; config: PolicyConfigDef; state: PolicyState; - /** The policy's configured steps (drives the detail "Enforces" flow). */ - steps: PipelineStep[]; - stats: PolicyStats; - activity: PolicyActivityItem[]; + steps: WirePipelineStep[]; + stats: import("@shared/policies/types").PolicyStats; + activity: import("@shared/policies/types").PolicyActivityItem[]; } -/** Catalogue strip totals shown above the cards. */ export interface PoliciesSummary { - /** Policies currently active (enabled). */ active: number; - /** Policies configured but paused. */ paused: number; - /** Categories available to configure. */ categories: number; - /** Documents enforced across all active policies. */ docsEnforced: number; } -/** The `GET /api/v1/policies` response, in the portal's catalogue shape. */ export interface PoliciesResponse { summary: PoliciesSummary; - /** Every catalogue category, each with its definition + (optional) state. */ catalogue: CatalogueEntry[]; } -/** One catalogue row: a category, its definition, and its current state. */ export interface CatalogueEntry { category: PolicyCategory; config: PolicyConfigDef; - /** The configured policy's runtime view, or null when not yet set up. */ policy: DecoratedPolicy | null; } /* ──────────────────────────────────────────────────────────────────────── */ /* Tool → endpoint registry */ -/* Maps a frontend tool id to its Stirling endpoint path. The setup flow's */ -/* pipeline steps carry endpoint paths (the backend's PipelineStep contract), */ -/* so this is the seam that keeps the preset chains plug-and-play. */ /* ──────────────────────────────────────────────────────────────────────── */ export const TOOL_ENDPOINTS: Record = { @@ -266,7 +133,6 @@ export const TOOL_ENDPOINTS: Record = { compress: "/api/v1/misc/compress-pdf", }; -/** A friendly label for an endpoint path (for the detail "Enforces" chips). */ export const ENDPOINT_LABELS: Record = { "/api/v1/security/auto-redact": "Redact PII", "/api/v1/security/sanitize-pdf": "Remove JavaScript", @@ -276,7 +142,6 @@ export const ENDPOINT_LABELS: Record = { "/api/v1/misc/compress-pdf": "Compress", }; -/** "/api/v1/security/auto-redact" → "Auto Redact" — fallback humanisation. */ export function humanizeEndpoint(path: string): string { if (ENDPOINT_LABELS[path]) return ENDPOINT_LABELS[path]; const last = path.split("/").filter(Boolean).pop() ?? path; @@ -287,15 +152,12 @@ export function humanizeEndpoint(path: string): string { } /* ──────────────────────────────────────────────────────────────────────── */ -/* Catalogue definitions — categories, configs, sources, doc types */ -/* Modelled on the editor's policyDefinitions. PII redact regexes are the */ -/* precise patterns the /auto-redact endpoint matches (wordsToRedact). */ +/* Catalogue definitions */ /* ──────────────────────────────────────────────────────────────────────── */ -/** PII regexes seeded into a Security policy's redact step (SSN + cards). */ const DEFAULT_PII_PATTERNS: string[] = [ - "\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b", // SSN - "\\b(?:4\\d{12}(?:\\d{3})?|5[1-5]\\d{14}|3[47]\\d{13}|6(?:011|5\\d{2})\\d{12})\\b", // cards + "\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b", + "\\b(?:4\\d{12}(?:\\d{3})?|5[1-5]\\d{14}|3[47]\\d{13}|6(?:011|5\\d{2})\\d{12})\\b", ]; export const POLICY_CATEGORIES: PolicyCategory[] = [ @@ -371,10 +233,8 @@ export const POLICY_CONFIG: Record = { security: { summary: "Detects and redacts PII, strips active content (JavaScript), and watermarks documents.", - rules: ["Redact PII", "Remove JavaScript"], + rules: ["Redact PII", "Remove JavaScript", "Watermark"], scopeLabel: "All documents", - // Default chain: redact PII (flattened to image so text is truly removed) + - // strip JavaScript. Watermark is offered in the designer but off by default. defaultOperations: [ { operation: TOOL_ENDPOINTS.redact, @@ -395,9 +255,14 @@ export const POLICY_CONFIG: Record = { removeFonts: false, }, }, + { + operation: TOOL_ENDPOINTS.watermark, + // convertPDFToImage bakes the watermark in so it can't be stripped + parameters: { + convertPDFToImage: true, + }, + }, ], - // The tool chain is configured per-tool in the designer (redact / sanitize / - // watermark); no separate policy-level fields. fields: [], }, compliance: { @@ -482,45 +347,6 @@ export const POLICY_CONFIG: Record = { }, }; -export const POLICY_SOURCES: PolicySource[] = [ - { - id: "editor", - label: "Editor", - desc: "Documents you save or export in Stirling", - icon: "file", - }, - { - id: "device", - label: "Entire device", - desc: "All PDFs on this machine, retroactively", - icon: "device", - }, - { - id: "sharepoint", - label: "SharePoint", - desc: "Connected SharePoint libraries", - icon: "globe", - }, - { - id: "dropbox", - label: "Dropbox", - desc: "Connected Dropbox folders", - icon: "cloud", - }, - { - id: "gmail", - label: "Gmail", - desc: "PDF attachments in email", - icon: "mail", - }, - { - id: "gdrive", - label: "Google Drive", - desc: "Connected Drive folders", - icon: "folder", - }, -]; - export const POLICY_DOC_TYPES: string[] = [ "Contracts", "Invoices", @@ -533,80 +359,86 @@ export const POLICY_DOC_TYPES: string[] = [ ]; /* ──────────────────────────────────────────────────────────────────────── */ -/* Seed policies — a few configured policies in the wire shape, so the store */ -/* behaves like a backend that already has policies set up. */ +/* Seed data — real backend wire format */ /* ──────────────────────────────────────────────────────────────────────── */ -/** The shipped default policies the handlers seed the store with. */ -export function seedPolicies(): Policy[] { +export function seedPolicies(): WirePolicy[] { return [ { id: "pol_security_default", name: "Security Policy", owner: "security@acme.com", enabled: true, - trigger: { event: "upload" }, - sources: [{ source: "editor" }], + trigger: null, steps: POLICY_CONFIG.security.defaultOperations, - output: { mode: "new_version", name: "", namePosition: "suffix" }, - categoryId: "security", + output: { + type: "inline", + options: { + runOn: "upload", + mode: "new_version", + name: "", + position: "suffix", + maxRetries: 3, + retryDelayMinutes: 5, + categoryId: "security", + sources: ["src-claims"], + scopeTypes: [], + reviewerEmail: "security@acme.com", + fieldValues: {}, + }, + }, }, ]; } -/** - * Per-policy runtime extras keyed by policy id — the parts the wire record - * doesn't carry (collected field values, scope, derived stats + activity). - * In a real backend these would be derived server-side from the user's files. - */ -export interface PolicyRuntime { - scopeTypes: string[]; - reviewerEmail: string; - fieldValues: Record; - stats: PolicyStats; - activity: PolicyActivityItem[]; - isDefault?: boolean; -} +const NOW = Date.now(); +const M = 60000; +const H = 3600000; +const D = 86400000; -export function seedRuntime(): Record { - return { - pol_security_default: { - scopeTypes: [], - reviewerEmail: "security@acme.com", - fieldValues: {}, - isDefault: true, - stats: { enforced: 4821, dataProcessed: "2.3 GB", activeFor: "34d" }, - activity: [ - { - doc: "Q2-vendor-agreement.pdf", - action: "Redacted 6 PII matches • JavaScript stripped", - time: "12m ago", - status: "enforced", - }, - { - doc: "patient-intake-0481.pdf", - action: "Low-confidence match — routed for review", - time: "1h ago", - status: "flagged", - }, - { - doc: "invoice-7782.pdf", - action: "Enforcing…", - time: "just now", - status: "processing", - }, - ], +/** Seed `PolicyRunView` records that drive the activity feed + stats. */ +export function seedPolicyRuns(): PolicyRunView[] { + return [ + { + runId: "run_001", + policyId: "pol_security_default", + status: "COMPLETED", + currentStep: 2, + stepCount: 2, + error: null, + outputs: [{ fileId: "f1", fileName: "Q2-vendor-agreement.pdf" }], + createdAt: NOW - 12 * M, }, - }; -} - -/** Empty stats/activity for a freshly-configured policy with no runs yet. */ -export function emptyRuntime(reviewerEmail = "you@acme.com"): PolicyRuntime { - return { - scopeTypes: [], - reviewerEmail, - fieldValues: {}, - stats: { enforced: 0, dataProcessed: "0 B", activeFor: "—" }, - activity: [], - }; + { + runId: "run_002", + policyId: "pol_security_default", + status: "FAILED", + currentStep: 1, + stepCount: 2, + error: "Low-confidence match — routed for review", + outputs: [{ fileId: "f2", fileName: "patient-intake-0481.pdf" }], + createdAt: NOW - 1 * H, + }, + { + runId: "run_003", + policyId: "pol_security_default", + status: "RUNNING", + currentStep: 1, + stepCount: 2, + error: null, + outputs: [{ fileId: "f3", fileName: "invoice-7782.pdf" }], + createdAt: NOW - 2 * M, + }, + // Older completed runs for stats + ...Array.from({ length: 4818 }, (_, i) => ({ + runId: `run_old_${i}`, + policyId: "pol_security_default", + status: "COMPLETED" as const, + currentStep: 2, + stepCount: 2, + error: null, + outputs: [] as { fileId: string; fileName: string }[], + createdAt: NOW - (34 * D + i * 10 * M), + })), + ]; } diff --git a/frontend/portal/src/views/Policies.css b/frontend/portal/src/views/Policies.css index d40df402bd..5744b2ff42 100644 --- a/frontend/portal/src/views/Policies.css +++ b/frontend/portal/src/views/Policies.css @@ -31,21 +31,16 @@ /* Category grid */ .portal-policies__grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.875rem; -} - -@media (max-width: 56rem) { - .portal-policies__grid { - grid-template-columns: 1fr; - } -} - -/* Category card */ -.portal-policies__card { display: flex; flex-direction: column; + gap: 0.375rem; +} + +/* Category card — horizontal table row */ +.portal-policies__card { + display: flex; + flex-direction: row; + align-items: center; gap: 0.75rem; } @@ -53,10 +48,47 @@ opacity: 0.7; } -.portal-policies__card-head { +.portal-policies__card-identity { display: flex; - align-items: flex-start; - gap: 0.625rem; + flex-direction: column; + gap: 0.125rem; + flex: 1; + min-width: 0; +} + +.portal-policies__card-enforces { + font-size: 0.75rem; + color: var(--color-text-4); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.portal-policies__card-meta { + display: flex; + align-items: center; + gap: 2rem; + flex-shrink: 0; +} + +.portal-policies__card-statpair { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.0625rem; +} + +.portal-policies__card-statval { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); + line-height: 1.2; +} + +.portal-policies__card-statlbl { + font-size: 0.6875rem; + color: var(--color-text-4); + line-height: 1.2; } .portal-policies__cat-icon { @@ -66,41 +98,8 @@ width: 2rem; height: 2rem; flex-shrink: 0; - border-radius: var(--radius-md); - font-size: 1rem; -} - -.portal-policies__cat-icon--neutral { - background: var(--color-bg-subtle); - color: var(--color-text-3); -} -.portal-policies__cat-icon--blue { - background: var(--color-blue-light); - color: var(--color-blue); -} -.portal-policies__cat-icon--purple { - background: var(--color-purple-light); - color: var(--color-purple); -} -.portal-policies__cat-icon--green { - background: var(--color-green-light); - color: var(--color-green-dark); -} -.portal-policies__cat-icon--amber { - background: var(--color-amber-light); - color: var(--color-amber-dark); -} -.portal-policies__cat-icon--red { - background: var(--color-red-light); - color: var(--color-red); -} - -.portal-policies__card-titles { - display: flex; - flex-direction: column; - gap: 0.125rem; - min-width: 0; - flex: 1; + font-size: 1.125rem; + color: var(--color-text-2); } .portal-policies__card-title { @@ -110,50 +109,6 @@ color: var(--color-text-1); } -.portal-policies__card-blurb { - font-size: 0.75rem; - color: var(--color-text-4); - line-height: 1.4; -} - -.portal-policies__card-summary { - margin: 0; - font-size: 0.8125rem; - line-height: 1.5; - color: var(--color-text-2); -} - -.portal-policies__card-foot { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - margin-top: auto; - flex-wrap: wrap; -} - -.portal-policies__card-rules { - display: flex; - flex-wrap: wrap; - gap: 0.375rem; -} - -.portal-policies__card-cta { - font-size: 0.75rem; - font-weight: 600; - color: var(--color-blue); -} - -/* Configured-card stat footer */ -.portal-policies__card-stats { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0.5rem; - margin-top: auto; - padding-top: 0.75rem; - border-top: 1px solid var(--color-border-light); -} - /* Wizard + detail shared chrome */ .portal-policies__wizard-title { display: inline-flex; @@ -196,6 +151,13 @@ font-weight: 600; } +.portal-policies__wizard-subheading { + margin: 0.875rem 0 0; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-3); +} + .portal-policies__fields { display: flex; flex-direction: column; @@ -225,17 +187,6 @@ color: var(--color-text-1); } -.portal-policies__tool-endpoint { - flex: 1; - min-width: 0; - font-family: var(--font-mono, monospace); - font-size: 0.6875rem; - color: var(--color-text-4); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - /* Sources picker */ .portal-policies__sources { display: grid; @@ -251,12 +202,12 @@ .portal-policies__source { display: flex; - align-items: flex-start; + align-items: center; gap: 0.5rem; padding: 0.625rem; text-align: left; background: var(--color-surface); - border: 1px solid var(--color-border); + border: 1.5px solid var(--color-border); border-radius: var(--radius-md); cursor: pointer; transition: @@ -264,6 +215,22 @@ background var(--motion-fast); } +.portal-policies__source::after { + content: ""; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: 1rem; + height: 1rem; + border-radius: var(--radius-sm); + border: 1.5px solid var(--color-border-strong); + transition: + border-color var(--motion-fast), + background var(--motion-fast); +} + .portal-policies__source:hover { background: var(--color-bg-hover); } @@ -273,12 +240,26 @@ background: var(--color-blue-light); } +.portal-policies__source--on::after { + content: "✓"; + font-size: 0.6875rem; + font-weight: 700; + color: #fff; + border-color: var(--color-blue); + background: var(--color-blue); +} + .portal-policies__source-icon { font-size: 1rem; line-height: 1.2; + flex-shrink: 0; color: var(--color-text-3); } +.portal-policies__source--on .portal-policies__source-icon { + color: var(--color-blue); +} + .portal-policies__source-text { display: flex; flex-direction: column; @@ -338,8 +319,13 @@ .portal-policies__detail-status { display: flex; align-items: center; - gap: 0.625rem; - margin-bottom: 0.25rem; + gap: 0.5rem; + margin-bottom: 0.625rem; +} + +.portal-policies__detail-sep { + color: var(--color-text-5); + font-size: 0.75rem; } .portal-policies__detail-meta { @@ -347,28 +333,31 @@ color: var(--color-text-4); } -.portal-policies__enforce-flow { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.375rem; -} - -.portal-policies__enforce-item { - display: inline-flex; - align-items: center; - gap: 0.375rem; -} - .portal-policies__enforce-arrow { color: var(--color-text-5); font-size: 0.75rem; } -.portal-policies__enforce-note { - margin: 0.75rem 0 0; - font-size: 0.75rem; +.portal-policies__detail-inline { + display: flex; + align-items: baseline; + gap: 0.625rem; + margin-bottom: 0.5rem; +} + +.portal-policies__detail-inline-label { + font-size: 0.6875rem; + font-weight: 600; color: var(--color-text-4); + text-transform: uppercase; + letter-spacing: 0.04em; + flex-shrink: 0; + width: 4.5rem; +} + +.portal-policies__detail-inline-value { + font-size: 0.8125rem; + color: var(--color-text-2); line-height: 1.5; } @@ -384,22 +373,32 @@ border-top: 1px solid var(--color-border-light); } -.portal-policies__activity-dot { - width: 0.5rem; - height: 0.5rem; +.portal-policies__activity-icon { flex-shrink: 0; - margin-top: 0.375rem; - border-radius: 50%; + margin-top: 0.125rem; + display: flex; + align-items: flex-start; + padding-top: 0.125rem; } -.portal-policies__activity-dot--success { - background: var(--color-green); +.portal-policies__activity-icon--success { + color: var(--color-green); } -.portal-policies__activity-dot--warning { - background: var(--color-amber); +.portal-policies__activity-icon--warning { + color: var(--color-amber); } -.portal-policies__activity-dot--info { - background: var(--color-blue); +.portal-policies__activity-icon--info { + color: var(--color-blue); +} + +@keyframes portal-policies-spin { + to { + transform: rotate(360deg); + } +} + +.portal-policies__activity-spin { + animation: portal-policies-spin 1s linear infinite; } .portal-policies__activity-text { @@ -427,6 +426,29 @@ white-space: nowrap; } +.portal-policies__activity-retry { + flex-shrink: 0; + font-size: 0.75rem; +} + +/* Error expand/collapse for long activity messages */ +.portal-policies__activity-error { + display: block; +} + +.portal-policies__activity-error-text--clamped { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.portal-policies__activity-error-toggle { + display: block; + margin-top: 0.125rem; + font-size: 0.6875rem; +} + /* Detail stats footer */ .portal-policies__detail-stats { display: grid; diff --git a/frontend/portal/src/views/Policies.tsx b/frontend/portal/src/views/Policies.tsx index a71190e919..e9eb33b95d 100644 --- a/frontend/portal/src/views/Policies.tsx +++ b/frontend/portal/src/views/Policies.tsx @@ -1,15 +1,18 @@ import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Skeleton } from "@shared/components"; +import { Banner, Button, Skeleton } from "@shared/components"; +import { errorMessage } from "@portal/api/http"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; import { + buildWireFromSetup, + buildWireFromState, deletePolicy, fetchPolicies, - runPolicy, savePolicy, + POLICY_CATEGORIES, + POLICY_CONFIG, type CatalogueEntry, type PoliciesResponse, - type Policy, type PolicySetupResult, } from "@portal/api/policies"; import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary"; @@ -18,51 +21,36 @@ import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard"; import "@portal/views/Policies.css"; -/** - * Translate the setup flow's collected result into the backend `Policy` wire - * record (Policy.java): the tool chain becomes the ordered pipeline `steps`, - * the run event becomes the `trigger`, and the output settings become `output`. - * Reuses the existing record's id on edit so the POST updates in place. - */ -function toWirePolicy( - entry: CatalogueEntry, - result: PolicySetupResult, -): Policy { - return { - id: entry.policy?.state.backendId ?? "", - name: `${entry.category.label} Policy`, - enabled: entry.policy ? entry.policy.state.status !== "paused" : true, - trigger: { event: result.runOn }, - sources: result.sources.map((source) => ({ source })), - steps: result.steps, - output: { - mode: result.outputMode, - name: result.outputName, - namePosition: result.outputNamePosition, - }, - categoryId: entry.category.id, - }; -} - export function Policies() { const { t } = useTranslation(); - // The catalogue is refetched after every mutation by bumping this counter, - // so the cards/detail reflect the in-memory store the handlers maintain. const [version, setVersion] = useState(0); const state = useAsync(() => fetchPolicies(), [version]); - const { data, loading } = state; + const { data, loading, error: fetchError } = state; const { isLoading } = useSectionFlags(state); - // The category whose detail panel is open (configured), and the one whose - // setup wizard is open. Both reference a catalogue entry. const [detail, setDetail] = useState(null); const [wizard, setWizard] = useState(null); const [busy, setBusy] = useState(false); + const [pageError, setPageError] = useState(null); const catalogue = data?.catalogue ?? []; const refetch = useCallback(() => setVersion((v) => v + 1), []); - // Open the detail panel for configured categories, the wizard otherwise. + const displayCatalogue: CatalogueEntry[] = + catalogue.length > 0 + ? catalogue + : POLICY_CATEGORIES.map((cat) => ({ + category: cat, + config: POLICY_CONFIG[cat.id] ?? { + summary: "", + rules: [], + scopeLabel: "", + fields: [], + defaultOperations: [], + }, + policy: null, + })); + function openEntry(entry: CatalogueEntry) { if (entry.policy) setDetail(entry); else setWizard(entry); @@ -72,50 +60,39 @@ export function Policies() { entry: CatalogueEntry, result: PolicySetupResult, ) { - await savePolicy(toWirePolicy(entry, result)); - setWizard(null); - setDetail(null); - refetch(); + setPageError(null); + try { + await savePolicy(buildWireFromSetup(entry, result)); + setWizard(null); + setDetail(null); + refetch(); + } catch (e) { + setPageError(errorMessage(e)); + } } async function runLifecycle(action: () => Promise) { if (busy) return; + setPageError(null); setBusy(true); try { await action(); setDetail(null); refetch(); + } catch (e) { + setPageError(errorMessage(e)); } finally { setBusy(false); } } - function handleRun() { - const id = detail?.policy?.state.backendId; - if (id) void runLifecycle(() => runPolicy(id)); - } - function handleTogglePause() { const entry = detail; const policy = entry?.policy; if (!entry || !policy?.state.backendId) return; - // Pause/resume is a re-save with the enabled flag flipped (the backend has - // no dedicated endpoint — every mutation routes through POST /policies). + const enabled = policy.state.status === "paused"; void runLifecycle(() => - savePolicy({ - id: policy.state.backendId!, - name: `${entry.category.label} Policy`, - enabled: policy.state.status === "paused", - trigger: { event: policy.state.runOn ?? "upload" }, - sources: policy.state.sources.map((source) => ({ source })), - steps: policy.steps, - output: { - mode: policy.state.outputMode ?? "new_version", - name: policy.state.outputName ?? "", - namePosition: "suffix", - }, - categoryId: entry.category.id, - }), + savePolicy(buildWireFromState(entry, policy, enabled)), ); } @@ -124,7 +101,6 @@ export function Policies() { if (id) void runLifecycle(() => deletePolicy(id)); } - // Reopen the wizard for the policy currently shown in the detail panel. function handleEdit() { if (detail) { setWizard(detail); @@ -139,19 +115,34 @@ export function Policies() {

{t("policies.subtitle")}

+ {pageError && } + {isLoading && (
{Array.from({ length: 5 }).map((_, i) => ( - + ))}
)} - {!isLoading && catalogue.length > 0 && ( + {!isLoading && fetchError && ( + + {t("policies.offline.retry")} + + } + /> + )} + + {!isLoading && !fetchError && (
- {catalogue.map((entry) => ( + {displayCatalogue.map((entry) => ( setDetail(null)} onEdit={handleEdit} - onRun={handleRun} onTogglePause={handleTogglePause} onDelete={handleDelete} /> diff --git a/frontend/portal/vitest.config.ts b/frontend/portal/vitest.config.ts index a34f840b89..172c551f37 100644 --- a/frontend/portal/vitest.config.ts +++ b/frontend/portal/vitest.config.ts @@ -23,12 +23,19 @@ export default defineConfig({ "@shared": sharedDir, }, }, + server: { + fs: { + // Allow Vite to serve files from the shared/ sibling directory when + // running tests with --root portal (which would otherwise block them). + allow: [".."], + }, + }, test: { globals: true, environment: "jsdom", setupFiles: ["./src/setupTests.ts"], css: false, - include: ["src/**/*.test.{ts,tsx}"], + include: ["src/**/*.test.{ts,tsx}", "../shared/**/*.test.ts"], testTimeout: 10000, hookTimeout: 10000, }, diff --git a/frontend/shared/policies/codec.test.ts b/frontend/shared/policies/codec.test.ts new file mode 100644 index 0000000000..e4b2647ec9 --- /dev/null +++ b/frontend/shared/policies/codec.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from "vitest"; +import { toWirePolicy, fromWirePolicy } from "@shared/policies/codec"; +import type { PolicyDecodedState } from "@shared/policies/types"; + +const FULL_STATE: PolicyDecodedState = { + id: "pol_123", + name: "Security Policy", + enabled: true, + categoryId: "security", + sources: ["editor", "gdrive"], + scopeTypes: ["Contracts", "Invoices"], + reviewerEmail: "admin@example.com", + fieldValues: { auditTrail: true, frameworks: ["HIPAA"] }, + runOn: "upload", + outputMode: "new_version", + outputName: "redacted", + outputNamePosition: "prefix", + maxRetries: 3, + retryDelayMinutes: 5, + steps: [ + { + operation: "/api/v1/security/auto-redact", + parameters: { mode: "automatic" }, + }, + ], +}; + +describe("toWirePolicy", () => { + it("sets trigger to null", () => { + expect(toWirePolicy(FULL_STATE).trigger).toBeNull(); + }); + + it("sets output.type to inline", () => { + expect(toWirePolicy(FULL_STATE).output.type).toBe("inline"); + }); + + it("packs metadata into output.options", () => { + const wire = toWirePolicy(FULL_STATE); + const opts = wire.output.options; + expect(opts.categoryId).toBe("security"); + expect(opts.sources).toEqual(["editor", "gdrive"]); + expect(opts.runOn).toBe("upload"); + expect(opts.mode).toBe("new_version"); + expect(opts.position).toBe("prefix"); + }); + + it("preserves steps at the top level", () => { + const wire = toWirePolicy(FULL_STATE); + expect(wire.steps).toEqual(FULL_STATE.steps); + }); +}); + +describe("fromWirePolicy → round-trip", () => { + it("recovers all fields after encode→decode", () => { + const wire = toWirePolicy(FULL_STATE); + const decoded = fromWirePolicy(wire); + expect(decoded.id).toBe(FULL_STATE.id); + expect(decoded.categoryId).toBe(FULL_STATE.categoryId); + expect(decoded.sources).toEqual(FULL_STATE.sources); + expect(decoded.scopeTypes).toEqual(FULL_STATE.scopeTypes); + expect(decoded.reviewerEmail).toBe(FULL_STATE.reviewerEmail); + expect(decoded.fieldValues).toEqual(FULL_STATE.fieldValues); + expect(decoded.runOn).toBe(FULL_STATE.runOn); + expect(decoded.outputMode).toBe(FULL_STATE.outputMode); + expect(decoded.outputName).toBe(FULL_STATE.outputName); + expect(decoded.outputNamePosition).toBe(FULL_STATE.outputNamePosition); + expect(decoded.maxRetries).toBe(FULL_STATE.maxRetries); + expect(decoded.retryDelayMinutes).toBe(FULL_STATE.retryDelayMinutes); + expect(decoded.steps).toEqual(FULL_STATE.steps); + }); + + it("defaults runOn to upload when missing", () => { + const wire = toWirePolicy(FULL_STATE); + delete (wire.output.options as Record).runOn; + expect(fromWirePolicy(wire).runOn).toBe("upload"); + }); + + it("defaults outputMode to new_version when missing", () => { + const wire = toWirePolicy(FULL_STATE); + delete (wire.output.options as Record).mode; + expect(fromWirePolicy(wire).outputMode).toBe("new_version"); + }); + + it("preserves export runOn", () => { + const wire = toWirePolicy({ ...FULL_STATE, runOn: "export" }); + expect(fromWirePolicy(wire).runOn).toBe("export"); + }); + + it("preserves new_file outputMode", () => { + const wire = toWirePolicy({ ...FULL_STATE, outputMode: "new_file" }); + expect(fromWirePolicy(wire).outputMode).toBe("new_file"); + }); + + it("preserves all three outputNamePosition values", () => { + for (const pos of ["prefix", "suffix", "auto-number"] as const) { + const wire = toWirePolicy({ ...FULL_STATE, outputNamePosition: pos }); + expect(fromWirePolicy(wire).outputNamePosition).toBe(pos); + } + }); + + it("handles empty options gracefully", () => { + const decoded = fromWirePolicy({ + id: "x", + name: "X", + enabled: false, + trigger: null, + steps: [], + output: { type: "inline", options: {} }, + }); + expect(decoded.categoryId).toBe(""); + expect(decoded.sources).toEqual([]); + expect(decoded.runOn).toBe("upload"); + expect(decoded.outputMode).toBe("new_version"); + }); + + it("defaults fieldValues to empty object when missing", () => { + const wire = toWirePolicy(FULL_STATE); + delete (wire.output.options as Record).fieldValues; + expect(fromWirePolicy(wire).fieldValues).toEqual({}); + }); +}); diff --git a/frontend/shared/policies/codec.ts b/frontend/shared/policies/codec.ts new file mode 100644 index 0000000000..3a79af7b5c --- /dev/null +++ b/frontend/shared/policies/codec.ts @@ -0,0 +1,77 @@ +/** + * Bidirectional codec between the portal's frontend `PolicyDecodedState` and + * the backend `WirePolicy`. All policy-level metadata rides in + * `output.options`; `trigger` is always null (the editor fires runs on + * upload/export via `/run`). Mirrors the editor's `buildBackendPolicy` / + * `fromBackendPolicy` from `policyPipeline.ts`, minus the editor-only + * `automation` blob and toolRegistry coupling. + */ + +import type { + PolicyDecodedState, + WireOutputOptions, + WirePolicy, +} from "@shared/policies/types"; + +const DEFAULTS = { + maxRetries: 3, + retryDelayMinutes: 5, +} as const; + +export function toWirePolicy(state: PolicyDecodedState): WirePolicy { + const options: WireOutputOptions = { + runOn: state.runOn, + mode: state.outputMode, + name: state.outputName, + position: state.outputNamePosition, + maxRetries: state.maxRetries, + retryDelayMinutes: state.retryDelayMinutes, + categoryId: state.categoryId, + sources: state.sources, + scopeTypes: state.scopeTypes, + reviewerEmail: state.reviewerEmail, + fieldValues: state.fieldValues, + }; + return { + id: state.id, + name: state.name, + owner: "", + enabled: state.enabled, + trigger: null, + steps: state.steps, + output: { type: "inline", options }, + }; +} + +export function fromWirePolicy(policy: WirePolicy): PolicyDecodedState { + const raw = policy.output?.options ?? {}; + const str = (v: unknown, fallback = "") => + typeof v === "string" ? v : fallback; + const num = (v: unknown, fallback: number) => + typeof v === "number" ? v : fallback; + const position = + raw.position === "suffix" + ? "suffix" + : raw.position === "auto-number" + ? "auto-number" + : "prefix"; + return { + id: policy.id, + name: policy.name, + enabled: policy.enabled, + categoryId: str(raw.categoryId), + sources: Array.isArray(raw.sources) ? (raw.sources as string[]) : [], + scopeTypes: Array.isArray(raw.scopeTypes) + ? (raw.scopeTypes as string[]) + : [], + reviewerEmail: str(raw.reviewerEmail), + fieldValues: raw.fieldValues ?? {}, + runOn: raw.runOn === "export" ? "export" : "upload", + outputMode: raw.mode === "new_file" ? "new_file" : "new_version", + outputName: str(raw.name), + outputNamePosition: position, + maxRetries: num(raw.maxRetries, DEFAULTS.maxRetries), + retryDelayMinutes: num(raw.retryDelayMinutes, DEFAULTS.retryDelayMinutes), + steps: Array.isArray(policy.steps) ? policy.steps : [], + }; +} diff --git a/frontend/shared/policies/runs.test.ts b/frontend/shared/policies/runs.test.ts new file mode 100644 index 0000000000..ed57b5fe8c --- /dev/null +++ b/frontend/shared/policies/runs.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from "vitest"; +import { runsToStats, runsToActivity } from "@shared/policies/runs"; +import type { PolicyRunView } from "@shared/policies/types"; + +const MIN = 60000; +const HOUR = 3600000; +const DAY = 86400000; +// Use the actual current time so relative-time formatting in runs.ts is correct. +const NOW = Date.now(); + +const completed = ( + id: string, + ts: number, + file = "doc.pdf", +): PolicyRunView => ({ + runId: id, + policyId: "pol_1", + status: "COMPLETED", + currentStep: 2, + stepCount: 2, + error: null, + outputs: [{ fileId: "f", fileName: file }], + createdAt: ts, +}); + +const failed = ( + id: string, + ts: number, + err = "Redaction failed", +): PolicyRunView => ({ + runId: id, + policyId: "pol_1", + status: "FAILED", + currentStep: 1, + stepCount: 2, + error: err, + outputs: [], + createdAt: ts, +}); + +const running = (id: string, ts: number, step = 1): PolicyRunView => ({ + runId: id, + policyId: "pol_1", + status: "RUNNING", + currentStep: step, + stepCount: 2, + error: null, + outputs: [], + createdAt: ts, +}); + +describe("runsToStats", () => { + it("counts only COMPLETED runs as enforced", () => { + const runs: PolicyRunView[] = [ + completed("a", NOW - 10 * MIN), + completed("b", NOW - 20 * MIN), + failed("c", NOW - 30 * MIN), + running("d", NOW - 5 * MIN), + ]; + expect(runsToStats(runs).enforced).toBe(2); + }); + + it("returns — for dataProcessed (not available from wire)", () => { + expect(runsToStats([completed("a", NOW - MIN)]).dataProcessed).toBe("—"); + }); + + it("returns — for activeFor when there are no runs", () => { + expect(runsToStats([]).activeFor).toBe("—"); + }); + + it("computes activeFor from the oldest run", () => { + const runs = [completed("a", NOW - 2 * DAY), completed("b", NOW - 5 * DAY)]; + expect(runsToStats(runs).activeFor).toBe("5d"); + }); +}); + +describe("runsToActivity", () => { + it("maps COMPLETED to enforced status", () => { + const [row] = runsToActivity([ + completed("a", NOW - 10 * MIN, "invoice.pdf"), + ]); + expect(row.status).toBe("enforced"); + expect(row.doc).toBe("invoice.pdf"); + }); + + it("maps FAILED to flagged status with the error message", () => { + const [row] = runsToActivity([failed("b", NOW - HOUR, "Low confidence")]); + expect(row.status).toBe("flagged"); + expect(row.action).toBe("Low confidence"); + }); + + it("maps RUNNING to processing status", () => { + const [row] = runsToActivity([running("c", NOW - MIN)]); + expect(row.status).toBe("processing"); + }); + + it("maps CANCELLED to flagged status", () => { + const run: PolicyRunView = { + ...failed("x", NOW - MIN, "Cancelled by user"), + status: "CANCELLED", + }; + expect(runsToActivity([run])[0].status).toBe("flagged"); + }); + + it("maps PENDING to processing status", () => { + const run: PolicyRunView = { + ...running("x", NOW - MIN), + status: "PENDING", + }; + expect(runsToActivity([run])[0].status).toBe("processing"); + }); + + it("maps WAITING_FOR_INPUT to processing status", () => { + const run: PolicyRunView = { + ...running("x", NOW - MIN), + status: "WAITING_FOR_INPUT", + }; + expect(runsToActivity([run])[0].status).toBe("processing"); + }); + + it("falls back to 'Enforcement failed' when FAILED run has no error message", () => { + const run: PolicyRunView = { ...failed("x", NOW - MIN), error: null }; + expect(runsToActivity([run])[0].action).toBe("Enforcement failed"); + }); + + it("shows step progress when currentStep and stepCount are set", () => { + const [row] = runsToActivity([running("c", NOW - MIN, 1)]); + expect(row.action).toContain("step 1/2"); + }); + + it("falls back to Policy run when outputs is empty", () => { + const noOutput: PolicyRunView = { ...running("x", NOW - MIN), outputs: [] }; + expect(runsToActivity([noOutput])[0].doc).toBe("Policy run"); + }); + + it("formats recent timestamps as Nm ago", () => { + const [row] = runsToActivity([completed("a", NOW - 5 * MIN)]); + expect(row.time).toBe("5m ago"); + }); + + it("formats hour-range timestamps as Nh ago", () => { + const [row] = runsToActivity([completed("a", NOW - 2 * HOUR)]); + expect(row.time).toBe("2h ago"); + }); + + it("formats day-range timestamps as Nd ago", () => { + const [row] = runsToActivity([completed("a", NOW - 3 * DAY)]); + expect(row.time).toBe("3d ago"); + }); +}); diff --git a/frontend/shared/policies/runs.ts b/frontend/shared/policies/runs.ts new file mode 100644 index 0000000000..209dccd13d --- /dev/null +++ b/frontend/shared/policies/runs.ts @@ -0,0 +1,65 @@ +/** + * Derives display data from raw backend `PolicyRunView` records. The backend + * `GET /api/v1/policies/runs` endpoint returns these; both the portal and + * (eventually) the editor read this same derivation rather than duplicating it. + */ + +import type { + PolicyActivityItem, + PolicyRunView, + PolicyStats, +} from "@shared/policies/types"; + +function relativeTime(epochMs: number): string { + if (!epochMs) return "Just now"; + const mins = Math.floor((Date.now() - epochMs) / 60000); + if (mins < 1) return "Just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +function durationSince(epochMs: number): string { + if (!epochMs) return "—"; + const ms = Date.now() - epochMs; + const days = Math.floor(ms / 86400000); + if (days >= 1) return `${days}d`; + const hrs = Math.floor(ms / 3600000); + return hrs >= 1 ? `${hrs}h` : "Today"; +} + +function activityStatus(run: PolicyRunView): PolicyActivityItem["status"] { + if (run.status === "COMPLETED") return "enforced"; + if (run.status === "FAILED" || run.status === "CANCELLED") return "flagged"; + return "processing"; +} + +function activityAction(run: PolicyRunView): string { + const s = activityStatus(run); + if (s === "enforced") return "Enforced"; + if (s === "flagged") return run.error ?? "Enforcement failed"; + const { currentStep, stepCount } = run; + return currentStep && stepCount + ? `Enforcing… · step ${currentStep}/${stepCount}` + : "Enforcing…"; +} + +export function runsToActivity(runs: PolicyRunView[]): PolicyActivityItem[] { + return runs.map((run) => ({ + doc: run.outputs[0]?.fileName ?? "Policy run", + action: activityAction(run), + time: relativeTime(run.createdAt), + status: activityStatus(run), + })); +} + +export function runsToStats(runs: PolicyRunView[]): PolicyStats { + const completed = runs.filter((r) => r.status === "COMPLETED"); + const oldest = runs.reduce((min, r) => Math.min(min, r.createdAt), Infinity); + return { + enforced: completed.length, + dataProcessed: "—", + activeFor: isFinite(oldest) ? durationSince(oldest) : "—", + }; +} diff --git a/frontend/shared/policies/types.ts b/frontend/shared/policies/types.ts new file mode 100644 index 0000000000..ef01397249 --- /dev/null +++ b/frontend/shared/policies/types.ts @@ -0,0 +1,112 @@ +/** + * Wire types for the Stirling policy API (`/api/v1/policies`), the decoded + * frontend shape, and the display types used to render stats and activity. + * + * The backend stores all portal-level metadata (categoryId, sources, scope, + * reviewer, fieldValues, runOn, output settings) inside `output.options` — the + * same "options bag" the editor uses. `trigger` is always null for + * portal/editor-authored policies; the editor fires runs on upload/export via + * `/run`, so there is no server-side trigger. + */ + +// ── Wire types (match Policy.java / PipelineStep.java / PolicyRunView.java) ── + +export interface WirePipelineStep { + operation: string; + parameters: Record; + fileParameters?: Record; +} + +export interface WireOutputOptions { + runOn: "upload" | "export"; + mode: "new_file" | "new_version"; + name: string; + position: "prefix" | "suffix" | "auto-number"; + maxRetries?: number; + retryDelayMinutes?: number; + categoryId: string; + sources: string[]; + scopeTypes: string[]; + reviewerEmail: string; + fieldValues: Record; +} + +export interface WireOutputSpec { + type: "inline"; + options: Partial; +} + +export interface WirePolicy { + id: string; + name: string; + owner?: string; + enabled: boolean; + trigger: null; + steps: WirePipelineStep[]; + output: WireOutputSpec; + teamId?: string; +} + +// ── Run view (mirrors PolicyRunView.java) ───────────────────────────────────── + +export type PolicyRunStatus = + | "PENDING" + | "RUNNING" + | "WAITING_FOR_INPUT" + | "COMPLETED" + | "FAILED" + | "CANCELLED"; + +export interface PolicyRunView { + runId: string; + policyId: string | null; + status: PolicyRunStatus; + currentStep: number; + stepCount: number; + error: string | null; + errorCode?: string | null; + errorSubscribed?: boolean | null; + outputs: { fileId: string; fileName: string }[]; + /** Creation timestamp in epoch milliseconds. */ + createdAt: number; +} + +// ── Frontend decoded shape ──────────────────────────────────────────────────── + +/** Policy settings unpacked from the wire record's `output.options` bag. */ +export interface PolicyDecodedState { + id: string; + name: string; + enabled: boolean; + categoryId: string; + sources: string[]; + scopeTypes: string[]; + reviewerEmail: string; + fieldValues: Record; + runOn: "upload" | "export"; + outputMode: "new_file" | "new_version"; + outputName: string; + outputNamePosition: "prefix" | "suffix" | "auto-number"; + maxRetries: number; + retryDelayMinutes: number; + steps: WirePipelineStep[]; +} + +// ── Display types (returned by runs.ts derivations) ─────────────────────────── + +export interface PolicyStats { + /** Completed enforcement runs. */ + enforced: number; + /** Human-formatted total data processed (e.g. "2.3 GB"), or "—" when unknown. */ + dataProcessed: string; + /** Human-formatted duration since first run (e.g. "34d"), or "—". */ + activeFor: string; +} + +export interface PolicyActivityItem { + doc: string; + action: string; + /** Relative timestamp, e.g. "2h ago". */ + time: string; + status: "enforced" | "flagged" | "processing"; +} diff --git a/scripts/find-free-port.ps1 b/scripts/find-free-port.ps1 index 241117a148..7c102d29fd 100644 --- a/scripts/find-free-port.ps1 +++ b/scripts/find-free-port.ps1 @@ -1,24 +1,24 @@ # Prints one free TCP port per preferred port given as an argument. # # For each element of -Preferred, emits that port if it's free; otherwise -# emits a random free port in 20000-49999. Probes by attempting to bind a -# TcpListener on loopback. Tracks picks within this run so outputs are -# guaranteed distinct from each other. +# emits a random free port in 20000-49999. Uses Get-NetTCPConnection to read +# the OS socket table directly — more reliable than TcpListener binding on +# Windows where SO_REUSEADDR can cause false "free" results. Tracks picks +# within this run so outputs are guaranteed distinct from each other. param([Parameter(ValueFromRemainingArguments = $true)][int[]]$Preferred) $script:picked = @() +# Build a set of ports currently in LISTEN or ESTABLISHED state once upfront. +$usedPorts = [System.Collections.Generic.HashSet[int]]::new() +Get-NetTCPConnection -ErrorAction SilentlyContinue | + Where-Object { $_.State -in 'Listen', 'Established' } | + ForEach-Object { $null = $usedPorts.Add($_.LocalPort) } + function Test-PortFree { param([int]$Port) if ($script:picked -contains $Port) { return $false } - try { - $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port) - $listener.Start() - $listener.Stop() - return $true - } catch { - return $false - } + return -not $usedPorts.Contains($Port) } function Get-RandomFreePort { From 6c85200eb90e29c7bc00db99808695e9208dc75e Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:49:02 +0100 Subject: [PATCH 5/5] Add portal access control and S3/MCP/API integration configs (#6795) --- .../access/config/AccessConfig.java | 20 ++ .../controller/ResourceGrantController.java | 101 ++++++++ .../access/model/AccessPermission.java | 7 + .../access/model/DefaultAccessPolicy.java | 14 ++ .../access/model/OwnedResource.java | 57 +++++ .../proprietary/access/model/OwnerScope.java | 8 + .../access/model/PrincipalType.java | 7 + .../access/model/ResourceGrant.java | 86 +++++++ .../access/model/ResourceType.java | 9 + .../repository/ResourceGrantRepository.java | 28 +++ .../security/ResourceAccessSecurity.java | 44 ++++ .../access/service/DefaultTeamLeadLookup.java | 17 ++ .../access/service/OwnershipService.java | 116 +++++++++ .../access/service/ResourceAccessService.java | 188 +++++++++++++++ .../access/service/SecretMasker.java | 151 ++++++++++++ .../access/service/TeamLeadLookup.java | 13 + .../IntegrationConfigController.java | 81 +++++++ .../crypto/CredentialEncryption.java | 135 +++++++++++ .../crypto/EncryptedStringConverter.java | 19 ++ .../dto/IntegrationConfigRequest.java | 18 ++ .../dto/IntegrationConfigResponse.java | 24 ++ .../integration/model/IntegrationConfig.java | 67 ++++++ .../integration/model/IntegrationType.java | 8 + .../IntegrationConfigRepository.java | 21 ++ .../service/IntegrationConfigService.java | 226 ++++++++++++++++++ .../configuration/DatabaseConfig.java | 8 +- .../controller/api/AuthController.java | 3 + .../access/service/OwnershipServiceTest.java | 174 ++++++++++++++ .../service/ResourceAccessServiceTest.java | 217 +++++++++++++++++ .../access/service/SecretMaskerTest.java | 80 +++++++ .../crypto/CredentialEncryptionTest.java | 64 +++++ .../service/IntegrationConfigServiceTest.java | 182 ++++++++++++++ .../api/AuthControllerLoginTest.java | 5 +- .../api/AuthControllerMoreTest.java | 5 +- .../migration/saas/V25__resource_grants.sql | 21 ++ .../saas/V26__integration_configs.sql | 25 ++ .../src/proprietary/routes/Login.test.tsx | 3 + frontend/portal/src/components/AuthGate.tsx | 20 +- frontend/shared/auth/context.ts | 1 + .../auth/guards/RequirePortalAccess.tsx | 35 +++ frontend/shared/auth/index.ts | 4 + frontend/shared/auth/spring/UseSession.tsx | 1 + frontend/shared/auth/supabase/UseSession.tsx | 1 + frontend/shared/auth/types.ts | 4 + 44 files changed, 2299 insertions(+), 19 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/model/AccessPermission.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/model/DefaultAccessPolicy.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnerScope.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceGrant.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/service/DefaultTeamLeadLookup.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/service/TeamLeadLookup.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/EncryptedStringConverter.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/dto/IntegrationConfigRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/dto/IntegrationConfigResponse.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationConfig.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/CredentialEncryptionTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java create mode 100644 app/saas/src/main/resources/db/migration/saas/V25__resource_grants.sql create mode 100644 app/saas/src/main/resources/db/migration/saas/V26__integration_configs.sql create mode 100644 frontend/shared/auth/guards/RequirePortalAccess.tsx diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java new file mode 100644 index 0000000000..b2d6d3d12d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java @@ -0,0 +1,20 @@ +package stirling.software.proprietary.access.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import stirling.software.proprietary.access.service.DefaultTeamLeadLookup; +import stirling.software.proprietary.access.service.TeamLeadLookup; + +/** Access-layer bean wiring. */ +@Configuration +public class AccessConfig { + + /** No-op {@link TeamLeadLookup} unless another bean is defined. */ + @Bean + @ConditionalOnMissingBean(TeamLeadLookup.class) + TeamLeadLookup defaultTeamLeadLookup() { + return new DefaultTeamLeadLookup(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java new file mode 100644 index 0000000000..5b69c6878e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java @@ -0,0 +1,101 @@ +package stirling.software.proprietary.access.controller; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.access.model.AccessPermission; +import stirling.software.proprietary.access.model.PrincipalType; +import stirling.software.proprietary.access.model.ResourceGrant; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.security.model.User; + +/** Admin endpoints to grant/revoke access to gated resources (the portal, integration configs). */ +@RestController +@RequestMapping("/api/v1/admin/access") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "Access Control", description = "Manage resource access grants (portal, integrations)") +public class ResourceGrantController { + + private final ResourceAccessService accessService; + + @GetMapping("/grants") + public ResponseEntity list( + @RequestParam ResourceType resourceType, + @RequestParam(required = false, defaultValue = "") String resourceId) { + List grants = accessService.listGrants(resourceType, resourceId); + return ResponseEntity.ok(grants.stream().map(this::toDto).toList()); + } + + @PostMapping("/grants") + public ResponseEntity create( + @RequestBody GrantRequest request, @AuthenticationPrincipal User admin) { + if (request.resourceType() == null + || request.principalType() == null + || request.principalId() == null) { + return ResponseEntity.badRequest() + .body( + Map.of( + "error", + "resourceType, principalType and principalId are required")); + } + AccessPermission permission = + request.permission() == null ? AccessPermission.USE : request.permission(); + // PORTAL is a singleton resource; its grants always target the whole type. + String resourceId = + request.resourceType() == ResourceType.PORTAL ? "" : request.resourceId(); + ResourceGrant grant = + accessService.grant( + request.resourceType(), + resourceId, + request.principalType(), + request.principalId(), + permission, + admin); + return ResponseEntity.ok(toDto(grant)); + } + + @DeleteMapping("/grants/{id}") + public ResponseEntity delete(@PathVariable Long id) { + accessService.revoke(id); + return ResponseEntity.ok(Map.of("message", "Grant revoked")); + } + + private Map toDto(ResourceGrant g) { + Map m = new HashMap<>(); + m.put("id", g.getId()); + m.put("resourceType", g.getResourceType()); + m.put("resourceId", g.getResourceId()); + m.put("principalType", g.getPrincipalType()); + m.put("principalId", g.getPrincipalId()); + m.put("permission", g.getPermission()); + m.put("createdAt", g.getCreatedAt()); + return m; + } + + /** Request body for creating a grant. */ + public record GrantRequest( + ResourceType resourceType, + String resourceId, + PrincipalType principalType, + Long principalId, + AccessPermission permission) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/AccessPermission.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/AccessPermission.java new file mode 100644 index 0000000000..2fd7e5e84e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/AccessPermission.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.access.model; + +/** Permission level a grant confers. MANAGE implies USE. */ +public enum AccessPermission { + USE, + MANAGE +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/DefaultAccessPolicy.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/DefaultAccessPolicy.java new file mode 100644 index 0000000000..9db62fe954 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/DefaultAccessPolicy.java @@ -0,0 +1,14 @@ +package stirling.software.proprietary.access.model; + +/** + * Fallback policy applied when no explicit {@link ResourceGrant} matches. Admins (org owners) + * always pass regardless of this policy. + */ +public enum DefaultAccessPolicy { + // Every authenticated user in the deployment (org) may use the resource. + ORG_ALL, + // Only org admins and team leaders. This is the default for the portal. + ADMINS_AND_TEAM_LEADS, + // Nobody but the owner, admins, and explicit grantees. + EXPLICIT_ONLY +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java new file mode 100644 index 0000000000..1bca14a420 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java @@ -0,0 +1,57 @@ +package stirling.software.proprietary.access.model; + +import jakarta.persistence.Column; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; + +import lombok.Getter; +import lombok.Setter; + +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; + +/** Base for a resource owned by a user, a team, or the server, with grant-based access. */ +@MappedSuperclass +@Getter +@Setter +public abstract class OwnedResource { + + @Enumerated(EnumType.STRING) + @Column(name = "scope", nullable = false, length = 32) + private OwnerScope scope; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_user_id") + private User ownerUser; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_team_id") + private Team ownerTeam; + + @Column(name = "enabled", nullable = false) + private boolean enabled = true; + + // Server resource that users cannot override with their own of the same kind. + @Column(name = "locked", nullable = false) + private boolean locked = false; + + // Who, besides owner/admin/grantees, may use this resource. + @Enumerated(EnumType.STRING) + @Column(name = "default_access", nullable = false, length = 32) + private DefaultAccessPolicy defaultAccess = DefaultAccessPolicy.EXPLICIT_ONLY; + + /** Subclass primary key. */ + public abstract Long getId(); + + public Long getOwnerUserId() { + return ownerUser != null ? ownerUser.getId() : null; + } + + public Long getOwnerTeamId() { + return ownerTeam != null ? ownerTeam.getId() : null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnerScope.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnerScope.java new file mode 100644 index 0000000000..5c800a7630 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnerScope.java @@ -0,0 +1,8 @@ +package stirling.software.proprietary.access.model; + +/** Ownership scope of an {@link OwnedResource}: a single user, a team, or the whole server. */ +public enum OwnerScope { + USER, + TEAM, + SERVER +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java new file mode 100644 index 0000000000..9019b32845 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.access.model; + +/** Who a {@link ResourceGrant} is granted to. Org-wide access is expressed via default policy. */ +public enum PrincipalType { + USER, + TEAM +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceGrant.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceGrant.java new file mode 100644 index 0000000000..266ef4ce15 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceGrant.java @@ -0,0 +1,86 @@ +package stirling.software.proprietary.access.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.security.model.User; + +/** Grants a user or team access to a resource. Owner and admin access are implicit. */ +@Entity +@Table( + name = "resource_grants", + uniqueConstraints = + @UniqueConstraint( + name = "uk_resource_grant", + columnNames = { + "resource_type", + "resource_id", + "principal_type", + "principal_id", + "permission" + }), + indexes = { + @Index(name = "idx_resource_grants_lookup", columnList = "resource_type,resource_id"), + @Index( + name = "idx_resource_grants_principal", + columnList = "principal_type,principal_id") + }) +@NoArgsConstructor +@Getter +@Setter +public class ResourceGrant implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "resource_grant_id") + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "resource_type", nullable = false, length = 64) + private ResourceType resourceType; + + // Empty string (never null) for a whole-type grant such as the portal. + @Column(name = "resource_id", nullable = false, length = 255) + private String resourceId = ""; + + @Enumerated(EnumType.STRING) + @Column(name = "principal_type", nullable = false, length = 32) + private PrincipalType principalType; + + @Column(name = "principal_id", nullable = false) + private Long principalId; + + @Enumerated(EnumType.STRING) + @Column(name = "permission", nullable = false, length = 32) + private AccessPermission permission; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "granted_by_user_id") + private User grantedBy; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java new file mode 100644 index 0000000000..9d0108db0f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java @@ -0,0 +1,9 @@ +package stirling.software.proprietary.access.model; + +/** Types of resources whose access can be gated by {@link ResourceGrant}. */ +public enum ResourceType { + // The admin portal / processor (frontend/portal). Singleton resource (empty resourceId). + PORTAL, + // A stored S3/MCP/API integration configuration. + INTEGRATION_CONFIG +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java new file mode 100644 index 0000000000..76209c1abe --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java @@ -0,0 +1,28 @@ +package stirling.software.proprietary.access.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.access.model.PrincipalType; +import stirling.software.proprietary.access.model.ResourceGrant; +import stirling.software.proprietary.access.model.ResourceType; + +@Repository +public interface ResourceGrantRepository extends JpaRepository { + + List findByResourceTypeAndResourceId( + ResourceType resourceType, String resourceId); + + List findByResourceTypeAndPrincipalTypeAndPrincipalId( + ResourceType resourceType, PrincipalType principalType, Long principalId); + + void deleteByResourceTypeAndResourceId(ResourceType resourceType, String resourceId); + + boolean existsByResourceTypeAndResourceIdAndPrincipalTypeAndPrincipalId( + ResourceType resourceType, + String resourceId, + PrincipalType principalType, + Long principalId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java new file mode 100644 index 0000000000..5189b703a1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java @@ -0,0 +1,44 @@ +package stirling.software.proprietary.access.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +/** {@code @PreAuthorize} bean for portal-access checks. Active in self-hosted and saas. */ +@Component("resourceAccess") +@RequiredArgsConstructor +public class ResourceAccessSecurity { + + private final ResourceAccessService accessService; + private final UserService userService; + + public boolean canUsePortal() { + User user = currentUser(); + return user != null && accessService.canAccessPortal(user); + } + + private User currentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + return null; + } + Object principal = auth.getPrincipal(); + if (principal instanceof User user) { + return user; + } + if (principal instanceof UserDetails userDetails) { + return userService.findByUsername(userDetails.getUsername()).orElse(null); + } + if (principal instanceof String username && !"anonymousUser".equals(username)) { + return userService.findByUsername(username).orElse(null); + } + return null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/DefaultTeamLeadLookup.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/DefaultTeamLeadLookup.java new file mode 100644 index 0000000000..11b01b8d6b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/DefaultTeamLeadLookup.java @@ -0,0 +1,17 @@ +package stirling.software.proprietary.access.service; + +import stirling.software.proprietary.security.model.User; + +/** No-op {@link TeamLeadLookup}: always false. */ +public class DefaultTeamLeadLookup implements TeamLeadLookup { + + @Override + public boolean isAnyTeamLeader(User user) { + return false; + } + + @Override + public boolean isLeaderOfTeam(User user, Long teamId) { + return false; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java new file mode 100644 index 0000000000..8db87f2c0e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java @@ -0,0 +1,116 @@ +package stirling.software.proprietary.access.service; + +import java.util.Set; +import java.util.function.BooleanSupplier; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.model.OwnedResource; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; + +/** Ownership and access checks for {@link OwnedResource}, backed by the resource-grant ACL. */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OwnershipService { + + private final ResourceAccessService accessService; + private final TeamLeadLookup teamLeadLookup; + private final TeamRepository teamRepository; + + /** Whether the user may use the resource. */ + public boolean canUse(ResourceType type, OwnedResource resource, User user) { + if (!resource.isEnabled()) { + return isAdmin(user) || isOwner(resource, user); + } + return accessService.canUseResource( + type, + String.valueOf(resource.getId()), + resource.getOwnerUserId(), + resource.getDefaultAccess(), + user); + } + + /** Whether the user may manage the resource. */ + public boolean canManage(ResourceType type, OwnedResource resource, User user) { + return accessService.canManageResource( + type, String.valueOf(resource.getId()), resource.getOwnerUserId(), user); + } + + /** + * Authorizes the scope and assigns ownership; {@code lockedOverrideBlocks} guards USER scope. + */ + public void assignOwnership( + OwnedResource resource, + OwnerScope scope, + Long teamId, + User user, + BooleanSupplier lockedOverrideBlocks) { + resource.setScope(scope); + switch (scope) { + case USER -> { + if (lockedOverrideBlocks.getAsBoolean() && !isAdmin(user)) { + throw forbidden( + "This is locked to the server configuration by an administrator"); + } + resource.setOwnerUser(user); + } + case SERVER -> { + if (!isAdmin(user)) { + throw forbidden("Only administrators can create server-owned resources"); + } + } + case TEAM -> { + if (teamId == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "ownerTeamId is required"); + } + Team team = + teamRepository + .findById(teamId) + .orElseThrow(() -> notFound("Team not found")); + if (!isAdmin(user) && !teamLeadLookup.isLeaderOfTeam(user, team.getId())) { + throw forbidden("Only admins or team leaders can create team-owned resources"); + } + resource.setOwnerTeam(team); + } + } + } + + /** Resource ids of the given type the user or their team holds a grant on. */ + public Set grantedResourceIds(ResourceType type, User user) { + return accessService.grantedResourceIds(type, user); + } + + public boolean isAdmin(User user) { + return user.getAuthorities().stream() + .anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority())); + } + + public boolean isOwner(OwnedResource resource, User user) { + if (resource.getOwnerUserId() != null && resource.getOwnerUserId().equals(user.getId())) { + return true; + } + // Team-owned: the lead of the owning team owns it. + return resource.getOwnerTeamId() != null + && teamLeadLookup.isLeaderOfTeam(user, resource.getOwnerTeamId()); + } + + private ResponseStatusException forbidden(String message) { + return new ResponseStatusException(HttpStatus.FORBIDDEN, message); + } + + private ResponseStatusException notFound(String message) { + return new ResponseStatusException(HttpStatus.NOT_FOUND, message); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java new file mode 100644 index 0000000000..ada4c95290 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java @@ -0,0 +1,188 @@ +package stirling.software.proprietary.access.service; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.model.AccessPermission; +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.PrincipalType; +import stirling.software.proprietary.access.model.ResourceGrant; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.security.model.User; + +/** Resolves access to gated resources: owner, then admin, then grant, then default policy. */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class ResourceAccessService { + + private final ResourceGrantRepository grantRepository; + private final TeamLeadLookup teamLeadLookup; + + @Value("${security.portal.defaultAccess:ADMINS_AND_TEAM_LEADS}") + private DefaultAccessPolicy portalDefaultPolicy; + + // ---- public checks ---- + + /** Whether the user may use the portal / processor. */ + public boolean canAccessPortal(User user) { + return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user); + } + + /** Whether the user may use a resource, falling back to its default policy. */ + public boolean canUseResource( + ResourceType type, + String resourceId, + Long ownerUserId, + DefaultAccessPolicy defaultPolicy, + User user) { + if (user == null) { + return false; + } + if (isOwner(ownerUserId, user) || isAdmin(user)) { + return true; + } + if (hasGrant(type, normalize(resourceId), user, AccessPermission.USE)) { + return true; + } + return matchesDefault(defaultPolicy, user); + } + + /** Whether the user may manage (edit/delete/share) a resource. No default-policy fallback. */ + public boolean canManageResource( + ResourceType type, String resourceId, Long ownerUserId, User user) { + if (user == null) { + return false; + } + if (isOwner(ownerUserId, user) || isAdmin(user)) { + return true; + } + return hasGrant(type, normalize(resourceId), user, AccessPermission.MANAGE); + } + + // ---- grant management ---- + + @Transactional + public ResourceGrant grant( + ResourceType type, + String resourceId, + PrincipalType principalType, + Long principalId, + AccessPermission permission, + User grantedBy) { + String rid = normalize(resourceId); + ResourceGrant grant = + grantRepository.findByResourceTypeAndResourceId(type, rid).stream() + .filter( + g -> + g.getPrincipalType() == principalType + && g.getPrincipalId().equals(principalId)) + .findFirst() + .orElseGet(ResourceGrant::new); + grant.setResourceType(type); + grant.setResourceId(rid); + grant.setPrincipalType(principalType); + grant.setPrincipalId(principalId); + grant.setPermission(permission); + if (grantedBy != null) { + grant.setGrantedBy(grantedBy); + } + return grantRepository.save(grant); + } + + @Transactional + public void revoke(Long grantId) { + grantRepository.deleteById(grantId); + } + + public List listGrants(ResourceType type, String resourceId) { + return grantRepository.findByResourceTypeAndResourceId(type, normalize(resourceId)); + } + + /** Resource ids of the given type that this user (or their team) holds any grant on. */ + public Set grantedResourceIds(ResourceType type, User user) { + if (user == null) { + return Set.of(); + } + Set ids = new HashSet<>(); + for (ResourceGrant g : + grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId( + type, PrincipalType.USER, user.getId())) { + ids.add(g.getResourceId()); + } + if (user.getTeam() != null) { + for (ResourceGrant g : + grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId( + type, PrincipalType.TEAM, user.getTeam().getId())) { + ids.add(g.getResourceId()); + } + } + return ids; + } + + // ---- internals ---- + + private boolean hasGrant( + ResourceType type, String resourceId, User user, AccessPermission required) { + Long teamId = user.getTeam() != null ? user.getTeam().getId() : null; + for (ResourceGrant g : grantRepository.findByResourceTypeAndResourceId(type, resourceId)) { + if (!permissionSatisfies(g.getPermission(), required)) { + continue; + } + if (g.getPrincipalType() == PrincipalType.USER + && g.getPrincipalId().equals(user.getId())) { + return true; + } + if (g.getPrincipalType() == PrincipalType.TEAM + && teamId != null + && g.getPrincipalId().equals(teamId)) { + return true; + } + } + return false; + } + + // MANAGE implies USE. + private boolean permissionSatisfies(AccessPermission held, AccessPermission required) { + if (required == AccessPermission.USE) { + return held == AccessPermission.USE || held == AccessPermission.MANAGE; + } + return held == AccessPermission.MANAGE; + } + + private boolean matchesDefault(DefaultAccessPolicy policy, User user) { + if (policy == null) { + return false; + } + return switch (policy) { + case ORG_ALL -> true; + // Admins already pass above; only team leads here. + case ADMINS_AND_TEAM_LEADS -> teamLeadLookup.isAnyTeamLeader(user); + case EXPLICIT_ONLY -> false; + }; + } + + private boolean isOwner(Long ownerUserId, User user) { + return ownerUserId != null && ownerUserId.equals(user.getId()); + } + + private boolean isAdmin(User user) { + return user.getAuthorities().stream() + .anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority())); + } + + private String normalize(String resourceId) { + return resourceId == null ? "" : resourceId; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java new file mode 100644 index 0000000000..917c180116 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java @@ -0,0 +1,151 @@ +package stirling.software.proprietary.access.service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Component; + +/** Masks, merges and sanitizes secret values in a config map, recursing into nested maps/lists. */ +@Component +public class SecretMasker { + + public static final String MASK = "********"; + + // Cap recursion so a pathologically nested payload cannot overflow the stack. + private static final int MAX_DEPTH = 32; + + private static final Set SENSITIVE_HINTS = + Set.of( + "secret", + "password", + "token", + "apikey", + "accesskey", + "credential", + "privatekey"); + + /** Replace sensitive values with the mask (recursively) for safe display. */ + public Map mask(Map config) { + return mask(config, 0); + } + + /** Drop sensitive blank/masked values from an incoming create payload. */ + public Map sanitize(Map config) { + return sanitize(config, 0); + } + + /** + * Merge an update over the stored map, keeping stored secrets where the incoming is redacted. + */ + public Map merge(Map stored, Map incoming) { + return merge(stored, incoming, 0); + } + + private Map mask(Map config, int depth) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : config.entrySet()) { + out.put(e.getKey(), maskValue(e.getKey(), e.getValue(), depth)); + } + return out; + } + + private Map sanitize(Map config, int depth) { + if (config == null) { + return new LinkedHashMap<>(); + } + Map out = new LinkedHashMap<>(); + for (Map.Entry e : config.entrySet()) { + if (isSensitive(e.getKey()) && isRedacted(e.getValue(), depth)) { + continue; + } + out.put( + e.getKey(), + e.getValue() instanceof Map m && depth < MAX_DEPTH + ? sanitize(castMap(m), depth + 1) + : e.getValue()); + } + return out; + } + + private Map merge( + Map stored, Map incoming, int depth) { + Map out = new LinkedHashMap<>(stored); + for (Map.Entry e : incoming.entrySet()) { + String key = e.getKey(); + Object value = e.getValue(); + if (isSensitive(key)) { + if (!isRedacted(value, depth)) { + out.put(key, value); // a real new secret replaces the stored one + } + continue; // redacted (blank / mask) -> keep stored + } + if (depth < MAX_DEPTH + && out.get(key) instanceof Map s + && value instanceof Map i) { + out.put(key, merge(castMap(s), castMap(i), depth + 1)); + } else { + out.put(key, value); + } + } + return out; + } + + // A sensitive key masks its whole value; recurse into non-sensitive containers. + private Object maskValue(String key, Object value, int depth) { + if (isSensitive(key)) { + if (value == null || (value instanceof String s && s.isBlank())) { + return value; + } + return MASK; + } + if (depth >= MAX_DEPTH) { + // Too deep to descend; mask containers rather than risk leaking an unmasked secret. + return value instanceof Map || value instanceof List ? MASK : value; + } + if (value instanceof Map m) { + return mask(castMap(m), depth + 1); + } + if (value instanceof List list) { + List out = new ArrayList<>(); + for (Object item : list) { + out.add(item instanceof Map m ? mask(castMap(m), depth + 1) : item); + } + return out; + } + return value; + } + + private boolean isSensitive(String key) { + String lower = key.toLowerCase(Locale.ROOT); + return SENSITIVE_HINTS.stream().anyMatch(lower::contains); + } + + /** Blank, the mask placeholder, or any structure that still contains the mask. */ + private boolean isRedacted(Object value, int depth) { + if (value == null) { + return true; + } + if (value instanceof String s) { + return s.isBlank() || MASK.equals(s); + } + if (depth >= MAX_DEPTH) { + return false; + } + if (value instanceof Map m) { + return m.values().stream().anyMatch(v -> isRedacted(v, depth + 1)); + } + if (value instanceof List list) { + return list.stream().anyMatch(v -> isRedacted(v, depth + 1)); + } + return false; + } + + @SuppressWarnings("unchecked") + private Map castMap(Map map) { + return (Map) map; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/TeamLeadLookup.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/TeamLeadLookup.java new file mode 100644 index 0000000000..9d05b05de5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/TeamLeadLookup.java @@ -0,0 +1,13 @@ +package stirling.software.proprietary.access.service; + +import stirling.software.proprietary.security.model.User; + +/** Resolves whether a user leads a team. */ +public interface TeamLeadLookup { + + /** Whether the user leads at least one team. */ + boolean isAnyTeamLeader(User user); + + /** Whether the user leads the given team. */ + boolean isLeaderOfTeam(User user, Long teamId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java new file mode 100644 index 0000000000..220c4e8d65 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java @@ -0,0 +1,81 @@ +package stirling.software.proprietary.integration.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.integration.dto.IntegrationConfigRequest; +import stirling.software.proprietary.integration.dto.IntegrationConfigResponse; +import stirling.software.proprietary.integration.service.IntegrationConfigService; +import stirling.software.proprietary.security.model.User; + +/** CRUD for S3/MCP/API integration configs. Secrets are never returned. */ +@RestController +@RequestMapping("/api/v1/integrations") +@RequiredArgsConstructor +@PreAuthorize("isAuthenticated()") +@Tag(name = "Integrations", description = "Manage S3/MCP/API integration configurations") +public class IntegrationConfigController { + + private final IntegrationConfigService service; + + @GetMapping + public ResponseEntity> list( + @AuthenticationPrincipal User user) { + requireUser(user); + return ResponseEntity.ok( + service.listVisible(user).stream().map(c -> service.toResponse(c, user)).toList()); + } + + @PostMapping + public ResponseEntity create( + @RequestBody IntegrationConfigRequest request, @AuthenticationPrincipal User user) { + requireUser(user); + return ResponseEntity.ok(service.toResponse(service.create(request, user), user)); + } + + @GetMapping("/{id}") + public ResponseEntity get( + @PathVariable Long id, @AuthenticationPrincipal User user) { + requireUser(user); + return ResponseEntity.ok(service.toResponse(service.getForUse(id, user), user)); + } + + @PutMapping("/{id}") + public ResponseEntity update( + @PathVariable Long id, + @RequestBody IntegrationConfigRequest request, + @AuthenticationPrincipal User user) { + requireUser(user); + return ResponseEntity.ok(service.toResponse(service.update(id, request, user), user)); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id, @AuthenticationPrincipal User user) { + requireUser(user); + service.delete(id, user); + return ResponseEntity.noContent().build(); + } + + private void requireUser(User user) { + if (user == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required"); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java new file mode 100644 index 0000000000..1fff2dd321 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java @@ -0,0 +1,135 @@ +package stirling.software.proprietary.integration.crypto; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; + +/** + * AES-256-GCM for stored credentials. Key from property, env var, or an auto-generated key file. + */ +@Component +@Slf4j +public class CredentialEncryption { + + private static final String ALGORITHM = "AES"; + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int GCM_TAG_BITS = 128; + private static final int IV_BYTES = 12; + private static final String KEY_FILE = "credential-encryption.key"; + private static final SecureRandom RANDOM = new SecureRandom(); + + private static volatile SecretKey key; + + private final String configuredKey; + + public CredentialEncryption( + @Value("${stirling.security.credentialEncryptionKey:}") String configuredKey) { + this.configuredKey = configuredKey; + } + + @PostConstruct + void init() { + key = resolveKey(); + log.info("Credential encryption initialised (AES-256-GCM)"); + } + + private SecretKey resolveKey() { + String configured = configuredKey; + if (configured == null || configured.isBlank()) { + configured = System.getenv("STIRLING_CREDENTIAL_ENCRYPTION_KEY"); + } + if (configured != null && !configured.isBlank()) { + return new SecretKeySpec(Base64.getDecoder().decode(configured.trim()), ALGORITHM); + } + return loadOrCreateKeyFile(); + } + + private SecretKey loadOrCreateKeyFile() { + Path path = Path.of(InstallationPathConfig.getConfigPath(), KEY_FILE); + try { + if (Files.exists(path)) { + String encoded = Files.readString(path).trim(); + return new SecretKeySpec(Base64.getDecoder().decode(encoded), ALGORITHM); + } + KeyGenerator generator = KeyGenerator.getInstance(ALGORITHM); + generator.init(256); + SecretKey generated = generator.generateKey(); + Files.createDirectories(path.getParent()); + Files.writeString(path, Base64.getEncoder().encodeToString(generated.getEncoded())); + log.warn( + "Generated a new credential encryption key at {}. Back this file up: losing it" + + " makes stored integration secrets unrecoverable.", + path); + return generated; + } catch (Exception e) { + throw new IllegalStateException("Unable to initialise credential encryption key", e); + } + } + + public static String encrypt(String plaintext) { + if (plaintext == null) { + return null; + } + try { + byte[] iv = new byte[IV_BYTES]; + RANDOM.nextBytes(iv); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); + byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + byte[] combined = new byte[iv.length + ciphertext.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length); + return Base64.getEncoder().encodeToString(combined); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to encrypt credential", e); + } + } + + public static String decrypt(String stored) { + if (stored == null) { + return null; + } + try { + byte[] combined = Base64.getDecoder().decode(stored); + byte[] iv = Arrays.copyOfRange(combined, 0, IV_BYTES); + byte[] ciphertext = Arrays.copyOfRange(combined, IV_BYTES, combined.length); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to decrypt credential", e); + } + } + + private static SecretKey requireKey() { + SecretKey current = key; + if (current == null) { + throw new IllegalStateException("Credential encryption not initialised"); + } + return current; + } + + /** For tests. */ + static void initialiseForTesting(SecretKey testKey) { + key = testKey; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/EncryptedStringConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/EncryptedStringConverter.java new file mode 100644 index 0000000000..2696d69f90 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/EncryptedStringConverter.java @@ -0,0 +1,19 @@ +package stirling.software.proprietary.integration.crypto; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** Transparently encrypts/decrypts a string column at rest via {@link CredentialEncryption}. */ +@Converter +public class EncryptedStringConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(String attribute) { + return CredentialEncryption.encrypt(attribute); + } + + @Override + public String convertToEntityAttribute(String dbData) { + return CredentialEncryption.decrypt(dbData); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/dto/IntegrationConfigRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/dto/IntegrationConfigRequest.java new file mode 100644 index 0000000000..279a5bf4b2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/dto/IntegrationConfigRequest.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.integration.dto; + +import java.util.Map; + +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.integration.model.IntegrationType; + +/** Create/update payload for an integration config. Sensitive config values left blank are kept. */ +public record IntegrationConfigRequest( + IntegrationType integrationType, + String name, + OwnerScope scope, + Long ownerTeamId, + Boolean enabled, + Boolean locked, + DefaultAccessPolicy defaultAccess, + Map config) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/dto/IntegrationConfigResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/dto/IntegrationConfigResponse.java new file mode 100644 index 0000000000..b1b3303571 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/dto/IntegrationConfigResponse.java @@ -0,0 +1,24 @@ +package stirling.software.proprietary.integration.dto; + +import java.time.LocalDateTime; +import java.util.Map; + +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.integration.model.IntegrationType; + +/** Integration config view. Sensitive config values are masked. */ +public record IntegrationConfigResponse( + Long id, + IntegrationType integrationType, + String name, + OwnerScope scope, + Long ownerUserId, + Long ownerTeamId, + boolean enabled, + boolean locked, + DefaultAccessPolicy defaultAccess, + Map config, + boolean canManage, + LocalDateTime createdAt, + LocalDateTime updatedAt) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationConfig.java new file mode 100644 index 0000000000..350ad7115d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationConfig.java @@ -0,0 +1,67 @@ +package stirling.software.proprietary.integration.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.access.model.OwnedResource; +import stirling.software.proprietary.integration.crypto.EncryptedStringConverter; + +/** A named S3/MCP/API integration config; scope and ownership live on {@link OwnedResource}. */ +@Entity +@Table( + name = "integration_configs", + indexes = { + @Index(name = "idx_integration_configs_owner", columnList = "owner_user_id"), + @Index(name = "idx_integration_configs_type", columnList = "integration_type"), + @Index(name = "idx_integration_configs_scope", columnList = "scope") + }) +@NoArgsConstructor +@Getter +@Setter +public class IntegrationConfig extends OwnedResource implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "integration_config_id") + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "integration_type", nullable = false, length = 32) + private IntegrationType integrationType; + + @Column(name = "name", nullable = false) + private String name; + + // Type-specific fields as an AES-GCM encrypted JSON blob. + @Convert(converter = EncryptedStringConverter.class) + @Column(name = "config_encrypted", columnDefinition = "text") + private String config; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java new file mode 100644 index 0000000000..1e5bc0c7d4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java @@ -0,0 +1,8 @@ +package stirling.software.proprietary.integration.model; + +/** Kind of external integration a stored config describes. */ +public enum IntegrationType { + S3, + MCP, + API +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java new file mode 100644 index 0000000000..6efb45d745 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java @@ -0,0 +1,21 @@ +package stirling.software.proprietary.integration.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; + +@Repository +public interface IntegrationConfigRepository extends JpaRepository { + + List findByOwnerUser(User ownerUser); + + List findByOwnerTeam(Team ownerTeam); + + List findByScope(OwnerScope scope); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java new file mode 100644 index 0000000000..b384cc48e6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java @@ -0,0 +1,226 @@ +package stirling.software.proprietary.integration.service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.access.service.SecretMasker; +import stirling.software.proprietary.integration.dto.IntegrationConfigRequest; +import stirling.software.proprietary.integration.dto.IntegrationConfigResponse; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.model.User; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +/** CRUD for {@link IntegrationConfig}; delegates ownership and masking to shared services. */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class IntegrationConfigService { + + private static final ResourceType TYPE = ResourceType.INTEGRATION_CONFIG; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final IntegrationConfigRepository repository; + private final OwnershipService ownership; + private final SecretMasker secretMasker; + + // ---- commands ---- + + @Transactional + public IntegrationConfig create(IntegrationConfigRequest request, User currentUser) { + OwnerScope scope = request.scope() == null ? OwnerScope.USER : request.scope(); + IntegrationConfig cfg = new IntegrationConfig(); + cfg.setIntegrationType(require(request.integrationType(), "integrationType")); + cfg.setName(require(request.name(), "name")); + cfg.setEnabled(request.enabled() == null || request.enabled()); + cfg.setLocked(request.locked() != null && request.locked()); + cfg.setDefaultAccess( + request.defaultAccess() == null + ? DefaultAccessPolicy.EXPLICIT_ONLY + : request.defaultAccess()); + + ownership.assignOwnership( + cfg, + scope, + request.ownerTeamId(), + currentUser, + () -> lockedServerExists(cfg.getIntegrationType())); + cfg.setConfig(writeJson(secretMasker.sanitize(request.config()))); + return repository.save(cfg); + } + + @Transactional + public IntegrationConfig update(Long id, IntegrationConfigRequest request, User currentUser) { + IntegrationConfig cfg = load(id); + if (!ownership.canManage(TYPE, cfg, currentUser)) { + throw forbidden("You cannot manage this integration"); + } + if (cfg.isLocked() && !ownership.isAdmin(currentUser)) { + throw forbidden("This integration is locked by an administrator"); + } + if (request.name() != null) { + cfg.setName(request.name()); + } + if (request.enabled() != null) { + cfg.setEnabled(request.enabled()); + } + if (request.locked() != null && request.locked() != cfg.isLocked()) { + if (!ownership.isAdmin(currentUser)) { + throw forbidden("Only administrators can change the locked flag"); + } + cfg.setLocked(request.locked()); + } + if (request.defaultAccess() != null) { + cfg.setDefaultAccess(request.defaultAccess()); + } + if (request.config() != null) { + cfg.setConfig( + writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config()))); + } + return repository.save(cfg); + } + + @Transactional + public void delete(Long id, User currentUser) { + IntegrationConfig cfg = load(id); + if (!ownership.canManage(TYPE, cfg, currentUser)) { + throw forbidden("You cannot manage this integration"); + } + repository.delete(cfg); + } + + // ---- queries ---- + + public IntegrationConfig getForUse(Long id, User currentUser) { + IntegrationConfig cfg = load(id); + if (!ownership.canUse(TYPE, cfg, currentUser)) { + throw forbidden("You cannot access this integration"); + } + return cfg; + } + + /** + * All configs the user owns, plus server/team configs and grant-shared configs they may use. + */ + public List listVisible(User currentUser) { + Map byId = new LinkedHashMap<>(); + for (IntegrationConfig c : repository.findByOwnerUser(currentUser)) { + byId.put(c.getId(), c); + } + for (IntegrationConfig c : repository.findByScope(OwnerScope.SERVER)) { + if (ownership.canUse(TYPE, c, currentUser)) { + byId.put(c.getId(), c); + } + } + if (currentUser.getTeam() != null) { + for (IntegrationConfig c : repository.findByOwnerTeam(currentUser.getTeam())) { + if (ownership.canUse(TYPE, c, currentUser)) { + byId.put(c.getId(), c); + } + } + } + for (String rid : ownership.grantedResourceIds(TYPE, currentUser)) { + if (rid == null || rid.isBlank()) { + continue; + } + Long cid; + try { + cid = Long.valueOf(rid); + } catch (NumberFormatException e) { + continue; + } + if (byId.containsKey(cid)) { + continue; + } + repository + .findById(cid) + .filter(c -> ownership.canUse(TYPE, c, currentUser)) + .ifPresent(c -> byId.put(c.getId(), c)); + } + return new ArrayList<>(byId.values()); + } + + public IntegrationConfigResponse toResponse(IntegrationConfig cfg, User user) { + return new IntegrationConfigResponse( + cfg.getId(), + cfg.getIntegrationType(), + cfg.getName(), + cfg.getScope(), + cfg.getOwnerUserId(), + cfg.getOwnerTeamId(), + cfg.isEnabled(), + cfg.isLocked(), + cfg.getDefaultAccess(), + secretMasker.mask(readJson(cfg.getConfig())), + ownership.canManage(TYPE, cfg, user), + cfg.getCreatedAt(), + cfg.getUpdatedAt()); + } + + // ---- integration-specific glue ---- + + /** A non-admin can't create a personal config of a type an admin has locked at server scope. */ + private boolean lockedServerExists(IntegrationType type) { + return repository.findByScope(OwnerScope.SERVER).stream() + .anyMatch(c -> c.getIntegrationType() == type && c.isLocked()); + } + + private IntegrationConfig load(Long id) { + return repository + .findById(id) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "Integration not found")); + } + + private String writeJson(Map config) { + try { + return OBJECT_MAPPER.writeValueAsString(config == null ? Map.of() : config); + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid config payload"); + } + } + + private Map readJson(String json) { + if (json == null || json.isBlank()) { + return new LinkedHashMap<>(); + } + try { + return OBJECT_MAPPER.readValue( + json, new TypeReference>() {}); + } catch (Exception e) { + log.error("Failed to parse integration config JSON", e); + return new LinkedHashMap<>(); + } + } + + private T require(T value, String field) { + if (value == null || (value instanceof String s && s.isBlank())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " is required"); + } + return value; + } + + private ResponseStatusException forbidden(String message) { + return new ResponseStatusException(HttpStatus.FORBIDDEN, message); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index ee449b31f2..c53d835ca5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -33,8 +33,10 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.storage.repository", "stirling.software.proprietary.workflow.repository", "stirling.software.proprietary.policy.store", + "stirling.software.proprietary.policy.source", "stirling.software.proprietary.accountlink", - "stirling.software.proprietary.policy.source" + "stirling.software.proprietary.access.repository", + "stirling.software.proprietary.integration.repository" }) @EntityScan({ "stirling.software.proprietary.security.model", @@ -42,8 +44,10 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.storage.model", "stirling.software.proprietary.workflow.model", "stirling.software.proprietary.policy.store", + "stirling.software.proprietary.policy.source", "stirling.software.proprietary.accountlink", - "stirling.software.proprietary.policy.source" + "stirling.software.proprietary.access.model", + "stirling.software.proprietary.integration.model" }) public class DatabaseConfig { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index 490dea2556..dcea7807b1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -28,6 +28,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.constants.JwtConstants; import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; @@ -64,6 +65,7 @@ public class AuthController { private final ApplicationProperties.Security securityProperties; private final ApplicationProperties applicationProperties; private final AiUserDataService aiUserDataService; + private final ResourceAccessService resourceAccessService; /** * Login endpoint - replaces Supabase signInWithPassword @@ -628,6 +630,7 @@ public class AuthController { userMap.put("username", user.getUsername()); userMap.put("role", user.getRolesAsString()); userMap.put("enabled", user.isEnabled()); + userMap.put("portalAccess", resourceAccessService.canAccessPortal(user)); userMap.put( "authenticationType", user.getAuthenticationType()); // Expose authentication type for SSO detection diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java new file mode 100644 index 0000000000..3f70058d0b --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java @@ -0,0 +1,174 @@ +package stirling.software.proprietary.access.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.proprietary.access.model.OwnedResource; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; + +@ExtendWith(MockitoExtension.class) +class OwnershipServiceTest { + + private static final ResourceType TYPE = ResourceType.INTEGRATION_CONFIG; + + @Mock private ResourceAccessService accessService; + @Mock private TeamLeadLookup teamLeadLookup; + @Mock private TeamRepository teamRepository; + + @InjectMocks private OwnershipService ownership; + + /** Minimal concrete OwnedResource for exercising the base behaviour. */ + static class TestResource extends OwnedResource { + private final Long id; + + TestResource(Long id) { + this.id = id; + } + + @Override + public Long getId() { + return id; + } + } + + // ---- scope-based create authorization ---- + + @Test + void userScopeSetsOwner() { + User user = user(7); + TestResource r = new TestResource(1L); + + ownership.assignOwnership(r, OwnerScope.USER, null, user, () -> false); + + assertThat(r.getScope()).isEqualTo(OwnerScope.USER); + assertThat(r.getOwnerUser()).isSameAs(user); + } + + @Test + void userScopeBlockedByLockedServerOverride() { + assertForbidden( + () -> + ownership.assignOwnership( + new TestResource(1L), OwnerScope.USER, null, user(7), () -> true)); + } + + @Test + void adminMayCreateServerScope() { + TestResource r = new TestResource(1L); + ownership.assignOwnership(r, OwnerScope.SERVER, null, admin(1), () -> false); + assertThat(r.getOwnerUser()).isNull(); + } + + @Test + void nonAdminCannotCreateServerScope() { + assertForbidden( + () -> + ownership.assignOwnership( + new TestResource(1L), + OwnerScope.SERVER, + null, + user(7), + () -> false)); + } + + @Test + void teamLeaderMayCreateTeamScope() { + Team team = new Team(); + team.setId(5L); + User user = user(7); + when(teamRepository.findById(5L)).thenReturn(Optional.of(team)); + when(teamLeadLookup.isLeaderOfTeam(user, 5L)).thenReturn(true); + + TestResource r = new TestResource(1L); + ownership.assignOwnership(r, OwnerScope.TEAM, 5L, user, () -> false); + + assertThat(r.getOwnerTeam()).isSameAs(team); + } + + @Test + void nonLeaderCannotCreateTeamScope() { + Team team = new Team(); + team.setId(5L); + when(teamRepository.findById(5L)).thenReturn(Optional.of(team)); + + assertForbidden( + () -> + ownership.assignOwnership( + new TestResource(1L), OwnerScope.TEAM, 5L, user(7), () -> false)); + } + + // ---- use / manage ---- + + @Test + void enabledResourceUseDelegatesToTheAcl() { + TestResource r = new TestResource(1L); + User user = user(7); + when(accessService.canUseResource(any(), any(), any(), any(), any())).thenReturn(true); + + assertThat(ownership.canUse(TYPE, r, user)).isTrue(); + } + + @Test + void disabledResourceUsableOnlyByOwnerOrAdmin() { + TestResource r = new TestResource(1L); + r.setEnabled(false); + r.setOwnerUser(user(7)); + + assertThat(ownership.canUse(TYPE, r, user(7))).isTrue(); // owner + assertThat(ownership.canUse(TYPE, r, user(8))).isFalse(); // someone else + assertThat(ownership.canUse(TYPE, r, admin(2))).isTrue(); // admin + } + + @Test + void disabledTeamResourceUsableByLeaderOfOwningTeam() { + Team team = new Team(); + team.setId(5L); + TestResource r = new TestResource(1L); + r.setEnabled(false); + r.setOwnerTeam(team); + User leader = user(7); + when(teamLeadLookup.isLeaderOfTeam(leader, 5L)).thenReturn(true); + + assertThat(ownership.canUse(TYPE, r, leader)).isTrue(); // lead of the owning team + assertThat(ownership.canUse(TYPE, r, user(8))).isFalse(); // not a lead + } + + // ---- helpers ---- + + private void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable call) { + assertThatThrownBy(call) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + private User user(long id) { + User u = new User(); + u.setId(id); + u.setUsername("user" + id); + return u; + } + + private User admin(long id) { + User u = user(id); + new Authority("ROLE_ADMIN", u); + return u; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java new file mode 100644 index 0000000000..230c207eb0 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java @@ -0,0 +1,217 @@ +package stirling.software.proprietary.access.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.proprietary.access.model.AccessPermission; +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.PrincipalType; +import stirling.software.proprietary.access.model.ResourceGrant; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.User; + +@ExtendWith(MockitoExtension.class) +class ResourceAccessServiceTest { + + private static final ResourceType TYPE = ResourceType.INTEGRATION_CONFIG; + private static final String RID = "42"; + + @Mock private ResourceGrantRepository grantRepository; + @Mock private TeamLeadLookup teamLeadLookup; + + @InjectMocks private ResourceAccessService service; + + @BeforeEach + void setPortalDefault() throws Exception { + Field f = ResourceAccessService.class.getDeclaredField("portalDefaultPolicy"); + f.setAccessible(true); + f.set(service, DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS); + } + + // ---- owner / admin short-circuits ---- + + @Test + void adminMayUseEvenWithExplicitOnlyAndNoGrants() { + assertThat( + service.canUseResource( + TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, admin(1))) + .isTrue(); + } + + @Test + void ownerMayUseEvenWithExplicitOnly() { + assertThat( + service.canUseResource( + TYPE, RID, 5L, DefaultAccessPolicy.EXPLICIT_ONLY, user(5))) + .isTrue(); + } + + @Test + void nullUserIsAlwaysDenied() { + assertThat(service.canUseResource(TYPE, RID, 5L, DefaultAccessPolicy.ORG_ALL, null)) + .isFalse(); + assertThat(service.canManageResource(TYPE, RID, 5L, null)).isFalse(); + } + + // ---- explicit grants ---- + + @Test + void explicitUserGrantAllowsUse() { + stubGrants(grant(PrincipalType.USER, 5L, AccessPermission.USE)); + assertThat( + service.canUseResource( + TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5))) + .isTrue(); + } + + @Test + void teamGrantAllowsUseForTeamMember() { + stubGrants(grant(PrincipalType.TEAM, 7L, AccessPermission.USE)); + assertThat( + service.canUseResource( + TYPE, + RID, + null, + DefaultAccessPolicy.EXPLICIT_ONLY, + userInTeam(5, 7))) + .isTrue(); + } + + @Test + void teamGrantDoesNotLeakToOtherTeams() { + stubGrants(grant(PrincipalType.TEAM, 7L, AccessPermission.USE)); + assertThat( + service.canUseResource( + TYPE, + RID, + null, + DefaultAccessPolicy.EXPLICIT_ONLY, + userInTeam(5, 99))) + .isFalse(); + } + + @Test + void manageGrantImpliesUse() { + stubGrants(grant(PrincipalType.USER, 5L, AccessPermission.MANAGE)); + assertThat( + service.canUseResource( + TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5))) + .isTrue(); + } + + @Test + void useGrantDoesNotImplyManage() { + stubGrants(grant(PrincipalType.USER, 5L, AccessPermission.USE)); + assertThat(service.canManageResource(TYPE, RID, null, user(5))).isFalse(); + } + + @Test + void manageGrantAllowsManage() { + stubGrants(grant(PrincipalType.USER, 5L, AccessPermission.MANAGE)); + assertThat(service.canManageResource(TYPE, RID, null, user(5))).isTrue(); + } + + // ---- default policies ---- + + @Test + void orgAllDefaultAllowsAnyUser() { + stubGrants(); + assertThat(service.canUseResource(TYPE, RID, null, DefaultAccessPolicy.ORG_ALL, user(5))) + .isTrue(); + } + + @Test + void explicitOnlyDefaultDeniesUngrantedUser() { + stubGrants(); + assertThat( + service.canUseResource( + TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5))) + .isFalse(); + } + + @Test + void teamLeadDefaultAllowsLeaderButNotRegularUser() { + stubGrants(); + User leader = user(5); + when(teamLeadLookup.isAnyTeamLeader(leader)).thenReturn(true); + assertThat( + service.canUseResource( + TYPE, RID, null, DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS, leader)) + .isTrue(); + + stubGrants(); + assertThat( + service.canUseResource( + TYPE, + RID, + null, + DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS, + user(6))) + .isFalse(); + } + + // ---- portal convenience (default policy ADMINS_AND_TEAM_LEADS) ---- + + @Test + void portalAccessibleByAdmin() { + assertThat(service.canAccessPortal(admin(1))).isTrue(); + } + + @Test + void portalDeniedToRegularUser() { + when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) + .thenReturn(List.of()); + assertThat(service.canAccessPortal(user(5))).isFalse(); + } + + // ---- helpers ---- + + private void stubGrants(ResourceGrant... grants) { + when(grantRepository.findByResourceTypeAndResourceId(TYPE, RID)) + .thenReturn(List.of(grants)); + } + + private ResourceGrant grant(PrincipalType type, long principalId, AccessPermission permission) { + ResourceGrant g = new ResourceGrant(); + g.setResourceType(TYPE); + g.setResourceId(RID); + g.setPrincipalType(type); + g.setPrincipalId(principalId); + g.setPermission(permission); + return g; + } + + private User user(long id) { + User u = new User(); + u.setId(id); + u.setUsername("user" + id); + return u; + } + + private User admin(long id) { + User u = user(id); + new Authority("ROLE_ADMIN", u); + return u; + } + + private User userInTeam(long id, long teamId) { + User u = user(id); + Team team = new Team(); + team.setId(teamId); + u.setTeam(team); + return u; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java new file mode 100644 index 0000000000..ea5597cb12 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java @@ -0,0 +1,80 @@ +package stirling.software.proprietary.access.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class SecretMaskerTest { + + private final SecretMasker masker = new SecretMasker(); + + @Test + void maskHidesFlatAndNestedSecretsButKeepsPlainFields() { + Map nested = new LinkedHashMap<>(); + nested.put("host", "h.example"); + nested.put("secretKey", "sk-real"); + Map config = new LinkedHashMap<>(); + config.put("bucket", "acme"); + config.put("accessKey", "AKIA123"); + config.put("connection", nested); // non-sensitive parent, sensitive child + + Map masked = masker.mask(config); + + assertThat(masked.get("bucket")).isEqualTo("acme"); + assertThat(masked.get("accessKey")).isEqualTo(SecretMasker.MASK); + @SuppressWarnings("unchecked") + Map conn = (Map) masked.get("connection"); + assertThat(conn.get("host")).isEqualTo("h.example"); + assertThat(conn.get("secretKey")).isEqualTo(SecretMasker.MASK); + } + + @Test + void mergeKeepsStoredSecretWhenIncomingIsMasked() { + Map stored = Map.of("bucket", "old", "secretKey", "REAL"); + Map incoming = Map.of("bucket", "new", "secretKey", SecretMasker.MASK); + + Map merged = masker.merge(stored, incoming); + + assertThat(merged.get("bucket")).isEqualTo("new"); // non-secret updated + assertThat(merged.get("secretKey")).isEqualTo("REAL"); // secret retained + } + + @Test + void mergeAcceptsARealNewSecret() { + Map merged = + masker.merge(Map.of("secretKey", "OLD"), Map.of("secretKey", "NEW")); + assertThat(merged.get("secretKey")).isEqualTo("NEW"); + } + + @Test + void sanitizeDropsBlankSecretsOnCreate() { + Map incoming = new LinkedHashMap<>(); + incoming.put("bucket", "b"); + incoming.put("secretKey", ""); + + Map clean = masker.sanitize(incoming); + + assertThat(clean).containsEntry("bucket", "b").doesNotContainKey("secretKey"); + } + + @Test + void deeplyNestedInputIsBoundedNotOverflowing() { + // Build a structure far deeper than the recursion cap. + Map root = new LinkedHashMap<>(); + Map cur = root; + for (int i = 0; i < 2000; i++) { + Map next = new LinkedHashMap<>(); + cur.put("child", next); + cur = next; + } + cur.put("secretKey", "deep"); + + // Must return (bounded recursion), not throw StackOverflowError. + assertThat(masker.mask(root)).isNotNull(); + assertThat(masker.sanitize(root)).isNotNull(); + assertThat(masker.merge(root, root)).isNotNull(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/CredentialEncryptionTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/CredentialEncryptionTest.java new file mode 100644 index 0000000000..e6e5d356bf --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/CredentialEncryptionTest.java @@ -0,0 +1,64 @@ +package stirling.software.proprietary.integration.crypto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Base64; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class CredentialEncryptionTest { + + @BeforeAll + static void initKey() throws Exception { + KeyGenerator generator = KeyGenerator.getInstance("AES"); + generator.init(256); + SecretKey key = generator.generateKey(); + CredentialEncryption.initialiseForTesting(key); + } + + @Test + void roundTripRecoversPlaintext() { + String plaintext = "s3-secret-key-ABC123/+=value"; + String encrypted = CredentialEncryption.encrypt(plaintext); + + assertThat(encrypted).isNotNull().isNotEqualTo(plaintext); + assertThat(CredentialEncryption.decrypt(encrypted)).isEqualTo(plaintext); + } + + @Test + void sameInputProducesDifferentCiphertext() { + String plaintext = "repeated-secret"; + + // Random IV per encryption => ciphertext must differ, but both decrypt back. + String first = CredentialEncryption.encrypt(plaintext); + String second = CredentialEncryption.encrypt(plaintext); + + assertThat(first).isNotEqualTo(second); + assertThat(CredentialEncryption.decrypt(first)).isEqualTo(plaintext); + assertThat(CredentialEncryption.decrypt(second)).isEqualTo(plaintext); + } + + @Test + void nullsPassThrough() { + assertThat(CredentialEncryption.encrypt(null)).isNull(); + assertThat(CredentialEncryption.decrypt(null)).isNull(); + } + + @Test + void tamperedCiphertextIsRejected() { + // GCM is authenticated: flipping a byte of the stored blob must fail decryption, not + // silently return corrupted plaintext. + String encrypted = CredentialEncryption.encrypt("top-secret"); + byte[] raw = Base64.getDecoder().decode(encrypted); + raw[raw.length - 1] ^= 0x01; + String tampered = Base64.getEncoder().encodeToString(raw); + + assertThatThrownBy(() -> CredentialEncryption.decrypt(tampered)) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java new file mode 100644 index 0000000000..9f3b2d84af --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java @@ -0,0 +1,182 @@ +package stirling.software.proprietary.integration.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.access.service.SecretMasker; +import stirling.software.proprietary.integration.dto.IntegrationConfigRequest; +import stirling.software.proprietary.integration.dto.IntegrationConfigResponse; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.model.User; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +/** + * Integration-glue tests. Ownership authorization and secret masking are covered by {@code + * OwnershipServiceTest} / {@code SecretMaskerTest}; here those collaborators are mocked and we + * assert this service delegates to them correctly. + */ +@ExtendWith(MockitoExtension.class) +class IntegrationConfigServiceTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Mock private IntegrationConfigRepository repository; + @Mock private OwnershipService ownership; + @Mock private SecretMasker secretMasker; + + @InjectMocks private IntegrationConfigService service; + + @Test + void createDelegatesOwnershipAndSanitizesConfig() { + when(secretMasker.sanitize(any())).thenReturn(Map.of("bucket", "b")); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + User user = user(7); + + IntegrationConfig created = + service.create(request(IntegrationType.S3, OwnerScope.USER, null), user); + + assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.S3); + assertThat(created.getName()).isEqualTo("name"); + verify(ownership) + .assignOwnership(eq(created), eq(OwnerScope.USER), isNull(), eq(user), any()); + verify(secretMasker).sanitize(any()); + } + + @Test + void updateMergesConfigViaSecretMasker() throws Exception { + IntegrationConfig cfg = config(2L); + cfg.setConfig("{\"bucket\":\"old\",\"secretKey\":\"REAL\"}"); + when(repository.findById(2L)).thenReturn(Optional.of(cfg)); + when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true); + when(secretMasker.merge(any(), any())) + .thenReturn(Map.of("bucket", "new", "secretKey", "REAL")); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + IntegrationConfigRequest req = + new IntegrationConfigRequest( + null, + null, + null, + null, + null, + null, + null, + Map.of("bucket", "new", "secretKey", "********")); + service.update(2L, req, user(7)); + + Map stored = + MAPPER.readValue(cfg.getConfig(), new TypeReference>() {}); + assertThat(stored.get("bucket")).isEqualTo("new"); + assertThat(stored.get("secretKey")).isEqualTo("REAL"); + } + + @Test + void updateForbiddenWhenCannotManage() { + IntegrationConfig cfg = config(3L); + when(repository.findById(3L)).thenReturn(Optional.of(cfg)); + when(ownership.canManage(any(), eq(cfg), any())).thenReturn(false); + + assertThatThrownBy( + () -> + service.update( + 3L, + request(IntegrationType.S3, OwnerScope.USER, null), + user(7))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void toResponseMasksConfig() { + IntegrationConfig cfg = config(4L); + cfg.setConfig("{\"secretKey\":\"x\"}"); + when(secretMasker.mask(any())).thenReturn(Map.of("secretKey", SecretMasker.MASK)); + when(ownership.canManage(any(), eq(cfg), any())).thenReturn(false); + + IntegrationConfigResponse resp = service.toResponse(cfg, user(7)); + + assertThat(resp.config().get("secretKey")).isEqualTo(SecretMasker.MASK); + assertThat(resp.canManage()).isFalse(); + } + + @Test + void listIncludesAConfigSharedViaAnExplicitGrant() { + User user = user(7); + when(repository.findByOwnerUser(user)).thenReturn(List.of()); + when(repository.findByScope(OwnerScope.SERVER)).thenReturn(List.of()); + when(ownership.grantedResourceIds(any(), eq(user))).thenReturn(Set.of("99")); + IntegrationConfig shared = config(99L); + when(repository.findById(99L)).thenReturn(Optional.of(shared)); + when(ownership.canUse(any(), eq(shared), eq(user))).thenReturn(true); + + List visible = service.listVisible(user); + + assertThat(visible).extracting(IntegrationConfig::getId).contains(99L); + } + + @Test + void nonAdminCannotChangeLockedFlag() { + IntegrationConfig cfg = config(5L); // locked = false + when(repository.findById(5L)).thenReturn(Optional.of(cfg)); + when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true); + when(ownership.isAdmin(any())).thenReturn(false); + + IntegrationConfigRequest req = + new IntegrationConfigRequest(null, null, null, null, null, true, null, null); + + assertThatThrownBy(() -> service.update(5L, req, user(7))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + // ---- helpers ---- + + private IntegrationConfig config(long id) { + IntegrationConfig cfg = new IntegrationConfig(); + cfg.setId(id); + cfg.setIntegrationType(IntegrationType.S3); + cfg.setName("cfg" + id); + cfg.setEnabled(true); + cfg.setDefaultAccess(DefaultAccessPolicy.EXPLICIT_ONLY); + return cfg; + } + + private IntegrationConfigRequest request(IntegrationType type, OwnerScope scope, Long teamId) { + return new IntegrationConfigRequest( + type, "name", scope, teamId, null, null, null, Map.of("bucket", "b")); + } + + private User user(long id) { + User u = new User(); + u.setId(id); + u.setUsername("user" + id); + return u; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java index 73b52fcd4c..6a27b4d578 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java @@ -28,6 +28,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; @@ -58,6 +59,7 @@ class AuthControllerLoginTest { @Mock private MfaService mfaService; @Mock private TotpService totpService; @Mock private RefreshRateLimitService refreshRateLimitService; + @Mock private ResourceAccessService resourceAccessService; @BeforeEach void setUp() { @@ -80,7 +82,8 @@ class AuthControllerLoginTest { refreshRateLimitService, securityProperties, applicationProperties, - new stirling.software.proprietary.service.AiUserDataService(null)); + new stirling.software.proprietary.service.AiUserDataService(null), + resourceAccessService); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java index 8f5b9e9235..611929b5b9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java @@ -28,6 +28,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; @@ -59,6 +60,7 @@ class AuthControllerMoreTest { @Mock private MfaService mfaService; @Mock private TotpService totpService; @Mock private RefreshRateLimitService refreshRateLimitService; + @Mock private ResourceAccessService resourceAccessService; @BeforeEach void setUp() { @@ -81,7 +83,8 @@ class AuthControllerMoreTest { refreshRateLimitService, securityProperties, applicationProperties, - new stirling.software.proprietary.service.AiUserDataService(null)); + new stirling.software.proprietary.service.AiUserDataService(null), + resourceAccessService); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } diff --git a/app/saas/src/main/resources/db/migration/saas/V25__resource_grants.sql b/app/saas/src/main/resources/db/migration/saas/V25__resource_grants.sql new file mode 100644 index 0000000000..f1eab58b15 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V25__resource_grants.sql @@ -0,0 +1,21 @@ +-- Resource access grants: which user/team may use a gated resource (portal, integration config). + +CREATE TABLE IF NOT EXISTS resource_grants ( + resource_grant_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + resource_type VARCHAR(64) NOT NULL, + resource_id VARCHAR(255) NOT NULL DEFAULT '', + principal_type VARCHAR(32) NOT NULL, + principal_id BIGINT NOT NULL, + permission VARCHAR(32) NOT NULL, + granted_by_user_id BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_resource_grant + ON resource_grants (resource_type, resource_id, principal_type, principal_id, permission); + +CREATE INDEX IF NOT EXISTS idx_resource_grants_lookup + ON resource_grants (resource_type, resource_id); + +CREATE INDEX IF NOT EXISTS idx_resource_grants_principal + ON resource_grants (principal_type, principal_id); diff --git a/app/saas/src/main/resources/db/migration/saas/V26__integration_configs.sql b/app/saas/src/main/resources/db/migration/saas/V26__integration_configs.sql new file mode 100644 index 0000000000..c78660248e --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V26__integration_configs.sql @@ -0,0 +1,25 @@ +-- S3/MCP/API integration configs; config_encrypted holds an AES-GCM encrypted JSON blob. + +CREATE TABLE IF NOT EXISTS integration_configs ( + integration_config_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + integration_type VARCHAR(32) NOT NULL, + name VARCHAR(255) NOT NULL, + scope VARCHAR(32) NOT NULL, + owner_user_id BIGINT, + owner_team_id BIGINT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + locked BOOLEAN NOT NULL DEFAULT FALSE, + default_access VARCHAR(32) NOT NULL DEFAULT 'EXPLICIT_ONLY', + config_encrypted TEXT, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_integration_configs_owner + ON integration_configs (owner_user_id); + +CREATE INDEX IF NOT EXISTS idx_integration_configs_type + ON integration_configs (integration_type); + +CREATE INDEX IF NOT EXISTS idx_integration_configs_scope + ON integration_configs (scope); diff --git a/frontend/editor/src/proprietary/routes/Login.test.tsx b/frontend/editor/src/proprietary/routes/Login.test.tsx index c83b9c500c..f70aa6ef95 100644 --- a/frontend/editor/src/proprietary/routes/Login.test.tsx +++ b/frontend/editor/src/proprietary/routes/Login.test.tsx @@ -114,6 +114,7 @@ describe("Login", () => { displayName: null, isAnonymous: false, isAdmin: false, + portalAccess: false, role: null, loading: false, error: null, @@ -169,6 +170,7 @@ describe("Login", () => { displayName: mockSession.user.username, isAnonymous: false, isAdmin: false, + portalAccess: false, role: mockSession.user.role, loading: false, error: null, @@ -196,6 +198,7 @@ describe("Login", () => { displayName: null, isAnonymous: false, isAdmin: false, + portalAccess: false, role: null, loading: true, error: null, diff --git a/frontend/portal/src/components/AuthGate.tsx b/frontend/portal/src/components/AuthGate.tsx index 774b269c9f..6d923abd12 100644 --- a/frontend/portal/src/components/AuthGate.tsx +++ b/frontend/portal/src/components/AuthGate.tsx @@ -1,15 +1,11 @@ import { type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { RequireAdmin } from "@shared/auth"; +import { RequirePortalAccess } from "@shared/auth"; import { Spinner } from "@shared/components"; import { LoginScreen } from "@portal/components/LoginScreen"; import { EDITOR_URL } from "@portal/auth/editorUrl"; -/** - * Module-level so the reference is stable across renders (RequireAdmin runs it - * from an effect). The portal is admin-only today; authenticated non-admins are - * bounced to the editor rather than shown an access-denied page. - */ +// Stable module-level ref; RequirePortalAccess calls it from an effect. function redirectToEditor(): void { window.location.href = EDITOR_URL; } @@ -31,17 +27,11 @@ function FullScreenMessage({ children }: { children: ReactNode }) { ); } -/** - * Gates the whole portal behind an authenticated admin session: - * - loading -> spinner - * - signed out -> login screen - * - signed in, not admin -> redirect to the editor - * - signed in admin -> the portal - */ +/** Gates the portal: login when signed out, redirect to the editor without portal access. */ export function AuthGate({ children }: { children: ReactNode }) { const { t } = useTranslation(); return ( - } onForbidden={redirectToEditor} loading={ @@ -56,6 +46,6 @@ export function AuthGate({ children }: { children: ReactNode }) { } > {children} - + ); } diff --git a/frontend/shared/auth/context.ts b/frontend/shared/auth/context.ts index fdc751996b..41ea15e889 100644 --- a/frontend/shared/auth/context.ts +++ b/frontend/shared/auth/context.ts @@ -12,6 +12,7 @@ export const AuthContext = createContext({ displayName: null, isAnonymous: false, isAdmin: false, + portalAccess: false, role: null, loading: true, error: null, diff --git a/frontend/shared/auth/guards/RequirePortalAccess.tsx b/frontend/shared/auth/guards/RequirePortalAccess.tsx new file mode 100644 index 0000000000..0e6e223013 --- /dev/null +++ b/frontend/shared/auth/guards/RequirePortalAccess.tsx @@ -0,0 +1,35 @@ +// Renders children only for a user with portal access; otherwise calls onForbidden. +import { useEffect, type ReactNode } from "react"; +import { useAuth } from "@shared/auth/context"; + +export interface RequirePortalAccessProps { + children: ReactNode; + /** Rendered when there is no session (e.g. the login panel). */ + fallback: ReactNode; + /** Invoked once when an authenticated user without portal access is detected. */ + onForbidden: () => void; + /** Rendered while the session is still resolving. */ + loading?: ReactNode; + /** Rendered for an authenticated user without portal access. */ + forbidden?: ReactNode; +} + +export function RequirePortalAccess({ + children, + fallback, + onForbidden, + loading = null, + forbidden = null, +}: RequirePortalAccessProps) { + const { session, loading: isLoading, portalAccess } = useAuth(); + + const shouldRedirect = !isLoading && !!session && !portalAccess; + useEffect(() => { + if (shouldRedirect) onForbidden(); + }, [shouldRedirect, onForbidden]); + + if (isLoading) return <>{loading}; + if (!session) return <>{fallback}; + if (!portalAccess) return <>{forbidden}; + return <>{children}; +} diff --git a/frontend/shared/auth/index.ts b/frontend/shared/auth/index.ts index 4cb9599c48..61a99c87ac 100644 --- a/frontend/shared/auth/index.ts +++ b/frontend/shared/auth/index.ts @@ -28,6 +28,10 @@ export { RequireAdmin, type RequireAdminProps, } from "@shared/auth/guards/RequireAdmin"; +export { + RequirePortalAccess, + type RequirePortalAccessProps, +} from "@shared/auth/guards/RequirePortalAccess"; // Spring backend export { diff --git a/frontend/shared/auth/spring/UseSession.tsx b/frontend/shared/auth/spring/UseSession.tsx index 3c76fbf0c6..9e0954ef82 100644 --- a/frontend/shared/auth/spring/UseSession.tsx +++ b/frontend/shared/auth/spring/UseSession.tsx @@ -195,6 +195,7 @@ export function SpringAuthProvider({ displayName: deriveDisplayName(user, translate), isAnonymous: user?.is_anonymous === true, isAdmin: isAdminRole(user?.role), + portalAccess: user?.portalAccess ?? isAdminRole(user?.role), role: user?.role ?? null, loading, error, diff --git a/frontend/shared/auth/supabase/UseSession.tsx b/frontend/shared/auth/supabase/UseSession.tsx index 51ad3da326..81cf6a9842 100644 --- a/frontend/shared/auth/supabase/UseSession.tsx +++ b/frontend/shared/auth/supabase/UseSession.tsx @@ -148,6 +148,7 @@ export function SupabaseAuthProvider({ displayName: deriveDisplayName(user, translate), isAnonymous: user?.is_anonymous === true, isAdmin: isAdminRole(user?.role), + portalAccess: user?.portalAccess ?? isAdminRole(user?.role), role: user?.role ?? null, loading, error, diff --git a/frontend/shared/auth/types.ts b/frontend/shared/auth/types.ts index 777805681d..c69adb033b 100644 --- a/frontend/shared/auth/types.ts +++ b/frontend/shared/auth/types.ts @@ -16,6 +16,8 @@ export interface AuthUser { is_anonymous?: boolean; isFirstLogin?: boolean; authenticationType?: string; + /** Whether the backend grants this user portal/processor access. */ + portalAccess?: boolean; app_metadata?: Record; } @@ -58,6 +60,8 @@ export interface AuthContextValue { isAnonymous: boolean; /** True when the current user holds an admin role. */ isAdmin: boolean; + /** True when the user may access the portal/processor (admins + ACL grants). */ + portalAccess: boolean; /** Raw backend role string, or null when signed out. */ role: string | null; loading: boolean;