From 375f5f071e219f67b854b263f540fb33820bac9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 16 Jul 2026 17:57:57 +0200 Subject: [PATCH] Gate private document access behind sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document, report, and file "Get Access" buttons were inert. Wire them to the per-resource access mutations, and gate unauthenticated requests behind the sign-in dialog: a signed-out click defers the request in the continue URL and resumes it after sign-in, mirroring the top-bar request-all flow. Extend useResumeAccessRequest to complete the deferred per-resource requests and route through the full-name gate. Signed-off-by: Émile Ré --- .../src/lib/auth/continueUrl.ts | 17 +- .../src/lib/auth/useResumeAccessRequest.ts | 149 +++++++++++-- .../pages/documents/DocumentViewerPage.tsx | 4 +- .../_components/AuditReportListItem.tsx | 9 +- .../_components/DocumentAccessAction.tsx | 25 ++- .../documents/_components/DocumentEntry.tsx | 22 +- .../_components/DocumentListItem.tsx | 5 + .../documents/_components/DocumentLocked.tsx | 22 +- .../_components/TrustCenterFileListItem.tsx | 5 + .../pages/documents/_lib/useAccessRequest.ts | 200 ++++++++++++++++++ 10 files changed, 425 insertions(+), 33 deletions(-) create mode 100644 apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts diff --git a/apps/compliance-portal/src/lib/auth/continueUrl.ts b/apps/compliance-portal/src/lib/auth/continueUrl.ts index cbbeb8e21..eb72d9227 100644 --- a/apps/compliance-portal/src/lib/auth/continueUrl.ts +++ b/apps/compliance-portal/src/lib/auth/continueUrl.ts @@ -20,9 +20,14 @@ import { getPathPrefix } from "#/lib/http/pathPrefix"; -// Marker appended to a post-auth `continue` URL so the portal fires the pending -// "request access" mutation once the user lands back authenticated. +// Markers appended to a post-auth `continue` URL so the portal fires the pending +// "request access" mutation once the user lands back authenticated. `request-all` +// covers the top-bar "Get Access"; the per-resource markers carry the id of a +// single document / report / file whose access was requested from a locked row. export const REQUEST_ALL_PARAM = "request-all"; +export const REQUEST_DOCUMENT_PARAM = "request-document-id"; +export const REQUEST_REPORT_PARAM = "request-report-id"; +export const REQUEST_FILE_PARAM = "request-file-id"; // Validates a `continue` target before we navigate to it. Only same-origin URLs // under the portal's path prefix are accepted; anything else falls back to the @@ -57,3 +62,11 @@ export function buildRequestAllContinueUrl(): string { url.searchParams.set(REQUEST_ALL_PARAM, "true"); return url.toString(); } + +// Absolute URL of the current page with a per-resource marker set, so a single +// document / report / file access request resumes after sign-in. +export function buildRequestAccessContinueUrl(param: string, id: string): string { + const url = new URL(window.location.href); + url.searchParams.set(param, id); + return url.toString(); +} diff --git a/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts b/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts index 8f8f41b9e..32728ef27 100644 --- a/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts +++ b/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts @@ -23,14 +23,22 @@ import type { GraphQLError } from "@probo/helpers"; import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router"; +import type { PayloadError } from "relay-runtime"; import { graphql } from "relay-runtime"; import { + buildRequestAccessContinueUrl, buildRequestAllContinueUrl, REQUEST_ALL_PARAM, + REQUEST_DOCUMENT_PARAM, + REQUEST_FILE_PARAM, + REQUEST_REPORT_PARAM, } from "#/lib/auth/continueUrl"; import { useMutation } from "#/lib/relay/useMutation"; +import type { useResumeAccessRequest_documentMutation } from "./__generated__/useResumeAccessRequest_documentMutation.graphql"; +import type { useResumeAccessRequest_fileMutation } from "./__generated__/useResumeAccessRequest_fileMutation.graphql"; +import type { useResumeAccessRequest_reportMutation } from "./__generated__/useResumeAccessRequest_reportMutation.graphql"; import type { useResumeAccessRequestMutation } from "./__generated__/useResumeAccessRequestMutation.graphql"; const requestAllAccessesMutation = graphql` @@ -43,9 +51,55 @@ const requestAllAccessesMutation = graphql` } `; +const requestDocumentMutation = graphql` + mutation useResumeAccessRequest_documentMutation($input: RequestDocumentAccessInput!) { + requestDocumentAccess(input: $input) { + document { + id + access { + id + status + } + } + } + } +`; + +const requestReportMutation = graphql` + mutation useResumeAccessRequest_reportMutation($input: RequestReportAccessInput!) { + requestReportAccess(input: $input) { + audit { + id + reportFile { + id + access { + id + status + } + } + } + } + } +`; + +const requestFileMutation = graphql` + mutation useResumeAccessRequest_fileMutation($input: RequestTrustCenterFileAccessInput!) { + requestTrustCenterFileAccess(input: $input) { + file { + id + access { + id + status + } + } + } + } +`; + // After a user signs in through the dialog, they land back on the page that -// carried the request-all marker. This hook fires the deferred -// `requestAllAccesses` mutation once (when authenticated), routes to the +// carried a deferred access marker. This hook fires the matching mutation once +// (when authenticated) — request-all from the top bar, or a single +// document / report / file requested from a locked row — routes to the // full-name gate when the backend asks for it, and clears the marker so a // refresh never re-triggers it. export function useResumeAccessRequest(isAuthenticated: boolean) { @@ -59,29 +113,43 @@ export function useResumeAccessRequest(isAuthenticated: boolean) { requestAllAccessesMutation, { errorToast: false }, ); - - const shouldResume - = isAuthenticated && searchParams.get(REQUEST_ALL_PARAM) === "true"; + const [requestDocumentAccess] = useMutation( + requestDocumentMutation, + { errorToast: false }, + ); + const [requestReportAccess] = useMutation( + requestReportMutation, + { errorToast: false }, + ); + const [requestFileAccess] = useMutation( + requestFileMutation, + { errorToast: false }, + ); useEffect(() => { - if (!shouldResume || firedRef.current) { + if (!isAuthenticated || firedRef.current) { return; } + + const documentId = searchParams.get(REQUEST_DOCUMENT_PARAM); + const reportId = searchParams.get(REQUEST_REPORT_PARAM); + const fileId = searchParams.get(REQUEST_FILE_PARAM); + const all = searchParams.get(REQUEST_ALL_PARAM) === "true"; + + if (!documentId && !reportId && !fileId && !all) { + return; + } + firedRef.current = true; - // Drop the marker up front so a reload can't queue a second request. - searchParams.delete(REQUEST_ALL_PARAM); - setSearchParams(searchParams, { replace: true }); - - void requestAllAccesses({ - variables: {}, - onCompleted: (_response, errors) => { + // Shared outcome handling: route to the full-name gate (preserving the + // marker so the request resumes), surface NDA / failures as a toast, and + // confirm success. `continueUrl` re-adds the current marker for the gate. + const makeHandlers = (continueUrl: string) => ({ + onCompleted: (_response: unknown, errors: PayloadError[] | null) => { const code = (errors?.[0] as GraphQLError | undefined)?.extensions?.code; - // The backend gates access behind a completed profile; send the user to - // the full-name step, preserving the marker so the request resumes. if (code === "FULL_NAME_REQUIRED") { - const continueUrl = buildRequestAllContinueUrl(); void navigate(`/full-name?continue=${encodeURIComponent(continueUrl)}`); return; } @@ -102,13 +170,56 @@ export function useResumeAccessRequest(isAuthenticated: boolean) { onError: () => { toast.add({ title: t("auth.errors.requestFailed"), type: "error" }); }, - // The awaitable wrapper rejects on failure; toasts are handled above, so - // swallow the rejection to avoid an unhandled promise. + }); + + // Drop the marker up front so a reload can't queue a second request. + const clear = (param: string) => { + searchParams.delete(param); + setSearchParams(searchParams, { replace: true }); + }; + + if (documentId) { + const continueUrl = buildRequestAccessContinueUrl(REQUEST_DOCUMENT_PARAM, documentId); + clear(REQUEST_DOCUMENT_PARAM); + void requestDocumentAccess({ + variables: { input: { documentId } }, + ...makeHandlers(continueUrl), + }).catch(() => {}); + return; + } + + if (reportId) { + const continueUrl = buildRequestAccessContinueUrl(REQUEST_REPORT_PARAM, reportId); + clear(REQUEST_REPORT_PARAM); + void requestReportAccess({ + variables: { input: { reportId } }, + ...makeHandlers(continueUrl), + }).catch(() => {}); + return; + } + + if (fileId) { + const continueUrl = buildRequestAccessContinueUrl(REQUEST_FILE_PARAM, fileId); + clear(REQUEST_FILE_PARAM); + void requestFileAccess({ + variables: { input: { trustCenterFileId: fileId } }, + ...makeHandlers(continueUrl), + }).catch(() => {}); + return; + } + + clear(REQUEST_ALL_PARAM); + void requestAllAccesses({ + variables: {}, + ...makeHandlers(buildRequestAllContinueUrl()), }).catch(() => {}); }, [ - shouldResume, + isAuthenticated, navigate, requestAllAccesses, + requestDocumentAccess, + requestReportAccess, + requestFileAccess, searchParams, setSearchParams, t, diff --git a/apps/compliance-portal/src/pages/documents/DocumentViewerPage.tsx b/apps/compliance-portal/src/pages/documents/DocumentViewerPage.tsx index 7fa37a6be..e65299e34 100644 --- a/apps/compliance-portal/src/pages/documents/DocumentViewerPage.tsx +++ b/apps/compliance-portal/src/pages/documents/DocumentViewerPage.tsx @@ -24,6 +24,7 @@ import { graphql, usePreloadedQuery } from "react-relay"; import type { DocumentViewerPageQuery } from "./__generated__/DocumentViewerPageQuery.graphql"; import { DocumentLocked } from "./_components/DocumentLocked"; import { DocumentViewer } from "./_components/DocumentViewer"; +import { useAccessRequest } from "./_lib/useAccessRequest"; import type { DocumentKind } from "./_lib/useDocumentExport"; import { useDocumentExport } from "./_lib/useDocumentExport"; @@ -81,9 +82,10 @@ export function DocumentViewerPage({ queryRef }: DocumentViewerPageProps) { const data = usePreloadedQuery(documentViewerPageQuery, queryRef); const node = resolveNode(data.aliasedNode); const { dataUri } = useDocumentExport(node.kind, node.id, node.isAuthorized); + const { requestAccess, isRequesting } = useAccessRequest(node.kind, node.id); if (!node.isAuthorized) { - return ; + return ; } return ; diff --git a/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx b/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx index f5962af3a..b5febb470 100644 --- a/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx @@ -20,6 +20,8 @@ import { graphql, useFragment } from "react-relay"; +import { useRequestReportAccess } from "../_lib/useAccessRequest"; + import type { AuditReportListItem_audit$key } from "./__generated__/AuditReportListItem_audit.graphql"; import { DocumentEntry } from "./DocumentEntry"; @@ -49,8 +51,11 @@ interface AuditReportListItemProps { // audit has no report file. export function AuditReportListItem({ auditKey }: AuditReportListItemProps) { const audit = useFragment(auditReportListItemFragment, auditKey); - const report = audit.reportFile; + // 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 ?? ""); + if (report == null) { return null; } @@ -62,6 +67,8 @@ export function AuditReportListItem({ auditKey }: AuditReportListItemProps) { isAuthorized={report.isUserAuthorized} requested={report.access?.status === "REQUESTED"} viewHref={`/documents/${encodeURIComponent(report.alias ?? report.id)}`} + onGetAccess={requestAccess} + isRequesting={isRequesting} /> ); } diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx index 250eb23c2..d81be9448 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx @@ -30,12 +30,22 @@ interface DocumentAccessActionProps { requested: boolean; // Route to the document viewer, used when authorized. viewHref: string; + // Requests access for this entry (gated behind sign-in when needed). + onGetAccess: () => void; + // Whether the access request is in flight. + isRequesting: boolean; } // Trailing access control for a document entry: a "View" link to the viewer when -// authorized, a pending label when access was requested, otherwise a (currently -// inert) "Get Access" call to action. -export function DocumentAccessAction({ isAuthorized, requested, viewHref }: DocumentAccessActionProps) { +// authorized, a pending label when access was requested, otherwise a "Get +// Access" action that requests access (prompting sign-in first when needed). +export function DocumentAccessAction({ + isAuthorized, + requested, + viewHref, + onGetAccess, + isRequesting, +}: DocumentAccessActionProps) { const { t } = useTranslation("documents"); if (isAuthorized) { @@ -55,7 +65,14 @@ export function DocumentAccessAction({ isAuthorized, requested, viewHref }: Docu } return ( - ); diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx index d4cea6b1b..e96567e6c 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx @@ -35,12 +35,24 @@ interface DocumentEntryProps { requested: boolean; // Route to the document viewer, used when authorized. viewHref: string; + // Requests access for this entry (gated behind sign-in when needed). + onGetAccess: () => void; + // Whether the access request is in flight. + isRequesting: boolean; } // Presentational row shared by the document / file / report list items: a title // with accent metadata and the trailing access action. The connection-item // wrappers own their fragments and supply these values. -export function DocumentEntry({ title, meta, isAuthorized, requested, viewHref }: DocumentEntryProps) { +export function DocumentEntry({ + title, + meta, + isAuthorized, + requested, + viewHref, + onGetAccess, + isRequesting, +}: DocumentEntryProps) { const { root, content } = documentListItem(); return ( @@ -53,7 +65,13 @@ export function DocumentEntry({ title, meta, isAuthorized, requested, viewHref } {meta} - + ); } diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx index d64cc3e49..1446cc62d 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx @@ -21,6 +21,8 @@ import { useTranslation } from "react-i18next"; import { graphql, useFragment } from "react-relay"; +import { useRequestDocumentAccess } from "../_lib/useAccessRequest"; + import type { DocumentListItem_document$key } from "./__generated__/DocumentListItem_document.graphql"; import { DocumentEntry } from "./DocumentEntry"; @@ -46,6 +48,7 @@ interface DocumentListItemProps { export function DocumentListItem({ documentKey }: DocumentListItemProps) { const { t } = useTranslation("documents"); const document = useFragment(documentListItemFragment, documentKey); + const { requestAccess, isRequesting } = useRequestDocumentAccess(document.id); return ( ); } diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentLocked.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentLocked.tsx index 7cf8b1428..51ab76093 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentLocked.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentLocked.tsx @@ -24,9 +24,17 @@ import { useTranslation } from "react-i18next"; import { EmptyState } from "#/components/EmptyState/EmptyState"; -// Shown when the viewer resolves a document the visitor may not access. The -// Get Access CTA is display-only until the auth flow lands (see the list rows). -export function DocumentLocked() { +interface DocumentLockedProps { + // Requests access for the locked resource (prompting sign-in first when + // needed). + onGetAccess: () => void; + // Whether the access request is in flight. + isRequesting: boolean; +} + +// Shown when the viewer resolves a document the visitor may not access, with a +// Get Access CTA that requests access (prompting sign-in first when needed). +export function DocumentLocked({ onGetAccess, isRequesting }: DocumentLockedProps) { const { t } = useTranslation("documents"); return ( @@ -36,7 +44,13 @@ export function DocumentLocked() { title={t("viewer.locked.title")} description={t("viewer.locked.description")} action={( - )} diff --git a/apps/compliance-portal/src/pages/documents/_components/TrustCenterFileListItem.tsx b/apps/compliance-portal/src/pages/documents/_components/TrustCenterFileListItem.tsx index 3913773da..028966389 100644 --- a/apps/compliance-portal/src/pages/documents/_components/TrustCenterFileListItem.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/TrustCenterFileListItem.tsx @@ -20,6 +20,8 @@ import { graphql, useFragment } from "react-relay"; +import { useRequestFileAccess } from "../_lib/useAccessRequest"; + import type { TrustCenterFileListItem_file$key } from "./__generated__/TrustCenterFileListItem_file.graphql"; import { DocumentEntry } from "./DocumentEntry"; @@ -44,6 +46,7 @@ interface TrustCenterFileListItemProps { // action linking to the viewer when authorized. export function TrustCenterFileListItem({ fileKey }: TrustCenterFileListItemProps) { const file = useFragment(trustCenterFileListItemFragment, fileKey); + const { requestAccess, isRequesting } = useRequestFileAccess(file.id); return ( ); } diff --git a/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts b/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts new file mode 100644 index 000000000..a1fc52880 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts @@ -0,0 +1,200 @@ +// 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 type { GraphQLError } from "@probo/helpers"; +import { UnAuthenticatedError } from "@probo/relay"; +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router"; +import type { PayloadError } from "relay-runtime"; +import { graphql } from "relay-runtime"; + +import { + buildRequestAccessContinueUrl, + REQUEST_DOCUMENT_PARAM, + REQUEST_FILE_PARAM, + REQUEST_REPORT_PARAM, +} from "#/lib/auth/continueUrl"; +import { useSignInDialog } from "#/lib/auth/signInDialogContext"; +import { useMutation } from "#/lib/relay/useMutation"; + +import type { useAccessRequestDocumentMutation } from "./__generated__/useAccessRequestDocumentMutation.graphql"; +import type { useAccessRequestFileMutation } from "./__generated__/useAccessRequestFileMutation.graphql"; +import type { useAccessRequestReportMutation } from "./__generated__/useAccessRequestReportMutation.graphql"; +import type { DocumentKind } from "./useDocumentExport"; + +export interface AccessRequest { + requestAccess: () => void; + isRequesting: boolean; +} + +// Each mutation echoes the updated access record so Relay flips the row to its +// "requested" state in place, without a refetch. +const documentMutation = graphql` + mutation useAccessRequestDocumentMutation($input: RequestDocumentAccessInput!) { + requestDocumentAccess(input: $input) { + document { + id + access { + id + status + } + } + } + } +`; + +const reportMutation = graphql` + mutation useAccessRequestReportMutation($input: RequestReportAccessInput!) { + requestReportAccess(input: $input) { + audit { + id + reportFile { + id + access { + id + status + } + } + } + } + } +`; + +const fileMutation = graphql` + mutation useAccessRequestFileMutation($input: RequestTrustCenterFileAccessInput!) { + requestTrustCenterFileAccess(input: $input) { + file { + id + access { + id + status + } + } + } + } +`; + +// Shared success / error handling for a single access request: full-name gate +// and unauthenticated visitors are routed to the sign-in flow (deferring the +// request via the continue URL), everything else surfaces a toast. +function useAccessRequestHandlers(param: string, id: string) { + const { openSignIn } = useSignInDialog(); + const navigate = useNavigate(); + const toast = Toast.useToastManager(); + const { t } = useTranslation(); + + return useMemo( + () => ({ + onCompleted: (_response: unknown, errors: PayloadError[] | null) => { + const code = (errors?.[0] as GraphQLError | undefined)?.extensions?.code; + + if (code === "FULL_NAME_REQUIRED") { + const continueUrl = buildRequestAccessContinueUrl(param, id); + void navigate(`/full-name?continue=${encodeURIComponent(continueUrl)}`); + return; + } + + if (errors && errors.length > 0) { + toast.add({ + title: + code === "NDA_SIGNATURE_REQUIRED" + ? t("auth.errors.ndaRequired") + : t("auth.errors.requestFailed"), + type: "error", + }); + return; + } + + toast.add({ title: t("auth.requestAccess.success"), type: "success" }); + }, + onError: (error: Error) => { + // Not signed in: open the dialog, deferring this request until the user + // lands back authenticated (see useResumeAccessRequest). + if (error instanceof UnAuthenticatedError) { + openSignIn({ continueTo: buildRequestAccessContinueUrl(param, id) }); + return; + } + toast.add({ title: t("auth.errors.requestFailed"), type: "error" }); + }, + }), + [openSignIn, navigate, toast, t, param, id], + ); +} + +export function useRequestDocumentAccess(id: string): AccessRequest { + const handlers = useAccessRequestHandlers(REQUEST_DOCUMENT_PARAM, id); + const [mutate, isRequesting] = useMutation( + documentMutation, + { errorToast: false }, + ); + + const requestAccess = useCallback(() => { + void mutate({ variables: { input: { documentId: id } }, ...handlers }).catch(() => {}); + }, [mutate, id, handlers]); + + return { requestAccess, isRequesting }; +} + +export function useRequestReportAccess(id: string): AccessRequest { + const handlers = useAccessRequestHandlers(REQUEST_REPORT_PARAM, id); + const [mutate, isRequesting] = useMutation( + reportMutation, + { errorToast: false }, + ); + + const requestAccess = useCallback(() => { + void mutate({ variables: { input: { reportId: id } }, ...handlers }).catch(() => {}); + }, [mutate, id, handlers]); + + return { requestAccess, isRequesting }; +} + +export function useRequestFileAccess(id: string): AccessRequest { + const handlers = useAccessRequestHandlers(REQUEST_FILE_PARAM, id); + const [mutate, isRequesting] = useMutation( + fileMutation, + { errorToast: false }, + ); + + const requestAccess = useCallback(() => { + void mutate({ variables: { input: { trustCenterFileId: id } }, ...handlers }).catch(() => {}); + }, [mutate, id, handlers]); + + return { requestAccess, isRequesting }; +} + +// Resolves the right request hook for a viewer node resolved by kind. Safe to +// call unconditionally (all three hooks run); returns the one matching `kind`. +export function useAccessRequest(kind: DocumentKind, id: string): AccessRequest { + const document = useRequestDocumentAccess(id); + const report = useRequestReportAccess(id); + const file = useRequestFileAccess(id); + + switch (kind) { + case "Document": + return document; + case "AuditReport": + return report; + case "TrustCenterFile": + return file; + } +}