diff --git a/packages/types/lib/invitations/api.ts b/packages/types/lib/invitations/api.ts index 7cebca88cb9..7e5e5fa7bd2 100644 --- a/packages/types/lib/invitations/api.ts +++ b/packages/types/lib/invitations/api.ts @@ -1,4 +1,4 @@ -import type { Endpoint } from '../api.js'; +import type { ApiError, Endpoint } from '../api.js'; import type { ApiInvitation, ApiTeam } from '../team/api.js'; import type { ApiUser } from '../user/api.js'; @@ -34,6 +34,7 @@ export type GetInvite = Endpoint<{ newTeamUsers: number; }; }; + Errors: ApiError<'not_found'>; }>; export type AcceptInvite = Endpoint<{ diff --git a/packages/webapp/src/App.tsx b/packages/webapp/src/App.tsx index 0cc2ab790ce..b6d20642231 100644 --- a/packages/webapp/src/App.tsx +++ b/packages/webapp/src/App.tsx @@ -13,7 +13,7 @@ import { EmailVerified } from './pages/Account/EmailVerified'; import ForgotPassword from './pages/Account/ForgotPassword'; import { InviteSignup } from './pages/Account/InviteSignup'; import ResetPassword from './pages/Account/ResetPassword'; -import Signin from './pages/Account/Signin'; +import { Signin } from './pages/Account/Signin'; import { Signup } from './pages/Account/Signup'; import { VerifyEmail } from './pages/Account/VerifyEmail'; import { VerifyEmailByExpiredToken } from './pages/Account/VerifyEmailByExpiredToken'; diff --git a/packages/webapp/src/components/ui/button/Auth/Google.tsx b/packages/webapp/src/components/ui/button/Auth/Google.tsx index b65af9a8d7b..9d008b685cd 100644 --- a/packages/webapp/src/components/ui/button/Auth/Google.tsx +++ b/packages/webapp/src/components/ui/button/Auth/Google.tsx @@ -29,7 +29,7 @@ export default function GoogleButton({ text, setServerErrorMessage, token }: Pro - {serverErrorMessage &&

{serverErrorMessage}

} - - - ) : ( -
Email sent to {email}
- )} - - +

Request password reset

