diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json index 29f9a0cb2..de6d9c8bd 100644 --- a/apps/compliance-portal/src/_locales/en-US.json +++ b/apps/compliance-portal/src/_locales/en-US.json @@ -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", diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json index 10244333c..2b072b175 100644 --- a/apps/compliance-portal/src/_locales/fr-FR.json +++ b/apps/compliance-portal/src/_locales/fr-FR.json @@ -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", diff --git a/apps/compliance-portal/src/components/TopBar/TopBarUserMenu.tsx b/apps/compliance-portal/src/components/TopBar/TopBarUserMenu.tsx index bd69078d5..171a3431d 100644 --- a/apps/compliance-portal/src/components/TopBar/TopBarUserMenu.tsx +++ b/apps/compliance-portal/src/components/TopBar/TopBarUserMenu.tsx @@ -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 ( + diff --git a/apps/compliance-portal/src/components/errors/PageErrorBoundary.tsx b/apps/compliance-portal/src/components/errors/PageErrorBoundary.tsx index 8ebe3ba11..41f581a97 100644 --- a/apps/compliance-portal/src/components/errors/PageErrorBoundary.tsx +++ b/apps/compliance-portal/src/components/errors/PageErrorBoundary.tsx @@ -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 ; + } + return ( ; + } + return ( . +// +// 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; +} diff --git a/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts b/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts index 32728ef27..32e28cb87 100644 --- a/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts +++ b/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts @@ -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" }); }, }); diff --git a/apps/compliance-portal/src/lib/relay/fetch.ts b/apps/compliance-portal/src/lib/relay/fetch.ts index 35010fef4..676daf4ed 100644 --- a/apps/compliance-portal/src/lib/relay/fetch.ts +++ b/apps/compliance-portal/src/lib/relay/fetch.ts @@ -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 diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentViewer.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentViewer.tsx index 79a0b89bf..67028fa6a 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentViewer.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentViewer.tsx @@ -118,19 +118,19 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP movePage(-1)} > - {t("viewer.pageOf", { current: currentPage, total: numPages })} + {t("common.pageOf", { current: currentPage, total: numPages })} = numPages} onClick={() => movePage(1)} > @@ -142,7 +142,7 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP setScale(value => clamp(value * 0.8, MIN_SCALE, MAX_SCALE))} > @@ -153,7 +153,7 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP setScale(value => clamp(value * 1.25, MIN_SCALE, MAX_SCALE))} > diff --git a/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts b/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts index a1fc52880..3bfd6e864 100644 --- a/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts +++ b/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts @@ -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" }); }, }), diff --git a/apps/compliance-portal/src/pages/documents/_locales/en-US.json b/apps/compliance-portal/src/pages/documents/_locales/en-US.json index 7128d64f7..5846bcd2d 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/en-US.json +++ b/apps/compliance-portal/src/pages/documents/_locales/en-US.json @@ -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", diff --git a/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json b/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json index 52ac8faf0..40614e11b 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json +++ b/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json @@ -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", diff --git a/apps/compliance-portal/src/pages/nda/NDAPage.tsx b/apps/compliance-portal/src/pages/nda/NDAPage.tsx new file mode 100644 index 000000000..3e53a5b1a --- /dev/null +++ b/apps/compliance-portal/src/pages/nda/NDAPage.tsx @@ -0,0 +1,329 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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; +} + +// 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(null); + const [numPages, setNumPages] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [scale, setScale] = useState(1); + + const data = usePreloadedQuery(ndaPageQuery, queryRef); + const trustCenter = data.currentTrustCenter; + const [fragment, refetch] = useRefetchableFragment( + ndaPageFragment, + trustCenter, + ); + + const nda = trustCenter.nonDisclosureAgreement; + const signature = fragment.nonDisclosureAgreement.viewerSignature; + + const safeContinueUrl = getSafeContinueUrl(searchParams.get("continue")); + + const [acceptSignature, isAccepting] = useMutation( + acceptSignatureMutation, + { errorToast: false }, + ); + const [recordSigningEvent] = useMutation( + 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 ; + } + + if (!nda || !signature || isCompleted) { + return ; + } + + const slots = ndaPage(); + + return ( +
+ +
+
+ + {t("title")} + + + {t("subtitle", { name: trustCenter.organization.name })} + + {signature.consentText != null && ( + + {signature.consentText} + + )} +
+ + {isFailed && ( + + {signature.lastError ?? t("failedDescription")} + + )} + +
+
+ {numPages > 0 && ( + <> +
+ movePage(-1)} + > + + + + {t("common.pageOf", { current: currentPage, total: numPages })} + + = numPages} + onClick={() => movePage(1)} + > + + +
+ +
+ setScale(value => clamp(value * 0.8, MIN_SCALE, MAX_SCALE))} + > + + + + {`${Math.round(scale * 100)}%`} + + setScale(value => clamp(value * 1.25, MIN_SCALE, MAX_SCALE))} + > + + +
+ + )} +
+
+ +
+
+
+
+ +
+ {nda.fileUrl + ? ( + + ) + :
} +
+
+ ); +} diff --git a/apps/compliance-portal/src/pages/nda/NDAPageLoader.tsx b/apps/compliance-portal/src/pages/nda/NDAPageLoader.tsx new file mode 100644 index 000000000..62a004430 --- /dev/null +++ b/apps/compliance-portal/src/pages/nda/NDAPageLoader.tsx @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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); + + useEffect(() => { + loadQuery({}); + }, [loadQuery]); + + if (!queryRef) { + return ; + } + + return ; +} diff --git a/apps/compliance-portal/src/pages/nda/NDAPageSkeleton.tsx b/apps/compliance-portal/src/pages/nda/NDAPageSkeleton.tsx new file mode 100644 index 000000000..0df676c0a --- /dev/null +++ b/apps/compliance-portal/src/pages/nda/NDAPageSkeleton.tsx @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( +
+ +
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/compliance-portal/src/pages/nda/_locales/en-US.json b/apps/compliance-portal/src/pages/nda/_locales/en-US.json new file mode 100644 index 000000000..3d46178ea --- /dev/null +++ b/apps/compliance-portal/src/pages/nda/_locales/en-US.json @@ -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." +} diff --git a/apps/compliance-portal/src/pages/nda/_locales/fr-FR.json b/apps/compliance-portal/src/pages/nda/_locales/fr-FR.json new file mode 100644 index 000000000..a742f96c9 --- /dev/null +++ b/apps/compliance-portal/src/pages/nda/_locales/fr-FR.json @@ -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." +} diff --git a/apps/compliance-portal/src/pages/nda/routes.ts b/apps/compliance-portal/src/pages/nda/routes.ts new file mode 100644 index 000000000..207a7be53 --- /dev/null +++ b/apps/compliance-portal/src/pages/nda/routes.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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[]; diff --git a/apps/compliance-portal/src/pages/nda/variants.ts b/apps/compliance-portal/src/pages/nda/variants.ts new file mode 100644 index 000000000..110eb6908 --- /dev/null +++ b/apps/compliance-portal/src/pages/nda/variants.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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", + }, +}); diff --git a/apps/compliance-portal/src/routes.tsx b/apps/compliance-portal/src/routes.tsx index a82da7eba..21d6e92cb 100644 --- a/apps/compliance-portal/src/routes.tsx +++ b/apps/compliance-portal/src/routes.tsx @@ -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