diff --git a/apps/compliance-portal/src/App.tsx b/apps/compliance-portal/src/App.tsx index c8471e4e5..4d60faf52 100644 --- a/apps/compliance-portal/src/App.tsx +++ b/apps/compliance-portal/src/App.tsx @@ -18,6 +18,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import { Toast } from "@base-ui/react/toast"; +import { Toaster } from "@probo/ui/src/v2/Toaster/Toaster"; import { RouterProvider } from "react-router"; import { RelayProvider } from "#/lib/relay/RelayProvider"; @@ -26,7 +28,10 @@ import { router } from "#/routes"; export function App() { return ( - + + + + ); } diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json index 482f7dbe7..530470117 100644 --- a/apps/compliance-portal/src/_locales/en-US.json +++ b/apps/compliance-portal/src/_locales/en-US.json @@ -1,4 +1,7 @@ { + "common": { + "error": "Something went wrong" + }, "topBar": { "tagline": "Compliance Portal", "getAccess": "Get Access", @@ -24,9 +27,6 @@ "viewAll": "View all" } }, - "documents": { - "title": "Documents" - }, "requests": { "title": "Data Requests", "newRequest": "New Request" diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json index aa603edb3..c951263bc 100644 --- a/apps/compliance-portal/src/_locales/fr-FR.json +++ b/apps/compliance-portal/src/_locales/fr-FR.json @@ -1,4 +1,7 @@ { + "common": { + "error": "Une erreur est survenue" + }, "topBar": { "tagline": "Portail de conformité", "getAccess": "Obtenir l'accès", @@ -24,9 +27,6 @@ "viewAll": "Voir tout" } }, - "documents": { - "title": "Documents" - }, "requests": { "title": "Demandes de données", "newRequest": "Nouvelle demande" diff --git a/apps/compliance-portal/src/components/HeaderBand/HeaderBand.tsx b/apps/compliance-portal/src/components/HeaderBand/HeaderBand.tsx index e6635363e..bae798bd8 100644 --- a/apps/compliance-portal/src/components/HeaderBand/HeaderBand.tsx +++ b/apps/compliance-portal/src/components/HeaderBand/HeaderBand.tsx @@ -22,12 +22,16 @@ import type { PropsWithChildren } from "react"; import { headerBand } from "./variants"; -export type HeaderBandProps = PropsWithChildren; +export type HeaderBandProps = PropsWithChildren<{ + // Drop the band's bottom padding so trailing content (e.g. a tab bar) aligns + // with the band's bottom border. + flushBottomSpace?: boolean; +}>; // Pure layout shell: the white band + centered content column. Consumers (Hero, // PageHeader) supply a single content column and own its internal spacing. -export function HeaderBand({ children }: HeaderBandProps) { - const { band, inner } = headerBand(); +export function HeaderBand({ children, flushBottomSpace }: HeaderBandProps) { + const { band, inner } = headerBand({ flushBottomSpace }); return (
diff --git a/apps/compliance-portal/src/components/HeaderBand/variants.ts b/apps/compliance-portal/src/components/HeaderBand/variants.ts index 048a7640b..a72135567 100644 --- a/apps/compliance-portal/src/components/HeaderBand/variants.ts +++ b/apps/compliance-portal/src/components/HeaderBand/variants.ts @@ -25,7 +25,19 @@ import { tv } from "tailwind-variants/lite"; // Hero and the page headers so the band is defined once. export const headerBand = tv({ slots: { - band: "flex w-full flex-col items-center border-b border-sand-a3 bg-sand-1 px-8 py-8", + band: "flex w-full flex-col items-center border-b border-sand-a3 bg-sand-1 px-8", inner: "w-full max-w-5xl", }, + variants: { + // Drops the bottom padding and pulls the content down 1px so a trailing + // toolbar's own bottom border (e.g. an underlined tab bar) overlaps — and + // paints over — the band's bottom border instead of stacking below it. + flushBottomSpace: { + true: { band: "pt-8", inner: "-mb-px" }, + false: { band: "py-8" }, + }, + }, + defaultVariants: { + flushBottomSpace: false, + }, }); diff --git a/apps/compliance-portal/src/components/PageHeader/PageHeader.tsx b/apps/compliance-portal/src/components/PageHeader/PageHeader.tsx index 578fe8ef8..79d56232d 100644 --- a/apps/compliance-portal/src/components/PageHeader/PageHeader.tsx +++ b/apps/compliance-portal/src/components/PageHeader/PageHeader.tsx @@ -33,15 +33,18 @@ export interface PageHeaderProps { actions?: ReactNode; // Optional toolbar (tabs / filters / search) rendered below the title. children?: ReactNode; + // Sit the toolbar flush on the band's bottom border (e.g. an underlined tab + // bar whose divider doubles as the header divider). + flushBottomSpace?: boolean; } // Page header for the Trust Center nav pages: a size-7 title in the shared white // band, with an optional count, inline actions, and a toolbar slot below. -export function PageHeader({ title, count, actions, children }: PageHeaderProps) { +export function PageHeader({ title, count, actions, children, flushBottomSpace }: PageHeaderProps) { const { content, titleRow, count: countSlot } = pageHeader(); return ( - +
diff --git a/apps/compliance-portal/src/pages/documents/DocumentsPage.tsx b/apps/compliance-portal/src/pages/documents/DocumentsPage.tsx new file mode 100644 index 000000000..96294406c --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/DocumentsPage.tsx @@ -0,0 +1,179 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { useEffect, useRef, useTransition } from "react"; +import { useTranslation } from "react-i18next"; +import type { PreloadedQuery } from "react-relay"; +import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay"; + +import { ListErrorBoundary } from "#/components/errors/ListErrorBoundary"; +import { PageHeader } from "#/components/PageHeader/PageHeader"; + +import type { DocumentsPage_query$key } from "./__generated__/DocumentsPage_query.graphql"; +import type { DocumentsPageQuery } from "./__generated__/DocumentsPageQuery.graphql"; +import type { DocumentsPageRefetchQuery } from "./__generated__/DocumentsPageRefetchQuery.graphql"; +import { AuditReportListItem } from "./_components/AuditReportListItem"; +import { DocumentListItem } from "./_components/DocumentListItem"; +import { DocumentSection } from "./_components/DocumentSection"; +import { DocumentsEmpty } from "./_components/DocumentsEmpty"; +import { DocumentsToolbar } from "./_components/DocumentsToolbar"; +import { TrustCenterFileListItem } from "./_components/TrustCenterFileListItem"; +import { groupByField } from "./_lib/groupByField"; +import { toQueryVariables } from "./_lib/toQueryVariables"; +import { useDocumentTab } from "./_lib/useDocumentTab"; + +export const documentsPageQuery = graphql` + query DocumentsPageQuery($visibility: TrustCenterVisibility) { + ...DocumentsPage_query @arguments(visibility: $visibility) + } +`; + +const documentsPageFragment = graphql` + fragment DocumentsPage_query on Query + @refetchable(queryName: "DocumentsPageRefetchQuery") + @argumentDefinitions(visibility: { type: "TrustCenterVisibility" }) { + currentTrustCenter @required(action: THROW) { + documents(first: 250, filter: { visibility: $visibility }) { + edges { + node { + id + documentType + ...DocumentListItem_document + } + } + } + audits(first: 250, filter: { visibility: $visibility }) { + edges { + node { + id + reportFile { + id + } + ...AuditReportListItem_audit + } + } + } + trustCenterFiles(first: 250, filter: { visibility: $visibility }) { + edges { + node { + id + category + ...TrustCenterFileListItem_file + } + } + } + } + } +`; + +interface DocumentsPageProps { + queryRef: PreloadedQuery; +} + +// Trust Center documents page: a unified list of published documents, uploaded +// files, and audit reports, grouped into category sections. The All/Public/ +// Private tabs are backed by a server-side visibility filter. +export function DocumentsPage({ queryRef }: DocumentsPageProps) { + const { t } = useTranslation("documents"); + const root = usePreloadedQuery(documentsPageQuery, queryRef); + const [data, refetch] = useRefetchableFragment( + documentsPageFragment, + root, + ); + + const { tab } = useDocumentTab(); + const [isRefetching, startTransition] = useTransition(); + + // The initial query already loaded with the URL's tab; only refetch on + // subsequent tab changes, inside a transition so the toolbar and current + // results stay mounted (dimmed via `isRefetching`) while the slice loads. + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + startTransition(() => { + refetch(toQueryVariables(tab), { fetchPolicy: "store-or-network" }); + }); + }, [refetch, tab]); + + const { currentTrustCenter } = data; + const documentNodes = currentTrustCenter.documents.edges.map(edge => edge.node); + const fileNodes = currentTrustCenter.trustCenterFiles.edges.map(edge => edge.node); + const auditNodes = currentTrustCenter.audits.edges + .map(edge => edge.node) + .filter(node => node.reportFile != null); + + const total = documentNodes.length + fileNodes.length + auditNodes.length; + + const documentGroups = groupByField(documentNodes, node => node.documentType) + .sort((a, b) => t(`types.${a.key}`).localeCompare(t(`types.${b.key}`))); + const fileGroups = groupByField(fileNodes, node => node.category) + .sort((a, b) => a.key.localeCompare(b.key)); + + return ( + <> + + + +
+
+ startTransition(() => { + refetch(toQueryVariables(tab), { fetchPolicy: "network-only", onComplete: done }); + })} + > + {total === 0 + ? + : ( + <> + {auditNodes.length > 0 && ( + + {auditNodes.map(node => ( + + ))} + + )} + {documentGroups.map(group => ( + + {group.nodes.map(node => ( + + ))} + + ))} + {fileGroups.map(group => ( + + {group.nodes.map(node => ( + + ))} + + ))} + + )} + +
+
+ + ); +} diff --git a/apps/compliance-portal/src/pages/documents/DocumentsPageLoader.tsx b/apps/compliance-portal/src/pages/documents/DocumentsPageLoader.tsx new file mode 100644 index 000000000..9007d1857 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/DocumentsPageLoader.tsx @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { useEffect, useRef } from "react"; +import { useQueryLoader } from "react-relay"; + +import type { DocumentsPageQuery } from "./__generated__/DocumentsPageQuery.graphql"; +import { toQueryVariables } from "./_lib/toQueryVariables"; +import { useDocumentTab } from "./_lib/useDocumentTab"; +import { DocumentsPage, documentsPageQuery } from "./DocumentsPage"; +import { DocumentsPageSkeleton } from "./DocumentsPageSkeleton"; + +export default function DocumentsPageLoader() { + const { tab } = useDocumentTab(); + const [queryRef, loadQuery] = useQueryLoader(documentsPageQuery); + + // Seed the first fetch with the URL's tab; later changes are handled by the + // page's refetch. + const initialVariables = useRef(toQueryVariables(tab)); + + useEffect(() => { + loadQuery(initialVariables.current); + }, [loadQuery]); + + if (!queryRef) { + return ; + } + + return ; +} diff --git a/apps/compliance-portal/src/pages/documents/DocumentsPageSkeleton.tsx b/apps/compliance-portal/src/pages/documents/DocumentsPageSkeleton.tsx new file mode 100644 index 000000000..b7c6e4c0a --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/DocumentsPageSkeleton.tsx @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { TabsSkeleton } from "@probo/ui/src/v2/Tabs/TabsSkeleton"; +import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton"; +import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton"; + +import { HeaderBand } from "#/components/HeaderBand/HeaderBand"; + +const SECTION_PLACEHOLDERS = ["a", "b"]; +const ROW_PLACEHOLDERS = ["x", "y", "z"]; + +export function DocumentsPageSkeleton() { + return ( + <> + +
+ + +
+
+
+
+ {SECTION_PLACEHOLDERS.map(section => ( +
+ +
+ {ROW_PLACEHOLDERS.map(row => ( +
+ ))} +
+
+ ))} +
+
+ + ); +} diff --git a/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx b/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx new file mode 100644 index 000000000..46dd6087a --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Text } from "@probo/ui/src/v2/typography/Text"; +import { graphql, useFragment } from "react-relay"; + +import { useMutation } from "#/lib/relay/useMutation"; + +import { openExportedFile } from "../_lib/openExportedFile"; + +import type { AuditReportListItem_audit$key } from "./__generated__/AuditReportListItem_audit.graphql"; +import type { AuditReportListItemExportMutation } from "./__generated__/AuditReportListItemExportMutation.graphql"; +import { DocumentAccessAction } from "./DocumentAccessAction"; +import { documentListItem } from "./variants"; + +const auditReportListItemFragment = graphql` + fragment AuditReportListItem_audit on Audit @throwOnFieldError { + framework { + name + } + reportFile { + id + fileName + isUserAuthorized + access { + status + } + } + } +`; + +const exportReportMutation = graphql` + mutation AuditReportListItemExportMutation($input: ExportReportPDFInput!) { + exportReportPDF(input: $input) { + data + } + } +`; + +interface AuditReportListItemProps { + auditKey: AuditReportListItem_audit$key; +} + +// A single audit report row: the framework name, the report file name, and an +// access action that opens the exported report when the viewer is authorized. +// Renders nothing when the audit has no report file. +export function AuditReportListItem({ auditKey }: AuditReportListItemProps) { + const audit = useFragment(auditReportListItemFragment, auditKey); + const [exportReport, isExporting] = useMutation(exportReportMutation); + const { root, content } = documentListItem(); + + const report = audit.reportFile; + if (report == null) { + return null; + } + + const handleView = () => { + exportReport({ + variables: { input: { reportId: report.id } }, + onCompleted: response => openExportedFile(response.exportReportPDF.data), + }).catch(() => { + // The mutation failure is already surfaced through a toast. + }); + }; + + return ( +
+
+ + {audit.framework.name} + + + {report.fileName} + +
+ +
+ ); +} diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx new file mode 100644 index 000000000..33f6d977c --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { ArrowSquareOutIcon, ClockIcon, LockSimpleIcon } from "@phosphor-icons/react"; +import { Button } from "@probo/ui/src/v2/Button/Button"; +import { useTranslation } from "react-i18next"; + +interface DocumentAccessActionProps { + // Whether the viewer may open the document (public or granted access). + isAuthorized: boolean; + // Whether an access request is already pending for this document. + requested: boolean; + // Opens the document; only invoked when authorized. + onView: () => void; + // Whether the export/open is in flight. + isViewing: boolean; +} + +// Trailing access control for a document entry: "View" when authorized, a +// pending label when access was requested, otherwise a (currently inert) "Get +// Access" call to action. +export function DocumentAccessAction({ isAuthorized, requested, onView, isViewing }: DocumentAccessActionProps) { + const { t } = useTranslation("documents"); + + if (isAuthorized) { + return ( + + ); + } + + if (requested) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx new file mode 100644 index 000000000..b4daf5272 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Text } from "@probo/ui/src/v2/typography/Text"; +import { useTranslation } from "react-i18next"; +import { graphql, useFragment } from "react-relay"; + +import { useMutation } from "#/lib/relay/useMutation"; + +import { openExportedFile } from "../_lib/openExportedFile"; + +import type { DocumentListItem_document$key } from "./__generated__/DocumentListItem_document.graphql"; +import type { DocumentListItemExportMutation } from "./__generated__/DocumentListItemExportMutation.graphql"; +import { DocumentAccessAction } from "./DocumentAccessAction"; +import { documentListItem } from "./variants"; + +const documentListItemFragment = graphql` + fragment DocumentListItem_document on Document @throwOnFieldError { + id + title + documentType + isUserAuthorized + access { + status + } + } +`; + +const exportDocumentMutation = graphql` + mutation DocumentListItemExportMutation($input: ExportDocumentPDFInput!) { + exportDocumentPDF(input: $input) { + data + } + } +`; + +interface DocumentListItemProps { + documentKey: DocumentListItem_document$key; +} + +// A single Probo document row: title, its document type, and an access action +// that opens the exported PDF when the viewer is authorized. +export function DocumentListItem({ documentKey }: DocumentListItemProps) { + const { t } = useTranslation("documents"); + const document = useFragment(documentListItemFragment, documentKey); + const [exportDocument, isExporting] = useMutation(exportDocumentMutation); + const { root, content } = documentListItem(); + + const handleView = () => { + exportDocument({ + variables: { input: { documentId: document.id } }, + onCompleted: response => openExportedFile(response.exportDocumentPDF.data), + }).catch(() => { + // The mutation failure is already surfaced through a toast. + }); + }; + + return ( +
+
+ + {document.title} + + + {t(`types.${document.documentType}`)} + +
+ +
+ ); +} diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentSection.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentSection.tsx new file mode 100644 index 000000000..a332a1b82 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentSection.tsx @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Text } from "@probo/ui/src/v2/typography/Text"; +import type { ReactNode } from "react"; + +import { documentSection } from "./variants"; + +interface DocumentSectionProps { + title: ReactNode; + description?: ReactNode; + // The list rows (document / file / report list items). + children: ReactNode; +} + +// One category group: a localized header above a bordered list of rows. +export function DocumentSection({ title, description, children }: DocumentSectionProps) { + const { root, header, list } = documentSection(); + + return ( +
+
+ + {title} + + {description != null && ( + + {description} + + )} +
+
{children}
+
+ ); +} diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentsEmpty.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentsEmpty.tsx new file mode 100644 index 000000000..b25148944 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentsEmpty.tsx @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { FileTextIcon } from "@phosphor-icons/react"; +import { useTranslation } from "react-i18next"; + +import { EmptyState } from "#/components/EmptyState/EmptyState"; + +import { useDocumentTab } from "../_lib/useDocumentTab"; + +// Empty state for the documents list. When a Public/Private tab is active it +// notes the filter; otherwise it states the trust center publishes no documents. +export function DocumentsEmpty() { + const { t } = useTranslation("documents"); + const { tab } = useDocumentTab(); + const filtered = tab !== "all"; + + return ( + } + title={filtered ? t("empty.filteredTitle") : t("empty.title")} + description={filtered ? t("empty.filteredDescription") : t("empty.description")} + /> + ); +} diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentsToolbar.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentsToolbar.tsx new file mode 100644 index 000000000..15920b2af --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentsToolbar.tsx @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Tabs } from "@probo/ui/src/v2/Tabs/Tabs"; +import { TabsIndicator } from "@probo/ui/src/v2/Tabs/TabsIndicator"; +import { TabsList } from "@probo/ui/src/v2/Tabs/TabsList"; +import { TabsTab } from "@probo/ui/src/v2/Tabs/TabsTab"; +import { useTranslation } from "react-i18next"; + +import type { DocumentTab } from "../_lib/useDocumentTab"; +import { DOCUMENT_TABS, useDocumentTab } from "../_lib/useDocumentTab"; + +// Access filter for the documents page: All / Public / Private tabs. Writes the +// active tab to the URL; the page reacts and refetches the matching slice. +export function DocumentsToolbar() { + const { t } = useTranslation("documents"); + const { tab, setTab } = useDocumentTab(); + + return ( + setTab(value as DocumentTab)}> + + {DOCUMENT_TABS.map(value => ( + + {t(`tabs.${value}`)} + + ))} + + + + ); +} diff --git a/apps/compliance-portal/src/pages/documents/_components/TrustCenterFileListItem.tsx b/apps/compliance-portal/src/pages/documents/_components/TrustCenterFileListItem.tsx new file mode 100644 index 000000000..0eb8f58d7 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_components/TrustCenterFileListItem.tsx @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Text } from "@probo/ui/src/v2/typography/Text"; +import { graphql, useFragment } from "react-relay"; + +import { useMutation } from "#/lib/relay/useMutation"; + +import { openExportedFile } from "../_lib/openExportedFile"; + +import type { TrustCenterFileListItem_file$key } from "./__generated__/TrustCenterFileListItem_file.graphql"; +import type { TrustCenterFileListItemExportMutation } from "./__generated__/TrustCenterFileListItemExportMutation.graphql"; +import { DocumentAccessAction } from "./DocumentAccessAction"; +import { documentListItem } from "./variants"; + +const trustCenterFileListItemFragment = graphql` + fragment TrustCenterFileListItem_file on TrustCenterFile @throwOnFieldError { + id + name + category + isUserAuthorized + access { + status + } + } +`; + +const exportTrustCenterFileMutation = graphql` + mutation TrustCenterFileListItemExportMutation($input: ExportTrustCenterFileInput!) { + exportTrustCenterFile(input: $input) { + data + } + } +`; + +interface TrustCenterFileListItemProps { + fileKey: TrustCenterFileListItem_file$key; +} + +// A single uploaded trust-center file row: name, its category, and an access +// action that opens the exported file when the viewer is authorized. +export function TrustCenterFileListItem({ fileKey }: TrustCenterFileListItemProps) { + const file = useFragment(trustCenterFileListItemFragment, fileKey); + const [exportFile, isExporting] = useMutation(exportTrustCenterFileMutation); + const { root, content } = documentListItem(); + + const handleView = () => { + exportFile({ + variables: { input: { trustCenterFileId: file.id } }, + onCompleted: response => openExportedFile(response.exportTrustCenterFile.data), + }).catch(() => { + // The mutation failure is already surfaced through a toast. + }); + }; + + return ( +
+
+ + {file.name} + + + {file.category} + +
+ +
+ ); +} diff --git a/apps/compliance-portal/src/pages/documents/_components/variants.ts b/apps/compliance-portal/src/pages/documents/_components/variants.ts new file mode 100644 index 000000000..375c7e7e7 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_components/variants.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { tv } from "tailwind-variants/lite"; + +// Document list: category sections, each a titled block above a bordered list. +// Entries share a common layout (title + accent metadata + trailing access +// action). + +export const documentSection = tv({ + slots: { + root: "flex flex-col gap-3", + header: "flex flex-col gap-0.5", + list: "overflow-hidden rounded-4 border border-sand-a4 bg-sand-1", + }, +}); + +export const documentListItem = tv({ + slots: { + root: "flex items-center gap-4 border-b border-sand-a3 px-4 py-3 last:border-b-0", + content: "flex min-w-0 flex-1 flex-col gap-0.5", + }, +}); diff --git a/apps/compliance-portal/src/pages/documents/_lib/groupByField.ts b/apps/compliance-portal/src/pages/documents/_lib/groupByField.ts new file mode 100644 index 000000000..87d7736f1 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_lib/groupByField.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +export interface FieldGroup { + key: string; + nodes: T[]; +} + +// Groups nodes by a derived string key, preserving first-seen order of the +// keys. Presentational only — the server already applied the visibility filter. +export function groupByField( + nodes: readonly T[], + getKey: (node: T) => string, +): FieldGroup[] { + const groups = new Map(); + + for (const node of nodes) { + const key = getKey(node); + const existing = groups.get(key); + if (existing) { + existing.push(node); + } else { + groups.set(key, [node]); + } + } + + return [...groups.entries()].map(([key, groupNodes]) => ({ key, nodes: groupNodes })); +} diff --git a/apps/compliance-portal/src/pages/documents/_lib/openExportedFile.ts b/apps/compliance-portal/src/pages/documents/_lib/openExportedFile.ts new file mode 100644 index 000000000..467eaee27 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_lib/openExportedFile.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Opens a base64 data URI (returned by the export mutations) in a new browser +// tab. Decodes to a Blob so the object URL carries the right MIME type and the +// browser previews the PDF inline instead of navigating to a huge data: URL. +export function openExportedFile(dataUri: string): void { + const commaIndex = dataUri.indexOf(","); + const base64 = commaIndex === -1 ? dataUri : dataUri.slice(commaIndex + 1); + const mimeMatch = dataUri.match(/^data:([^;]+);/); + const mimeType = mimeMatch?.[1] ?? "application/octet-stream"; + + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + + const blob = new Blob([bytes], { type: mimeType }); + const objectUrl = URL.createObjectURL(blob); + window.open(objectUrl, "_blank", "noopener,noreferrer"); + + // Give the new tab time to claim the URL before releasing it. + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); +} diff --git a/apps/compliance-portal/src/pages/documents/_lib/toQueryVariables.ts b/apps/compliance-portal/src/pages/documents/_lib/toQueryVariables.ts new file mode 100644 index 000000000..c24bef0d1 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_lib/toQueryVariables.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { DocumentsPageQuery$variables } from "../__generated__/DocumentsPageQuery.graphql"; + +import type { DocumentTab } from "./useDocumentTab"; + +// Maps the active tab to the typed GraphQL visibility variable. "All" omits the +// filter (both public and private); the other tabs pin a single visibility so +// the server returns just that slice. +export function toQueryVariables(tab: DocumentTab): DocumentsPageQuery$variables { + switch (tab) { + case "public": + return { visibility: "PUBLIC" }; + case "private": + return { visibility: "PRIVATE" }; + default: + return { visibility: null }; + } +} diff --git a/apps/compliance-portal/src/pages/documents/_lib/useDocumentTab.ts b/apps/compliance-portal/src/pages/documents/_lib/useDocumentTab.ts new file mode 100644 index 000000000..8e8d1945c --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_lib/useDocumentTab.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { useCallback } from "react"; +import { useSearchParams } from "react-router"; + +export type DocumentTab = "all" | "public" | "private"; + +export const DOCUMENT_TABS: readonly DocumentTab[] = ["all", "public", "private"]; + +interface DocumentTabState { + tab: DocumentTab; + setTab: (value: DocumentTab) => void; +} + +// Active documents tab (All / Public / Private), persisted in the URL so it is +// shareable and survives reloads. Pure URL state — no local state or effects — +// so the loader, page, and toolbar can all read it without racing. +export function useDocumentTab(): DocumentTabState { + const [searchParams, setSearchParams] = useSearchParams(); + + const raw = searchParams.get("tab"); + const tab: DocumentTab = raw === "public" || raw === "private" ? raw : "all"; + + const setTab = useCallback((value: DocumentTab) => { + setSearchParams((previous) => { + const next = new URLSearchParams(previous); + if (value === "all") { + next.delete("tab"); + } else { + next.set("tab", value); + } + return next; + }, { replace: true }); + }, [setSearchParams]); + + return { tab, setTab }; +} diff --git a/apps/compliance-portal/src/pages/documents/_locales/en-US.json b/apps/compliance-portal/src/pages/documents/_locales/en-US.json new file mode 100644 index 000000000..ef388f872 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_locales/en-US.json @@ -0,0 +1,34 @@ +{ + "title": "Documents", + "tabs": { + "all": "All", + "public": "Public", + "private": "Private" + }, + "sections": { + "reports": "Compliance reports" + }, + "types": { + "OTHER": "Other", + "GOVERNANCE": "Governance", + "POLICY": "Policies", + "PROCEDURE": "Procedures", + "PLAN": "Plans", + "REGISTER": "Registers", + "RECORD": "Records", + "REPORT": "Reports", + "TEMPLATE": "Templates", + "STATEMENT_OF_APPLICABILITY": "Statement of Applicability" + }, + "actions": { + "view": "View", + "getAccess": "Get Access", + "requested": "Access requested" + }, + "empty": { + "title": "No documents available", + "description": "This trust center has not published any documents yet.", + "filteredTitle": "No documents match this filter", + "filteredDescription": "Try a different tab to see available documents." + } +} diff --git a/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json b/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json new file mode 100644 index 000000000..52b45d2f2 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json @@ -0,0 +1,34 @@ +{ + "title": "Documents", + "tabs": { + "all": "Tous", + "public": "Public", + "private": "Privé" + }, + "sections": { + "reports": "Rapports de conformité" + }, + "types": { + "OTHER": "Autres", + "GOVERNANCE": "Gouvernance", + "POLICY": "Politiques", + "PROCEDURE": "Procédures", + "PLAN": "Plans", + "REGISTER": "Registres", + "RECORD": "Enregistrements", + "REPORT": "Rapports", + "TEMPLATE": "Modèles", + "STATEMENT_OF_APPLICABILITY": "Déclaration d'applicabilité" + }, + "actions": { + "view": "Consulter", + "getAccess": "Obtenir l'accès", + "requested": "Accès demandé" + }, + "empty": { + "title": "Aucun document disponible", + "description": "Ce trust center n'a pas encore publié de documents.", + "filteredTitle": "Aucun document ne correspond à ce filtre", + "filteredDescription": "Essayez un autre onglet pour voir les documents disponibles." + } +} diff --git a/apps/compliance-portal/src/pages/documents/routes.ts b/apps/compliance-portal/src/pages/documents/routes.ts new file mode 100644 index 000000000..1f81dd001 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/routes.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { lazy } from "@probo/react-lazy"; +import type { AppRoute } from "@probo/routes"; + +import { DocumentsPageSkeleton } from "./DocumentsPageSkeleton"; + +export const documentRoutes = [ + { + path: "documents", + Fallback: DocumentsPageSkeleton, + Component: lazy(() => import("./DocumentsPageLoader")), + }, +] satisfies AppRoute[]; diff --git a/apps/compliance-portal/src/routes.tsx b/apps/compliance-portal/src/routes.tsx index 8ad4dd3f3..6453462ec 100644 --- a/apps/compliance-portal/src/routes.tsx +++ b/apps/compliance-portal/src/routes.tsx @@ -25,6 +25,7 @@ import { createBrowserRouter } from "react-router"; import { PageErrorBoundary } from "#/components/errors/PageErrorBoundary"; import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary"; import { getPathPrefix } from "#/lib/http/pathPrefix"; +import { documentRoutes } from "#/pages/documents/routes"; import { HomePageSkeleton } from "#/pages/HomePageSkeleton"; import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton"; import { subprocessorRoutes } from "#/pages/subprocessors/routes"; @@ -48,10 +49,7 @@ const routes = [ Fallback: HomePageSkeleton, Component: lazy(() => import("#/pages/HomePageLoader")), }, - { - path: "documents", - Component: lazy(() => import("#/pages/DocumentsPage")), - }, + ...documentRoutes, ...subprocessorRoutes, ...updateRoutes, { diff --git a/packages/ui/src/v2/Tabs/Tabs.stories.tsx b/packages/ui/src/v2/Tabs/Tabs.stories.tsx new file mode 100644 index 000000000..815a24362 --- /dev/null +++ b/packages/ui/src/v2/Tabs/Tabs.stories.tsx @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { useState } from "react"; + +import { Tabs } from "./Tabs"; +import { TabsIndicator } from "./TabsIndicator"; +import { TabsList } from "./TabsList"; +import { TabsSkeleton } from "./TabsSkeleton"; +import { TabsTab } from "./TabsTab"; + +export default { + title: "v2/Tabs", + component: Tabs, +}; + +export function Default() { + return ( + + + All + Public + Private + + + + ); +} + +export function Controlled() { + const [value, setValue] = useState("all"); + return ( +
+ setValue(next as string)}> + + All + Public + Private + + + + + Selected: + {" "} + {value} + +
+ ); +} + +export function Skeleton() { + return ; +} diff --git a/apps/compliance-portal/src/pages/DocumentsPage.tsx b/packages/ui/src/v2/Tabs/Tabs.tsx similarity index 73% rename from apps/compliance-portal/src/pages/DocumentsPage.tsx rename to packages/ui/src/v2/Tabs/Tabs.tsx index cbd59b7a7..099d78de7 100644 --- a/apps/compliance-portal/src/pages/DocumentsPage.tsx +++ b/packages/ui/src/v2/Tabs/Tabs.tsx @@ -18,13 +18,12 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { useTranslation } from "react-i18next"; +import { Tabs as BaseTabs } from "@base-ui/react/tabs"; -import { PageHeader } from "#/components/PageHeader/PageHeader"; +// Root of the tabs (Radix "Tabs"). Controlled the same way as Base UI's Tabs: +// `value` / `onValueChange`, or left uncontrolled. See contrib/claude/ui.md. +export type TabsProps = BaseTabs.Root.Props; -// Toolbar (All/Public/Private tabs, framework filter, search) and the document -// count are deferred until the v2 Tabs/Select/TextField components exist. -export default function DocumentsPage() { - const { t } = useTranslation(); - return ; +export function Tabs(props: TabsProps) { + return ; } diff --git a/packages/ui/src/v2/Tabs/TabsIndicator.tsx b/packages/ui/src/v2/Tabs/TabsIndicator.tsx new file mode 100644 index 000000000..c7f3b3da9 --- /dev/null +++ b/packages/ui/src/v2/Tabs/TabsIndicator.tsx @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Tabs as BaseTabs } from "@base-ui/react/tabs"; +import type { ComponentProps } from "react"; + +import { tabsIndicator } from "./variants"; + +export type TabsIndicatorProps = Omit, "className"> & { + className?: string; +}; + +// The sliding underline tracking the active tab, positioned from Base UI's +// `--active-tab-left` / `--active-tab-width` CSS variables. +export function TabsIndicator(props: TabsIndicatorProps) { + const { className, ...rest } = props; + + return ; +} diff --git a/packages/ui/src/v2/Tabs/TabsList.tsx b/packages/ui/src/v2/Tabs/TabsList.tsx new file mode 100644 index 000000000..aa48cb792 --- /dev/null +++ b/packages/ui/src/v2/Tabs/TabsList.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Tabs as BaseTabs } from "@base-ui/react/tabs"; +import type { ComponentProps } from "react"; + +import { tabsList } from "./variants"; + +export type TabsListProps = Omit, "className"> & { + className?: string; +}; + +// The tab bar: holds the tab triggers and (optionally) the sliding indicator. +export function TabsList(props: TabsListProps) { + const { className, ...rest } = props; + + return ; +} diff --git a/packages/ui/src/v2/Tabs/TabsSkeleton.tsx b/packages/ui/src/v2/Tabs/TabsSkeleton.tsx new file mode 100644 index 000000000..78fc6ad53 --- /dev/null +++ b/packages/ui/src/v2/Tabs/TabsSkeleton.tsx @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { ComponentProps } from "react"; + +import { tabsSkeleton } from "./variants"; + +export type TabsSkeletonProps = Omit, "children"> & { + // Number of placeholder tabs to render (defaults to 3). + count?: number; +}; + +// Loading placeholder paired with the tab bar: a row of pulse blocks over the +// shared bottom border. +export function TabsSkeleton(props: TabsSkeletonProps) { + const { count = 3, className, ...rest } = props; + const { root, item } = tabsSkeleton(); + + return ( +
+ {Array.from({ length: count }, (_, index) => ( + + ))} +
+ ); +} diff --git a/packages/ui/src/v2/Tabs/TabsTab.tsx b/packages/ui/src/v2/Tabs/TabsTab.tsx new file mode 100644 index 000000000..d376af73e --- /dev/null +++ b/packages/ui/src/v2/Tabs/TabsTab.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Tabs as BaseTabs } from "@base-ui/react/tabs"; +import type { ComponentProps } from "react"; + +import { tabsTab } from "./variants"; + +export type TabsTabProps = Omit, "className"> & { + className?: string; +}; + +// A single tab trigger. Selected state is driven by Base UI (`data-active`). +export function TabsTab(props: TabsTabProps) { + const { className, ...rest } = props; + + return ; +} diff --git a/packages/ui/src/v2/Tabs/variants.ts b/packages/ui/src/v2/Tabs/variants.ts new file mode 100644 index 000000000..923be3815 --- /dev/null +++ b/packages/ui/src/v2/Tabs/variants.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { tv } from "tailwind-variants/lite"; + +// Tabs (Radix "Tabs" over Base UI's Tabs). An underlined tab bar whose active +// item is tracked by a sliding indicator positioned from Base UI's +// `--active-tab-*` CSS variables. + +export const tabsList = tv({ + base: "relative flex items-center gap-1 border-b border-sand-a3", +}); + +export const tabsTab = tv({ + base: [ + "relative flex h-full cursor-pointer items-center justify-center gap-2 px-2 py-4 text-2 text-sand-a11", + "select-none outline-none transition-colors", + "hover:text-sand-12", + "focus-visible:ring-2 focus-visible:ring-sand-8 focus-visible:ring-offset-1 focus-visible:ring-offset-sand-1", + "data-active:font-medium data-active:text-sand-12", + "data-disabled:pointer-events-none data-disabled:opacity-50", + ], +}); + +export const tabsIndicator = tv({ + base: [ + "absolute bottom-0 left-0 h-[2px] w-(--active-tab-width) rounded-1 bg-sand-12", + "translate-x-(--active-tab-left) transition-all duration-200 ease-out", + ], +}); + +export const tabsSkeleton = tv({ + slots: { + root: "flex items-center gap-4 border-b border-sand-a3", + item: "my-4 h-5 animate-pulse rounded-1 bg-sand-3", + }, +}); diff --git a/packages/ui/src/v2/Toaster/Toaster.tsx b/packages/ui/src/v2/Toaster/Toaster.tsx new file mode 100644 index 000000000..85d408a64 --- /dev/null +++ b/packages/ui/src/v2/Toaster/Toaster.tsx @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Toast } from "@base-ui/react/toast"; +import { XIcon } from "@phosphor-icons/react"; + +import { toaster } from "./variants"; + +type ToastType = "success" | "error" | "neutral"; + +function resolveType(type: string | undefined): ToastType { + return type === "success" || type === "error" ? type : "neutral"; +} + +// Renders the active toasts from the Base UI toast manager. Mount once at the +// app root, inside a ``. +export function Toaster() { + const { toasts } = Toast.useToastManager(); + const slots = toaster(); + + return ( + + + {toasts.map((toast) => { + const variant = { type: resolveType(toast.type) }; + + return ( + +
+ + +
+ + + +
+ ); + })} +
+
+ ); +} diff --git a/packages/ui/src/v2/Toaster/variants.ts b/packages/ui/src/v2/Toaster/variants.ts new file mode 100644 index 000000000..51d4f1bd5 --- /dev/null +++ b/packages/ui/src/v2/Toaster/variants.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { tv } from "tailwind-variants/lite"; + +// Toaster (Radix "Toast" over Base UI's Toast). A bottom-right stack of +// dismissible notifications, colored by type. + +export const toaster = tv({ + slots: { + viewport: "fixed right-4 bottom-4 z-50 flex w-full max-w-sm flex-col gap-2 outline-none", + toast: [ + "pointer-events-auto flex items-start gap-3 rounded-3 border border-sand-a6 bg-sand-1 p-3 shadow-3", + "transition-all duration-200 ease-out", + "data-starting-style:translate-x-4 data-starting-style:opacity-0", + "data-ending-style:translate-x-4 data-ending-style:opacity-0", + ], + content: "flex min-w-0 flex-1 flex-col gap-1", + title: "text-2 font-medium text-sand-12", + description: "text-2 break-words text-sand-11", + close: "flex size-5 shrink-0 items-center justify-center rounded-1 text-sand-a10 outline-none transition-colors hover:bg-sand-3 hover:text-sand-12 [&_svg]:size-4", + }, + variants: { + type: { + success: { toast: "border-green-6 bg-green-2", title: "text-green-12" }, + error: { toast: "border-red-6 bg-red-2", title: "text-red-12" }, + neutral: {}, + }, + }, + defaultVariants: { + type: "neutral", + }, +}); diff --git a/pkg/coredata/audit_filter.go b/pkg/coredata/audit_filter.go index 7906ff6a6..d37075143 100644 --- a/pkg/coredata/audit_filter.go +++ b/pkg/coredata/audit_filter.go @@ -43,6 +43,11 @@ func NewAuditTrustCenterFilter() *AuditFilter { } } +func (f *AuditFilter) WithTrustCenterVisibilities(visibilities ...TrustCenterVisibility) *AuditFilter { + f.trustCenterVisibilities = visibilities + return f +} + func (f *AuditFilter) SQLArguments() pgx.NamedArgs { args := pgx.NamedArgs{} diff --git a/pkg/coredata/document_filter.go b/pkg/coredata/document_filter.go index 499eae2dd..a56f80dbd 100644 --- a/pkg/coredata/document_filter.go +++ b/pkg/coredata/document_filter.go @@ -63,6 +63,11 @@ func (f *DocumentFilter) WithPublished(published *bool) *DocumentFilter { return f } +func (f *DocumentFilter) WithTrustCenterVisibilities(visibilities ...TrustCenterVisibility) *DocumentFilter { + f.trustCenterVisibilities = visibilities + return f +} + func (f *DocumentFilter) WithEmployeeIdentityID(identityID *gid.GID, modes ...EmployeeFilterMode) *DocumentFilter { f.employeeIdentityID = identityID f.employeeFilterModes = modes diff --git a/pkg/server/api/trust/v1/graphql/trust_center.graphql b/pkg/server/api/trust/v1/graphql/trust_center.graphql index f3d9b4705..4767b1836 100644 --- a/pkg/server/api/trust/v1/graphql/trust_center.graphql +++ b/pkg/server/api/trust/v1/graphql/trust_center.graphql @@ -16,6 +16,7 @@ type TrustCenter implements Node { after: CursorKey last: Int before: CursorKey + filter: TrustCenterVisibilityFilter ): DocumentConnection! @goField(forceResolver: true) audits( @@ -23,6 +24,7 @@ type TrustCenter implements Node { after: CursorKey last: Int before: CursorKey + filter: TrustCenterVisibilityFilter ): AuditConnection! @goField(forceResolver: true) subprocessors( @@ -50,6 +52,7 @@ type TrustCenter implements Node { after: CursorKey last: Int before: CursorKey + filter: TrustCenterVisibilityFilter ): TrustCenterFileConnection! @goField(forceResolver: true) complianceFrameworks( @@ -95,6 +98,18 @@ enum DocumentType ) } +enum TrustCenterVisibility + @goModel(model: "go.probo.inc/probo/pkg/coredata.TrustCenterVisibility") { + PRIVATE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.TrustCenterVisibilityPrivate") + PUBLIC + @goEnum(value: "go.probo.inc/probo/pkg/coredata.TrustCenterVisibilityPublic") +} + +input TrustCenterVisibilityFilter { + visibility: TrustCenterVisibility +} + type Document implements Node @nda { id: ID! title: String! diff --git a/pkg/server/api/trust/v1/trust_center_resolvers.go b/pkg/server/api/trust/v1/trust_center_resolvers.go index 391a8c83f..084ae323d 100644 --- a/pkg/server/api/trust/v1/trust_center_resolvers.go +++ b/pkg/server/api/trust/v1/trust_center_resolvers.go @@ -758,7 +758,7 @@ func (r *trustCenterResolver) Organization(ctx context.Context, obj *types.Trust } // Documents is the resolver for the documents field. -func (r *trustCenterResolver) Documents(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.DocumentConnection, error) { +func (r *trustCenterResolver) Documents(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey, filter *types.TrustCenterVisibilityFilter) (*types.DocumentConnection, error) { compliancePage := compliancepage.CompliancePageFromContext(ctx) scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID) trustService := r.trust @@ -768,7 +768,12 @@ func (r *trustCenterResolver) Documents(ctx context.Context, obj *types.TrustCen } cursor := types.NewCursor(first, after, last, before, pageOrderBy) - documentPage, err := trustService.Documents.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor) + documentFilter := coredata.NewDocumentTrustCenterFilter() + if filter != nil && filter.Visibility != nil { + documentFilter = documentFilter.WithTrustCenterVisibilities(*filter.Visibility) + } + + documentPage, err := trustService.Documents.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor, documentFilter) if err != nil { r.logger.ErrorCtx(ctx, "cannot list public documents", log.Error(err)) return nil, gqlutils.Internal(ctx) @@ -778,7 +783,7 @@ func (r *trustCenterResolver) Documents(ctx context.Context, obj *types.TrustCen } // Audits is the resolver for the audits field. -func (r *trustCenterResolver) Audits(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.AuditConnection, error) { +func (r *trustCenterResolver) Audits(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey, filter *types.TrustCenterVisibilityFilter) (*types.AuditConnection, error) { compliancePage := compliancepage.CompliancePageFromContext(ctx) scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID) trustService := r.trust @@ -788,7 +793,12 @@ func (r *trustCenterResolver) Audits(ctx context.Context, obj *types.TrustCenter } cursor := types.NewCursor(first, after, last, before, pageOrderBy) - auditPage, err := trustService.Audits.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor) + auditFilter := coredata.NewAuditTrustCenterFilter() + if filter != nil && filter.Visibility != nil { + auditFilter = auditFilter.WithTrustCenterVisibilities(*filter.Visibility) + } + + auditPage, err := trustService.Audits.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor, auditFilter) if err != nil { r.logger.ErrorCtx(ctx, "cannot list public audits", log.Error(err)) return nil, gqlutils.Internal(ctx) @@ -888,7 +898,7 @@ func (r *trustCenterResolver) References(ctx context.Context, obj *types.TrustCe } // TrustCenterFiles is the resolver for the trustCenterFiles field. -func (r *trustCenterResolver) TrustCenterFiles(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.TrustCenterFileConnection, error) { +func (r *trustCenterResolver) TrustCenterFiles(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey, filter *types.TrustCenterVisibilityFilter) (*types.TrustCenterFileConnection, error) { compliancePage := compliancepage.CompliancePageFromContext(ctx) scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID) trustService := r.trust @@ -898,14 +908,19 @@ func (r *trustCenterResolver) TrustCenterFiles(ctx context.Context, obj *types.T } cursor := types.NewCursor(first, after, last, before, pageOrderBy) - filter := coredata.NewTrustCenterFileFilter( - coredata.WithTrustCenterFileVisibilities( - coredata.TrustCenterVisibilityPublic, - coredata.TrustCenterVisibilityPrivate, - ), + visibilities := []coredata.TrustCenterVisibility{ + coredata.TrustCenterVisibilityPublic, + coredata.TrustCenterVisibilityPrivate, + } + if filter != nil && filter.Visibility != nil { + visibilities = []coredata.TrustCenterVisibility{*filter.Visibility} + } + + fileFilter := coredata.NewTrustCenterFileFilter( + coredata.WithTrustCenterFileVisibilities(visibilities...), ) - trustCenterFilePage, err := trustService.TrustCenterFiles.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor, filter) + trustCenterFilePage, err := trustService.TrustCenterFiles.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor, fileFilter) if err != nil { r.logger.ErrorCtx(ctx, "cannot list public trust center files", log.Error(err)) return nil, gqlutils.Internal(ctx) diff --git a/pkg/trust/audit_service.go b/pkg/trust/audit_service.go index d20863657..47c925a22 100644 --- a/pkg/trust/audit_service.go +++ b/pkg/trust/audit_service.go @@ -88,14 +88,17 @@ func (s AuditService) ListForOrganizationId( scope coredata.Scoper, organizationID gid.GID, cursor *page.Cursor[coredata.AuditOrderField], + filter *coredata.AuditFilter, ) (*page.Page[*coredata.Audit, coredata.AuditOrderField], error) { var audits coredata.Audits + if filter == nil { + filter = coredata.NewAuditTrustCenterFilter() + } + err := s.svc.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - filter := coredata.NewAuditTrustCenterFilter() - err := audits.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter) if err != nil { return fmt.Errorf("cannot load audits: %w", err) diff --git a/pkg/trust/compliance_page_service.go b/pkg/trust/compliance_page_service.go index b5501b3b9..3fb6420c6 100644 --- a/pkg/trust/compliance_page_service.go +++ b/pkg/trust/compliance_page_service.go @@ -271,7 +271,7 @@ func (s *Service) fetchDocumentIDs(ctx context.Context, scope coredata.Scoper, o }, ) - result, err := s.Documents.ListForOrganizationId(ctx, scope, orgID, cursor) + result, err := s.Documents.ListForOrganizationId(ctx, scope, orgID, cursor, nil) if err != nil { return nil, fmt.Errorf("cannot list documents: %w", err) } @@ -345,7 +345,7 @@ func (s *Service) fetchDocumentIDs(ctx context.Context, scope coredata.Scoper, o }, ) - result, err := s.Audits.ListForOrganizationId(ctx, scope, orgID, cursor) + result, err := s.Audits.ListForOrganizationId(ctx, scope, orgID, cursor, nil) if err != nil { return nil, fmt.Errorf("cannot list audits: %w", err) } @@ -454,7 +454,7 @@ func (s *Service) fetchDocuments(ctx context.Context, scope coredata.Scoper, org }, ) - result, err := s.Documents.ListForOrganizationId(ctx, scope, orgID, cursor) + result, err := s.Documents.ListForOrganizationId(ctx, scope, orgID, cursor, nil) if err != nil { return nil, fmt.Errorf("cannot list documents: %w", err) } @@ -500,7 +500,7 @@ func (s *Service) fetchAudits(ctx context.Context, scope coredata.Scoper, orgID }, ) - result, err := s.Audits.ListForOrganizationId(ctx, scope, orgID, cursor) + result, err := s.Audits.ListForOrganizationId(ctx, scope, orgID, cursor, nil) if err != nil { return nil, fmt.Errorf("cannot list audits: %w", err) } diff --git a/pkg/trust/document_service.go b/pkg/trust/document_service.go index 04e642bdb..f23fac9a4 100644 --- a/pkg/trust/document_service.go +++ b/pkg/trust/document_service.go @@ -55,14 +55,17 @@ func (s *DocumentService) ListForOrganizationId( scope coredata.Scoper, organizationID gid.GID, cursor *page.Cursor[coredata.DocumentOrderField], + filter *coredata.DocumentFilter, ) (*page.Page[*coredata.Document, coredata.DocumentOrderField], error) { var documents coredata.Documents + if filter == nil { + filter = coredata.NewDocumentTrustCenterFilter() + } + err := s.svc.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - filter := coredata.NewDocumentTrustCenterFilter() - if err := documents.LoadPublishedByOrganizationID(ctx, conn, scope, organizationID, cursor, filter); err != nil { return fmt.Errorf("cannot load published documents: %w", err) }