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";
|
import { getPathPrefix } from "#/lib/http/pathPrefix";
|
||||||
|
|
||||||
// Marker appended to a post-auth `continue` URL so the portal fires the pending
|
// Markers appended to a post-auth `continue` URL so the portal fires the pending
|
||||||
// "request access" mutation once the user lands back authenticated.
|
// "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_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
|
// 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
|
// 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");
|
url.searchParams.set(REQUEST_ALL_PARAM, "true");
|
||||||
return url.toString();
|
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 { useEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate, useSearchParams } from "react-router";
|
import { useNavigate, useSearchParams } from "react-router";
|
||||||
|
import type { PayloadError } from "relay-runtime";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildRequestAccessContinueUrl,
|
||||||
buildRequestAllContinueUrl,
|
buildRequestAllContinueUrl,
|
||||||
REQUEST_ALL_PARAM,
|
REQUEST_ALL_PARAM,
|
||||||
|
REQUEST_DOCUMENT_PARAM,
|
||||||
|
REQUEST_FILE_PARAM,
|
||||||
|
REQUEST_REPORT_PARAM,
|
||||||
} from "#/lib/auth/continueUrl";
|
} from "#/lib/auth/continueUrl";
|
||||||
import { useMutation } from "#/lib/relay/useMutation";
|
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";
|
import type { useResumeAccessRequestMutation } from "./__generated__/useResumeAccessRequestMutation.graphql";
|
||||||
|
|
||||||
const requestAllAccessesMutation = 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
|
// 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
|
// carried a deferred access marker. This hook fires the matching mutation once
|
||||||
// `requestAllAccesses` mutation once (when authenticated), routes to the
|
// (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
|
// full-name gate when the backend asks for it, and clears the marker so a
|
||||||
// refresh never re-triggers it.
|
// refresh never re-triggers it.
|
||||||
export function useResumeAccessRequest(isAuthenticated: boolean) {
|
export function useResumeAccessRequest(isAuthenticated: boolean) {
|
||||||
@@ -59,29 +113,43 @@ export function useResumeAccessRequest(isAuthenticated: boolean) {
|
|||||||
requestAllAccessesMutation,
|
requestAllAccessesMutation,
|
||||||
{ errorToast: false },
|
{ errorToast: false },
|
||||||
);
|
);
|
||||||
|
const [requestDocumentAccess] = useMutation<useResumeAccessRequest_documentMutation>(
|
||||||
const shouldResume
|
requestDocumentMutation,
|
||||||
= isAuthenticated && searchParams.get(REQUEST_ALL_PARAM) === "true";
|
{ errorToast: false },
|
||||||
|
);
|
||||||
|
const [requestReportAccess] = useMutation<useResumeAccessRequest_reportMutation>(
|
||||||
|
requestReportMutation,
|
||||||
|
{ errorToast: false },
|
||||||
|
);
|
||||||
|
const [requestFileAccess] = useMutation<useResumeAccessRequest_fileMutation>(
|
||||||
|
requestFileMutation,
|
||||||
|
{ errorToast: false },
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldResume || firedRef.current) {
|
if (!isAuthenticated || firedRef.current) {
|
||||||
return;
|
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;
|
firedRef.current = true;
|
||||||
|
|
||||||
// Drop the marker up front so a reload can't queue a second request.
|
// Shared outcome handling: route to the full-name gate (preserving the
|
||||||
searchParams.delete(REQUEST_ALL_PARAM);
|
// marker so the request resumes), surface NDA / failures as a toast, and
|
||||||
setSearchParams(searchParams, { replace: true });
|
// confirm success. `continueUrl` re-adds the current marker for the gate.
|
||||||
|
const makeHandlers = (continueUrl: string) => ({
|
||||||
void requestAllAccesses({
|
onCompleted: (_response: unknown, errors: PayloadError[] | null) => {
|
||||||
variables: {},
|
|
||||||
onCompleted: (_response, errors) => {
|
|
||||||
const code = (errors?.[0] as GraphQLError | undefined)?.extensions?.code;
|
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") {
|
if (code === "FULL_NAME_REQUIRED") {
|
||||||
const continueUrl = buildRequestAllContinueUrl();
|
|
||||||
void navigate(`/full-name?continue=${encodeURIComponent(continueUrl)}`);
|
void navigate(`/full-name?continue=${encodeURIComponent(continueUrl)}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -102,13 +170,56 @@ export function useResumeAccessRequest(isAuthenticated: boolean) {
|
|||||||
onError: () => {
|
onError: () => {
|
||||||
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
|
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(() => {});
|
}).catch(() => {});
|
||||||
}, [
|
}, [
|
||||||
shouldResume,
|
isAuthenticated,
|
||||||
navigate,
|
navigate,
|
||||||
requestAllAccesses,
|
requestAllAccesses,
|
||||||
|
requestDocumentAccess,
|
||||||
|
requestReportAccess,
|
||||||
|
requestFileAccess,
|
||||||
searchParams,
|
searchParams,
|
||||||
setSearchParams,
|
setSearchParams,
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { graphql, usePreloadedQuery } from "react-relay";
|
|||||||
import type { DocumentViewerPageQuery } from "./__generated__/DocumentViewerPageQuery.graphql";
|
import type { DocumentViewerPageQuery } from "./__generated__/DocumentViewerPageQuery.graphql";
|
||||||
import { DocumentLocked } from "./_components/DocumentLocked";
|
import { DocumentLocked } from "./_components/DocumentLocked";
|
||||||
import { DocumentViewer } from "./_components/DocumentViewer";
|
import { DocumentViewer } from "./_components/DocumentViewer";
|
||||||
|
import { useAccessRequest } from "./_lib/useAccessRequest";
|
||||||
import type { DocumentKind } from "./_lib/useDocumentExport";
|
import type { DocumentKind } from "./_lib/useDocumentExport";
|
||||||
import { useDocumentExport } from "./_lib/useDocumentExport";
|
import { useDocumentExport } from "./_lib/useDocumentExport";
|
||||||
|
|
||||||
@@ -81,9 +82,10 @@ export function DocumentViewerPage({ queryRef }: DocumentViewerPageProps) {
|
|||||||
const data = usePreloadedQuery<DocumentViewerPageQuery>(documentViewerPageQuery, queryRef);
|
const data = usePreloadedQuery<DocumentViewerPageQuery>(documentViewerPageQuery, queryRef);
|
||||||
const node = resolveNode(data.aliasedNode);
|
const node = resolveNode(data.aliasedNode);
|
||||||
const { dataUri } = useDocumentExport(node.kind, node.id, node.isAuthorized);
|
const { dataUri } = useDocumentExport(node.kind, node.id, node.isAuthorized);
|
||||||
|
const { requestAccess, isRequesting } = useAccessRequest(node.kind, node.id);
|
||||||
|
|
||||||
if (!node.isAuthorized) {
|
if (!node.isAuthorized) {
|
||||||
return <DocumentLocked />;
|
return <DocumentLocked onGetAccess={requestAccess} isRequesting={isRequesting} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <DocumentViewer title={node.title} dataUri={dataUri} downloadName={node.title} />;
|
return <DocumentViewer title={node.title} dataUri={dataUri} downloadName={node.title} />;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
|
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
|
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";
|
||||||
import { DocumentEntry } from "./DocumentEntry";
|
import { DocumentEntry } from "./DocumentEntry";
|
||||||
|
|
||||||
@@ -49,8 +51,11 @@ interface AuditReportListItemProps {
|
|||||||
// audit has no report file.
|
// audit has no report file.
|
||||||
export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
||||||
const audit = useFragment(auditReportListItemFragment, auditKey);
|
const audit = useFragment(auditReportListItemFragment, auditKey);
|
||||||
|
|
||||||
const report = audit.reportFile;
|
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) {
|
if (report == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -62,6 +67,8 @@ export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
|||||||
isAuthorized={report.isUserAuthorized}
|
isAuthorized={report.isUserAuthorized}
|
||||||
requested={report.access?.status === "REQUESTED"}
|
requested={report.access?.status === "REQUESTED"}
|
||||||
viewHref={`/documents/${encodeURIComponent(report.alias ?? report.id)}`}
|
viewHref={`/documents/${encodeURIComponent(report.alias ?? report.id)}`}
|
||||||
|
onGetAccess={requestAccess}
|
||||||
|
isRequesting={isRequesting}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,22 @@ interface DocumentAccessActionProps {
|
|||||||
requested: boolean;
|
requested: boolean;
|
||||||
// Route to the document viewer, used when authorized.
|
// Route to the document viewer, used when authorized.
|
||||||
viewHref: string;
|
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
|
// 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
|
// authorized, a pending label when access was requested, otherwise a "Get
|
||||||
// inert) "Get Access" call to action.
|
// Access" action that requests access (prompting sign-in first when needed).
|
||||||
export function DocumentAccessAction({ isAuthorized, requested, viewHref }: DocumentAccessActionProps) {
|
export function DocumentAccessAction({
|
||||||
|
isAuthorized,
|
||||||
|
requested,
|
||||||
|
viewHref,
|
||||||
|
onGetAccess,
|
||||||
|
isRequesting,
|
||||||
|
}: DocumentAccessActionProps) {
|
||||||
const { t } = useTranslation("documents");
|
const { t } = useTranslation("documents");
|
||||||
|
|
||||||
if (isAuthorized) {
|
if (isAuthorized) {
|
||||||
@@ -55,7 +65,14 @@ export function DocumentAccessAction({ isAuthorized, requested, viewHref }: Docu
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant="ghost" color="neutral" highContrast iconStart={<LockSimpleIcon />}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
highContrast
|
||||||
|
loading={isRequesting}
|
||||||
|
iconStart={<LockSimpleIcon />}
|
||||||
|
onClick={onGetAccess}
|
||||||
|
>
|
||||||
{t("actions.getAccess")}
|
{t("actions.getAccess")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,12 +35,24 @@ interface DocumentEntryProps {
|
|||||||
requested: boolean;
|
requested: boolean;
|
||||||
// Route to the document viewer, used when authorized.
|
// Route to the document viewer, used when authorized.
|
||||||
viewHref: string;
|
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
|
// Presentational row shared by the document / file / report list items: a title
|
||||||
// with accent metadata and the trailing access action. The connection-item
|
// with accent metadata and the trailing access action. The connection-item
|
||||||
// wrappers own their fragments and supply these values.
|
// 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();
|
const { root, content } = documentListItem();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,7 +65,13 @@ export function DocumentEntry({ title, meta, isAuthorized, requested, viewHref }
|
|||||||
{meta}
|
{meta}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<DocumentAccessAction isAuthorized={isAuthorized} requested={requested} viewHref={viewHref} />
|
<DocumentAccessAction
|
||||||
|
isAuthorized={isAuthorized}
|
||||||
|
requested={requested}
|
||||||
|
viewHref={viewHref}
|
||||||
|
onGetAccess={onGetAccess}
|
||||||
|
isRequesting={isRequesting}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
|
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";
|
||||||
import { DocumentEntry } from "./DocumentEntry";
|
import { DocumentEntry } from "./DocumentEntry";
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ interface DocumentListItemProps {
|
|||||||
export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
||||||
const { t } = useTranslation("documents");
|
const { t } = useTranslation("documents");
|
||||||
const document = useFragment(documentListItemFragment, documentKey);
|
const document = useFragment(documentListItemFragment, documentKey);
|
||||||
|
const { requestAccess, isRequesting } = useRequestDocumentAccess(document.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentEntry
|
<DocumentEntry
|
||||||
@@ -54,6 +57,8 @@ export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
|||||||
isAuthorized={document.isUserAuthorized}
|
isAuthorized={document.isUserAuthorized}
|
||||||
requested={document.access?.status === "REQUESTED"}
|
requested={document.access?.status === "REQUESTED"}
|
||||||
viewHref={`/documents/${encodeURIComponent(document.alias ?? document.id)}`}
|
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";
|
import { EmptyState } from "#/components/EmptyState/EmptyState";
|
||||||
|
|
||||||
// Shown when the viewer resolves a document the visitor may not access. The
|
interface DocumentLockedProps {
|
||||||
// Get Access CTA is display-only until the auth flow lands (see the list rows).
|
// Requests access for the locked resource (prompting sign-in first when
|
||||||
export function DocumentLocked() {
|
// 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");
|
const { t } = useTranslation("documents");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +44,13 @@ export function DocumentLocked() {
|
|||||||
title={t("viewer.locked.title")}
|
title={t("viewer.locked.title")}
|
||||||
description={t("viewer.locked.description")}
|
description={t("viewer.locked.description")}
|
||||||
action={(
|
action={(
|
||||||
<Button color="neutral" highContrast iconStart={<LockSimpleIcon />}>
|
<Button
|
||||||
|
color="neutral"
|
||||||
|
highContrast
|
||||||
|
loading={isRequesting}
|
||||||
|
iconStart={<LockSimpleIcon />}
|
||||||
|
onClick={onGetAccess}
|
||||||
|
>
|
||||||
{t("actions.getAccess")}
|
{t("actions.getAccess")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
|
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
|
import { useRequestFileAccess } from "../_lib/useAccessRequest";
|
||||||
|
|
||||||
import type { TrustCenterFileListItem_file$key } from "./__generated__/TrustCenterFileListItem_file.graphql";
|
import type { TrustCenterFileListItem_file$key } from "./__generated__/TrustCenterFileListItem_file.graphql";
|
||||||
import { DocumentEntry } from "./DocumentEntry";
|
import { DocumentEntry } from "./DocumentEntry";
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ interface TrustCenterFileListItemProps {
|
|||||||
// action linking to the viewer when authorized.
|
// action linking to the viewer when authorized.
|
||||||
export function TrustCenterFileListItem({ fileKey }: TrustCenterFileListItemProps) {
|
export function TrustCenterFileListItem({ fileKey }: TrustCenterFileListItemProps) {
|
||||||
const file = useFragment(trustCenterFileListItemFragment, fileKey);
|
const file = useFragment(trustCenterFileListItemFragment, fileKey);
|
||||||
|
const { requestAccess, isRequesting } = useRequestFileAccess(file.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentEntry
|
<DocumentEntry
|
||||||
@@ -52,6 +55,8 @@ export function TrustCenterFileListItem({ fileKey }: TrustCenterFileListItemProp
|
|||||||
isAuthorized={file.isUserAuthorized}
|
isAuthorized={file.isUserAuthorized}
|
||||||
requested={file.access?.status === "REQUESTED"}
|
requested={file.access?.status === "REQUESTED"}
|
||||||
viewHref={`/documents/${encodeURIComponent(file.alias ?? file.id)}`}
|
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