+ + {serverErrorMessage && ( + + + {serverErrorMessage} + + )} + + {!done && ( +
+ + ( + + + + + + + + + )} + /> + + + + + )} + + {done && ( + + We've sent a password reset email to {form.getValues('email')}. + + )} ); } diff --git a/packages/webapp/src/pages/Account/InviteSignup.tsx b/packages/webapp/src/pages/Account/InviteSignup.tsx index 7e013f2d02f..e1288759146 100644 --- a/packages/webapp/src/pages/Account/InviteSignup.tsx +++ b/packages/webapp/src/pages/Account/InviteSignup.tsx @@ -1,16 +1,16 @@ -import { ExitIcon } from '@radix-ui/react-icons'; +import { LogOut } from 'lucide-react'; import { useState } from 'react'; -import { Link, useNavigate, useParams } from 'react-router-dom'; +import { Helmet } from 'react-helmet'; +import { useNavigate, useParams } from 'react-router-dom'; import { SignupForm } from './components/SignupForm'; -import { Info } from '../../components/Info'; -import { Skeleton } from '../../components/ui/Skeleton'; -import { Button, ButtonLink } from '../../components/ui/button/Button'; import { apiAcceptInvite, apiDeclineInvite, useInvite } from '../../hooks/useInvite'; import { useToast } from '../../hooks/useToast'; import { useUser } from '../../hooks/useUser'; import DefaultLayout from '../../layout/DefaultLayout'; import { useSignout } from '../../utils/user'; +import { StyledLink } from '@/components-v2/StyledLink'; +import { Button, ButtonLink } from '@/components-v2/ui/button'; export const InviteSignup: React.FC = () => { const { token } = useParams(); @@ -19,7 +19,7 @@ export const InviteSignup: React.FC = () => { const signout = useSignout(); const { user: isLogged } = useUser(true, { onError: () => null }); - const { data, error, loading } = useInvite(token); + const { data: inviteResponse, error: inviteError, isPending: _isInvitePending } = useInvite(token); const [loadingDecline, setLoadingDecline] = useState(false); const [loadingAccept, setLoadingAccept] = useState(false); @@ -48,106 +48,119 @@ export const InviteSignup: React.FC = () => { setLoadingDecline(false); }; - if (loading) { + if (inviteError) { return ( - -
-
- -
-
+ + + Invitation Error - Nango + + +

Invitation error

+ +

+ An error occurred, refresh your page or reach out to the support. + {inviteError.json.error.code === 'generic_error_support' && ( + <> + (id: {inviteError.json.error.payload as string}) + + )} +

); } - if (error) { + if (!inviteResponse) { + return null; + } + + if (inviteResponse.status === 400) { return ( - -
-
- {error.error.code === 'not_found' ? ( - <> -
-

Invitation Error

-
This invitation no longer exists or is expired.
-
-
- - Back to signup - -
- - ) : ( - - An error occurred, refresh your page or reach out to the support.{' '} - {error.error.code === 'generic_error_support' && ( - <> - (id: {error.error.payload}) - - )} - - )} -
+ + + Invitation Error - Nango + + +
+

Invitation error

+ +

This invitation no longer exists or is expired.

+ + + Back to signup +
); } - if (!data) { - return null; - } - if (isLogged && isLogged.email !== data.invitation.email) { + const inviteData = inviteResponse.json.data; + + if (isLogged && isLogged.email !== inviteData.invitation.email) { return ( - -
-
-
-

Invitation Error

-
- This invitation was sent to a different email. Please logout and use the correct account -
-
- -
- - - - -
-
+ + + Invitation Error - Nango + + +
+

Invitation error

+ +

+ This invitation was sent to a different email. Please logout and use the correct account. +

+
+ +
+ + Back to home + +
); } return ( - -
-
-

{isLogged ? 'Request to join a different team' : 'Join a team'}

-
-

- {data.invitedBy.name} has invited you to transfer to a new team: {data.newTeam.name} ( - {data.newTeamUsers} {data.newTeamUsers > 1 ? 'members' : 'member'}) -

{' '} - {isLogged &&

If you accept, you will permanently lose access to your existing team.

} -
- {isLogged && ( -
- - -
- )} -
{!isLogged && }
+ + + Invitation Error - Nango + + +
+

{isLogged ? 'Request to join a different team' : 'Join a team'}

+ +
+ + {inviteData.invitedBy.name} has invited you to join their team: +
+ {inviteData.newTeam.name} ({inviteData.newTeamUsers} + {inviteData.newTeamUsers > 1 ? ' members' : ' member'}) +
+ + {isLogged && If you accept, you will permanently lose access to your existing team.}
+ + {isLogged ? ( +
+ + +
+ ) : ( +
+ + + Already have an account? Log in. + +
+ )}
); }; diff --git a/packages/webapp/src/pages/Account/ResetPassword.tsx b/packages/webapp/src/pages/Account/ResetPassword.tsx index f5e51dfb8cc..6ba6ea7a180 100644 --- a/packages/webapp/src/pages/Account/ResetPassword.tsx +++ b/packages/webapp/src/pages/Account/ResetPassword.tsx @@ -1,70 +1,86 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { CircleX } from 'lucide-react'; import { useState } from 'react'; import { Helmet } from 'react-helmet'; +import { useForm } from 'react-hook-form'; import { useNavigate, useParams } from 'react-router-dom'; -import { toast } from 'react-toastify'; +import z from 'zod'; -import { Password } from './components/Password'; -import { Button } from '../../components/ui/button/Button'; +import { Password, passwordSchema } from './components/Password'; +import { useResetPasswordAPI } from '../../hooks/useAuth'; import DefaultLayout from '../../layout/DefaultLayout'; -import { useResetPasswordAPI } from '../../utils/api'; +import { Alert, AlertDescription } from '@/components-v2/ui/alert'; +import { Button } from '@/components-v2/ui/button'; +import { Form, FormField } from '@/components-v2/ui/form'; +import { useToast } from '@/hooks/useToast'; + +const resetPasswordSchema = z.object({ + password: passwordSchema +}); + +type ResetPasswordFormData = z.infer; export default function ResetPassword() { - const resetPasswordAPI = useResetPasswordAPI(); + const form = useForm({ + resolver: zodResolver(resetPasswordSchema), + mode: 'onTouched' + }); + const { mutateAsync: resetPassword, isPending } = useResetPasswordAPI(); + const navigate = useNavigate(); + const { toast } = useToast(); const [serverErrorMessage, setServerErrorMessage] = useState(''); const { token } = useParams(); - const [password, setPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [passwordStrength, setPasswordStrength] = useState(false); - const handleSubmit = async (e: React.SyntheticEvent) => { - e.preventDefault(); + if (!token) { + // Route doesn't exist without token, so just satisfy the type checker + return null; + } + + const onSubmit = async (data: ResetPasswordFormData) => { setServerErrorMessage(''); - setLoading(true); - const res = await resetPasswordAPI(token!, password); + try { + const result = await resetPassword({ token: token, password: data.password }); - if (res?.status === 200) { - toast.success('Password updated!', { position: toast.POSITION.BOTTOM_CENTER }); - navigate('/'); - } else if (res?.status === 400) { - setServerErrorMessage('Your reset token is invalid or expired.'); - } else { - setServerErrorMessage('Unkown error...'); + if (result.status === 200) { + toast({ title: 'Password updated!', variant: 'success' }); + navigate('/signin'); + } else { + setServerErrorMessage('Your reset token is invalid or expired.'); + } + } catch { + setServerErrorMessage('Issue resetting password. Please try again.'); } - setLoading(false); }; - if (!token) { - return null; - } - return ( - + Reset Password - Nango -
-
-

Reset password

-
- { - setPassword(tmpPass); - setPasswordStrength(tmpStrength); - }} - autoFocus - /> +

Reset password

+ + {serverErrorMessage && ( + + + {serverErrorMessage} + + )} + + + + } + /> -
- - {serverErrorMessage &&

{serverErrorMessage}

} -
- -
-
+ + +
); } diff --git a/packages/webapp/src/pages/Account/Signin.tsx b/packages/webapp/src/pages/Account/Signin.tsx index d8345eb73fd..4632ab9c3ac 100644 --- a/packages/webapp/src/pages/Account/Signin.tsx +++ b/packages/webapp/src/pages/Account/Signin.tsx @@ -1,173 +1,204 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { CircleX, ExternalLink, Loader2, TriangleAlert } from 'lucide-react'; import { useState } from 'react'; import { Helmet } from 'react-helmet'; -import { Link, useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; +import z from 'zod'; -import GoogleButton from '../../components/ui/button/Auth/Google'; -import { Button } from '../../components/ui/button/Button'; -import DefaultLayout from '../../layout/DefaultLayout'; -import { apiFetch, useSigninAPI } from '../../utils/api'; -import { globalEnv } from '../../utils/env'; -import { useSignin } from '../../utils/user'; +import GoogleButton from '@/components/ui/button/Auth/Google'; +import { StyledLink } from '@/components-v2/StyledLink'; +import { Alert, AlertActions, AlertButton, AlertDescription, AlertTitle } from '@/components-v2/ui/alert'; +import { Button } from '@/components-v2/ui/button'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components-v2/ui/form'; +import { InputGroup, InputGroupInput } from '@/components-v2/ui/input-group'; +import { useResendVerificationEmail, useSigninAPI } from '@/hooks/useAuth'; +import { useToast } from '@/hooks/useToast'; +import DefaultLayout from '@/layout/DefaultLayout'; +import { globalEnv } from '@/utils/env'; +import { useSignin } from '@/utils/user'; -import type { ApiUser, PostSignin } from '@nangohq/types'; +import type { ApiUser } from '@nangohq/types'; -export default function Signin() { - const [serverErrorMessage, setServerErrorMessage] = useState(''); - const [showResendEmail, setShowResendEmail] = useState(false); - const [email, setEmail] = useState(''); - const navigate = useNavigate(); +const signinSchema = z.object({ + email: z.string().email('Please enter a valid email address'), + password: z.string().min(1, 'Password is required') +}); + +type SigninFormData = z.infer; + +export const Signin: React.FC = () => { + const { mutateAsync: signinMutation, isPending } = useSigninAPI(); + const { mutateAsync: resendVerificationEmailMutation, isPending: isResendingEmail } = useResendVerificationEmail(); const signin = useSignin(); - const signinAPI = useSigninAPI(); + const navigate = useNavigate(); + const { toast } = useToast(); + + const [errorMessage, setServerErrorMessage] = useState(''); + const [showResendEmail, setShowResendEmail] = useState(false); + + const form = useForm({ + resolver: zodResolver(signinSchema), + mode: 'onTouched' + }); - const handleSubmit = async (e: React.SyntheticEvent) => { - e.preventDefault(); + const onSubmitForm = async (data: SigninFormData) => { setServerErrorMessage(''); - setShowResendEmail(false); - - const target = e.target as typeof e.target & { - email: { value: string }; - password: { value: string }; - }; - - const res = await signinAPI(target.email.value, target.password.value); - - if (res?.status === 200) { - const data = await res.json(); - const user: ApiUser = data['user']; - signin(user); - navigate('/'); - } else if (res?.status === 401) { - setServerErrorMessage('Invalid email or password.'); - } else if (res?.status === 400) { - const errorResponse: PostSignin['Errors'] = (await res.json()) as PostSignin['Errors']; - if (errorResponse.error.code === 'email_not_verified') { - setShowResendEmail(true); - setEmail(target.email.value); - setServerErrorMessage('Please verify your email before logging in.'); - } else { - setServerErrorMessage('Issue logging in. Please try again.'); + try { + const res = await signinMutation({ email: data.email, password: data.password }); + + if (res.status === 200) { + const user: ApiUser = res.json.user; + signin(user); + navigate('/'); + } else if (res.status === 401) { + setServerErrorMessage('Invalid email or password.'); + form.resetField('password', { defaultValue: '' }); + form.setFocus('password'); + } else if (res.status === 400) { + if (res.json.error.code === 'email_not_verified') { + setShowResendEmail(true); + setServerErrorMessage('Please verify your email before logging in.'); + } else { + setServerErrorMessage('Issue logging in. Please try again.'); + } } + } catch { + setServerErrorMessage('Issue logging in. Please try again.'); } }; const resendVerificationEmail = async () => { - setShowResendEmail(false); setServerErrorMessage(''); - const res = await apiFetch('/api/v1/account/resend-verification-email/by-email', { - method: 'POST', - body: JSON.stringify({ - email - }) - }); + const email = form.getValues('email'); - if (res?.status === 200) { - setServerErrorMessage('Verification email sent.'); - } else { + try { + await resendVerificationEmailMutation({ email }); + toast({ + title: 'Verification email sent.', + variant: 'success' + }); + setShowResendEmail(false); + } catch { setServerErrorMessage('Issue sending verification email. Please try again.'); } }; return ( - + - Login - Nango + Sign in - Nango -
-
-

Log in to Nango

-
-
-
- -
-
-
-
-
- - Forgot your password? - -
-
-
- +
+

Log in to Nango

+ + Don't have an account? Sign up. + +
+ + {errorMessage && !showResendEmail && ( + + + {errorMessage} + + )} + + {showResendEmail && ( + + + Please verify your email + We've sent a verification email to {form.getValues('email')}. + + + Resend + {isResendingEmail ? : } + + + + )} + + + +
+ ( + + + + + + + + + )} + /> + +
+ ( + + + + + + + + + )} />
-
-
- - {serverErrorMessage && ( - <> -

{serverErrorMessage}

- {showResendEmail && ( - - )} - - )} + {/* Using `order` to show this above the password input, but tabbing from email input goes to password input first*/} + + Forgot your password? +
- {globalEnv.features.managedAuth && ( - <> -
-
- or continue with -
-
- - - - )} + -
-
-
-

Don't have an account?

- - Sign up. - -
-
-
-
-

- By signing in, you agree to our - - Terms of Service - - and - - Privacy Policy - - . -

+ +
+ +
+ {globalEnv.features.managedAuth && ( +
+
+
+ or continue with +
+
+ +
-
+ )} + + + By signing in, you agree to our
{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . +
); -} +}; diff --git a/packages/webapp/src/pages/Account/Signup.tsx b/packages/webapp/src/pages/Account/Signup.tsx index ef4caf28030..c21824fe8a7 100644 --- a/packages/webapp/src/pages/Account/Signup.tsx +++ b/packages/webapp/src/pages/Account/Signup.tsx @@ -2,18 +2,23 @@ import { Helmet } from 'react-helmet'; import { SignupForm } from './components/SignupForm'; import DefaultLayout from '../../layout/DefaultLayout'; +import { StyledLink } from '@/components-v2/StyledLink'; export const Signup: React.FC = () => { return ( - + - Signup - Nango + Sign up - Nango -
-
- -
+ +
+

Sign up to Nango

+ + Already have an account? Log in. +
+ + ); }; diff --git a/packages/webapp/src/pages/Account/VerifyEmail.tsx b/packages/webapp/src/pages/Account/VerifyEmail.tsx index e34990801b0..baa77283ae4 100644 --- a/packages/webapp/src/pages/Account/VerifyEmail.tsx +++ b/packages/webapp/src/pages/Account/VerifyEmail.tsx @@ -1,96 +1,81 @@ -import { Loading } from '@geist-ui/core'; +import { CircleX } from 'lucide-react'; import { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import { useNavigate, useParams } from 'react-router-dom'; -import { toast } from 'react-toastify'; +import { useEmailByUuid, useResendVerificationEmailByUuid } from '../../hooks/useAuth'; import DefaultLayout from '../../layout/DefaultLayout'; -import { apiFetch } from '../../utils/api'; - -import type { GetEmailByUuid, ResendVerificationEmailByUuid } from '@nangohq/types'; +import { APIError } from '../../utils/api'; +import { StyledLink } from '@/components-v2/StyledLink'; +import { Alert, AlertDescription } from '@/components-v2/ui/alert'; +import { Button } from '@/components-v2/ui/button'; +import { useToast } from '@/hooks/useToast'; export function VerifyEmail() { const [serverErrorMessage, setServerErrorMessage] = useState(''); - const [email, setEmail] = useState(''); - const [loaded, setLoaded] = useState(false); const navigate = useNavigate(); - + const { toast } = useToast(); const { uuid } = useParams(); + const { data, error } = useEmailByUuid(uuid); + const { mutateAsync: resendVerificationEmailByUuid, isPending: isResendingVerificationEmailByUuid } = useResendVerificationEmailByUuid(); + useEffect(() => { - if (!uuid) { - navigate('/'); + if (data?.verified) { + toast({ title: 'Email already verified. Routing to the login page', variant: 'success' }); + navigate('/signin'); } - }, [uuid, navigate]); + }, [data?.verified, navigate, toast]); useEffect(() => { - const getEmail = async () => { - const res = await apiFetch(`/api/v1/account/email/${uuid}`); + if (error instanceof APIError && error.json && typeof error.json === 'object' && 'error' in error.json) { + const err = error.json as { error: { message?: string } }; + setServerErrorMessage(err.error?.message ?? 'Issue verifying email. Please try again.'); + } + }, [error]); - if (res?.status === 200) { - const response: GetEmailByUuid['Success'] = (await res.json()) as GetEmailByUuid['Success']; - const { email, verified } = response; + if (!uuid) { + // The route doesn't exist without a uuid, so just satisfy the type checker + return null; + } - if (verified) { - toast.success('Email already verified. Routing to the login page', { position: toast.POSITION.BOTTOM_CENTER }); - navigate('/signin'); - } - setEmail(email); - } else { - const errorResponse: GetEmailByUuid['Errors'] = (await res.json()) as GetEmailByUuid['Errors']; - setServerErrorMessage(errorResponse.error.message || 'Issue verifying email. Please try again.'); + const handleResendEmail = async () => { + setServerErrorMessage(''); + try { + const res = await resendVerificationEmailByUuid({ uuid: uuid }); + if (res.success) { + toast({ title: 'Verification email sent again!', variant: 'success' }); } - setLoaded(true); - }; - - if (!loaded) { - getEmail(); + } catch { + setServerErrorMessage('Issue sending verification email. Please try again.'); } - }, [uuid, loaded, setLoaded, navigate]); + }; - const resendEmail = async (e: React.SyntheticEvent) => { - e.preventDefault(); - setServerErrorMessage(''); + return ( + + + Verify Email - Nango + - const res = await apiFetch('/api/v1/account/resend-verification-email/by-uuid', { - method: 'POST', - body: JSON.stringify({ - uuid - }) - }); +
+

Verify your email

- if (res?.status === 200) { - toast.success('Verification email sent again!', { position: toast.POSITION.BOTTOM_CENTER }); - } else { - const response: ResendVerificationEmailByUuid['Errors'] = await res.json(); - setServerErrorMessage(response.error.message || 'Unkown error...'); - } - }; + {serverErrorMessage && ( + + + {serverErrorMessage} + + )} - if (!loaded) { - return ; - } - return ( - <> - -
-
-

Verify your email

- {email ? ( -
- Check {email} to verify your account and get started. -
- -
-
- ) : ( - Invalid user id. Please try and signup again. - )} - {serverErrorMessage &&

{serverErrorMessage}

} -
-
-
- + + Check {data?.email || 'your email'} to verify your account and get started. If you verified your email from a different device,{' '} + sign in here. + +
+ + +
); } diff --git a/packages/webapp/src/pages/Account/components/Password.tsx b/packages/webapp/src/pages/Account/components/Password.tsx index 1da7f928c14..eb2c15ebaf9 100644 --- a/packages/webapp/src/pages/Account/components/Password.tsx +++ b/packages/webapp/src/pages/Account/components/Password.tsx @@ -1,69 +1,82 @@ -import { CheckCircledIcon, CrossCircledIcon } from '@radix-ui/react-icons'; -import { useEffect, useMemo, useState } from 'react'; +import { CheckIcon, X } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useController, useFormContext } from 'react-hook-form'; +import z from 'zod'; -import { HoverCard, HoverCardArrow, HoverCardContent, HoverCardTrigger } from '../../../components/ui/HoverCard'; -import { Input } from '../../../components/ui/input/Input'; -import { cn } from '../../../utils/utils'; +import { FormControl, FormItem, FormMessage, useFormField } from '@/components-v2/ui/form'; +import { InputGroup, InputGroupInput } from '@/components-v2/ui/input-group'; +import { cn } from '@/utils/utils'; + +export const passwordSchema = z + .string() + .min(8, 'Password must be at least 8 characters') + .refine((value) => /[A-Z]/.test(value), 'Password must contain at least one uppercase letter') + .refine((value) => /[0-9]/.test(value), 'Password must contain at least one number') + .refine((value) => /[^a-zA-Z0-9]/.test(value), 'Password must contain at least one special character'); + +export const Password: React.FC> = (props) => { + const { name } = useFormField(); + const { control } = useFormContext(); + const { field, fieldState } = useController({ name, control }); -export const Password: React.FC<{ setPassword: (password: string, good: boolean) => void } & React.InputHTMLAttributes> = ({ - setPassword, - ...props -}) => { - const [local, setLocal] = useState(''); const [open, setOpen] = useState(false); - const checks = useMemo(() => { - return { - length: local.length >= 8, - uppercase: local.match(/[A-Z]/) !== null, - number: local.match(/[0-9]/) !== null, - special: local.match(/[^a-zA-Z0-9]/) !== null - }; - }, [local]); - useEffect(() => { - setPassword(local, checks.length && checks.uppercase && checks.number && checks.special); - }, [checks, local]); + const value = String(field.value ?? ''); + + const checks = useMemo( + () => ({ + length: value.length >= 8, + uppercase: value.match(/[A-Z]/) !== null, + number: value.match(/[0-9]/) !== null, + special: value.match(/[^a-zA-Z0-9]/) !== null + }), + [value] + ); return ( - - -
- + + + setLocal(e.target.value)} - className="border-border-gray bg-dark-600" - onFocus={() => setOpen(true)} - onBlur={() => setOpen(false)} + aria-invalid={!!fieldState.error} + aria-describedby="password-requirements" + onFocus={() => { + setOpen(true); + }} {...props} /> -
-
- - -
-
- {checks.length ? : } At least 8 characters -
-
- {checks.uppercase ? : } 1 uppercase letter -
-
- {checks.number ? : } 1 number -
-
- {checks.special ? : } 1 special character -
-
-
-
+ + + + +
+ Password must contain: + + + + +
+ + ); +}; + +const Requirement: React.FC<{ text: string; check: boolean }> = ({ text, check }) => { + return ( + + {check ? : } + {text} + ); }; diff --git a/packages/webapp/src/pages/Account/components/SignupForm.tsx b/packages/webapp/src/pages/Account/components/SignupForm.tsx index 5f1b139bc2b..64126bcfe10 100644 --- a/packages/webapp/src/pages/Account/components/SignupForm.tsx +++ b/packages/webapp/src/pages/Account/components/SignupForm.tsx @@ -1,159 +1,188 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { CircleX, ExternalLink, Loader2, TriangleAlert } from 'lucide-react'; import { useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; +import z from 'zod'; -import { Password } from './Password'; -import GoogleButton from '../../../components/ui/button/Auth/Google'; -import { Button } from '../../../components/ui/button/Button'; -import { Input } from '../../../components/ui/input/Input'; -import { apiFetch, useSignupAPI } from '../../../utils/api'; -import { globalEnv } from '../../../utils/env'; +import GoogleButton from '@/components/ui/button/Auth/Google'; +import { StyledLink } from '@/components-v2/StyledLink'; +import { Alert, AlertActions, AlertButton, AlertDescription, AlertTitle } from '@/components-v2/ui/alert'; +import { Button } from '@/components-v2/ui/button'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components-v2/ui/form'; +import { InputGroup, InputGroupInput } from '@/components-v2/ui/input-group'; +import { useResendVerificationEmail, useSignupAPI } from '@/hooks/useAuth'; +import { useToast } from '@/hooks/useToast'; +import { Password, passwordSchema } from '@/pages/Account/components/Password'; +import { APIError } from '@/utils/api'; +import { globalEnv } from '@/utils/env'; -import type { ApiInvitation, PostSignup } from '@nangohq/types'; +import type { ApiInvitation } from '@nangohq/types'; + +const signupSchema = z.object({ + name: z.string().min(1, 'Name is required'), + email: z.string().email('Please enter a valid email address'), + password: passwordSchema +}); + +type SignupFormData = z.infer; export const SignupForm: React.FC<{ invitation?: ApiInvitation; token?: string }> = ({ invitation, token }) => { + const form = useForm({ + resolver: zodResolver(signupSchema), + defaultValues: { + name: '', + email: invitation?.email || '', + password: '' + }, + mode: 'onTouched' + }); + const navigate = useNavigate(); - const signupAPI = useSignupAPI(); + const { toast } = useToast(); + + const { mutateAsync: signupMutation, isPending } = useSignupAPI(); + const { mutateAsync: resendVerificationEmailMutation, isPending: isResendingEmail } = useResendVerificationEmail(); const [serverErrorMessage, setServerErrorMessage] = useState(''); const [showResendEmail, setShowResendEmail] = useState(false); - const [email, setEmail] = useState(() => invitation?.email || ''); - const [name, setName] = useState(''); - const [password, setPassword] = useState(''); - const [passwordStrength, setPasswordStrength] = useState(false); - const [loading, setLoading] = useState(false); - - const handleSubmit = async (e: React.SyntheticEvent) => { - e.preventDefault(); - setServerErrorMessage(''); - setShowResendEmail(false); - setLoading(true); - - const res = await signupAPI({ name, email, password, token }); - - if (res?.status === 200) { - const response: PostSignup['Success'] = await res.json(); - const { - data: { uuid, verified } - } = response; - if (!verified) { - navigate(`/verify-email/${uuid}`); - } else { - navigate('/'); + const onSubmitForm = async (data: SignupFormData) => { + setServerErrorMessage(''); + try { + const res = await signupMutation(token ? { ...data, token } : data); + if (res.status === 200) { + const { uuid, verified } = res.json.data; + if (!verified) { + navigate(`/verify-email/${uuid}`); + } else { + navigate('/'); + if (invitation) { + toast({ title: 'You are now a member of the team', variant: 'success' }); + } + } + return; } - } else { - const errorResponse: PostSignup['Errors'] = await res?.json(); - if (errorResponse.error.code === 'email_not_verified') { + + setServerErrorMessage(res.json.error.message || 'Issue signing up. Please try again.'); + if (res.json.error.code === 'email_not_verified') { setShowResendEmail(true); } - setServerErrorMessage(errorResponse?.error?.message || 'Issue signing up. Please try again.'); + } catch { + setServerErrorMessage('Issue signing up. Please try again.'); } - setLoading(false); }; const resendVerificationEmail = async () => { - setShowResendEmail(false); setServerErrorMessage(''); - const res = await apiFetch('/api/v1/account/resend-verification-email/by-email', { - method: 'POST', - body: JSON.stringify({ - email - }) - }); - - if (res?.status === 200) { - setServerErrorMessage('Verification email sent.'); - } else { - setServerErrorMessage('Issue sending verification email. Please try again.'); + const email = form.getValues('email'); + + try { + await resendVerificationEmailMutation({ email }); + toast({ + title: 'Verification email sent.', + variant: 'success' + }); + } catch (err) { + if (err instanceof APIError && err.json?.error?.message) { + setServerErrorMessage(err.json.error.message); + } else { + setServerErrorMessage('Issue sending verification email. Please try again.'); + } } + setShowResendEmail(false); }; return ( - <> -
-

Sign up to Nango

-
-
- +
+ {serverErrorMessage && !showResendEmail && ( + + + {serverErrorMessage} + + )} + + {showResendEmail && ( + + + Please verify your email + We've sent a verification email to {form.getValues('email')}. + + + Resend + {isResendingEmail ? : } + + + + )} + + + + setName(e.target.value)} - className="border-border-gray bg-dark-600" + render={({ field, fieldState }) => ( + + + + + + + + + )} /> - - setEmail(e.target.value)} - disabled={Boolean(invitation?.email)} - className="border-border-gray bg-dark-600" + render={({ field, fieldState }) => ( + + + + + + + + + )} /> - { - setPassword(tmpPass); - setPasswordStrength(tmpStrength); - }} - /> -
+ } /> -
- - {serverErrorMessage && ( - <> -

{serverErrorMessage}

- {showResendEmail && ( - - )} - - )} -
- + + +
+ +
{globalEnv.features.managedAuth && ( - <> -
-
- or continue with -
+
+
+
+ or continue with +
- - + + +
)} + + + By signing up, you agree to our
{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . +
-
-
-

Already have an account?

- - Sign in. - -
-
- +
); }; diff --git a/packages/webapp/src/pages/Onboarding/HearAboutUs.tsx b/packages/webapp/src/pages/Onboarding/HearAboutUs.tsx index ad661d36928..2ed442e0f44 100644 --- a/packages/webapp/src/pages/Onboarding/HearAboutUs.tsx +++ b/packages/webapp/src/pages/Onboarding/HearAboutUs.tsx @@ -1,15 +1,14 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { Helmet } from 'react-helmet'; import { useNavigate } from 'react-router-dom'; +import { useOnboardingHearAboutUs, usePostOnboardingHearAboutUs } from '../../hooks/useAuth'; import DefaultLayout from '../../layout/DefaultLayout'; import { useAnalyticsTrack } from '../../utils/analytics'; -import { apiFetch } from '../../utils/api'; import { Button } from '@/components-v2/ui/button'; import { Skeleton } from '@/components-v2/ui/skeleton'; -import { useToast } from '@/hooks/useToast'; -import type { GetOnboardingHearAboutUs, PostOnboardingHearAboutUs } from '@nangohq/types'; +import type { PostOnboardingHearAboutUs } from '@nangohq/types'; const HEAR_ABOUT_OPTIONS: { label: string; value: PostOnboardingHearAboutUs['Body']['source'] }[] = [ { label: 'My team is already using Nango', value: 'my_team_already_using' }, @@ -24,100 +23,78 @@ const HEAR_ABOUT_OPTIONS: { label: string; value: PostOnboardingHearAboutUs['Bod export const HearAboutUs: React.FC = () => { const navigate = useNavigate(); const analyticsTrack = useAnalyticsTrack(); - const { toast } = useToast(); - const [loading, setLoading] = useState(true); - const [submitting, setSubmitting] = useState(false); + + const { data, isLoading, error } = useOnboardingHearAboutUs(); + const { mutateAsync: postHearAboutUs, isPending } = usePostOnboardingHearAboutUs(); useEffect(() => { - if (sessionStorage.getItem('show-email-verified-toast') !== 'true') { + if (error) { + navigate('/', { replace: true }); return; } - - sessionStorage.removeItem('show-email-verified-toast'); - toast({ title: 'Email verified successfully!', variant: 'success' }); - }, [toast]); - - useEffect(() => { - const check = async () => { - const res = await apiFetch('/api/v1/account/onboarding/hear-about-us'); - if (res.status !== 200) { - navigate('/', { replace: true }); - return; - } - const data = (await res.json()) as GetOnboardingHearAboutUs['Success']; - if (!data.data.showHearAboutUs) { - navigate('/', { replace: true }); - return; - } - setLoading(false); - }; - void check(); - }, [navigate]); + if (data && !data.data.showHearAboutUs) { + navigate('/', { replace: true }); + } + }, [data, error, navigate]); const submit = async (source: PostOnboardingHearAboutUs['Body']['source']) => { - setSubmitting(true); analyticsTrack('signup_hear_about', { source }); try { - const res = await apiFetch('/api/v1/account/onboarding/hear-about-us', { - method: 'POST', - body: JSON.stringify({ source }) - }); - if (res.status === 200) { - navigate('/', { replace: true }); - } + await postHearAboutUs({ source }); } finally { - setSubmitting(false); + // Don't block on errors as this is not critical + navigate('/', { replace: true }); } }; - if (loading) { + if (isLoading) { return ( - + How did you hear about Nango? - Nango -
- -
- {Array.from({ length: HEAR_ABOUT_OPTIONS.length }).map((_, index) => ( - - ))} -
- + + + +
+ {Array.from({ length: HEAR_ABOUT_OPTIONS.length }).map((_, index) => ( + + ))}
+ + ); } return ( - + How did you hear about Nango? - Nango -
-

How did you hear about Nango?

-
- {HEAR_ABOUT_OPTIONS.map(({ label, value }) => ( - - ))} -
- + +

How did you hear about Nango?

+ +
+ {HEAR_ABOUT_OPTIONS.map(({ label, value }) => ( + + ))}
+ + + Not sure?{' '} + submit('skipped')}> + Skip for now + + ); }; diff --git a/packages/webapp/src/utils/api.tsx b/packages/webapp/src/utils/api.tsx index 1a97413d3da..50b93538c94 100644 --- a/packages/webapp/src/utils/api.tsx +++ b/packages/webapp/src/utils/api.tsx @@ -2,7 +2,7 @@ import { toast } from 'react-toastify'; import { globalEnv } from './env'; -import type { ApiError, PostSignup } from '@nangohq/types'; +import type { ApiError } from '@nangohq/types'; export async function apiFetch(input: string | URL | Request, init?: RequestInit) { return await fetch(new URL(input as string, globalEnv.apiUrl), { @@ -63,53 +63,6 @@ function serverErrorToast() { toast.error('Server error...', { position: toast.POSITION.BOTTOM_CENTER }); } -export function useLogoutAPI() { - return async () => { - const options = { - method: 'POST' - }; - - await apiFetch('/api/v1/account/logout', options); - }; -} - -export function useSignupAPI() { - return async (body: PostSignup['Body']) => { - try { - const options = { - method: 'POST', - body: JSON.stringify(body) - }; - - return await apiFetch('/api/v1/account/signup', options); - } catch { - requestErrorToast(); - } - }; -} - -export function useSigninAPI() { - return async (email: string, password: string) => { - try { - const options = { - method: 'POST', - body: JSON.stringify({ email: email, password: password }) - }; - - const res = await apiFetch('/api/v1/account/signin', options); - - if (res.status !== 200 && res.status !== 401 && res.status !== 400) { - serverErrorToast(); - return; - } - - return res; - } catch { - requestErrorToast(); - } - }; -} - export function useHostedSigninAPI() { return async () => { try { @@ -127,36 +80,6 @@ export function useHostedSigninAPI() { }; } -export function useRequestPasswordResetAPI() { - return async (email: string) => { - try { - const res = await apiFetch(`/api/v1/account/forgot-password`, { - method: 'POST', - body: JSON.stringify({ email: email }) - }); - - return res; - } catch { - requestErrorToast(); - } - }; -} - -export function useResetPasswordAPI() { - return async (token: string, password: string) => { - try { - const res = await apiFetch(`/api/v1/account/reset-password`, { - method: 'PUT', - body: JSON.stringify({ password: password, token: token }) - }); - - return res; - } catch { - requestErrorToast(); - } - }; -} - export function useGetHmacAPI(env: string) { return async (providerConfigKey: string, connectionId: string) => { try { diff --git a/packages/webapp/src/utils/user.tsx b/packages/webapp/src/utils/user.tsx index 9c26c24546c..f8d289da310 100644 --- a/packages/webapp/src/utils/user.tsx +++ b/packages/webapp/src/utils/user.tsx @@ -1,7 +1,8 @@ +import { useQueryClient } from '@tanstack/react-query'; import { useSWRConfig } from 'swr'; import { useAnalyticsIdentify, useAnalyticsReset } from './analytics'; -import { useLogoutAPI } from '../utils/api'; +import { useLogoutAPI } from '../hooks/useAuth'; import storage, { LocalStorageKeys } from '../utils/local-storage'; import type { ApiUser } from '@nangohq/types'; @@ -21,9 +22,9 @@ export function useSignin() { export function useSignout() { const analyticsReset = useAnalyticsReset(); - //const nav = useNavigate(); const { mutate, cache } = useSWRConfig(); - const logoutAPI = useLogoutAPI(); + const queryClient = useQueryClient(); + const { mutateAsync: logoutAPI } = useLogoutAPI(); return async () => { storage.clear(); @@ -31,6 +32,8 @@ export function useSignout() { await logoutAPI(); // Destroy server session. await mutate(() => true, undefined, { revalidate: false }); // clean all cache + await queryClient.cancelQueries(); + queryClient.clear(); // swr/infinite doesn't currently support clearing cache keys with the // default mechanism. see https://github.com/vercel/swr/issues/2497