Implement guard on empty full name before NDA is signed

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-05 17:07:58 +04:00
parent 6896c1bbb8
commit 97e957f394
19 changed files with 637 additions and 91 deletions

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<d32c91bec3630db95573556172f06d35>>
* @generated SignedSource<<35d27a50dd067775566c69dd0e555b25>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -17,7 +17,6 @@ export type CompliancePageAccessPageQuery$data = {
readonly organization: {
readonly __typename: "Organization";
readonly compliancePage: {
readonly canCreateAccess: boolean;
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"CompliancePageAccessListFragment">;
};
@@ -61,20 +60,7 @@ v3 = {
"name": "id",
"storageKey": null
},
v4 = {
"alias": "canCreateAccess",
"args": [
{
"kind": "Literal",
"name": "action",
"value": "core:trust-center-access:create"
}
],
"kind": "ScalarField",
"name": "permission",
"storageKey": "permission(action:\"core:trust-center-access:create\")"
},
v5 = [
v4 = [
{
"kind": "Literal",
"name": "first",
@@ -119,7 +105,6 @@ return {
"plural": false,
"selections": [
(v3/*: any*/),
(v4/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
@@ -168,10 +153,9 @@ return {
"plural": false,
"selections": [
(v3/*: any*/),
(v4/*: any*/),
{
"alias": null,
"args": (v5/*: any*/),
"args": (v4/*: any*/),
"concreteType": "TrustCenterAccessConnection",
"kind": "LinkedField",
"name": "accesses",
@@ -350,7 +334,7 @@ return {
},
{
"alias": null,
"args": (v5/*: any*/),
"args": (v4/*: any*/),
"filters": [
"orderBy"
],
@@ -373,16 +357,16 @@ return {
]
},
"params": {
"cacheID": "c556c1f73c80950e432e5298b7aa1162",
"cacheID": "b4d6f7aa127d6ee60ec6344fb291ab4e",
"id": null,
"metadata": {},
"name": "CompliancePageAccessPageQuery",
"operationKind": "query",
"text": "query CompliancePageAccessPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n compliancePage: trustCenter {\n id\n canCreateAccess: permission(action: \"core:trust-center-access:create\")\n ...CompliancePageAccessListFragment\n }\n }\n id\n }\n}\n\nfragment CompliancePageAccessListFragment on TrustCenter {\n accesses(first: 10, orderBy: {field: CREATED_AT, direction: DESC}) {\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n edges {\n cursor\n node {\n id\n ...CompliancePageAccessListItemFragment\n __typename\n }\n }\n }\n id\n}\n\nfragment CompliancePageAccessListItemFragment on TrustCenterAccess {\n id\n createdAt\n profile {\n fullName\n emailAddress\n state\n id\n }\n activeCount\n pendingRequestCount\n ndaSignature {\n status\n id\n }\n canUpdate: permission(action: \"core:trust-center-access:update\")\n}\n"
"text": "query CompliancePageAccessPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n compliancePage: trustCenter {\n id\n ...CompliancePageAccessListFragment\n }\n }\n id\n }\n}\n\nfragment CompliancePageAccessListFragment on TrustCenter {\n accesses(first: 10, orderBy: {field: CREATED_AT, direction: DESC}) {\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n edges {\n cursor\n node {\n id\n ...CompliancePageAccessListItemFragment\n __typename\n }\n }\n }\n id\n}\n\nfragment CompliancePageAccessListItemFragment on TrustCenterAccess {\n id\n createdAt\n profile {\n fullName\n emailAddress\n state\n id\n }\n activeCount\n pendingRequestCount\n ndaSignature {\n status\n id\n }\n canUpdate: permission(action: \"core:trust-center-access:update\")\n}\n"
}
};
})();
(node as any).hash = "4cc473e91fd49ddd83b6cdb0c8da0abf";
(node as any).hash = "29f59aac000603a1d11c88a441fb595b";
export default node;

View File

@@ -1,4 +1,4 @@
import { NDASignatureRequiredError, UnAuthenticatedError } from "@probo/relay";
import { FullNameRequiredError, NDASignatureRequiredError, UnAuthenticatedError } from "@probo/relay";
import { Navigate, useLocation, useRouteError } from "react-router";
import { getPathPrefix } from "#/utils/pathPrefix";
@@ -29,6 +29,18 @@ export function RootErrorBoundary() {
);
}
if (error instanceof FullNameRequiredError) {
return (
<Navigate
replace
to={{
pathname: "/full-name",
search: queryString ? "?" + queryString : "",
}}
/>
);
}
if (error instanceof NDASignatureRequiredError) {
return (
<Navigate

View File

@@ -37,7 +37,6 @@ const sendMagicLinkMutation = graphql`
`;
const schema = z.object({
fullName: z.string().min(2),
email: z.string().email(),
});
@@ -104,7 +103,6 @@ export function ConnectPage(props: {
} = useFormWithSchema(schema, {
defaultValues: {
email: "",
fullName: "",
},
});
@@ -112,8 +110,8 @@ export function ConnectPage(props: {
sendMagicLinkMutation,
);
const handleSubmit = handleSubmitWrapper(({ email, fullName }: FormData) => {
const input: SendMagicLinkInput = { email, fullName };
const handleSubmit = handleSubmitWrapper(({ email }: FormData) => {
const input: SendMagicLinkInput = { email };
if (safeContinueUrl) {
input.continue = safeContinueUrl;
}
@@ -121,7 +119,6 @@ export function ConnectPage(props: {
variables: {
input: {
email,
fullName,
continue: safeContinueUrl,
},
},
@@ -173,14 +170,6 @@ export function ConnectPage(props: {
</div>
<form onSubmit={e => void handleSubmit(e)} className="space-y-6">
<Field
label={__("Full Name")}
placeholder="John Doe"
{...register("fullName")}
type="text"
required
error={formState.errors.fullName?.message}
/>
<Field
label={__("Email")}
placeholder="john.doe@acme.com"

View File

@@ -0,0 +1,135 @@
import type { GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Button, Field, useToast } from "@probo/ui";
import {
useMutation,
} from "react-relay";
import { useSearchParams } from "react-router";
import { graphql } from "relay-runtime";
import { z } from "zod";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { getPathPrefix } from "#/utils/pathPrefix";
import type { FullNamePageMutation } from "./__generated__/FullNamePageMutation.graphql";
const updateMutation = graphql`
mutation FullNamePageMutation($input: UpdateFullNameInput!) {
updateFullName(input: $input) {
success
}
}
`;
const schema = z.object({
fullName: z.string().min(2),
});
type FormData = z.infer<typeof schema>;
export default function FullNamePage() {
const { __ } = useTranslate();
const { toast } = useToast();
const [searchParams] = useSearchParams();
const continueUrlParam = searchParams.get("continue");
let safeContinueUrl: string;
if (continueUrlParam) {
try {
const continueUrl = new URL(continueUrlParam, window.location.origin);
if (continueUrl.origin === window.location.origin && continueUrl.pathname.startsWith(`${getPathPrefix()}/`)) {
safeContinueUrl = window.location.origin + continueUrl.pathname + continueUrl.search;
} else {
safeContinueUrl = window.location.origin + (getPathPrefix() || "/");
}
} catch {
safeContinueUrl = window.location.origin + (getPathPrefix() || "/");
}
} else {
safeContinueUrl = window.location.origin + (getPathPrefix() || "/");
}
const {
handleSubmit: handleSubmitWrapper,
register,
formState,
} = useFormWithSchema(schema, {
defaultValues: {
fullName: "",
},
});
const [update] = useMutation<FullNamePageMutation>(
updateMutation,
);
const handleSubmit = handleSubmitWrapper(({ fullName }: FormData) => {
update({
variables: {
input: {
fullName,
},
},
onCompleted: (_, errors: GraphQLError[] | null) => {
if (errors) {
for (const err of errors) {
if (err.extensions?.code === "ALREADY_AUTHENTICATED") {
window.location.href = getPathPrefix() || "/";
return;
}
}
toast({
title: __("Error"),
description: __("Cannot send magic link"),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Full name updated!"),
variant: "success",
});
window.location.href = safeContinueUrl;
},
onError: (error) => {
toast({
title: __("Error"),
description: error.message,
variant: "error",
});
},
});
});
return (
<div className="space-y-6 w-full max-w-md mx-auto pt-8">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">
{__("Please set your profile's full name")}
</h1>
</div>
<form onSubmit={e => void handleSubmit(e)} className="space-y-6">
<Field
label={__("Full Name")}
placeholder="John Doe"
{...register("fullName")}
type="text"
required
error={formState.errors.fullName?.message}
/>
<Button
type="submit"
className="w-xs h-10 mx-auto"
disabled={formState.isSubmitting}
>
{__("Continue")}
</Button>
</form>
</div>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<f567da0a977d6ae788cdd5d6eff1d59f>>
* @generated SignedSource<<711ecaa392c23004a3bd1dfb24a5751f>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -12,7 +12,6 @@ import { ConcreteRequest } from 'relay-runtime';
export type SendMagicLinkInput = {
continue?: string | null | undefined;
email: any;
fullName: string;
};
export type ConnectPageMutation$variables = {
input: SendMagicLinkInput;

View File

@@ -0,0 +1,92 @@
/**
* @generated SignedSource<<7861673355d6d647183c2a4425e9a5b9>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type UpdateFullNameInput = {
fullName: string;
};
export type FullNamePageMutation$variables = {
input: UpdateFullNameInput;
};
export type FullNamePageMutation$data = {
readonly updateFullName: {
readonly success: boolean;
} | null | undefined;
};
export type FullNamePageMutation = {
response: FullNamePageMutation$data;
variables: FullNamePageMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "UpdateFullNamePayload",
"kind": "LinkedField",
"name": "updateFullName",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "FullNamePageMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "FullNamePageMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "502db67e5bab72aff71fec9a0be0eeaf",
"id": null,
"metadata": {},
"name": "FullNamePageMutation",
"operationKind": "mutation",
"text": "mutation FullNamePageMutation(\n $input: UpdateFullNameInput!\n) {\n updateFullName(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "3d6352889babf98a37fe06a1bcaead15";
export default node;

View File

@@ -10,32 +10,6 @@ import {
import { getPathPrefix } from "#/utils/pathPrefix";
export class UnAuthenticatedError extends Error {
constructor() {
super("UNAUTHENTICATED");
this.name = "UnAuthenticatedError";
}
}
export class InvalidError extends Error {
field?: string;
cause?: string;
constructor(message?: string, field?: string, cause?: string) {
super(message || "INVALID");
this.name = "InvalidError";
this.field = field;
this.cause = cause;
}
}
export class InternalServerError extends Error {
constructor() {
super("INTERNAL_SERVER_ERROR");
this.name = "InternalServerError";
}
}
export function buildEndpoint(): string {
let host = import.meta.env.VITE_API_URL;

View File

@@ -37,6 +37,10 @@ const routes = [
path: "/verify-magic-link",
Component: lazy(() => import("#/pages/auth/VerifyMagicLinkPage")),
},
{
path: "/full-name",
Component: lazy(() => import("#/pages/auth/FullNamePage")),
},
],
},
{