Add NDA and full-name gates to compliance portal

Handle NDA_SIGNATURE_REQUIRED and FULL_NAME_REQUIRED the way the trust
app does: the Relay fetch throws the typed errors and the route error
boundaries redirect to /full-name or a new self-contained /nda page,
carrying a continue URL. The request-access hooks move both gates to
onError accordingly (NDA is toast-only, matching trust, since the query
boundary is its primary path).

The NDA page is styled like the document viewer: a header band with the
title, org subtitle, consent, and sign action, over the NDA PDF, with the
same page-navigation and zoom controls. It records the signing events,
accepts the electronic signature, polls until sealed, then returns to the
continue URL.

Also fall back to the email in the top-bar user menu when a member has no
full name yet, and hoist the shared PDF-viewer control labels into the
app common namespace (deduplicating the document viewer and NDA copies,
and collapsing a duplicated common block in the locales).

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-16 18:45:26 +02:00
parent 375f5f071e
commit 4d3cf1f320
20 changed files with 665 additions and 69 deletions

View File

@@ -1,7 +1,4 @@
{
"common": {
"error": "Something went wrong"
},
"topBar": {
"tagline": "Compliance Portal",
"getAccess": "Get Access",
@@ -17,7 +14,12 @@
},
"common": {
"cancel": "Cancel",
"error": "Something went wrong"
"error": "Something went wrong",
"previousPage": "Previous page",
"nextPage": "Next page",
"pageOf": "Page {{current}} of {{total}}",
"zoomOut": "Zoom out",
"zoomIn": "Zoom in"
},
"auth": {
"backToPortal": "Back to portal",

View File

@@ -1,7 +1,4 @@
{
"common": {
"error": "Une erreur est survenue"
},
"topBar": {
"tagline": "Portail de conformité",
"getAccess": "Obtenir l'accès",
@@ -17,7 +14,12 @@
},
"common": {
"cancel": "Annuler",
"error": "Une erreur est survenue"
"error": "Une erreur est survenue",
"previousPage": "Page pr\u00e9c\u00e9dente",
"nextPage": "Page suivante",
"pageOf": "Page {{current}} sur {{total}}",
"zoomOut": "Zoom arri\u00e8re",
"zoomIn": "Zoom avant"
},
"auth": {
"backToPortal": "Retour au portail",

View File

@@ -49,11 +49,14 @@ export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
const { t } = useTranslation();
const identity = useFragment(topBarUserMenuFragment, identityKey);
// New users may not have set a full name yet; fall back to the email.
const displayName = identity.fullName.trim() || identity.email;
return (
<Dropdown>
<DropdownTrigger
render={(
<button type="button" className={topBarUserMenuTrigger()} aria-label={identity.fullName}>
<button type="button" className={topBarUserMenuTrigger()} aria-label={displayName}>
<Avatar
size={1}
variant="soft"
@@ -62,7 +65,7 @@ export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
fallback={<UserIcon />}
/>
<Text size={2} weight="medium" color="neutral" highContrast>
{identity.fullName}
{displayName}
</Text>
<CaretDownIcon className="size-4 text-sand-11" />
</button>

View File

@@ -12,15 +12,23 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useRouteError } from "react-router";
import { Navigate, useRouteError } from "react-router";
import { GlobalError } from "./GlobalError";
import { resolveGateRedirect } from "./resolveGateRedirect";
// Child-route boundary: a page failure is contained to the layout's Outlet, so
// the error renders inside the app chrome (TopBar + footer survive).
export function PageErrorBoundary() {
const error = useRouteError();
// Full-name / NDA gates are recoverable: send the user to the gate page and
// return them here afterwards, instead of showing an error.
const gateRedirect = resolveGateRedirect(error);
if (gateRedirect) {
return <Navigate replace to={gateRedirect} />;
}
return (
<GlobalError
error={error}

View File

@@ -12,9 +12,10 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useRouteError } from "react-router";
import { Navigate, useRouteError } from "react-router";
import { GlobalError } from "./GlobalError";
import { resolveGateRedirect } from "./resolveGateRedirect";
// Root route boundary: a failure in the layout (or anything above the page
// boundaries) takes down the whole tree, so it renders a standalone full-page
@@ -22,6 +23,13 @@ import { GlobalError } from "./GlobalError";
export function RootErrorBoundary() {
const error = useRouteError();
// Full-name / NDA gates are recoverable: send the user to the gate page and
// return them here afterwards, instead of showing an error.
const gateRedirect = resolveGateRedirect(error);
if (gateRedirect) {
return <Navigate replace to={gateRedirect} />;
}
return (
<GlobalError
error={error}

View File

@@ -0,0 +1,40 @@
// 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 { FullNameRequiredError, NDASignatureRequiredError } from "@probo/relay";
// Maps a caught gate error to the route that resolves it, carrying the current
// URL as a `continue` target so the user returns here once the gate is cleared.
// Returns a router-relative path (basename applied by the router); the target
// page validates the continue URL via getSafeContinueUrl. Returns null for any
// other error so the boundary can fall through to its normal error UI.
export function resolveGateRedirect(error: unknown): string | null {
const continueUrl = encodeURIComponent(window.location.href);
if (error instanceof FullNameRequiredError) {
return `/full-name?continue=${continueUrl}`;
}
if (error instanceof NDASignatureRequiredError) {
return `/nda?continue=${continueUrl}`;
}
return null;
}

View File

@@ -19,7 +19,7 @@
// SOFTWARE.
import { Toast } from "@base-ui/react/toast";
import type { GraphQLError } from "@probo/helpers";
import { FullNameRequiredError, NDASignatureRequiredError } from "@probo/relay";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router";
@@ -142,32 +142,27 @@ export function useResumeAccessRequest(isAuthenticated: boolean) {
firedRef.current = true;
// 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.
// Shared outcome handling. The full-name and NDA gates are thrown by the
// fetch layer, so they arrive in `onError`: full-name deep-links to its gate
// (preserving the marker so the request resumes), NDA is a toast (its
// primary path is the query-load boundary), failures toast, success confirms.
const makeHandlers = (continueUrl: string) => ({
onCompleted: (_response: unknown, errors: PayloadError[] | null) => {
const code = (errors?.[0] as GraphQLError | undefined)?.extensions?.code;
if (code === "FULL_NAME_REQUIRED") {
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) => {
if (error instanceof FullNameRequiredError) {
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",
});
if (error instanceof NDASignatureRequiredError) {
toast.add({ title: t("auth.errors.ndaRequired"), type: "error" });
return;
}
toast.add({ title: t("auth.requestAccess.success"), type: "success" });
},
onError: () => {
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
},
});

View File

@@ -12,7 +12,13 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { ForbiddenError, InternalServerError, UnAuthenticatedError } from "@probo/relay";
import {
ForbiddenError,
FullNameRequiredError,
InternalServerError,
NDASignatureRequiredError,
UnAuthenticatedError,
} from "@probo/relay";
import { type GraphQLError } from "graphql";
import { type FetchFunction, type GraphQLResponse } from "relay-runtime";
@@ -98,6 +104,24 @@ export const makeFetchQuery = (endpoint: string): FetchFunction => {
throw new UnAuthenticatedError(unauthenticated.message);
}
// Full-name and NDA are global gates (the backend attaches a resolver
// path even to these), so — like UNAUTHENTICATED — scan every error and
// throw so the route boundary can redirect to the matching gate page.
// Full name is required before the NDA check, so it is scanned first.
const fullNameRequired = json.errors.find(
error => error.extensions?.code === "FULL_NAME_REQUIRED",
);
if (fullNameRequired) {
throw new FullNameRequiredError(fullNameRequired.message);
}
const ndaRequired = json.errors.find(
error => error.extensions?.code === "NDA_SIGNATURE_REQUIRED",
);
if (ndaRequired) {
throw new NDASignatureRequiredError(ndaRequired.message);
}
// Everything else is only thrown here when it is request-level (no path) —
// a whole-operation failure. Field-level errors (including a FORBIDDEN on
// a single field/section) are left in the response so Relay surfaces them

View File

@@ -118,19 +118,19 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
<IconButton
variant="ghost"
color="neutral"
aria-label={t("viewer.previousPage")}
aria-label={t("common.previousPage")}
disabled={currentPage <= 1}
onClick={() => movePage(-1)}
>
<CaretLeftIcon />
</IconButton>
<Text size={2} color="neutral">
{t("viewer.pageOf", { current: currentPage, total: numPages })}
{t("common.pageOf", { current: currentPage, total: numPages })}
</Text>
<IconButton
variant="ghost"
color="neutral"
aria-label={t("viewer.nextPage")}
aria-label={t("common.nextPage")}
disabled={currentPage >= numPages}
onClick={() => movePage(1)}
>
@@ -142,7 +142,7 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
<IconButton
variant="ghost"
color="neutral"
aria-label={t("viewer.zoomOut")}
aria-label={t("common.zoomOut")}
onClick={() => setScale(value => clamp(value * 0.8, MIN_SCALE, MAX_SCALE))}
>
<MagnifyingGlassMinusIcon />
@@ -153,7 +153,7 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
<IconButton
variant="ghost"
color="neutral"
aria-label={t("viewer.zoomIn")}
aria-label={t("common.zoomIn")}
onClick={() => setScale(value => clamp(value * 1.25, MIN_SCALE, MAX_SCALE))}
>
<MagnifyingGlassPlusIcon />

View File

@@ -19,8 +19,11 @@
// SOFTWARE.
import { Toast } from "@base-ui/react/toast";
import type { GraphQLError } from "@probo/helpers";
import { UnAuthenticatedError } from "@probo/relay";
import {
FullNameRequiredError,
NDASignatureRequiredError,
UnAuthenticatedError,
} from "@probo/relay";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
@@ -93,9 +96,12 @@ const fileMutation = graphql`
}
`;
// 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.
// Shared success / error handling for a single access request. The auth,
// full-name, and NDA gates are thrown by the fetch layer, so they surface in
// `onError` (not `onError`'s GraphQL-errors argument): unauthenticated opens the
// sign-in dialog, full-name deep-links to its gate (both deferring the request
// via the continue URL), NDA is a toast (its primary path is the query-load
// boundary), and everything else is a generic toast.
function useAccessRequestHandlers(param: string, id: string) {
const { openSignIn } = useSignInDialog();
const navigate = useNavigate();
@@ -105,25 +111,10 @@ function useAccessRequestHandlers(param: string, id: string) {
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",
});
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
return;
}
toast.add({ title: t("auth.requestAccess.success"), type: "success" });
},
onError: (error: Error) => {
@@ -133,6 +124,19 @@ function useAccessRequestHandlers(param: string, id: string) {
openSignIn({ continueTo: buildRequestAccessContinueUrl(param, id) });
return;
}
// Missing profile name: send them to the full-name gate, preserving the
// marker so the request resumes afterwards.
if (error instanceof FullNameRequiredError) {
const continueUrl = buildRequestAccessContinueUrl(param, id);
void navigate(`/full-name?continue=${encodeURIComponent(continueUrl)}`);
return;
}
// NDA is enforced at query load (the route boundary redirects to /nda);
// here we only inform, matching the trust app.
if (error instanceof NDASignatureRequiredError) {
toast.add({ title: t("auth.errors.ndaRequired"), type: "error" });
return;
}
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
},
}),

View File

@@ -33,11 +33,6 @@
},
"viewer": {
"back": "Documents",
"pageOf": "Page {{current}} of {{total}}",
"previousPage": "Previous page",
"nextPage": "Next page",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"share": "Share document",
"linkCopied": "Link copied to clipboard",
"download": "Download",

View File

@@ -33,11 +33,6 @@
},
"viewer": {
"back": "Documents",
"pageOf": "Page {{current}} sur {{total}}",
"previousPage": "Page précédente",
"nextPage": "Page suivante",
"zoomIn": "Zoom avant",
"zoomOut": "Zoom arrière",
"share": "Partager le document",
"linkCopied": "Lien copié dans le presse-papiers",
"download": "Télécharger",

View File

@@ -0,0 +1,329 @@
// 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 {
CaretLeftIcon,
CaretRightIcon,
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
} from "@phosphor-icons/react";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { Callout } from "@probo/ui/src/v2/Callout/Callout";
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
import { Separator } from "@probo/ui/src/v2/Separator/Separator";
import { Heading } from "@probo/ui/src/v2/typography/Heading";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { startTransition, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PreloadedQuery } from "react-relay";
import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay";
import { Navigate, useSearchParams } from "react-router";
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
import { getSafeContinueUrl } from "#/lib/auth/continueUrl";
import { useMutation } from "#/lib/relay/useMutation";
import { PdfPreview, type PdfPreviewHandle } from "#/pages/documents/_components/PdfPreview";
import type { NDAPageAcceptMutation } from "./__generated__/NDAPageAcceptMutation.graphql";
import type { NDAPageFragment$key } from "./__generated__/NDAPageFragment.graphql";
import type { NDAPageQuery as NDAPageQueryType } from "./__generated__/NDAPageQuery.graphql";
import type { NDAPageRecordEventMutation } from "./__generated__/NDAPageRecordEventMutation.graphql";
import type { NDAPageRefetchQuery } from "./__generated__/NDAPageRefetchQuery.graphql";
import { ndaPage } from "./variants";
const POLL_INTERVAL_MS = 1500;
const MIN_SCALE = 0.5;
const MAX_SCALE = 3;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export const ndaPageQuery = graphql`
query NDAPageQuery {
viewer {
id
}
currentTrustCenter @required(action: THROW) {
organization {
name
}
nonDisclosureAgreement {
fileUrl
}
...NDAPageFragment
}
}
`;
const ndaPageFragment = graphql`
fragment NDAPageFragment on TrustCenter
@refetchable(queryName: "NDAPageRefetchQuery") {
nonDisclosureAgreement @required(action: THROW) {
viewerSignature {
id
status
consentText
lastError
}
}
}
`;
const acceptSignatureMutation = graphql`
mutation NDAPageAcceptMutation($input: AcceptElectronicSignatureInput!) {
acceptElectronicSignature(input: $input) {
signature {
id
status
}
}
}
`;
const recordSigningEventMutation = graphql`
mutation NDAPageRecordEventMutation($input: RecordSigningEventInput!) {
recordSigningEvent(input: $input) {
success
}
}
`;
interface NDAPageProps {
queryRef: PreloadedQuery<NDAPageQueryType>;
}
// Non-Disclosure Agreement gate: the user reviews the NDA (rendered in the body)
// and signs it via the header action. Signing records the consent events, accepts
// the electronic signature, polls until it is sealed, then returns to the
// continue URL. Reached from the route boundary on NDA_SIGNATURE_REQUIRED.
export function NDAPage({ queryRef }: NDAPageProps) {
const { t } = useTranslation("nda");
const [searchParams] = useSearchParams();
const documentViewedRef = useRef(false);
const pdfRef = useRef<PdfPreviewHandle>(null);
const [numPages, setNumPages] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [scale, setScale] = useState(1);
const data = usePreloadedQuery<NDAPageQueryType>(ndaPageQuery, queryRef);
const trustCenter = data.currentTrustCenter;
const [fragment, refetch] = useRefetchableFragment<NDAPageRefetchQuery, NDAPageFragment$key>(
ndaPageFragment,
trustCenter,
);
const nda = trustCenter.nonDisclosureAgreement;
const signature = fragment.nonDisclosureAgreement.viewerSignature;
const safeContinueUrl = getSafeContinueUrl(searchParams.get("continue"));
const [acceptSignature, isAccepting] = useMutation<NDAPageAcceptMutation>(
acceptSignatureMutation,
{ errorToast: false },
);
const [recordSigningEvent] = useMutation<NDAPageRecordEventMutation>(
recordSigningEventMutation,
{ errorToast: false },
);
const isProcessing = signature?.status === "ACCEPTED" || signature?.status === "PROCESSING";
const isFailed = signature?.status === "FAILED";
const isCompleted = signature?.status === "COMPLETED";
// Once the signature is sealed, leave the gate and resume where the user was.
useEffect(() => {
if (isCompleted) {
window.location.href = safeContinueUrl;
}
}, [isCompleted, safeContinueUrl]);
// While the backend seals the signature, poll the fragment for the new status.
useEffect(() => {
if (!isProcessing) {
return;
}
const interval = setInterval(() => {
startTransition(() => {
refetch({}, { fetchPolicy: "network-only" });
});
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [isProcessing, refetch]);
// Record that the document was viewed once, on first render of a pending gate.
useEffect(() => {
if (signature?.status === "PENDING" && !documentViewedRef.current) {
documentViewedRef.current = true;
void recordSigningEvent({
variables: { input: { signatureId: signature.id, eventType: "DOCUMENT_VIEWED" } },
}).catch(() => {});
}
}, [signature, recordSigningEvent]);
const handleAccept = () => {
if (!signature) {
return;
}
if (signature.status === "PENDING") {
void recordSigningEvent({
variables: { input: { signatureId: signature.id, eventType: "FULL_NAME_TYPED" } },
}).catch(() => {});
}
void recordSigningEvent({
variables: { input: { signatureId: signature.id, eventType: "CONSENT_GIVEN" } },
onCompleted: () => {
void acceptSignature({
variables: { input: { signatureId: signature.id } },
}).catch(() => {});
},
}).catch(() => {});
};
const movePage = (direction: 1 | -1) => {
const next = clamp(currentPage + direction, 1, numPages);
pdfRef.current?.scrollToPage(next);
setCurrentPage(next);
};
if (!data.viewer) {
return <Navigate to="/" replace />;
}
if (!nda || !signature || isCompleted) {
return <Navigate to="/" replace />;
}
const slots = ndaPage();
return (
<div className={slots.root()}>
<HeaderBand flushBottomSpace>
<div className={slots.header()}>
<div className={slots.text()}>
<Heading level={1} size={7} weight="medium" highContrast>
{t("title")}
</Heading>
<Text size={2} color="neutral">
{t("subtitle", { name: trustCenter.organization.name })}
</Text>
{signature.consentText != null && (
<Text size={1} color="faint" className={slots.consent()}>
{signature.consentText}
</Text>
)}
</div>
{isFailed && (
<Callout color="red" variant="surface">
{signature.lastError ?? t("failedDescription")}
</Callout>
)}
<div className={slots.toolbar()}>
<div className={slots.toolbarStart()}>
{numPages > 0 && (
<>
<div className={slots.controls()}>
<IconButton
variant="ghost"
color="neutral"
aria-label={t("common.previousPage")}
disabled={currentPage <= 1}
onClick={() => movePage(-1)}
>
<CaretLeftIcon />
</IconButton>
<Text size={2} color="neutral">
{t("common.pageOf", { current: currentPage, total: numPages })}
</Text>
<IconButton
variant="ghost"
color="neutral"
aria-label={t("common.nextPage")}
disabled={currentPage >= numPages}
onClick={() => movePage(1)}
>
<CaretRightIcon />
</IconButton>
</div>
<Separator orientation="vertical" className={slots.separator()} />
<div className={slots.controls()}>
<IconButton
variant="ghost"
color="neutral"
aria-label={t("common.zoomOut")}
onClick={() => setScale(value => clamp(value * 0.8, MIN_SCALE, MAX_SCALE))}
>
<MagnifyingGlassMinusIcon />
</IconButton>
<Text size={2} color="neutral">
{`${Math.round(scale * 100)}%`}
</Text>
<IconButton
variant="ghost"
color="neutral"
aria-label={t("common.zoomIn")}
onClick={() => setScale(value => clamp(value * 1.25, MIN_SCALE, MAX_SCALE))}
>
<MagnifyingGlassPlusIcon />
</IconButton>
</div>
</>
)}
</div>
<div className={slots.actions()}>
<Button
type="button"
color="neutral"
highContrast
loading={isAccepting || isProcessing}
disabled={isProcessing}
onClick={handleAccept}
>
{isProcessing
? t("sealing")
: isFailed
? t("tryAgain")
: t("reviewAndSign")}
</Button>
</div>
</div>
</div>
</HeaderBand>
<div className={slots.body()}>
{nda.fileUrl
? (
<PdfPreview
ref={pdfRef}
file={nda.fileUrl}
scale={scale}
onNumPages={setNumPages}
onVisiblePageChange={setCurrentPage}
/>
)
: <div className={slots.stage()} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
// 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 { useEffect } from "react";
import { useQueryLoader } from "react-relay";
import type { NDAPageQuery } from "./__generated__/NDAPageQuery.graphql";
import { NDAPage, ndaPageQuery } from "./NDAPage";
import { NDAPageSkeleton } from "./NDAPageSkeleton";
export default function NDAPageLoader() {
const [queryRef, loadQuery] = useQueryLoader<NDAPageQuery>(ndaPageQuery);
useEffect(() => {
loadQuery({});
}, [loadQuery]);
if (!queryRef) {
return <NDAPageSkeleton />;
}
return <NDAPage queryRef={queryRef} />;
}

View File

@@ -0,0 +1,56 @@
// 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 { ButtonSkeleton } from "@probo/ui/src/v2/Button/ButtonSkeleton";
import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton";
import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton";
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
import { ndaPage } from "./variants";
export function NDAPageSkeleton() {
const slots = ndaPage();
return (
<div className={slots.root()}>
<HeaderBand flushBottomSpace>
<div className={slots.header()}>
<div className={slots.text()}>
<HeadingSkeleton size={7} className="w-80" />
<TextSkeleton size={2} className="w-96" />
<TextSkeleton size={1} className="w-full max-w-2xl" />
</div>
<div className={slots.toolbar()}>
<div className={slots.toolbarStart()}>
<ButtonSkeleton size={2} className="w-40" />
</div>
<div className={slots.actions()}>
<ButtonSkeleton size={2} className="w-32" />
</div>
</div>
</div>
</HeaderBand>
<div className={slots.body()}>
<div className={slots.stage()} />
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
{
"title": "Non-Disclosure Agreement",
"subtitle": "{{name}} requires you to sign an NDA before accessing compliance documents.",
"reviewAndSign": "Review and sign",
"tryAgain": "Try again",
"sealing": "Sealing your signature\u2026",
"failedDescription": "We encountered an issue processing your signature. Please try again."
}

View File

@@ -0,0 +1,8 @@
{
"title": "Accord de confidentialit\u00e9",
"subtitle": "{{name}} exige la signature d'un accord de confidentialit\u00e9 avant d'acc\u00e9der aux documents de conformit\u00e9.",
"reviewAndSign": "Lire et signer",
"tryAgain": "R\u00e9essayer",
"sealing": "Scellement de votre signature\u2026",
"failedDescription": "Un probl\u00e8me est survenu lors du traitement de votre signature. Veuillez r\u00e9essayer."
}

View File

@@ -0,0 +1,38 @@
// 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 { lazy } from "@probo/react-lazy";
import type { AppRoute } from "@probo/routes";
import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary";
import { NDAPageSkeleton } from "./NDAPageSkeleton";
// Self-contained NDA gate, reached from the route boundaries on
// NDA_SIGNATURE_REQUIRED. Sits outside the MainLayout shell (no TopBar), like
// the auth pages, keeping the user focused on signing before browsing.
export const ndaRoutes = [
{
path: "nda",
Fallback: NDAPageSkeleton,
Component: lazy(() => import("#/pages/nda/NDAPageLoader")),
ErrorBoundary: RootErrorBoundary,
},
] satisfies AppRoute[];

View File

@@ -0,0 +1,39 @@
// 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 { tv } from "tailwind-variants/lite";
// Self-contained NDA gate, laid out like the document viewer: a header band
// with the title, subtitle, consent, and sign action above a grey PDF stage.
export const ndaPage = tv({
slots: {
root: "flex h-dvh flex-col",
header: "flex w-full flex-col gap-3",
text: "flex flex-col gap-1",
toolbar: "flex min-h-16 items-center justify-between gap-4",
toolbarStart: "flex items-center gap-2",
controls: "flex items-center gap-1",
separator: "h-6",
consent: "max-w-2xl",
actions: "flex shrink-0 items-center gap-2",
body: "min-h-0 flex-1",
stage: "grid h-full place-items-center bg-sand-3",
},
});

View File

@@ -29,6 +29,7 @@ import { authRoutes } from "#/pages/auth/routes";
import { documentRoutes } from "#/pages/documents/routes";
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
import { ndaRoutes } from "#/pages/nda/routes";
import { subprocessorRoutes } from "#/pages/subprocessors/routes";
import { updateRoutes } from "#/pages/updates/routes";
@@ -66,6 +67,7 @@ const routes = [
],
},
...authRoutes,
...ndaRoutes,
] satisfies AppRoute[];
// The portal is served under a /trust/{slug} path prefix (or a bare custom