Files
probo/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts
Émile Ré d0c9327e99 Require explicit portal access request IDs
Drop the request-all shortcut so callers always name the
documents, reports, and files to request. TopBar Get Access
now only signs in; bulk selection is the multi-resource path.

Signed-off-by: Émile Ré <emile@probo.com>
2026-07-27 18:27:21 +02:00

197 lines
6.5 KiB
TypeScript

// 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 { 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,
gateRedirectPath,
REQUEST_DOCUMENT_PARAM,
REQUEST_FILE_PARAM,
REQUEST_REPORT_PARAM,
} from "#/lib/auth/continueUrl";
import { useLocale } from "#/lib/i18n/useLocale";
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";
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: RequestCompliancePortalFileAccessInput!) {
requestCompliancePortalFileAccess(input: $input) {
file {
id
access {
id
status
}
}
}
}
`;
// After a user signs in through OAuth /initiate, they land back on the page that
// carried a deferred access marker. This hook fires the matching mutation once
// (when authenticated) — 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) {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const locale = useLocale();
const toast = Toast.useToastManager();
const { t } = useTranslation();
const firedRef = useRef(false);
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 (!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);
if (!documentId && !reportId && !fileId) {
return;
}
firedRef.current = true;
// Shared outcome handling. The full-name and NDA gates are thrown by the
// fetch layer, so they arrive in `onError` and deep-link to their gate page,
// preserving the marker so the request resumes once cleared. Other failures
// toast; success confirms.
const makeHandlers = (continueUrl: string) => ({
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" });
},
onError: (error: Error) => {
const gatePath = gateRedirectPath(error, continueUrl, locale);
if (gatePath) {
void navigate(gatePath);
return;
}
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
},
});
// 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: { compliancePortalFileId: fileId } },
...makeHandlers(continueUrl),
}).catch(() => {});
}
}, [
isAuthenticated,
locale,
navigate,
requestDocumentAccess,
requestReportAccess,
requestFileAccess,
searchParams,
setSearchParams,
t,
toast,
]);
}