Gate private document access behind sign-in
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é <emile@probo.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<useResumeAccessRequest_documentMutation>(
|
||||
requestDocumentMutation,
|
||||
{ errorToast: false },
|
||||
);
|
||||
const [requestReportAccess] = useMutation<useResumeAccessRequest_reportMutation>(
|
||||
requestReportMutation,
|
||||
{ errorToast: false },
|
||||
);
|
||||
const [requestFileAccess] = useMutation<useResumeAccessRequest_fileMutation>(
|
||||
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,
|
||||
|
||||
@@ -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>(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 <DocumentLocked />;
|
||||
return <DocumentLocked onGetAccess={requestAccess} isRequesting={isRequesting} />;
|
||||
}
|
||||
|
||||
return <DocumentViewer title={node.title} dataUri={dataUri} downloadName={node.title} />;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Button variant="ghost" color="neutral" highContrast iconStart={<LockSimpleIcon />}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
highContrast
|
||||
loading={isRequesting}
|
||||
iconStart={<LockSimpleIcon />}
|
||||
onClick={onGetAccess}
|
||||
>
|
||||
{t("actions.getAccess")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
</Text>
|
||||
</div>
|
||||
<DocumentAccessAction isAuthorized={isAuthorized} requested={requested} viewHref={viewHref} />
|
||||
<DocumentAccessAction
|
||||
isAuthorized={isAuthorized}
|
||||
requested={requested}
|
||||
viewHref={viewHref}
|
||||
onGetAccess={onGetAccess}
|
||||
isRequesting={isRequesting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<DocumentEntry
|
||||
@@ -54,6 +57,8 @@ export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
||||
isAuthorized={document.isUserAuthorized}
|
||||
requested={document.access?.status === "REQUESTED"}
|
||||
viewHref={`/documents/${encodeURIComponent(document.alias ?? document.id)}`}
|
||||
onGetAccess={requestAccess}
|
||||
isRequesting={isRequesting}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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={(
|
||||
<Button color="neutral" highContrast iconStart={<LockSimpleIcon />}>
|
||||
<Button
|
||||
color="neutral"
|
||||
highContrast
|
||||
loading={isRequesting}
|
||||
iconStart={<LockSimpleIcon />}
|
||||
onClick={onGetAccess}
|
||||
>
|
||||
{t("actions.getAccess")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<DocumentEntry
|
||||
@@ -52,6 +55,8 @@ export function TrustCenterFileListItem({ fileKey }: TrustCenterFileListItemProp
|
||||
isAuthorized={file.isUserAuthorized}
|
||||
requested={file.access?.status === "REQUESTED"}
|
||||
viewHref={`/documents/${encodeURIComponent(file.alias ?? file.id)}`}
|
||||
onGetAccess={requestAccess}
|
||||
isRequesting={isRequesting}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// 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 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<useAccessRequestDocumentMutation>(
|
||||
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<useAccessRequestReportMutation>(
|
||||
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<useAccessRequestFileMutation>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user