Add document viewer with proper 404 handling for trust center

Move document download/view to a dedicated viewer page with PDF preview,
access request flow, and a proper 404 error boundary when documents are
not found. The backend now returns NOT_FOUND instead of INTERNAL for
missing documents and reports.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-16 19:02:51 +01:00
parent dc8e6d0817
commit 7ffb2d5e94
17 changed files with 750 additions and 137 deletions

View File

@@ -1,4 +1,4 @@
import { downloadFile, formatError } from "@probo/helpers";
import { formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { UnAuthenticatedError } from "@probo/relay";
import {
@@ -7,10 +7,9 @@ import {
Dialog,
DialogContent,
FrameworkLogo,
IconArrowInbox,
IconArrowLink,
IconLock,
IconMedal,
Spinner,
Table,
useToast,
} from "@probo/ui";
@@ -19,11 +18,9 @@ import { useFragment, useMutation } from "react-relay";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "#/hooks/useMutationWithToast";
import { getPathPrefix } from "#/utils/pathPrefix";
import type { AuditRow_requestAccessMutation } from "./__generated__/AuditRow_requestAccessMutation.graphql";
import type { AuditRowDownloadMutation } from "./__generated__/AuditRowDownloadMutation.graphql";
import type { AuditRowFragment$key } from "./__generated__/AuditRowFragment.graphql";
const requestAccessMutation = graphql`
@@ -41,20 +38,11 @@ const requestAccessMutation = graphql`
}
`;
const downloadMutation = graphql`
mutation AuditRowDownloadMutation($input: ExportReportPDFInput!) {
exportReportPDF(input: $input) {
data
}
}
`;
const auditRowFragment = graphql`
fragment AuditRowFragment on Audit {
name
report {
id
filename
isUserAuthorized
access {
id
@@ -82,8 +70,6 @@ export function AuditRow(props: { audit: AuditRowFragment$key }) {
const [requestAccess, isRequestingAccess]
= useMutation<AuditRow_requestAccessMutation>(requestAccessMutation);
const [commitDownload, downloading]
= useMutationWithToasts<AuditRowDownloadMutation>(downloadMutation);
const handleRequestAccess = () => {
requestAccess({
@@ -129,22 +115,6 @@ export function AuditRow(props: { audit: AuditRowFragment$key }) {
});
};
const handleDownload = async () => {
if (!audit.report?.id) {
return;
}
await commitDownload({
variables: {
input: {
reportId: audit.report.id,
},
},
onSuccess(response) {
downloadFile(response.exportReportPDF.data, audit.report!.filename);
},
});
};
return (
<div className="text-sm border border-border-solid -mt-px flex gap-3 flex-col md:flex-row md:justify-between px-6 py-3">
<div className="flex items-center gap-2">
@@ -156,11 +126,10 @@ export function AuditRow(props: { audit: AuditRowFragment$key }) {
<Button
className="w-full md:w-max"
variant="secondary"
disabled={downloading}
icon={downloading ? Spinner : IconArrowInbox}
onClick={() => void handleDownload()}
icon={IconArrowLink}
to={`/documents/${audit.report.id}`}
>
{downloading ? __("Downloading") : __("Download")}
{__("View")}
</Button>
)
: (

View File

@@ -0,0 +1,103 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import {
FullNameRequiredError,
NDASignatureRequiredError,
UnAuthenticatedError,
} from "@probo/relay";
import { Button, IconChevronLeft, IconPageCross } from "@probo/ui";
import { Link, Navigate, useLocation, useRouteError } from "react-router";
import { getPathPrefix } from "#/utils/pathPrefix";
export function DocumentPageErrorBoundary() {
const error = useRouteError();
const location = useLocation();
const { __ } = useTranslate();
const search = new URLSearchParams();
if (location.pathname !== (getPathPrefix() || "/") || location.search !== "") {
search.set("continue", window.location.href);
}
const queryString = search.toString();
if (error instanceof UnAuthenticatedError) {
return (
<Navigate
replace
to={{
pathname: "/connect",
search: queryString ? "?" + queryString : "",
}}
/>
);
}
if (error instanceof FullNameRequiredError) {
return (
<Navigate
replace
to={{
pathname: "/full-name",
search: queryString ? "?" + queryString : "",
}}
/>
);
}
if (error instanceof NDASignatureRequiredError) {
return (
<Navigate
replace
to={{
pathname: "/nda",
search: queryString ? "?" + queryString : "",
}}
/>
);
}
return (
<div className="flex flex-col h-screen bg-level-2">
<header className="flex items-center h-12 gap-3 border-b border-border-solid px-4 flex-none bg-level-1">
<Link
to="/documents"
className="size-8 grid place-items-center hover:bg-secondary-hover rounded-sm transition-all"
>
<IconChevronLeft size={16} />
</Link>
</header>
<main className="flex-1 min-h-0 flex items-center justify-center">
<div className="text-center max-w-sm">
<IconPageCross size={32} className="mx-auto text-txt-tertiary mb-4" />
<h2 className="text-lg font-medium mb-2">
{__("Document not found")}
</h2>
<p className="text-sm text-txt-secondary mb-6">
{__("The document you are looking for does not exist or has been removed.")}
</p>
<Button variant="secondary" asChild className="inline-flex">
<Link to="/documents">
{__("Back to documents")}
</Link>
</Button>
</div>
</main>
</div>
);
}

View File

@@ -1,23 +1,20 @@
import { downloadFile, formatError } from "@probo/helpers";
import { formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { UnAuthenticatedError } from "@probo/relay";
import {
Button,
IconArrowInbox,
IconArrowLink,
IconLock,
IconPageTextLine,
Spinner,
useToast,
} from "@probo/ui";
import { useFragment, useMutation } from "react-relay";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "#/hooks/useMutationWithToast";
import { getPathPrefix } from "#/utils/pathPrefix";
import type { DocumentRow_requestAccessMutation } from "./__generated__/DocumentRow_requestAccessMutation.graphql";
import type { DocumentRowDownloadMutation } from "./__generated__/DocumentRowDownloadMutation.graphql";
import type { DocumentRowFragment$key } from "./__generated__/DocumentRowFragment.graphql";
const requestAccessMutation = graphql`
@@ -35,14 +32,6 @@ const requestAccessMutation = graphql`
}
`;
const downloadMutation = graphql`
mutation DocumentRowDownloadMutation($input: ExportDocumentPDFInput!) {
exportDocumentPDF(input: $input) {
data
}
}
`;
const documentRowFragment = graphql`
fragment DocumentRowFragment on Document {
id
@@ -67,8 +56,6 @@ export function DocumentRow(props: { document: DocumentRowFragment$key }) {
const [requestAccess, isRequestingAccess]
= useMutation<DocumentRow_requestAccessMutation>(requestAccessMutation);
const [commitDownload, downloading]
= useMutationWithToasts<DocumentRowDownloadMutation>(downloadMutation);
const handleRequestAccess = () => {
requestAccess({
@@ -114,19 +101,6 @@ export function DocumentRow(props: { document: DocumentRowFragment$key }) {
});
};
const handleDownload = async () => {
await commitDownload({
variables: {
input: {
documentId: document.id,
},
},
onSuccess(response) {
downloadFile(response.exportDocumentPDF.data, document.title);
},
});
};
return (
<div className="text-sm border border-border-solid -mt-px flex gap-3 flex-col md:flex-row md:justify-between px-6 py-3">
<div className="flex items-center gap-2">
@@ -138,11 +112,10 @@ export function DocumentRow(props: { document: DocumentRowFragment$key }) {
<Button
className="w-full md:w-max"
variant="secondary"
disabled={downloading}
icon={downloading ? Spinner : IconArrowInbox}
onClick={() => void handleDownload()}
icon={IconArrowLink}
onClick={() => void navigate(`/documents/${document.id}`)}
>
{downloading ? __("Downloading") : __("Download")}
{__("View")}
</Button>
)
: (

View File

@@ -32,7 +32,7 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) {
}
}, [location, resetErrorBoundary]);
if (!error || (error instanceof Error && error.message.includes("PAGE_NOT_FOUND"))) {
if (!error) {
return (
<div className={classNames.wrapper}>
<h1 className={classNames.title}>

View File

@@ -1,23 +1,20 @@
import { downloadFile, formatError } from "@probo/helpers";
import { formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { UnAuthenticatedError } from "@probo/relay";
import {
Button,
IconArrowInbox,
IconArrowLink,
IconLock,
IconPageTextLine,
Spinner,
useToast,
} from "@probo/ui";
import { useFragment, useMutation } from "react-relay";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "#/hooks/useMutationWithToast";
import { getPathPrefix } from "#/utils/pathPrefix";
import type { TrustCenterFileRow_requestAccessMutation } from "./__generated__/TrustCenterFileRow_requestAccessMutation.graphql";
import type { TrustCenterFileRowDownloadMutation } from "./__generated__/TrustCenterFileRowDownloadMutation.graphql";
import type { TrustCenterFileRowFragment$key } from "./__generated__/TrustCenterFileRowFragment.graphql";
const requestAccessMutation = graphql`
@@ -35,16 +32,6 @@ const requestAccessMutation = graphql`
}
`;
const downloadMutation = graphql`
mutation TrustCenterFileRowDownloadMutation(
$input: ExportTrustCenterFileInput!
) {
exportTrustCenterFile(input: $input) {
data
}
}
`;
const trustCenterFileRowFragment = graphql`
fragment TrustCenterFileRowFragment on TrustCenterFile {
id
@@ -73,8 +60,6 @@ export function TrustCenterFileRow(props: {
= useMutation<TrustCenterFileRow_requestAccessMutation>(
requestAccessMutation,
);
const [commitDownload, downloading]
= useMutationWithToasts<TrustCenterFileRowDownloadMutation>(downloadMutation);
const handleRequestAccess = () => {
requestAccess({
@@ -120,19 +105,6 @@ export function TrustCenterFileRow(props: {
});
};
const handleDownload = async () => {
await commitDownload({
variables: {
input: {
trustCenterFileId: file.id,
},
},
onSuccess(response) {
downloadFile(response.exportTrustCenterFile.data, file.name);
},
});
};
return (
<div className="text-sm border border-border-solid -mt-px flex gap-3 flex-col md:flex-row md:justify-between px-6 py-3">
<div className="flex items-center gap-2">
@@ -144,11 +116,10 @@ export function TrustCenterFileRow(props: {
<Button
className="w-full md:w-max"
variant="secondary"
disabled={downloading}
icon={downloading ? Spinner : IconArrowInbox}
onClick={() => void handleDownload()}
icon={IconArrowLink}
onClick={() => void navigate(`/documents/${file.id}`)}
>
{downloading ? __("Downloading") : __("Download")}
{__("View")}
</Button>
)
: (