diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json index de6d9c8bd..eae17b626 100644 --- a/apps/compliance-portal/src/_locales/en-US.json +++ b/apps/compliance-portal/src/_locales/en-US.json @@ -65,8 +65,7 @@ "magicLinkFailed": "Couldn't send the magic link. Please try again.", "verifyFailed": "Couldn't verify the link. Please try again.", "fullNameFailed": "Couldn't save your name. Please try again.", - "requestFailed": "Couldn't complete your access request. Please try again.", - "ndaRequired": "You need to sign the NDA before requesting access." + "requestFailed": "Couldn't complete your access request. Please try again." } }, "home": { diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json index 2b072b175..e6b17aecc 100644 --- a/apps/compliance-portal/src/_locales/fr-FR.json +++ b/apps/compliance-portal/src/_locales/fr-FR.json @@ -65,8 +65,7 @@ "magicLinkFailed": "Impossible d'envoyer le lien magique. Veuillez réessayer.", "verifyFailed": "Impossible de vérifier le lien. Veuillez réessayer.", "fullNameFailed": "Impossible d'enregistrer votre nom. Veuillez réessayer.", - "requestFailed": "Impossible de finaliser votre demande d'accès. Veuillez réessayer.", - "ndaRequired": "Vous devez signer l'accord de confidentialité avant de demander l'accès." + "requestFailed": "Impossible de finaliser votre demande d'accès. Veuillez réessayer." } }, "home": { diff --git a/apps/compliance-portal/src/components/errors/resolveGateRedirect.ts b/apps/compliance-portal/src/components/errors/resolveGateRedirect.ts index c38424dc4..10bb13d19 100644 --- a/apps/compliance-portal/src/components/errors/resolveGateRedirect.ts +++ b/apps/compliance-portal/src/components/errors/resolveGateRedirect.ts @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { FullNameRequiredError, NDASignatureRequiredError } from "@probo/relay"; +import { gateRedirectPath } from "#/lib/auth/continueUrl"; // 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. @@ -26,15 +26,5 @@ import { FullNameRequiredError, NDASignatureRequiredError } from "@probo/relay"; // 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; + return gateRedirectPath(error, window.location.href); } diff --git a/apps/compliance-portal/src/lib/auth/continueUrl.ts b/apps/compliance-portal/src/lib/auth/continueUrl.ts index eb72d9227..a5e0aa792 100644 --- a/apps/compliance-portal/src/lib/auth/continueUrl.ts +++ b/apps/compliance-portal/src/lib/auth/continueUrl.ts @@ -18,6 +18,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import { FullNameRequiredError, NDASignatureRequiredError } from "@probo/relay"; + import { getPathPrefix } from "#/lib/http/pathPrefix"; // Markers appended to a post-auth `continue` URL so the portal fires the pending @@ -70,3 +72,18 @@ export function buildRequestAccessContinueUrl(param: string, id: string): string url.searchParams.set(param, id); return url.toString(); } + +// Maps a caught auth-gate error to the route that resolves it, carrying the +// given `continueUrl` so the user returns here (and any deferred request +// resumes) once the gate is cleared. Returns null for non-gate errors. Shared +// by the route boundaries and the request-access flows so all gate handling +// stays in one place. +export function gateRedirectPath(error: unknown, continueUrl: string): string | null { + if (error instanceof FullNameRequiredError) { + return `/full-name?continue=${encodeURIComponent(continueUrl)}`; + } + if (error instanceof NDASignatureRequiredError) { + return `/nda?continue=${encodeURIComponent(continueUrl)}`; + } + return null; +} diff --git a/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts b/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts index 32e28cb87..d0176729d 100644 --- a/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts +++ b/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts @@ -19,7 +19,6 @@ // SOFTWARE. import { Toast } from "@base-ui/react/toast"; -import { FullNameRequiredError, NDASignatureRequiredError } from "@probo/relay"; import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router"; @@ -29,6 +28,7 @@ import { graphql } from "relay-runtime"; import { buildRequestAccessContinueUrl, buildRequestAllContinueUrl, + gateRedirectPath, REQUEST_ALL_PARAM, REQUEST_DOCUMENT_PARAM, REQUEST_FILE_PARAM, @@ -143,9 +143,9 @@ export function useResumeAccessRequest(isAuthenticated: boolean) { firedRef.current = true; // 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. + // fetch layer, so they arrive in `onError` and deep-link to their gate page, + // preserving the marker so the request resumes once cleared. Other failures + // toast; success confirms. const makeHandlers = (continueUrl: string) => ({ onCompleted: (_response: unknown, errors: PayloadError[] | null) => { if (errors && errors.length > 0) { @@ -155,12 +155,9 @@ export function useResumeAccessRequest(isAuthenticated: boolean) { 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 (error instanceof NDASignatureRequiredError) { - toast.add({ title: t("auth.errors.ndaRequired"), type: "error" }); + const gatePath = gateRedirectPath(error, continueUrl); + if (gatePath) { + void navigate(gatePath); return; } toast.add({ title: t("auth.errors.requestFailed"), type: "error" }); @@ -203,10 +200,11 @@ export function useResumeAccessRequest(isAuthenticated: boolean) { return; } + const allContinueUrl = buildRequestAllContinueUrl(); clear(REQUEST_ALL_PARAM); void requestAllAccesses({ variables: {}, - ...makeHandlers(buildRequestAllContinueUrl()), + ...makeHandlers(allContinueUrl), }).catch(() => {}); }, [ isAuthenticated, diff --git a/apps/compliance-portal/src/pages/auth/VerifyMagicLinkPage.tsx b/apps/compliance-portal/src/pages/auth/VerifyMagicLinkPage.tsx index fc627d41e..803d6d94a 100644 --- a/apps/compliance-portal/src/pages/auth/VerifyMagicLinkPage.tsx +++ b/apps/compliance-portal/src/pages/auth/VerifyMagicLinkPage.tsx @@ -68,7 +68,9 @@ export default function VerifyMagicLinkPage() { const code = (errors?.[0] as GraphQLError | undefined)?.extensions?.code; if (code === "ALREADY_AUTHENTICATED") { - window.location.href = getSafeContinueUrl(null); + // Already signed in: honor a `continue` on the URL if present so a + // deferred access request still resumes, instead of always going home. + window.location.href = getSafeContinueUrl(searchParams.get("continue")); return; } if (code === "TOKEN_EXPIRED") { diff --git a/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts b/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts index 3bfd6e864..e2b6d420a 100644 --- a/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts +++ b/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts @@ -19,11 +19,7 @@ // SOFTWARE. import { Toast } from "@base-ui/react/toast"; -import { - FullNameRequiredError, - NDASignatureRequiredError, - UnAuthenticatedError, -} from "@probo/relay"; +import { UnAuthenticatedError } from "@probo/relay"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; @@ -32,6 +28,7 @@ import { graphql } from "relay-runtime"; import { buildRequestAccessContinueUrl, + gateRedirectPath, REQUEST_DOCUMENT_PARAM, REQUEST_FILE_PARAM, REQUEST_REPORT_PARAM, @@ -98,10 +95,9 @@ const fileMutation = graphql` // 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. +// `onError`: unauthenticated opens the sign-in dialog, while full-name and NDA +// deep-link to their gate page — all deferring the request via the continue URL +// so it resumes once the gate is cleared. Everything else is a generic toast. function useAccessRequestHandlers(param: string, id: string) { const { openSignIn } = useSignInDialog(); const navigate = useNavigate(); @@ -118,23 +114,19 @@ function useAccessRequestHandlers(param: string, id: string) { toast.add({ title: t("auth.requestAccess.success"), type: "success" }); }, onError: (error: Error) => { + const continueUrl = buildRequestAccessContinueUrl(param, id); + // 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) }); + openSignIn({ continueTo: continueUrl }); return; } - // Missing profile name: send them to the full-name gate, preserving the + // Full-name / NDA gate: deep-link to the gate page, 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" }); + const gatePath = gateRedirectPath(error, continueUrl); + if (gatePath) { + void navigate(gatePath); return; } toast.add({ title: t("auth.errors.requestFailed"), type: "error" }); diff --git a/apps/compliance-portal/src/pages/nda/NDAPage.tsx b/apps/compliance-portal/src/pages/nda/NDAPage.tsx index 3e53a5b1a..bb00bf051 100644 --- a/apps/compliance-portal/src/pages/nda/NDAPage.tsx +++ b/apps/compliance-portal/src/pages/nda/NDAPage.tsx @@ -189,14 +189,22 @@ export function NDAPage({ queryRef }: NDAPageProps) { }).catch(() => {}); } - void recordSigningEvent({ - variables: { input: { signatureId: signature.id, eventType: "CONSENT_GIVEN" } }, - onCompleted: () => { - void acceptSignature({ - variables: { input: { signatureId: signature.id } }, - }).catch(() => {}); + // Consent + acceptance are the critical steps: surface failures (via the + // default mutation error toast) so the user can retry, instead of leaving + // the sign button apparently inert. The fire-and-forget events above stay + // silent (errorToast: false on the hook). + void recordSigningEvent( + { + variables: { input: { signatureId: signature.id, eventType: "CONSENT_GIVEN" } }, + onCompleted: () => { + void acceptSignature( + { variables: { input: { signatureId: signature.id } } }, + { errorToast: true }, + ).catch(() => {}); + }, }, - }).catch(() => {}); + { errorToast: true }, + ).catch(() => {}); }; const movePage = (direction: 1 | -1) => { @@ -209,10 +217,16 @@ export function NDAPage({ queryRef }: NDAPageProps) { return ; } - if (!nda || !signature || isCompleted) { + if (!nda || !signature) { return ; } + // Signature already sealed: the effect above redirects to the continue URL; + // render nothing meanwhile so we don't flash the sign UI or the home page. + if (isCompleted) { + return null; + } + const slots = ndaPage(); return ( diff --git a/pkg/server/api/trust/v1/auth_resolvers.go b/pkg/server/api/trust/v1/auth_resolvers.go index b80314ac9..1ded8d77f 100644 --- a/pkg/server/api/trust/v1/auth_resolvers.go +++ b/pkg/server/api/trust/v1/auth_resolvers.go @@ -161,10 +161,15 @@ func (r *mutationResolver) UpdateFullName(ctx context.Context, input types.Updat profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, compliancePage.OrganizationID) if err != nil { - if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); !ok { - r.logger.ErrorCtx(ctx, "cannot get profile", log.Error(err)) - return nil, gqlutils.Internal(ctx) + // External trust-center visitors have no organization profile; updating + // the identity's full name above is all that is needed for them. + if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); ok { + return &types.UpdateFullNamePayload{Success: true}, nil } + + r.logger.ErrorCtx(ctx, "cannot get profile", log.Error(err)) + + return nil, gqlutils.Internal(ctx) } if profile.Source == coredata.ProfileSourceManual {