Add documents page to the compliance portal

Build the Trust Center documents page: a unified list of published
documents, uploaded files, and audit reports, grouped into category
sections. An All/Public/Private tab bar filters the list by trust
center visibility.

Expose that filter over the trust v1 API by adding a
TrustCenterVisibility enum and a shared TrustCenterVisibilityFilter
input, wiring it through the documents, audits, and trustCenterFiles
connections down to the existing coredata SQL filters. "All" keeps the
default public+private slice; the other tabs pin a single visibility.

Access controls are display-only for now (auth is handled separately):
authorized or public entries open their exported PDF via the export
mutations, requested entries show a pending state, and everything else
shows an inert Get Access affordance.

Add the v2 Tabs and Toaster kit components (Base UI headless) needed by
the page and mount a toast provider at the app root for mutation
feedback.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-15 18:33:46 +02:00
parent 3c3276a90b
commit 158861ccdd
41 changed files with 1589 additions and 43 deletions

View File

@@ -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 (
<RelayProvider>
<RouterProvider router={router} />
<Toast.Provider>
<RouterProvider router={router} />
<Toaster />
</Toast.Provider>
</RelayProvider>
);
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 (
<header className={band()}>

View File

@@ -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,
},
});

View File

@@ -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 (
<HeaderBand>
<HeaderBand flushBottomSpace={flushBottomSpace}>
<div className={content()}>
<div className={titleRow()}>
<Heading level={1} size={7} weight="medium" highContrast>

View File

@@ -0,0 +1,179 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<DocumentsPageQuery>;
}
// 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>(documentsPageQuery, queryRef);
const [data, refetch] = useRefetchableFragment<DocumentsPageRefetchQuery, DocumentsPage_query$key>(
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 (
<>
<PageHeader title={t("title")} count={total} flushBottomSpace>
<DocumentsToolbar />
</PageHeader>
<div className="flex w-full flex-col items-center px-8 py-8">
<div
aria-busy={isRefetching}
className={`flex w-full max-w-5xl flex-col gap-8 transition-opacity duration-150 ${isRefetching ? "opacity-60" : ""}`}
>
<ListErrorBoundary
onRetry={done => startTransition(() => {
refetch(toQueryVariables(tab), { fetchPolicy: "network-only", onComplete: done });
})}
>
{total === 0
? <DocumentsEmpty />
: (
<>
{auditNodes.length > 0 && (
<DocumentSection title={t("sections.reports")}>
{auditNodes.map(node => (
<AuditReportListItem key={node.id} auditKey={node} />
))}
</DocumentSection>
)}
{documentGroups.map(group => (
<DocumentSection key={`type:${group.key}`} title={t(`types.${group.key}`)}>
{group.nodes.map(node => (
<DocumentListItem key={node.id} documentKey={node} />
))}
</DocumentSection>
))}
{fileGroups.map(group => (
<DocumentSection key={`category:${group.key}`} title={group.key}>
{group.nodes.map(node => (
<TrustCenterFileListItem key={node.id} fileKey={node} />
))}
</DocumentSection>
))}
</>
)}
</ListErrorBoundary>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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>(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 <DocumentsPageSkeleton />;
}
return <DocumentsPage queryRef={queryRef} />;
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 (
<>
<HeaderBand flushBottomSpace>
<div className="flex w-full flex-col gap-2">
<HeadingSkeleton size={7} className="w-64" />
<TabsSkeleton />
</div>
</HeaderBand>
<div className="flex w-full flex-col items-center px-8 py-8">
<div className="flex w-full max-w-5xl flex-col gap-8">
{SECTION_PLACEHOLDERS.map(section => (
<div key={section} className="flex flex-col gap-3">
<TextSkeleton size={3} className="w-40" />
<div className="overflow-hidden rounded-4 border border-sand-a4 bg-sand-1">
{ROW_PLACEHOLDERS.map(row => (
<div key={row} className="h-16 animate-pulse border-b border-sand-a3 bg-sand-2 last:border-b-0" />
))}
</div>
</div>
))}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<AuditReportListItemExportMutation>(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 (
<div className={root()}>
<div className={content()}>
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
{audit.framework.name}
</Text>
<Text size={1} color="gold" className="truncate">
{report.fileName}
</Text>
</div>
<DocumentAccessAction
isAuthorized={report.isUserAuthorized}
requested={report.access?.status === "REQUESTED"}
onView={handleView}
isViewing={isExporting}
/>
</div>
);
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 (
<Button
variant="ghost"
color="neutral"
highContrast
iconStart={<ArrowSquareOutIcon />}
loading={isViewing}
onClick={onView}
>
{t("actions.view")}
</Button>
);
}
if (requested) {
return (
<Button variant="ghost" color="neutral" disabled iconStart={<ClockIcon />}>
{t("actions.requested")}
</Button>
);
}
return (
<Button variant="ghost" color="neutral" highContrast iconStart={<LockSimpleIcon />}>
{t("actions.getAccess")}
</Button>
);
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<DocumentListItemExportMutation>(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 (
<div className={root()}>
<div className={content()}>
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
{document.title}
</Text>
<Text size={1} color="gold" className="truncate">
{t(`types.${document.documentType}`)}
</Text>
</div>
<DocumentAccessAction
isAuthorized={document.isUserAuthorized}
requested={document.access?.status === "REQUESTED"}
onView={handleView}
isViewing={isExporting}
/>
</div>
);
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 (
<section className={root()}>
<div className={header()}>
<Text size={3} weight="medium" color="neutral" highContrast role="heading" aria-level={2}>
{title}
</Text>
{description != null && (
<Text size={2} color="neutral">
{description}
</Text>
)}
</div>
<div className={list()}>{children}</div>
</section>
);
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 (
<EmptyState
icon={<FileTextIcon />}
title={filtered ? t("empty.filteredTitle") : t("empty.title")}
description={filtered ? t("empty.filteredDescription") : t("empty.description")}
/>
);
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 (
<Tabs value={tab} onValueChange={value => setTab(value as DocumentTab)}>
<TabsList>
{DOCUMENT_TABS.map(value => (
<TabsTab key={value} value={value}>
{t(`tabs.${value}`)}
</TabsTab>
))}
<TabsIndicator />
</TabsList>
</Tabs>
);
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<TrustCenterFileListItemExportMutation>(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 (
<div className={root()}>
<div className={content()}>
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
{file.name}
</Text>
<Text size={1} color="gold" className="truncate">
{file.category}
</Text>
</div>
<DocumentAccessAction
isAuthorized={file.isUserAuthorized}
requested={file.access?.status === "REQUESTED"}
onView={handleView}
isViewing={isExporting}
/>
</div>
);
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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",
},
});

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<T> {
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<T>(
nodes: readonly T[],
getKey: (node: T) => string,
): FieldGroup<T>[] {
const groups = new Map<string, T[]>();
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 }));
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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);
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 };
}
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 };
}

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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[];

