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:
Émile Ré
2026-07-16 17:57:57 +02:00
parent 4e3e4d8975
commit 375f5f071e
10 changed files with 425 additions and 33 deletions

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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} />;

View File

@@ -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}
/>
);
}

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
)}

View File

@@ -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}
/>
);
}

View File

@@ -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;
}
}