From 9353d85d032be542c1335a710be3041b21f930cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 22 Jul 2026 19:11:07 +0200 Subject: [PATCH] Add bulk request access to portal documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visitors could only request access to one document, report, or file at a time. Add row checkboxes and a bottom selection toolbar to the compliance portal documents page so a visitor can select several rows and request access to all still-locked ones in a single round-trip. Expose a selection-scoped requestAccesses mutation that forwards the chosen id lists to the existing RequestPortalAccess service (one transaction, one NDA/auth gate). The resolver loads and tenant-checks every target before requesting so a foreign id is rejected before any access row is written, and echoes the affected nodes so the client flips each row to pending in place. Add a styled Base UI Checkbox to the v2 kit, a local selection context shared by the independent row fragments, and mirror the new selection strings across all locales. Signed-off-by: Émile Ré --- .../src/pages/documents/DocumentsPage.tsx | 50 +++++- .../_components/AuditReportListItem.tsx | 6 + .../CompliancePortalFileListItem.tsx | 4 + .../documents/_components/DocumentEntry.tsx | 19 +++ .../_components/DocumentListItem.tsx | 4 + .../_components/DocumentsSelectionBar.tsx | 90 ++++++++++ .../_lib/DocumentSelectionContext.tsx | 106 ++++++++++++ .../documents/_lib/useBulkRequestAccess.ts | 148 ++++++++++++++++ .../src/pages/documents/_locales/de-DE.json | 7 + .../src/pages/documents/_locales/en-US.json | 7 + .../src/pages/documents/_locales/es-ES.json | 7 + .../src/pages/documents/_locales/fr-FR.json | 7 + .../src/pages/documents/_locales/id-ID.json | 7 + .../src/pages/documents/_locales/it-IT.json | 7 + .../src/pages/documents/_locales/ja-JP.json | 7 + .../src/pages/documents/_locales/ko-KR.json | 7 + .../src/pages/documents/_locales/pl-PL.json | 7 + .../src/pages/documents/_locales/pt-PT.json | 7 + .../src/pages/documents/_locales/tr-TR.json | 7 + .../src/pages/documents/_locales/uk-UA.json | 7 + .../src/pages/documents/_locales/zh-CN.json | 7 + .../src/pages/documents/variants.ts | 2 +- ...compliance_portal_request_accesses_test.go | 158 ++++++++++++++++++ .../ui/src/v2/Checkbox/Checkbox.stories.tsx | 56 +++++++ packages/ui/src/v2/Checkbox/Checkbox.tsx | 45 +++++ .../ui/src/v2/Checkbox/CheckboxSkeleton.tsx | 32 ++++ packages/ui/src/v2/Checkbox/variants.ts | 41 +++++ .../v1/compliance_portal_resolvers.go | 104 ++++++++++++ .../v1/graphql/compliance_portal.graphql | 18 ++ 29 files changed, 971 insertions(+), 3 deletions(-) create mode 100644 apps/compliance-portal/src/pages/documents/_components/DocumentsSelectionBar.tsx create mode 100644 apps/compliance-portal/src/pages/documents/_lib/DocumentSelectionContext.tsx create mode 100644 apps/compliance-portal/src/pages/documents/_lib/useBulkRequestAccess.ts create mode 100644 e2e/trust/compliance_portal_request_accesses_test.go create mode 100644 packages/ui/src/v2/Checkbox/Checkbox.stories.tsx create mode 100644 packages/ui/src/v2/Checkbox/Checkbox.tsx create mode 100644 packages/ui/src/v2/Checkbox/CheckboxSkeleton.tsx create mode 100644 packages/ui/src/v2/Checkbox/variants.ts diff --git a/apps/compliance-portal/src/pages/documents/DocumentsPage.tsx b/apps/compliance-portal/src/pages/documents/DocumentsPage.tsx index a68f9d6de..745e06bc7 100644 --- a/apps/compliance-portal/src/pages/documents/DocumentsPage.tsx +++ b/apps/compliance-portal/src/pages/documents/DocumentsPage.tsx @@ -35,7 +35,10 @@ import { CompliancePortalFileListItem } from "./_components/CompliancePortalFile import { DocumentListItem } from "./_components/DocumentListItem"; import { DocumentSection } from "./_components/DocumentSection"; import { DocumentsEmpty } from "./_components/DocumentsEmpty"; +import { DocumentsSelectionBar } from "./_components/DocumentsSelectionBar"; import { DocumentsToolbar } from "./_components/DocumentsToolbar"; +import type { DocumentSelectionEntry } from "./_lib/DocumentSelectionContext"; +import { DocumentSelectionProvider } from "./_lib/DocumentSelectionContext"; import { toQueryVariables } from "./_lib/toQueryVariables"; import { useDocumentTab } from "./_lib/useDocumentTab"; import { documentsLayout } from "./variants"; @@ -56,6 +59,10 @@ const documentsPageFragment = graphql` node { id documentType + isUserAuthorized + access { + status + } ...DocumentListItem_document } } @@ -66,6 +73,10 @@ const documentsPageFragment = graphql` id reportFile { id + isUserAuthorized + access { + status + } } ...AuditReportListItem_audit } @@ -76,6 +87,10 @@ const documentsPageFragment = graphql` node { id category + isUserAuthorized + access { + status + } ...CompliancePortalFileListItem_file } } @@ -149,6 +164,36 @@ export function DocumentsPage({ queryRef }: DocumentsPageProps) { const total = documentNodes.length + fileNodes.length + auditNodes.length; + // A row is "locked" (an access request would do something) when the viewer is + // not authorized and no request is already pending. Computed at page level so + // the selection bar can count locked rows without reaching into each fragment. + const isLocked = (isUserAuthorized: boolean, status: string | null | undefined) => + !isUserAuthorized && status !== "REQUESTED"; + + const selectionEntries: DocumentSelectionEntry[] = [ + ...documentNodes.map(node => ({ + id: node.id, + kind: "Document" as const, + locked: isLocked(node.isUserAuthorized, node.access?.status), + })), + ...auditNodes.flatMap((node): DocumentSelectionEntry[] => { + const report = node.reportFile; + if (report == null) { + return []; + } + return [{ + id: report.id, + kind: "AuditReport", + locked: isLocked(report.isUserAuthorized, report.access?.status), + }]; + }), + ...fileNodes.map(node => ({ + id: node.id, + kind: "CompliancePortalFile" as const, + locked: isLocked(node.isUserAuthorized, node.access?.status), + })), + ]; + const documentGroups = Object.entries(groupBy(documentNodes, node => node.documentType)) .map(([key, nodes]) => ({ key, nodes })) .sort((a, b) => t(`types.${a.key}`).localeCompare(t(`types.${b.key}`))); @@ -159,7 +204,7 @@ export function DocumentsPage({ queryRef }: DocumentsPageProps) { const { page, results } = documentsLayout({ busy: isRefetching }); return ( - <> + @@ -200,6 +245,7 @@ export function DocumentsPage({ queryRef }: DocumentsPageProps) { - + + ); } diff --git a/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx b/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx index 7c35d7005..5c46a511f 100644 --- a/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx @@ -22,6 +22,7 @@ import { graphql, useFragment } from "react-relay"; import { useLocalizedPath } from "#/lib/i18n/useLocale"; +import { useDocumentSelection } from "../_lib/DocumentSelectionContext"; import { useRequestReportAccess } from "../_lib/useAccessRequest"; import type { AuditReportListItem_audit$key } from "./__generated__/AuditReportListItem_audit.graphql"; @@ -58,11 +59,14 @@ export function AuditReportListItem({ auditKey }: AuditReportListItemProps) { // Hook must run unconditionally; the empty id is never used when there is no // report file (the component returns null below). const { requestAccess, isRequesting } = useRequestReportAccess(report?.id ?? ""); + const { isSelected, toggle } = useDocumentSelection(); if (report == null) { return null; } + const reportId = report.id; + return ( toggle(reportId)} /> ); } diff --git a/apps/compliance-portal/src/pages/documents/_components/CompliancePortalFileListItem.tsx b/apps/compliance-portal/src/pages/documents/_components/CompliancePortalFileListItem.tsx index 0390ec71e..5a741489f 100644 --- a/apps/compliance-portal/src/pages/documents/_components/CompliancePortalFileListItem.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/CompliancePortalFileListItem.tsx @@ -22,6 +22,7 @@ import { graphql, useFragment } from "react-relay"; import { useLocalizedPath } from "#/lib/i18n/useLocale"; +import { useDocumentSelection } from "../_lib/DocumentSelectionContext"; import { useRequestFileAccess } from "../_lib/useAccessRequest"; import type { CompliancePortalFileListItem_file$key } from "./__generated__/CompliancePortalFileListItem_file.graphql"; @@ -50,6 +51,7 @@ export function CompliancePortalFileListItem({ fileKey }: CompliancePortalFileLi const localizedPath = useLocalizedPath(); const file = useFragment(compliancePortalFileListItemFragment, fileKey); const { requestAccess, isRequesting } = useRequestFileAccess(file.id); + const { isSelected, toggle } = useDocumentSelection(); return ( toggle(file.id)} /> ); } diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx index 84918a713..821e06382 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx @@ -18,6 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import { Checkbox } from "@probo/ui/src/v2/Checkbox/Checkbox"; import { ListItem } from "@probo/ui/src/v2/List/ListItem"; import { ListItemContent } from "@probo/ui/src/v2/List/ListItemContent"; import { Text } from "@probo/ui/src/v2/typography/Text"; @@ -42,6 +43,11 @@ interface DocumentEntryProps { onGetAccess: () => void; // Whether the access request is in flight. isRequesting: boolean; + // Whether this row is currently part of the multi-selection. + selected?: boolean; + // Toggles this row's membership in the multi-selection. When provided, a + // leading checkbox is rendered; omit it to render a non-selectable row. + onSelectedChange?: () => void; } // Presentational row shared by the document / file / report list items: a title @@ -55,6 +61,8 @@ export function DocumentEntry({ viewHref, onGetAccess, isRequesting, + selected, + onSelectedChange, }: DocumentEntryProps) { const { t } = useTranslation("documents"); @@ -71,6 +79,17 @@ export function DocumentEntry({ mobileHitLabel != null ? "max-sm:cursor-pointer max-sm:hover:bg-sand-2" : "", ].filter(Boolean).join(" ")} > + {onSelectedChange != null && ( + // Sit above the mobile full-row overlay (z-1) so ticking a row never + // triggers the row's view / request-access activation. + + )} + {title} diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx index a98073507..75583bad8 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx @@ -23,6 +23,7 @@ import { graphql, useFragment } from "react-relay"; import { useLocalizedPath } from "#/lib/i18n/useLocale"; +import { useDocumentSelection } from "../_lib/DocumentSelectionContext"; import { useRequestDocumentAccess } from "../_lib/useAccessRequest"; import type { DocumentListItem_document$key } from "./__generated__/DocumentListItem_document.graphql"; @@ -52,6 +53,7 @@ export function DocumentListItem({ documentKey }: DocumentListItemProps) { const localizedPath = useLocalizedPath(); const document = useFragment(documentListItemFragment, documentKey); const { requestAccess, isRequesting } = useRequestDocumentAccess(document.id); + const { isSelected, toggle } = useDocumentSelection(); return ( toggle(document.id)} /> ); } diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentsSelectionBar.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentsSelectionBar.tsx new file mode 100644 index 000000000..684535de3 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentsSelectionBar.tsx @@ -0,0 +1,90 @@ +// 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 { LockSimpleIcon } from "@phosphor-icons/react"; +import { Button } from "@probo/ui/src/v2/Button/Button"; +import { Text } from "@probo/ui/src/v2/typography/Text"; +import { useTranslation } from "react-i18next"; + +import type { DocumentSelectionEntry } from "../_lib/DocumentSelectionContext"; +import { useDocumentSelection } from "../_lib/DocumentSelectionContext"; +import { useBulkRequestAccess } from "../_lib/useBulkRequestAccess"; + +interface DocumentsSelectionBarProps { + // Every selectable row on the page, used to resolve the current selection into + // concrete entries (kind + lock state) and to power "Select all". + entries: DocumentSelectionEntry[]; +} + +// Bottom action bar shown while rows are selected: the selection count, clear / +// select-all shortcuts, and a bulk "Request Access" that acts only on the +// selected rows still locked. +export function DocumentsSelectionBar({ entries }: DocumentsSelectionBarProps) { + const { t } = useTranslation("documents"); + const { selectedIds, selectAll, clear } = useDocumentSelection(); + const { requestAccess, isRequesting } = useBulkRequestAccess(clear); + + if (selectedIds.size === 0) { + return null; + } + + const lockedSelected = entries.filter(entry => selectedIds.has(entry.id) && entry.locked); + const lockedCount = lockedSelected.length; + + const handleRequestAccess = () => { + if (lockedCount === 0) { + return; + } + requestAccess(lockedSelected.map(entry => ({ id: entry.id, kind: entry.kind }))); + }; + + return ( +
+
+ + {t("selection.count", { count: selectedIds.size })} + +
+ + + +
+
+
+ ); +} diff --git a/apps/compliance-portal/src/pages/documents/_lib/DocumentSelectionContext.tsx b/apps/compliance-portal/src/pages/documents/_lib/DocumentSelectionContext.tsx new file mode 100644 index 000000000..e171246be --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_lib/DocumentSelectionContext.tsx @@ -0,0 +1,106 @@ +// 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 { ReactNode } from "react"; +import { createContext, useCallback, useContext, useMemo, useState } from "react"; + +// The three selectable resource kinds on the documents page. They map onto the +// requestAccesses mutation's id lists (Document → documentIds, AuditReport → +// reportIds using the report file id, CompliancePortalFile → compliancePortalFileIds). +export type DocumentKind = "Document" | "AuditReport" | "CompliancePortalFile"; + +// A selectable row, resolved at page level so the toolbar can compute counts +// (e.g. how many selected rows are still locked) without touching each fragment. +export interface DocumentSelectionEntry { + id: string; + kind: DocumentKind; + locked: boolean; +} + +interface DocumentSelectionContextValue { + selectedIds: ReadonlySet; + isSelected: (id: string) => boolean; + toggle: (id: string) => void; + selectAll: (ids: string[]) => void; + clear: () => void; +} + +const DocumentSelectionContext = createContext(null); + +interface DocumentSelectionProviderProps { + // Selection is cleared whenever this value changes (e.g. the active tab), so + // switching between slices never carries a stale selection across. + resetKey?: string; + children: ReactNode; +} + +export function DocumentSelectionProvider({ resetKey, children }: DocumentSelectionProviderProps) { + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + + // Reset the selection during render when the key changes (e.g. the active + // tab). This is React's "adjust state on prop change" pattern, avoiding an + // effect and the extra commit it would cost. + const [prevResetKey, setPrevResetKey] = useState(resetKey); + if (resetKey !== prevResetKey) { + setPrevResetKey(resetKey); + setSelectedIds(new Set()); + } + + const toggle = useCallback((id: string) => { + setSelectedIds((current) => { + const next = new Set(current); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const selectAll = useCallback((ids: string[]) => { + setSelectedIds(new Set(ids)); + }, []); + + const clear = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + const isSelected = useCallback((id: string) => selectedIds.has(id), [selectedIds]); + + const value = useMemo( + () => ({ selectedIds, isSelected, toggle, selectAll, clear }), + [selectedIds, isSelected, toggle, selectAll, clear], + ); + + return ( + + {children} + + ); +} + +export function useDocumentSelection() { + const context = useContext(DocumentSelectionContext); + if (context == null) { + throw new Error("useDocumentSelection must be used within a DocumentSelectionProvider"); + } + return context; +} diff --git a/apps/compliance-portal/src/pages/documents/_lib/useBulkRequestAccess.ts b/apps/compliance-portal/src/pages/documents/_lib/useBulkRequestAccess.ts new file mode 100644 index 000000000..4fcdd72bb --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_lib/useBulkRequestAccess.ts @@ -0,0 +1,148 @@ +// 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 { UnAuthenticatedError } from "@probo/relay"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router"; +import type { PayloadError } from "relay-runtime"; +import { graphql } from "relay-runtime"; + +import { gateRedirectPath, getSafeContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl"; +import { useLocale } from "#/lib/i18n/useLocale"; +import { useMutation } from "#/lib/relay/useMutation"; + +import type { useBulkRequestAccessMutation } from "./__generated__/useBulkRequestAccessMutation.graphql"; +import type { DocumentKind } from "./DocumentSelectionContext"; + +// One selection-scoped call for the whole batch. The payload echoes each +// affected node's updated access record so Relay flips every requested row to +// its "pending" state in place, without a refetch. +const bulkMutation = graphql` + mutation useBulkRequestAccessMutation($input: RequestAccessesInput!) { + requestAccesses(input: $input) { + documents { + id + access { + id + status + } + } + audits { + id + reportFile { + id + access { + id + status + } + } + } + files { + id + access { + id + status + } + } + } + } +`; + +export interface BulkAccessRequestEntry { + id: string; + kind: DocumentKind; +} + +export interface BulkAccessRequest { + requestAccess: (entries: BulkAccessRequestEntry[]) => void; + isRequesting: boolean; +} + +// Requests access for a mixed selection of documents / reports / files in a +// single mutation. Auth, full-name, and NDA gates are thrown by the fetch layer +// and surface in `onError`: unauthenticated redirects to OAuth /initiate, while +// full-name and NDA deep-link to their gate page. Unlike the single-row flow +// this is a "simple redirect": the current URL carries no batch marker, so the +// selection is not resumed after the gate is cleared (the user re-selects). +export function useBulkRequestAccess(onSuccess?: () => void): BulkAccessRequest { + const navigate = useNavigate(); + const locale = useLocale(); + const toast = Toast.useToastManager(); + const { t } = useTranslation(); + const [mutate, isRequesting] = useMutation( + bulkMutation, + { errorToast: false }, + ); + + const requestAccess = useCallback( + (entries: BulkAccessRequestEntry[]) => { + const documentIds: string[] = []; + const reportIds: string[] = []; + const compliancePortalFileIds: string[] = []; + + for (const entry of entries) { + switch (entry.kind) { + case "Document": + documentIds.push(entry.id); + break; + case "AuditReport": + reportIds.push(entry.id); + break; + case "CompliancePortalFile": + compliancePortalFileIds.push(entry.id); + break; + } + } + + void mutate({ + variables: { input: { documentIds, reportIds, compliancePortalFileIds } }, + onCompleted: (_response: unknown, errors: PayloadError[] | null) => { + if (errors && errors.length > 0) { + toast.add({ title: t("auth.errors.requestFailed"), type: "error" }); + return; + } + toast.add({ title: t("auth.requestAccess.success"), type: "success" }); + onSuccess?.(); + }, + onError: (error: Error) => { + const continueUrl = getSafeContinueUrl(window.location.href); + + if (error instanceof UnAuthenticatedError) { + redirectToInitiate(continueUrl); + return; + } + + const gatePath = gateRedirectPath(error, continueUrl, locale); + if (gatePath) { + void navigate(gatePath); + return; + } + + toast.add({ title: t("auth.errors.requestFailed"), type: "error" }); + }, + }).catch(() => {}); + }, + [mutate, toast, t, navigate, locale, onSuccess], + ); + + return { requestAccess, isRequesting }; +} diff --git a/apps/compliance-portal/src/pages/documents/_locales/de-DE.json b/apps/compliance-portal/src/pages/documents/_locales/de-DE.json index ec0db5e8b..0594e72f8 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/de-DE.json +++ b/apps/compliance-portal/src/pages/documents/_locales/de-DE.json @@ -25,6 +25,13 @@ "getAccess": "Zugang anfordern", "requested": "Zugang angefordert" }, + "selection": { + "count": "{{count}} ausgewählt", + "clear": "Auswahl aufheben", + "selectAll": "Alle auswählen", + "requestAccess": "Zugang anfordern ({{count}})", + "selectRow": "{{title}} auswählen" + }, "empty": { "title": "Keine Dokumente verfügbar", "description": "Dieses Compliance-Portal hat noch keine Dokumente veröffentlicht.", diff --git a/apps/compliance-portal/src/pages/documents/_locales/en-US.json b/apps/compliance-portal/src/pages/documents/_locales/en-US.json index 4da822888..bcc62ba19 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/en-US.json +++ b/apps/compliance-portal/src/pages/documents/_locales/en-US.json @@ -25,6 +25,13 @@ "getAccess": "Get Access", "requested": "Access requested" }, + "selection": { + "count": "{{count}} selected", + "clear": "Clear selection", + "selectAll": "Select all", + "requestAccess": "Request Access ({{count}})", + "selectRow": "Select {{title}}" + }, "empty": { "title": "No documents available", "description": "This compliance portal has not published any documents yet.", diff --git a/apps/compliance-portal/src/pages/documents/_locales/es-ES.json b/apps/compliance-portal/src/pages/documents/_locales/es-ES.json index 29b74b510..a86216dfe 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/es-ES.json +++ b/apps/compliance-portal/src/pages/documents/_locales/es-ES.json @@ -25,6 +25,13 @@ "getAccess": "Solicitar acceso", "requested": "Acceso solicitado" }, + "selection": { + "count": "{{count}} seleccionados", + "clear": "Borrar selección", + "selectAll": "Seleccionar todo", + "requestAccess": "Solicitar acceso ({{count}})", + "selectRow": "Seleccionar {{title}}" + }, "empty": { "title": "No hay documentos disponibles", "description": "Este portal de cumplimiento aún no ha publicado ningún documento.", diff --git a/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json b/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json index 34bbf6a19..2512d7bdd 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json +++ b/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json @@ -25,6 +25,13 @@ "getAccess": "Obtenir l'accès", "requested": "Accès demandé" }, + "selection": { + "count": "{{count}} sélectionné(s)", + "clear": "Effacer la sélection", + "selectAll": "Tout sélectionner", + "requestAccess": "Demander l'accès ({{count}})", + "selectRow": "Sélectionner {{title}}" + }, "empty": { "title": "Aucun document disponible", "description": "Ce portail de conformité n'a pas encore publié de documents.", diff --git a/apps/compliance-portal/src/pages/documents/_locales/id-ID.json b/apps/compliance-portal/src/pages/documents/_locales/id-ID.json index 87a963a06..63d614d80 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/id-ID.json +++ b/apps/compliance-portal/src/pages/documents/_locales/id-ID.json @@ -25,6 +25,13 @@ "getAccess": "Dapatkan Akses", "requested": "Akses telah diminta" }, + "selection": { + "count": "{{count}} dipilih", + "clear": "Hapus pilihan", + "selectAll": "Pilih semua", + "requestAccess": "Minta Akses ({{count}})", + "selectRow": "Pilih {{title}}" + }, "empty": { "title": "Belum ada dokumen yang tersedia", "description": "Portal kepatuhan ini belum mempublikasikan dokumen apa pun.", diff --git a/apps/compliance-portal/src/pages/documents/_locales/it-IT.json b/apps/compliance-portal/src/pages/documents/_locales/it-IT.json index a94bfc352..5fd96d643 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/it-IT.json +++ b/apps/compliance-portal/src/pages/documents/_locales/it-IT.json @@ -25,6 +25,13 @@ "getAccess": "Richiedi accesso", "requested": "Accesso richiesto" }, + "selection": { + "count": "{{count}} selezionati", + "clear": "Cancella selezione", + "selectAll": "Seleziona tutto", + "requestAccess": "Richiedi accesso ({{count}})", + "selectRow": "Seleziona {{title}}" + }, "empty": { "title": "Nessun documento disponibile", "description": "Questo portale di conformità non ha ancora pubblicato alcun documento.", diff --git a/apps/compliance-portal/src/pages/documents/_locales/ja-JP.json b/apps/compliance-portal/src/pages/documents/_locales/ja-JP.json index 5718ea2c0..5ee114731 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/ja-JP.json +++ b/apps/compliance-portal/src/pages/documents/_locales/ja-JP.json @@ -25,6 +25,13 @@ "getAccess": "アクセスをリクエスト", "requested": "アクセスをリクエスト済み" }, + "selection": { + "count": "{{count}} 件選択中", + "clear": "選択をクリア", + "selectAll": "すべて選択", + "requestAccess": "アクセスをリクエスト ({{count}})", + "selectRow": "{{title}} を選択" + }, "empty": { "title": "利用可能なドキュメントはありません", "description": "このコンプライアンスポータルでは、まだドキュメントが公開されていません。", diff --git a/apps/compliance-portal/src/pages/documents/_locales/ko-KR.json b/apps/compliance-portal/src/pages/documents/_locales/ko-KR.json index b16e5cfa4..5290cdc71 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/ko-KR.json +++ b/apps/compliance-portal/src/pages/documents/_locales/ko-KR.json @@ -25,6 +25,13 @@ "getAccess": "액세스 요청", "requested": "액세스가 요청되었습니다" }, + "selection": { + "count": "{{count}}개 선택됨", + "clear": "선택 해제", + "selectAll": "모두 선택", + "requestAccess": "액세스 요청 ({{count}})", + "selectRow": "{{title}} 선택" + }, "empty": { "title": "이용 가능한 문서가 없습니다", "description": "이 컴플라이언스 포털은 아직 문서를 공개하지 않았습니다.", diff --git a/apps/compliance-portal/src/pages/documents/_locales/pl-PL.json b/apps/compliance-portal/src/pages/documents/_locales/pl-PL.json index 11bfa99cc..10e65b995 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/pl-PL.json +++ b/apps/compliance-portal/src/pages/documents/_locales/pl-PL.json @@ -25,6 +25,13 @@ "getAccess": "Uzyskaj dostęp", "requested": "Poproszono o dostęp" }, + "selection": { + "count": "Zaznaczono: {{count}}", + "clear": "Wyczyść zaznaczenie", + "selectAll": "Zaznacz wszystko", + "requestAccess": "Poproś o dostęp ({{count}})", + "selectRow": "Zaznacz {{title}}" + }, "empty": { "title": "Brak dostępnych dokumentów", "description": "Ten Portal Zgodności nie opublikował jeszcze żadnych dokumentów.", diff --git a/apps/compliance-portal/src/pages/documents/_locales/pt-PT.json b/apps/compliance-portal/src/pages/documents/_locales/pt-PT.json index 6965221ce..7eecc9068 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/pt-PT.json +++ b/apps/compliance-portal/src/pages/documents/_locales/pt-PT.json @@ -25,6 +25,13 @@ "getAccess": "Obter Acesso", "requested": "Acesso solicitado" }, + "selection": { + "count": "{{count}} selecionados", + "clear": "Limpar seleção", + "selectAll": "Selecionar tudo", + "requestAccess": "Solicitar acesso ({{count}})", + "selectRow": "Selecionar {{title}}" + }, "empty": { "title": "Nenhum documento disponível", "description": "Este portal de conformidade ainda não publicou documentos.", diff --git a/apps/compliance-portal/src/pages/documents/_locales/tr-TR.json b/apps/compliance-portal/src/pages/documents/_locales/tr-TR.json index b5ca9e99f..fc048cb21 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/tr-TR.json +++ b/apps/compliance-portal/src/pages/documents/_locales/tr-TR.json @@ -25,6 +25,13 @@ "getAccess": "Erişim İste", "requested": "Erişim talep edildi" }, + "selection": { + "count": "{{count}} seçildi", + "clear": "Seçimi temizle", + "selectAll": "Tümünü seç", + "requestAccess": "Erişim İste ({{count}})", + "selectRow": "{{title}} öğesini seç" + }, "empty": { "title": "Kullanılabilir belge yok", "description": "Bu Uyumluluk Portalı henüz herhangi bir belge yayınlamadı.", diff --git a/apps/compliance-portal/src/pages/documents/_locales/uk-UA.json b/apps/compliance-portal/src/pages/documents/_locales/uk-UA.json index 508f6d729..30dfe0059 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/uk-UA.json +++ b/apps/compliance-portal/src/pages/documents/_locales/uk-UA.json @@ -25,6 +25,13 @@ "getAccess": "Отримати доступ", "requested": "Доступ запитано" }, + "selection": { + "count": "Вибрано: {{count}}", + "clear": "Очистити вибір", + "selectAll": "Вибрати все", + "requestAccess": "Запитати доступ ({{count}})", + "selectRow": "Вибрати {{title}}" + }, "empty": { "title": "Немає доступних документів", "description": "Цей портал відповідності ще не опублікував жодних документів.", diff --git a/apps/compliance-portal/src/pages/documents/_locales/zh-CN.json b/apps/compliance-portal/src/pages/documents/_locales/zh-CN.json index 124886bde..c22284b27 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/zh-CN.json +++ b/apps/compliance-portal/src/pages/documents/_locales/zh-CN.json @@ -25,6 +25,13 @@ "getAccess": "获取访问权限", "requested": "已申请访问权限" }, + "selection": { + "count": "已选择 {{count}} 项", + "clear": "清除选择", + "selectAll": "全选", + "requestAccess": "申请访问权限 ({{count}})", + "selectRow": "选择 {{title}}" + }, "empty": { "title": "暂无可用文档", "description": "此合规门户尚未发布任何文档。", diff --git a/apps/compliance-portal/src/pages/documents/variants.ts b/apps/compliance-portal/src/pages/documents/variants.ts index b66aaf220..ad8a978cb 100644 --- a/apps/compliance-portal/src/pages/documents/variants.ts +++ b/apps/compliance-portal/src/pages/documents/variants.ts @@ -24,7 +24,7 @@ import { tv } from "tailwind-variants/lite"; // `busy` variant dims the current results while a filtered slice refetches. export const documentsLayout = tv({ slots: { - page: "flex w-full flex-col items-center px-8 py-8 max-md:px-4", + page: "flex w-full flex-col items-center px-8 pt-8 pb-28 max-md:px-4", results: "flex w-full max-w-5xl flex-col gap-8 transition-opacity duration-150", }, variants: { diff --git a/e2e/trust/compliance_portal_request_accesses_test.go b/e2e/trust/compliance_portal_request_accesses_test.go new file mode 100644 index 000000000..a32148553 --- /dev/null +++ b/e2e/trust/compliance_portal_request_accesses_test.go @@ -0,0 +1,158 @@ +// 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. + +package trust_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +const requestAccessesMutation = ` + mutation RequestAccesses($input: RequestAccessesInput!) { + requestAccesses(input: $input) { + documents { + id + access { status } + } + audits { + reportFile { id } + } + files { + id + } + } + } +` + +// requestAccessesResult mirrors the shape selected by requestAccessesMutation. +type requestAccessesResult struct { + RequestAccesses struct { + Documents []struct { + ID string `json:"id"` + Access *struct { + Status string `json:"status"` + } `json:"access"` + } `json:"documents"` + Audits []struct { + ReportFile struct { + ID string `json:"id"` + } `json:"reportFile"` + } `json:"audits"` + Files []struct { + ID string `json:"id"` + } `json:"files"` + } `json:"requestAccesses"` +} + +// TestCompliancePortal_RequestAccesses_Batch verifies that an authenticated +// visitor can request access to a specific selection of private documents in a +// single mutation, and that each affected row comes back flagged as REQUESTED. +func TestCompliancePortal_RequestAccesses_Batch(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + documentID := setupPrivatePortalDocument(t, owner) + compliancePortalID := lookupCompliancePortalID(t, owner) + trustHost := lookupTrustHost(t, owner, compliancePortalID) + + visitor := testutil.SelfProvisionCompliancePortalVisitor(t, trustHost) + + var result requestAccessesResult + err := visitor.ExecuteTrust(trustHost, requestAccessesMutation, map[string]any{ + "input": map[string]any{ + "documentIds": []string{documentID}, + "reportIds": []string{}, + "compliancePortalFileIds": []string{}, + }, + }, &result) + require.NoError(t, err, "an authenticated visitor must be able to request access to a selection") + + require.Len(t, result.RequestAccesses.Documents, 1, "the payload must echo the requested document") + assert.Equal(t, documentID, result.RequestAccesses.Documents[0].ID) + require.NotNil(t, result.RequestAccesses.Documents[0].Access, "the requested document must carry an access record") + assert.Equal(t, "REQUESTED", result.RequestAccesses.Documents[0].Access.Status) + assert.Empty(t, result.RequestAccesses.Audits, "no reports were requested") + assert.Empty(t, result.RequestAccesses.Files, "no files were requested") +} + +// TestCompliancePortal_RequestAccesses_TenantIsolation verifies that a visitor +// on one organization's compliance portal cannot request access to another +// organization's document by supplying a foreign document GID: the request is +// rejected before any access row is written. +func TestCompliancePortal_RequestAccesses_TenantIsolation(t *testing.T) { + t.Parallel() + + victimOwner := testutil.NewClient(t, testutil.RoleOwner) + attackerOwner := testutil.NewClient(t, testutil.RoleOwner) + + victimDocumentID := setupPrivatePortalDocument(t, victimOwner) + + attackerCompliancePortalID := lookupCompliancePortalID(t, attackerOwner) + attackerTrustHost := lookupTrustHost(t, attackerOwner, attackerCompliancePortalID) + + attacker := testutil.SelfProvisionCompliancePortalVisitor(t, attackerTrustHost) + + err := attacker.ExecuteTrust(attackerTrustHost, requestAccessesMutation, map[string]any{ + "input": map[string]any{ + "documentIds": []string{victimDocumentID}, + "reportIds": []string{}, + "compliancePortalFileIds": []string{}, + }, + }, nil) + require.Error(t, err, "a foreign compliance portal must not request access to another org's document") + assert.Contains( + t, + err.Error(), + "not found", + "cross-tenant document GID must be rejected as not found", + ) +} + +// setupPrivatePortalDocument creates a document and marks it privately visible on +// the owner's compliance portal, returning the document ID. +func setupPrivatePortalDocument(t *testing.T, owner *testutil.Client) string { + t.Helper() + + documentID := factory.NewDocument(owner).WithTitle(factory.SafeName("Document")).Create() + + const updateMutation = ` + mutation UpdateDocument($input: UpdateDocumentInput!) { + updateDocument(input: $input) { + document { id } + } + } + ` + + err := owner.Execute(updateMutation, map[string]any{ + "input": map[string]any{ + "id": documentID, + "compliancePortalVisibility": "PRIVATE", + }, + }, nil) + require.NoError(t, err) + + return documentID +} diff --git a/packages/ui/src/v2/Checkbox/Checkbox.stories.tsx b/packages/ui/src/v2/Checkbox/Checkbox.stories.tsx new file mode 100644 index 000000000..3e14267eb --- /dev/null +++ b/packages/ui/src/v2/Checkbox/Checkbox.stories.tsx @@ -0,0 +1,56 @@ +// 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 { Meta, StoryObj } from "@storybook/react"; + +import { Checkbox } from "./Checkbox"; +import { CheckboxSkeleton } from "./CheckboxSkeleton"; + +export default { + title: "v2/Checkbox", + component: Checkbox, + args: { + "aria-label": "Checkbox", + }, +} satisfies Meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const States: Story = { + render: () => ( +
+ + + + + +
+ ), +}; + +export const Skeleton: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/packages/ui/src/v2/Checkbox/Checkbox.tsx b/packages/ui/src/v2/Checkbox/Checkbox.tsx new file mode 100644 index 000000000..9edfe009c --- /dev/null +++ b/packages/ui/src/v2/Checkbox/Checkbox.tsx @@ -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. + +import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"; +import { CheckIcon, MinusIcon } from "@phosphor-icons/react"; +import type { ComponentProps } from "react"; + +import { checkbox, checkboxIndicator } from "./variants"; + +export type CheckboxProps + = & Omit, "className"> + & { + className?: string; + }; + +// Styled Base UI checkbox. Controlled with `checked` / `onCheckedChange` (or +// uncontrolled via `defaultChecked`); `indeterminate` renders a mixed state. +export function Checkbox(props: CheckboxProps) { + const { className, indeterminate, ...rest } = props; + + return ( + + + {indeterminate ? : } + + + ); +} diff --git a/packages/ui/src/v2/Checkbox/CheckboxSkeleton.tsx b/packages/ui/src/v2/Checkbox/CheckboxSkeleton.tsx new file mode 100644 index 000000000..010c8736d --- /dev/null +++ b/packages/ui/src/v2/Checkbox/CheckboxSkeleton.tsx @@ -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 type { ComponentProps } from "react"; + +import { checkboxSkeleton } from "./variants"; + +export type CheckboxSkeletonProps = Omit, "children">; + +// Loading placeholder paired with Checkbox: a pulse block matching its box. +export function CheckboxSkeleton(props: CheckboxSkeletonProps) { + const { className, ...rest } = props; + + return ; +} diff --git a/packages/ui/src/v2/Checkbox/variants.ts b/packages/ui/src/v2/Checkbox/variants.ts new file mode 100644 index 000000000..4880a1703 --- /dev/null +++ b/packages/ui/src/v2/Checkbox/variants.ts @@ -0,0 +1,41 @@ +// 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"; + +// A 20px control box (Radix "Checkbox"). The checked/indeterminate surface and +// the disabled treatment resolve off Base UI's data-* state attributes. +export const checkbox = tv({ + base: [ + "inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-2 border border-sand-a7 bg-sand-1 text-gold-1 outline-none transition-colors", + "focus-visible:ring-2 focus-visible:ring-gold-8", + "data-[checked]:border-gold-12 data-[checked]:bg-gold-12", + "data-[indeterminate]:border-gold-12 data-[indeterminate]:bg-gold-12", + "data-[disabled]:cursor-not-allowed data-[disabled]:border-sand-a3 data-[disabled]:bg-sand-2", + ], +}); + +export const checkboxIndicator = tv({ + base: "flex items-center justify-center text-current [&_svg]:size-3.5", +}); + +export const checkboxSkeleton = tv({ + base: "inline-block size-5 shrink-0 animate-pulse rounded-2 bg-sand-3 align-middle", +}); diff --git a/pkg/server/api/complianceportal/v1/compliance_portal_resolvers.go b/pkg/server/api/complianceportal/v1/compliance_portal_resolvers.go index 7a3f430ea..fba720296 100644 --- a/pkg/server/api/complianceportal/v1/compliance_portal_resolvers.go +++ b/pkg/server/api/complianceportal/v1/compliance_portal_resolvers.go @@ -1108,6 +1108,110 @@ func (r *mutationResolver) RequestCompliancePortalFileAccess(ctx context.Context }, nil } +// RequestAccesses is the resolver for the requestAccesses field. +func (r *mutationResolver) RequestAccesses(ctx context.Context, input types.RequestAccessesInput) (*types.RequestAccessesResultPayload, error) { + compliancePortal := complianceportal.CompliancePortalFromContext(ctx) + scope := coredata.NewScopeFromObjectID(compliancePortal.ID) + visitorService := r.visitor + + identity := authn.IdentityFromContext(ctx) + if identity == nil { + return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to request access") + } + + // Coerce to non-nil slices: an empty list means "none of that type", whereas + // a nil slice is interpreted by RequestPortalAccess as "all of that type". + documentIDs := input.DocumentIds + if documentIDs == nil { + documentIDs = []gid.GID{} + } + + reportIDs := input.ReportIds + if reportIDs == nil { + reportIDs = []gid.GID{} + } + + compliancePortalFileIDs := input.CompliancePortalFileIds + if compliancePortalFileIDs == nil { + compliancePortalFileIDs = []gid.GID{} + } + + // Load and tenant-check every target before requesting so a foreign or + // invisible GID is rejected before any access row is written (mirrors the + // per-resource resolvers, which guard with a load ahead of the request). + payload := &types.RequestAccessesResultPayload{ + Documents: make([]*types.Document, 0, len(documentIDs)), + Audits: make([]*types.Audit, 0, len(reportIDs)), + Files: make([]*types.CompliancePortalFile, 0, len(compliancePortalFileIDs)), + } + + for _, documentID := range documentIDs { + document, err := visitorService.GetDocument(ctx, scope, compliancePortal.OrganizationID, documentID) + if err != nil { + if errors.Is(err, visitor.ErrDocumentNotFound) || errors.Is(err, visitor.ErrDocumentNotVisible) || errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFoundf(ctx, "document %q not found", documentID) + } + + if _, ok := errors.AsType[*visitor.ErrDocumentArchived](err); ok { + return nil, gqlutils.NotFoundf(ctx, "document %q not found", documentID) + } + + r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + payload.Documents = append(payload.Documents, types.NewDocument(document)) + } + + for _, reportID := range reportIDs { + audit, err := visitorService.GetAuditByReportFileID(ctx, scope, reportID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFoundf(ctx, "report %q not found", reportID) + } + + r.logger.ErrorCtx(ctx, "cannot load audit", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + payload.Audits = append(payload.Audits, types.NewAudit(audit)) + } + + for _, fileID := range compliancePortalFileIDs { + portalFile, err := visitorService.GetPortalFile(ctx, scope, compliancePortal.OrganizationID, fileID) + if err != nil { + if errors.Is(err, visitor.ErrPortalFileNotFound) || errors.Is(err, visitor.ErrPortalFileNotVisible) { + return nil, gqlutils.NotFoundf(ctx, "compliance portal file %q not found", fileID) + } + + r.logger.ErrorCtx(ctx, "cannot load compliance portal file", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + payload.Files = append(payload.Files, types.NewCompliancePortalFile(portalFile)) + } + + if _, err := visitorService.RequestPortalAccess( + ctx, scope, + &visitor.PortalAccessRequest{ + CompliancePortalID: compliancePortal.ID, + IdentityID: identity.ID, + DocumentIDs: documentIDs, + ReportIDs: reportIDs, + CompliancePortalFileIDs: compliancePortalFileIDs, + }, + ); err != nil { + r.logger.ErrorCtx(ctx, "cannot request accesses", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return payload, nil +} + // TotalCount is the resolver for the totalCount field. func (r *subprocessorConnectionResolver) TotalCount(ctx context.Context, obj *types.SubprocessorConnection) (int, error) { scope := coredata.NewScopeFromObjectID(obj.ParentID) diff --git a/pkg/server/api/complianceportal/v1/graphql/compliance_portal.graphql b/pkg/server/api/complianceportal/v1/graphql/compliance_portal.graphql index d4f1b51bf..1ffdd86aa 100644 --- a/pkg/server/api/complianceportal/v1/graphql/compliance_portal.graphql +++ b/pkg/server/api/complianceportal/v1/graphql/compliance_portal.graphql @@ -489,6 +489,10 @@ extend type Mutation { requestCompliancePortalFileAccess( input: RequestCompliancePortalFileAccessInput! ): RequestFileAccessPayload! @authentication(required: PRESENT) @nda + + requestAccesses( + input: RequestAccessesInput! + ): RequestAccessesResultPayload! @authentication(required: PRESENT) @nda } type RequestDocumentAccessPayload { @@ -507,6 +511,14 @@ type RequestAccessesPayload { compliancePortalAccess: CompliancePortalAccess! } +# Returns the affected nodes so the client can update each row in place. Mirrors +# the per-resource payloads but for a selection-scoped batch request. +type RequestAccessesResultPayload { + documents: [Document!]! + audits: [Audit!]! + files: [CompliancePortalFile!]! +} + input ExportDocumentPDFInput { documentId: ID! } @@ -527,6 +539,12 @@ input RequestCompliancePortalFileAccessInput { compliancePortalFileId: ID! } +input RequestAccessesInput { + documentIds: [ID!]! + reportIds: [ID!]! + compliancePortalFileIds: [ID!]! +} + input ExportCompliancePortalFileInput { compliancePortalFileId: ID! }