mirror of
https://github.com/documenso/documenso.git
synced 2026-07-14 23:07:13 +10:00
400b6a24f1
## Description This MR improves the real-time validation experience on the signup page. Currently, both the email and password fields validate only on blur, causing users to continue seeing error messages even after correcting their input until they click outside the field. This update switches the React Hook Form configuration from mode: 'onBlur' to mode: 'onChange', ensuring validations update immediately as the user types. This results in a smoother and less confusing signup experience. ## Related Issue Fixes #2200 ## Changes Made - Updated React Hook Form configuration in the signup form from: - mode: 'onBlur' ➜ mode: 'onChange' - Ensures real-time validation for email and password fields - No schema or logic changes — only validation trigger updated ## Testing Performed - Manually tested the signup page for: - Real-time password rule updates - Real-time email validation feedback - Error disappearance immediately after correcting input ## Checklist - [x] I have tested these changes locally and they work as expected. - [ ] I have added/updated tests that prove the effectiveness of these changes. - [x] I have updated the documentation to reflect these changes, if applicable. - [x] I have followed the project's coding style guidelines. - [x] I have addressed the code review feedback from the previous submission, if applicable. ## Additional Notes This is a small UX-focused improvement; the validation schema remains unchanged. Co-authored-by: Catalin Pit <catalinpit@gmail.com>
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
|
import { authClient } from '@documenso/auth/client';
|
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
|
import { env } from '@documenso/lib/utils/env';
|
|
import { zEmail } from '@documenso/lib/utils/zod';
|
|
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
|
import { cn } from '@documenso/ui/lib/utils';
|
|
import { Button } from '@documenso/ui/primitives/button';
|
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
|
import { Input } from '@documenso/ui/primitives/input';
|
|
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import type { MessageDescriptor } from '@lingui/core';
|
|
import { msg } from '@lingui/core/macro';
|
|
import { useLingui } from '@lingui/react';
|
|
import { Trans } from '@lingui/react/macro';
|
|
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
|
import { Turnstile } from '@marsidev/react-turnstile';
|
|
import { useEffect, useRef } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
import { FaIdCardClip } from 'react-icons/fa6';
|
|
import { FcGoogle } from 'react-icons/fc';
|
|
import { Link, useNavigate, useSearchParams } from 'react-router';
|
|
import { z } from 'zod';
|
|
|
|
import { UserProfileTimur } from '~/components/general/user-profile-timur';
|
|
|
|
export const ZSignUpFormSchema = z
|
|
.object({
|
|
name: ZNameSchema,
|
|
email: zEmail().min(1),
|
|
password: ZPasswordSchema,
|
|
signature: z.string().min(1, { message: msg`We need your signature to sign documents`.id }),
|
|
})
|
|
.refine(
|
|
(data) => {
|
|
const { name, email, password } = data;
|
|
return !password.includes(name) && !password.includes(email.split('@')[0]);
|
|
},
|
|
{
|
|
message: msg`Password should not be common or based on personal information`.id,
|
|
path: ['password'],
|
|
},
|
|
);
|
|
|
|
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
|
|
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
|
|
SIGNUP_DISPOSABLE_EMAIL: msg`Disposable email addresses are not allowed. Please sign up with a permanent email address.`,
|
|
[AppErrorCode.ALREADY_EXISTS]: msg`We were unable to create your account. If you already have an account, try signing in instead.`,
|
|
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
|
|
};
|
|
|
|
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
|
|
|
export type SignUpFormProps = {
|
|
className?: string;
|
|
initialEmail?: string;
|
|
isEmailPasswordSignupEnabled?: boolean;
|
|
isGoogleSignupEnabled?: boolean;
|
|
isMicrosoftSignupEnabled?: boolean;
|
|
isOidcSignupEnabled?: boolean;
|
|
returnTo?: string;
|
|
};
|
|
|
|
export const SignUpForm = ({
|
|
className,
|
|
initialEmail,
|
|
isEmailPasswordSignupEnabled = true,
|
|
isGoogleSignupEnabled,
|
|
isMicrosoftSignupEnabled,
|
|
isOidcSignupEnabled,
|
|
returnTo,
|
|
}: SignUpFormProps) => {
|
|
const { _ } = useLingui();
|
|
const { toast } = useToast();
|
|
|
|
const analytics = useAnalytics();
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
|
|
const utmSrc = searchParams.get('utm_source') ?? null;
|
|
|
|
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
|
|
const turnstileRef = useRef<TurnstileInstance>(null);
|
|
|
|
const hasSocialAuthEnabled = isGoogleSignupEnabled || isMicrosoftSignupEnabled || isOidcSignupEnabled;
|
|
|
|
const form = useForm<TSignUpFormSchema>({
|
|
values: {
|
|
name: '',
|
|
email: initialEmail ?? '',
|
|
password: '',
|
|
signature: '',
|
|
},
|
|
mode: 'onChange',
|
|
resolver: zodResolver(ZSignUpFormSchema),
|
|
});
|
|
|
|
const isSubmitting = form.formState.isSubmitting;
|
|
|
|
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
|
|
try {
|
|
let token: string | undefined;
|
|
|
|
if (turnstileSiteKey) {
|
|
token = await turnstileRef.current?.getResponsePromise(3000).catch((_err) => undefined);
|
|
|
|
if (!token) {
|
|
toast({
|
|
title: _(msg`Human verification required`),
|
|
description: _(msg`Please complete the CAPTCHA challenge before signing in.`),
|
|
variant: 'destructive',
|
|
});
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
await authClient.emailPassword.signUp({
|
|
name,
|
|
email,
|
|
password,
|
|
signature,
|
|
captchaToken: token ?? undefined,
|
|
});
|
|
|
|
await navigate(returnTo ? returnTo : '/unverified-account');
|
|
|
|
toast({
|
|
title: _(msg`Registration Successful`),
|
|
description: _(
|
|
msg`You have successfully registered. Please verify your account by clicking on the link you received in the email.`,
|
|
),
|
|
duration: 5000,
|
|
});
|
|
|
|
analytics.capture('App: User Sign Up', {
|
|
email,
|
|
timestamp: new Date().toISOString(),
|
|
custom_campaign_params: { src: utmSrc },
|
|
});
|
|
} catch (err) {
|
|
const error = AppError.parseError(err);
|
|
|
|
const errorMessage = SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST;
|
|
|
|
toast({
|
|
title: _(msg`An error occurred`),
|
|
description: _(errorMessage),
|
|
variant: 'destructive',
|
|
});
|
|
|
|
turnstileRef.current?.reset();
|
|
}
|
|
};
|
|
|
|
const onSignUpWithGoogleClick = async () => {
|
|
try {
|
|
await authClient.google.signIn();
|
|
} catch {
|
|
toast({
|
|
title: _(msg`An unknown error occurred`),
|
|
description: _(msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const onSignUpWithMicrosoftClick = async () => {
|
|
try {
|
|
await authClient.microsoft.signIn();
|
|
} catch {
|
|
toast({
|
|
title: _(msg`An unknown error occurred`),
|
|
description: _(msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const onSignUpWithOIDCClick = async () => {
|
|
try {
|
|
await authClient.oidc.signIn();
|
|
} catch {
|
|
toast({
|
|
title: _(msg`An unknown error occurred`),
|
|
description: _(msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const hash = window.location.hash.slice(1);
|
|
|
|
const params = new URLSearchParams(hash);
|
|
|
|
const email = params.get('email');
|
|
|
|
if (email) {
|
|
form.setValue('email', email);
|
|
}
|
|
}, [form]);
|
|
|
|
return (
|
|
<div className={cn('flex justify-center gap-x-12', className)}>
|
|
<div className="relative hidden flex-1 overflow-hidden rounded-xl border border-border xl:flex">
|
|
<div className="absolute -inset-8 -z-[2] backdrop-blur">
|
|
<img
|
|
src={communityCardsImage}
|
|
alt="community-cards"
|
|
className="h-full w-full object-cover dark:brightness-95 dark:contrast-[70%] dark:invert"
|
|
/>
|
|
</div>
|
|
|
|
<div className="absolute -inset-8 -z-[1] bg-background/50 backdrop-blur-[2px]" />
|
|
|
|
<div className="relative flex h-full w-full flex-col items-center justify-evenly">
|
|
<div className="rounded-2xl border bg-background px-4 py-1 font-medium text-sm">
|
|
<Trans>User profiles are here!</Trans>
|
|
</div>
|
|
|
|
<div className="w-full max-w-md">
|
|
<UserProfileTimur rows={2} className="rounded-2xl border border-border bg-background shadow-md" />
|
|
</div>
|
|
|
|
<div />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
|
|
<div className="h-20">
|
|
<h1 className="font-semibold text-xl md:text-2xl">
|
|
<Trans>Create a new account</Trans>
|
|
</h1>
|
|
|
|
<p className="mt-2 text-muted-foreground text-xs md:text-sm">
|
|
<Trans>
|
|
Create your account and start using state-of-the-art document signing. Open and beautiful signing is
|
|
within your grasp.
|
|
</Trans>
|
|
</p>
|
|
</div>
|
|
|
|
<hr className="-mx-6 my-4" />
|
|
|
|
<Form {...form}>
|
|
<form className="flex w-full flex-1 flex-col gap-y-4" onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
|
{isEmailPasswordSignupEnabled && (
|
|
<>
|
|
<FormField
|
|
control={form.control}
|
|
name="name"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
<Trans>Full Name</Trans>
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input type="text" {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="email"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
<Trans>Email Address</Trans>
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input type="email" {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="password"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
<Trans>Password</Trans>
|
|
</FormLabel>
|
|
|
|
<FormControl>
|
|
<PasswordInput {...field} />
|
|
</FormControl>
|
|
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="signature"
|
|
render={({ field: { onChange, value } }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
<Trans>Sign Here</Trans>
|
|
</FormLabel>
|
|
<FormControl>
|
|
<SignaturePadDialog
|
|
disabled={isSubmitting}
|
|
value={value}
|
|
onChange={(v) => onChange(v ?? '')}
|
|
/>
|
|
</FormControl>
|
|
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{turnstileSiteKey && (
|
|
<Turnstile
|
|
ref={turnstileRef}
|
|
siteKey={turnstileSiteKey}
|
|
options={{
|
|
size: 'flexible',
|
|
appearance: 'always',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{hasSocialAuthEnabled && (
|
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
|
<div className="h-px flex-1 bg-border" />
|
|
<span className="bg-transparent text-muted-foreground">
|
|
<Trans>Or</Trans>
|
|
</span>
|
|
<div className="h-px flex-1 bg-border" />
|
|
</div>
|
|
)}
|
|
|
|
{isGoogleSignupEnabled && (
|
|
<Button
|
|
type="button"
|
|
size="lg"
|
|
variant={'outline'}
|
|
className="border bg-background text-muted-foreground"
|
|
disabled={isSubmitting}
|
|
onClick={onSignUpWithGoogleClick}
|
|
>
|
|
<FcGoogle className="mr-2 h-5 w-5" />
|
|
<Trans>Sign Up with Google</Trans>
|
|
</Button>
|
|
)}
|
|
|
|
{isMicrosoftSignupEnabled && (
|
|
<Button
|
|
type="button"
|
|
size="lg"
|
|
variant={'outline'}
|
|
className="border bg-background text-muted-foreground"
|
|
disabled={isSubmitting}
|
|
onClick={onSignUpWithMicrosoftClick}
|
|
>
|
|
<img className="mr-2 h-4 w-4" alt="Microsoft Logo" src={'/static/microsoft.svg'} />
|
|
<Trans>Sign Up with Microsoft</Trans>
|
|
</Button>
|
|
)}
|
|
|
|
{isOidcSignupEnabled && (
|
|
<Button
|
|
type="button"
|
|
size="lg"
|
|
variant={'outline'}
|
|
className="border bg-background text-muted-foreground"
|
|
disabled={isSubmitting}
|
|
onClick={onSignUpWithOIDCClick}
|
|
>
|
|
<FaIdCardClip className="mr-2 h-5 w-5" />
|
|
<Trans>Sign Up with OIDC</Trans>
|
|
</Button>
|
|
)}
|
|
|
|
<p className="mt-4 text-muted-foreground text-sm">
|
|
<Trans>
|
|
Already have an account?{' '}
|
|
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
|
|
Sign in instead
|
|
</Link>
|
|
</Trans>
|
|
</p>
|
|
</fieldset>
|
|
|
|
{isEmailPasswordSignupEnabled && (
|
|
<Button loading={form.formState.isSubmitting} type="submit" size="lg" className="mt-6 w-full">
|
|
<Trans>Create account</Trans>
|
|
</Button>
|
|
)}
|
|
</form>
|
|
</Form>
|
|
<p className="mt-6 text-muted-foreground text-xs">
|
|
<Trans>
|
|
By proceeding, you agree to our{' '}
|
|
<Link
|
|
to="https://documen.so/terms"
|
|
target="_blank"
|
|
className="text-documenso-700 duration-200 hover:opacity-70"
|
|
>
|
|
Terms of Service
|
|
</Link>{' '}
|
|
and{' '}
|
|
<Link
|
|
to="https://documen.so/privacy"
|
|
target="_blank"
|
|
className="text-documenso-700 duration-200 hover:opacity-70"
|
|
>
|
|
Privacy Policy
|
|
</Link>
|
|
.
|
|
</Trans>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|