Add bulk request access to portal documents
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é <emile@probo.com>
This commit is contained in:
@@ -35,7 +35,10 @@ import { CompliancePortalFileListItem } from "./_components/CompliancePortalFile
|
|||||||
import { DocumentListItem } from "./_components/DocumentListItem";
|
import { DocumentListItem } from "./_components/DocumentListItem";
|
||||||
import { DocumentSection } from "./_components/DocumentSection";
|
import { DocumentSection } from "./_components/DocumentSection";
|
||||||
import { DocumentsEmpty } from "./_components/DocumentsEmpty";
|
import { DocumentsEmpty } from "./_components/DocumentsEmpty";
|
||||||
|
import { DocumentsSelectionBar } from "./_components/DocumentsSelectionBar";
|
||||||
import { DocumentsToolbar } from "./_components/DocumentsToolbar";
|
import { DocumentsToolbar } from "./_components/DocumentsToolbar";
|
||||||
|
import type { DocumentSelectionEntry } from "./_lib/DocumentSelectionContext";
|
||||||
|
import { DocumentSelectionProvider } from "./_lib/DocumentSelectionContext";
|
||||||
import { toQueryVariables } from "./_lib/toQueryVariables";
|
import { toQueryVariables } from "./_lib/toQueryVariables";
|
||||||
import { useDocumentTab } from "./_lib/useDocumentTab";
|
import { useDocumentTab } from "./_lib/useDocumentTab";
|
||||||
import { documentsLayout } from "./variants";
|
import { documentsLayout } from "./variants";
|
||||||
@@ -56,6 +59,10 @@ const documentsPageFragment = graphql`
|
|||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
documentType
|
documentType
|
||||||
|
isUserAuthorized
|
||||||
|
access {
|
||||||
|
status
|
||||||
|
}
|
||||||
...DocumentListItem_document
|
...DocumentListItem_document
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +73,10 @@ const documentsPageFragment = graphql`
|
|||||||
id
|
id
|
||||||
reportFile {
|
reportFile {
|
||||||
id
|
id
|
||||||
|
isUserAuthorized
|
||||||
|
access {
|
||||||
|
status
|
||||||
|
}
|
||||||
}
|
}
|
||||||
...AuditReportListItem_audit
|
...AuditReportListItem_audit
|
||||||
}
|
}
|
||||||
@@ -76,6 +87,10 @@ const documentsPageFragment = graphql`
|
|||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
category
|
category
|
||||||
|
isUserAuthorized
|
||||||
|
access {
|
||||||
|
status
|
||||||
|
}
|
||||||
...CompliancePortalFileListItem_file
|
...CompliancePortalFileListItem_file
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +164,36 @@ export function DocumentsPage({ queryRef }: DocumentsPageProps) {
|
|||||||
|
|
||||||
const total = documentNodes.length + fileNodes.length + auditNodes.length;
|
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))
|
const documentGroups = Object.entries(groupBy(documentNodes, node => node.documentType))
|
||||||
.map(([key, nodes]) => ({ key, nodes }))
|
.map(([key, nodes]) => ({ key, nodes }))
|
||||||
.sort((a, b) => t(`types.${a.key}`).localeCompare(t(`types.${b.key}`)));
|
.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 });
|
const { page, results } = documentsLayout({ busy: isRefetching });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<DocumentSelectionProvider resetKey={tab}>
|
||||||
<PageHeader title={t("title")} count={total} flushBottomSpace>
|
<PageHeader title={t("title")} count={total} flushBottomSpace>
|
||||||
<DocumentsToolbar />
|
<DocumentsToolbar />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -200,6 +245,7 @@ export function DocumentsPage({ queryRef }: DocumentsPageProps) {
|
|||||||
</ListErrorBoundary>
|
</ListErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
<DocumentsSelectionBar entries={selectionEntries} />
|
||||||
|
</DocumentSelectionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { graphql, useFragment } from "react-relay";
|
|||||||
|
|
||||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
|
|
||||||
|
import { useDocumentSelection } from "../_lib/DocumentSelectionContext";
|
||||||
import { useRequestReportAccess } from "../_lib/useAccessRequest";
|
import { useRequestReportAccess } from "../_lib/useAccessRequest";
|
||||||
|
|
||||||
import type { AuditReportListItem_audit$key } from "./__generated__/AuditReportListItem_audit.graphql";
|
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
|
// Hook must run unconditionally; the empty id is never used when there is no
|
||||||
// report file (the component returns null below).
|
// report file (the component returns null below).
|
||||||
const { requestAccess, isRequesting } = useRequestReportAccess(report?.id ?? "");
|
const { requestAccess, isRequesting } = useRequestReportAccess(report?.id ?? "");
|
||||||
|
const { isSelected, toggle } = useDocumentSelection();
|
||||||
|
|
||||||
if (report == null) {
|
if (report == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reportId = report.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentEntry
|
<DocumentEntry
|
||||||
title={audit.framework.name}
|
title={audit.framework.name}
|
||||||
@@ -72,6 +76,8 @@ export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
|||||||
viewHref={localizedPath(`/documents/${encodeURIComponent(report.alias ?? report.id)}`)}
|
viewHref={localizedPath(`/documents/${encodeURIComponent(report.alias ?? report.id)}`)}
|
||||||
onGetAccess={requestAccess}
|
onGetAccess={requestAccess}
|
||||||
isRequesting={isRequesting}
|
isRequesting={isRequesting}
|
||||||
|
selected={isSelected(reportId)}
|
||||||
|
onSelectedChange={() => toggle(reportId)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { graphql, useFragment } from "react-relay";
|
|||||||
|
|
||||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
|
|
||||||
|
import { useDocumentSelection } from "../_lib/DocumentSelectionContext";
|
||||||
import { useRequestFileAccess } from "../_lib/useAccessRequest";
|
import { useRequestFileAccess } from "../_lib/useAccessRequest";
|
||||||
|
|
||||||
import type { CompliancePortalFileListItem_file$key } from "./__generated__/CompliancePortalFileListItem_file.graphql";
|
import type { CompliancePortalFileListItem_file$key } from "./__generated__/CompliancePortalFileListItem_file.graphql";
|
||||||
@@ -50,6 +51,7 @@ export function CompliancePortalFileListItem({ fileKey }: CompliancePortalFileLi
|
|||||||
const localizedPath = useLocalizedPath();
|
const localizedPath = useLocalizedPath();
|
||||||
const file = useFragment(compliancePortalFileListItemFragment, fileKey);
|
const file = useFragment(compliancePortalFileListItemFragment, fileKey);
|
||||||
const { requestAccess, isRequesting } = useRequestFileAccess(file.id);
|
const { requestAccess, isRequesting } = useRequestFileAccess(file.id);
|
||||||
|
const { isSelected, toggle } = useDocumentSelection();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentEntry
|
<DocumentEntry
|
||||||
@@ -60,6 +62,8 @@ export function CompliancePortalFileListItem({ fileKey }: CompliancePortalFileLi
|
|||||||
viewHref={localizedPath(`/documents/${encodeURIComponent(file.alias ?? file.id)}`)}
|
viewHref={localizedPath(`/documents/${encodeURIComponent(file.alias ?? file.id)}`)}
|
||||||
onGetAccess={requestAccess}
|
onGetAccess={requestAccess}
|
||||||
isRequesting={isRequesting}
|
isRequesting={isRequesting}
|
||||||
|
selected={isSelected(file.id)}
|
||||||
|
onSelectedChange={() => toggle(file.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
|
import { Checkbox } from "@probo/ui/src/v2/Checkbox/Checkbox";
|
||||||
import { ListItem } from "@probo/ui/src/v2/List/ListItem";
|
import { ListItem } from "@probo/ui/src/v2/List/ListItem";
|
||||||
import { ListItemContent } from "@probo/ui/src/v2/List/ListItemContent";
|
import { ListItemContent } from "@probo/ui/src/v2/List/ListItemContent";
|
||||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||||
@@ -42,6 +43,11 @@ interface DocumentEntryProps {
|
|||||||
onGetAccess: () => void;
|
onGetAccess: () => void;
|
||||||
// Whether the access request is in flight.
|
// Whether the access request is in flight.
|
||||||
isRequesting: boolean;
|
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
|
// Presentational row shared by the document / file / report list items: a title
|
||||||
@@ -55,6 +61,8 @@ export function DocumentEntry({
|
|||||||
viewHref,
|
viewHref,
|
||||||
onGetAccess,
|
onGetAccess,
|
||||||
isRequesting,
|
isRequesting,
|
||||||
|
selected,
|
||||||
|
onSelectedChange,
|
||||||
}: DocumentEntryProps) {
|
}: DocumentEntryProps) {
|
||||||
const { t } = useTranslation("documents");
|
const { t } = useTranslation("documents");
|
||||||
|
|
||||||
@@ -71,6 +79,17 @@ export function DocumentEntry({
|
|||||||
mobileHitLabel != null ? "max-sm:cursor-pointer max-sm:hover:bg-sand-2" : "",
|
mobileHitLabel != null ? "max-sm:cursor-pointer max-sm:hover:bg-sand-2" : "",
|
||||||
].filter(Boolean).join(" ")}
|
].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.
|
||||||
|
<Checkbox
|
||||||
|
className="relative z-2"
|
||||||
|
checked={selected ?? false}
|
||||||
|
onCheckedChange={onSelectedChange}
|
||||||
|
aria-label={t("selection.selectRow", { title: typeof title === "string" ? title : "" })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ListItemContent>
|
<ListItemContent>
|
||||||
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
|
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { graphql, useFragment } from "react-relay";
|
|||||||
|
|
||||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
|
|
||||||
|
import { useDocumentSelection } from "../_lib/DocumentSelectionContext";
|
||||||
import { useRequestDocumentAccess } from "../_lib/useAccessRequest";
|
import { useRequestDocumentAccess } from "../_lib/useAccessRequest";
|
||||||
|
|
||||||
import type { DocumentListItem_document$key } from "./__generated__/DocumentListItem_document.graphql";
|
import type { DocumentListItem_document$key } from "./__generated__/DocumentListItem_document.graphql";
|
||||||
@@ -52,6 +53,7 @@ export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
|||||||
const localizedPath = useLocalizedPath();
|
const localizedPath = useLocalizedPath();
|
||||||
const document = useFragment(documentListItemFragment, documentKey);
|
const document = useFragment(documentListItemFragment, documentKey);
|
||||||
const { requestAccess, isRequesting } = useRequestDocumentAccess(document.id);
|
const { requestAccess, isRequesting } = useRequestDocumentAccess(document.id);
|
||||||
|
const { isSelected, toggle } = useDocumentSelection();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentEntry
|
<DocumentEntry
|
||||||
@@ -62,6 +64,8 @@ export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
|||||||
viewHref={localizedPath(`/documents/${encodeURIComponent(document.alias ?? document.id)}`)}
|
viewHref={localizedPath(`/documents/${encodeURIComponent(document.alias ?? document.id)}`)}
|
||||||
onGetAccess={requestAccess}
|
onGetAccess={requestAccess}
|
||||||
isRequesting={isRequesting}
|
isRequesting={isRequesting}
|
||||||
|
selected={isSelected(document.id)}
|
||||||
|
onSelectedChange={() => toggle(document.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// 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 { 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 (
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-10 border-t border-sand-a3 bg-sand-1/80 px-8 py-4 backdrop-blur max-md:px-4">
|
||||||
|
<div className="mx-auto flex w-full max-w-5xl items-center justify-between gap-4">
|
||||||
|
<Text size={2} weight="medium" color="neutral" highContrast>
|
||||||
|
{t("selection.count", { count: selectedIds.size })}
|
||||||
|
</Text>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" color="neutral" onClick={clear}>
|
||||||
|
{t("selection.clear")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
onClick={() => selectAll(entries.map(entry => entry.id))}
|
||||||
|
>
|
||||||
|
{t("selection.selectAll")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
color="neutral"
|
||||||
|
highContrast
|
||||||
|
iconStart={<LockSimpleIcon />}
|
||||||
|
loading={isRequesting}
|
||||||
|
disabled={lockedCount === 0}
|
||||||
|
onClick={handleRequestAccess}
|
||||||
|
>
|
||||||
|
{t("selection.requestAccess", { count: lockedCount })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// 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 { 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<string>;
|
||||||
|
isSelected: (id: string) => boolean;
|
||||||
|
toggle: (id: string) => void;
|
||||||
|
selectAll: (ids: string[]) => void;
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentSelectionContext = createContext<DocumentSelectionContextValue | null>(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<Set<string>>(() => 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<DocumentSelectionContextValue>(
|
||||||
|
() => ({ selectedIds, isSelected, toggle, selectAll, clear }),
|
||||||
|
[selectedIds, isSelected, toggle, selectAll, clear],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentSelectionContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</DocumentSelectionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDocumentSelection() {
|
||||||
|
const context = useContext(DocumentSelectionContext);
|
||||||
|
if (context == null) {
|
||||||
|
throw new Error("useDocumentSelection must be used within a DocumentSelectionProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// 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 { 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<useBulkRequestAccessMutation>(
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -25,6 +25,13 @@
|
|||||||
"getAccess": "Zugang anfordern",
|
"getAccess": "Zugang anfordern",
|
||||||
"requested": "Zugang angefordert"
|
"requested": "Zugang angefordert"
|
||||||
},
|
},
|
||||||
|
"selection": {
|
||||||
|
"count": "{{count}} ausgewählt",
|
||||||
|
"clear": "Auswahl aufheben",
|
||||||
|
"selectAll": "Alle auswählen",
|
||||||
|
"requestAccess": "Zugang anfordern ({{count}})",
|
||||||
|
"selectRow": "{{title}} auswählen"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Keine Dokumente verfügbar",
|
"title": "Keine Dokumente verfügbar",
|
||||||
"description": "Dieses Compliance-Portal hat noch keine Dokumente veröffentlicht.",
|
"description": "Dieses Compliance-Portal hat noch keine Dokumente veröffentlicht.",
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
"getAccess": "Get Access",
|
"getAccess": "Get Access",
|
||||||
"requested": "Access requested"
|
"requested": "Access requested"
|
||||||
},
|
},
|
||||||
|
"selection": {
|
||||||
|
"count": "{{count}} selected",
|
||||||
|
"clear": "Clear selection",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"requestAccess": "Request Access ({{count}})",
|
||||||
|
"selectRow": "Select {{title}}"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "No documents available",
|
"title": "No documents available",
|
||||||
"description": "This compliance portal has not published any documents yet.",
|
"description": "This compliance portal has not published any documents yet.",
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
"getAccess": "Solicitar acceso",
|
"getAccess": "Solicitar acceso",
|
||||||
"requested": "Acceso solicitado"
|
"requested": "Acceso solicitado"
|
||||||
},
|
},
|
||||||
|
"selection": {
|
||||||
|
"count": "{{count}} seleccionados",
|
||||||
|
"clear": "Borrar selección",
|
||||||
|
"selectAll": "Seleccionar todo",
|
||||||
|
"requestAccess": "Solicitar acceso ({{count}})",
|
||||||
|
"selectRow": "Seleccionar {{title}}"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "No hay documentos disponibles",
|
"title": "No hay documentos disponibles",
|
||||||
"description": "Este portal de cumplimiento aún no ha publicado ningún documento.",
|
"description": "Este portal de cumplimiento aún no ha publicado ningún documento.",
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
"getAccess": "Obtenir l'accès",
|
"getAccess": "Obtenir l'accès",
|
||||||
"requested": "Accès demandé"
|
"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": {
|
"empty": {
|
||||||
"title": "Aucun document disponible",
|
"title": "Aucun document disponible",
|
||||||
"description": "Ce portail de conformité n'a pas encore publié de documents.",
|
"description": "Ce portail de conformité n'a pas encore publié de documents.",
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
"getAccess": "Dapatkan Akses",
|
"getAccess": "Dapatkan Akses",
|
||||||
"requested": "Akses telah diminta"
|
"requested": "Akses telah diminta"
|
||||||
},
|
},
|
||||||
|
"selection": {
|
||||||
|
"count": "{{count}} dipilih",
|
||||||
|
"clear": "Hapus pilihan",
|
||||||
|
"selectAll": "Pilih semua",
|
||||||
|
"requestAccess": "Minta Akses ({{count}})",
|
||||||
|
"selectRow": "Pilih {{title}}"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Belum ada dokumen yang tersedia",
|
"title": "Belum ada dokumen yang tersedia",
|
||||||
"description": "Portal kepatuhan ini belum mempublikasikan dokumen apa pun.",
|
"description": "Portal kepatuhan ini belum mempublikasikan dokumen apa pun.",
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
"getAccess": "Richiedi accesso",
|
"getAccess": "Richiedi accesso",
|
||||||
"requested": "Accesso richiesto"
|
"requested": "Accesso richiesto"
|
||||||
},
|
},
|
||||||
|
"selection": {
|
||||||
|
"count": "{{count}} selezionati",
|
||||||
|
"clear": "Cancella selezione",
|
||||||
|
"selectAll": "Seleziona tutto",
|
||||||
|
"requestAccess": "Richiedi accesso ({{count}})",
|
||||||
|
"selectRow": "Seleziona {{title}}"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Nessun documento disponibile",
|
"title": "Nessun documento disponibile",
|
||||||
"description": "Questo portale di conformità non ha ancora pubblicato alcun documento.",
|
"description": "Questo portale di conformità non ha ancora pubblicato alcun documento.",
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
"getAccess": "アクセスをリクエスト",
|
"getAccess": "アクセスをリクエスト",
|
||||||
"requested": "アクセスをリクエスト済み"
|
"requested": "アクセスをリクエスト済み"
|
||||||
},
|
},
|
||||||
|
"selection": {
|
||||||
|
"count": "{{count}} 件選択中",
|
||||||
|
"clear": "選択をクリア",
|
||||||
|
"selectAll": "すべて選択",
|
||||||
|
"requestAccess": "アクセスをリクエスト ({{count}})",
|
||||||
|
"selectRow": "{{title}} を選択"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "利用可能なドキュメントはありません",
|
"title": "利用可能なドキュメントはありません",
|
||||||
"description": "このコンプライアンスポータルでは、まだドキュメントが公開されていません。",
|
"description": "このコンプライアンスポータルでは、まだドキュメントが公開されていません。",
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
"getAccess": "액세스 요청",
|
"getAccess": "액세스 요청",
|
||||||
"requested": "액세스가 요청되었습니다"
|
"requested": "액세스가 요청되었습니다"
|
||||||
},
|
},
|
||||||
|
"selection": {
|
||||||
|
"count": "{{count}}개 선택됨",
|
||||||
|
"clear": "선택 해제",
|
||||||
|
"selectAll": "모두 선택",
|
||||||
|
"requestAccess": "액세스 요청 ({{count}})",
|
||||||
|
"selectRow": "{{title}} 선택"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "이용 가능한 문서가 없습니다",
|
"title": "이용 가능한 문서가 없습니다",
|
||||||
"description": "이 컴플라이언스 포털은 아직 문서를 공개하지 않았습니다.",
|
"description": "이 컴플라이언스 포털은 아직 문서를 공개하지 않았습니다.",
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
"getAccess": "Uzyskaj dostęp",
|
"getAccess": "Uzyskaj dostęp",
|
||||||
"requested": "Poproszono o 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": {
|
"empty": {
|
||||||
"title": "Brak dostępnych dokumentów",
|
"title": "Brak dostępnych dokumentów",
|
||||||
"description": "Ten Portal Zgodności nie opublikował jeszcze żadnych dokumentów.",
|
"description": "Ten Portal Zgodności nie opublikował jeszcze żadnych dokumentów.",
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
"getAccess": "Obter Acesso",
|
"getAccess": "Obter Acesso",
|
||||||
"requested": "Acesso solicitado"
|
"requested": "Acesso solicitado"
|
||||||
},
|
},
|
||||||
|
"selection": {
|
||||||
|
"count": "{{count}} selecionados",
|
||||||
|
"clear": "Limpar seleção",
|
||||||
|
"selectAll": "Selecionar tudo",
|
||||||
|
"requestAccess": "Solicitar acesso ({{count}})",
|
||||||
|
"selectRow": "Selecionar {{title}}"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Nenhum documento disponível",
|
"title": "Nenhum documento disponível",
|
||||||
"description": "Este portal de conformidade ainda não publicou documentos.",
|
"description": "Este portal de conformidade ainda não publicou documentos.",
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
"getAccess": "Erişim İste",
|
"getAccess": "Erişim İste",
|
||||||
"requested": "Erişim talep edildi"
|
"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": {
|
"empty": {
|
||||||
"title": "Kullanılabilir belge yok",
|
"title": "Kullanılabilir belge yok",
|
||||||
"description": "Bu Uyumluluk Portalı henüz herhangi bir belge yayınlamadı.",
|
"description": "Bu Uyumluluk Portalı henüz herhangi bir belge yayınlamadı.",
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
"getAccess": "Отримати доступ",
|
"getAccess": "Отримати доступ",
|
||||||
"requested": "Доступ запитано"
|
"requested": "Доступ запитано"
|
||||||
},
|
},
|
||||||
|
"selection": {
|
||||||
|
"count": "Вибрано: {{count}}",
|
||||||
|
"clear": "Очистити вибір",
|
||||||
|
"selectAll": "Вибрати все",
|
||||||
|
"requestAccess": "Запитати доступ ({{count}})",
|
||||||
|
"selectRow": "Вибрати {{title}}"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Немає доступних документів",
|
"title": "Немає доступних документів",
|
||||||
"description": "Цей портал відповідності ще не опублікував жодних документів.",
|
"description": "Цей портал відповідності ще не опублікував жодних документів.",
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
"getAccess": "获取访问权限",
|
"getAccess": "获取访问权限",
|
||||||
"requested": "已申请访问权限"
|
"requested": "已申请访问权限"
|
||||||
},
|
},
|
||||||
|
"selection": {
|
||||||
|
"count": "已选择 {{count}} 项",
|
||||||
|
"clear": "清除选择",
|
||||||
|
"selectAll": "全选",
|
||||||
|
"requestAccess": "申请访问权限 ({{count}})",
|
||||||
|
"selectRow": "选择 {{title}}"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "暂无可用文档",
|
"title": "暂无可用文档",
|
||||||
"description": "此合规门户尚未发布任何文档。",
|
"description": "此合规门户尚未发布任何文档。",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { tv } from "tailwind-variants/lite";
|
|||||||
// `busy` variant dims the current results while a filtered slice refetches.
|
// `busy` variant dims the current results while a filtered slice refetches.
|
||||||
export const documentsLayout = tv({
|
export const documentsLayout = tv({
|
||||||
slots: {
|
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",
|
results: "flex w-full max-w-5xl flex-col gap-8 transition-opacity duration-150",
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
|
|||||||
158
e2e/trust/compliance_portal_request_accesses_test.go
Normal file
158
e2e/trust/compliance_portal_request_accesses_test.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
56
packages/ui/src/v2/Checkbox/Checkbox.stories.tsx
Normal file
56
packages/ui/src/v2/Checkbox/Checkbox.stories.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// 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 { 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<typeof Checkbox>;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Checkbox>;
|
||||||
|
|
||||||
|
export const Playground: Story = {};
|
||||||
|
|
||||||
|
export const States: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Checkbox aria-label="Unchecked" />
|
||||||
|
<Checkbox aria-label="Checked" defaultChecked />
|
||||||
|
<Checkbox aria-label="Indeterminate" indeterminate />
|
||||||
|
<Checkbox aria-label="Disabled" disabled />
|
||||||
|
<Checkbox aria-label="Disabled checked" disabled defaultChecked />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Skeleton: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<CheckboxSkeleton />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
45
packages/ui/src/v2/Checkbox/Checkbox.tsx
Normal file
45
packages/ui/src/v2/Checkbox/Checkbox.tsx
Normal 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.
|
||||||
|
|
||||||
|
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<ComponentProps<typeof BaseCheckbox.Root>, "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 (
|
||||||
|
<BaseCheckbox.Root indeterminate={indeterminate} className={checkbox({ className })} {...rest}>
|
||||||
|
<BaseCheckbox.Indicator className={checkboxIndicator()}>
|
||||||
|
{indeterminate ? <MinusIcon weight="bold" /> : <CheckIcon weight="bold" />}
|
||||||
|
</BaseCheckbox.Indicator>
|
||||||
|
</BaseCheckbox.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
packages/ui/src/v2/Checkbox/CheckboxSkeleton.tsx
Normal file
32
packages/ui/src/v2/Checkbox/CheckboxSkeleton.tsx
Normal 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 type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
import { checkboxSkeleton } from "./variants";
|
||||||
|
|
||||||
|
export type CheckboxSkeletonProps = Omit<ComponentProps<"span">, "children">;
|
||||||
|
|
||||||
|
// Loading placeholder paired with Checkbox: a pulse block matching its box.
|
||||||
|
export function CheckboxSkeleton(props: CheckboxSkeletonProps) {
|
||||||
|
const { className, ...rest } = props;
|
||||||
|
|
||||||
|
return <span className={checkboxSkeleton({ className })} {...rest} aria-hidden />;
|
||||||
|
}
|
||||||
41
packages/ui/src/v2/Checkbox/variants.ts
Normal file
41
packages/ui/src/v2/Checkbox/variants.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// 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";
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
});
|
||||||
@@ -1108,6 +1108,110 @@ func (r *mutationResolver) RequestCompliancePortalFileAccess(ctx context.Context
|
|||||||
}, nil
|
}, 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.
|
// TotalCount is the resolver for the totalCount field.
|
||||||
func (r *subprocessorConnectionResolver) TotalCount(ctx context.Context, obj *types.SubprocessorConnection) (int, error) {
|
func (r *subprocessorConnectionResolver) TotalCount(ctx context.Context, obj *types.SubprocessorConnection) (int, error) {
|
||||||
scope := coredata.NewScopeFromObjectID(obj.ParentID)
|
scope := coredata.NewScopeFromObjectID(obj.ParentID)
|
||||||
|
|||||||
@@ -489,6 +489,10 @@ extend type Mutation {
|
|||||||
requestCompliancePortalFileAccess(
|
requestCompliancePortalFileAccess(
|
||||||
input: RequestCompliancePortalFileAccessInput!
|
input: RequestCompliancePortalFileAccessInput!
|
||||||
): RequestFileAccessPayload! @authentication(required: PRESENT) @nda
|
): RequestFileAccessPayload! @authentication(required: PRESENT) @nda
|
||||||
|
|
||||||
|
requestAccesses(
|
||||||
|
input: RequestAccessesInput!
|
||||||
|
): RequestAccessesResultPayload! @authentication(required: PRESENT) @nda
|
||||||
}
|
}
|
||||||
|
|
||||||
type RequestDocumentAccessPayload {
|
type RequestDocumentAccessPayload {
|
||||||
@@ -507,6 +511,14 @@ type RequestAccessesPayload {
|
|||||||
compliancePortalAccess: CompliancePortalAccess!
|
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 {
|
input ExportDocumentPDFInput {
|
||||||
documentId: ID!
|
documentId: ID!
|
||||||
}
|
}
|
||||||
@@ -527,6 +539,12 @@ input RequestCompliancePortalFileAccessInput {
|
|||||||
compliancePortalFileId: ID!
|
compliancePortalFileId: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input RequestAccessesInput {
|
||||||
|
documentIds: [ID!]!
|
||||||
|
reportIds: [ID!]!
|
||||||
|
compliancePortalFileIds: [ID!]!
|
||||||
|
}
|
||||||
|
|
||||||
input ExportCompliancePortalFileInput {
|
input ExportCompliancePortalFileInput {
|
||||||
compliancePortalFileId: ID!
|
compliancePortalFileId: ID!
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user