View File

@@ -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,
{

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 (
<Tabs defaultValue="all">
<TabsList>
<TabsTab value="all">All</TabsTab>
<TabsTab value="public">Public</TabsTab>
<TabsTab value="private">Private</TabsTab>
<TabsIndicator />
</TabsList>
</Tabs>
);
}
export function Controlled() {
const [value, setValue] = useState<string>("all");
return (
<div className="flex flex-col gap-3">
<Tabs value={value} onValueChange={next => setValue(next as string)}>
<TabsList>
<TabsTab value="all">All</TabsTab>
<TabsTab value="public">Public</TabsTab>
<TabsTab value="private">Private</TabsTab>
<TabsIndicator />
</TabsList>
</Tabs>
<span className="text-2 text-sand-11">
Selected:
{" "}
{value}
</span>
</div>
);
}
export function Skeleton() {
return <TabsSkeleton />;
}

View File

@@ -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 <PageHeader title={t("documents.title")} />;
export function Tabs(props: TabsProps) {
return <BaseTabs.Root {...props} />;
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<ComponentProps<typeof BaseTabs.Indicator>, "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 <BaseTabs.Indicator className={tabsIndicator({ className })} {...rest} />;
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<ComponentProps<typeof BaseTabs.List>, "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 <BaseTabs.List className={tabsList({ className })} {...rest} />;
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<ComponentProps<"div">, "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 (
<div className={root({ className })} aria-hidden {...rest}>
{Array.from({ length: count }, (_, index) => (
<span key={index} className={item()} style={{ width: `${64 + (index % 3) * 16}px` }} />
))}
</div>
);
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<ComponentProps<typeof BaseTabs.Tab>, "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 <BaseTabs.Tab className={tabsTab({ className })} {...rest} />;
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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",
},
});

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 `<Toast.Provider>`.
export function Toaster() {
const { toasts } = Toast.useToastManager();
const slots = toaster();
return (
<Toast.Portal>
<Toast.Viewport className={slots.viewport()}>
{toasts.map((toast) => {
const variant = { type: resolveType(toast.type) };
return (
<Toast.Root key={toast.id} toast={toast} className={slots.toast(variant)}>
<div className={slots.content()}>
<Toast.Title className={slots.title(variant)} />
<Toast.Description className={slots.description()} />
</div>
<Toast.Close className={slots.close()} aria-label="Close">
<XIcon />
</Toast.Close>
</Toast.Root>
);
})}
</Toast.Viewport>
</Toast.Portal>
);
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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",
},
});

View File

@@ -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{}

View File

@@ -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

View File

@@ -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!

View File

@@ -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)

View File

@@ -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)

View File

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

View File

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