Address PR review on the auth gates

Route the full-name and NDA gates from the request-access flows to their
gate pages (deep-linking with the deferred continue URL) instead of a
dead-end toast, so signing or naming resumes the original request; the
shared gate-to-route mapping now lives in one helper reused by the route
boundaries and both request hooks.

Fix the NDA page redirecting to home while also redirecting to the
continue URL once the signature is sealed, surface consent/accept
failures so the sign button isn't silently inert, and build the
request-all continue URL before clearing its marker.

On the backend, return success from updateFullName when the identity has
no organization profile instead of dereferencing a nil profile, which
crashed external trust-center visitors completing the full-name gate.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-16 19:22:01 +02:00
parent 4d3cf1f320
commit 3e2651cbe5
9 changed files with 75 additions and 59 deletions

View File

@@ -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": {

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

@@ -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") {

View File

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

View File

@@ -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 <Navigate to="/" replace />;
}
if (!nda || !signature || isCompleted) {
if (!nda || !signature) {
return <Navigate to="/" replace />;
}
// 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 (

View File

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