Skip to content

Commit afd82d1

Browse files
Saerisjoshenlim
andauthored
[Replace yup] Refactor Sign In/Up forms to use shadcn and replace yup with zod (supabase#37913)
* refactor sign-in forms, remove `yup-password` * add missing disabled state to email field * Fix zod validations for min length * minor fix loading state sign up form, and use motion div for success state * Remove unnecessary useState for isSubmitting * Nit * Nit --------- Co-authored-by: Joshen Lim <[email protected]>
1 parent 0952d46 commit afd82d1

File tree

15 files changed

+1518
-406
lines changed

15 files changed

+1518
-406
lines changed

apps/studio/components/interfaces/SignIn/ForgotPasswordWizard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { Button, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input_Sha
1313
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
1414

1515
const forgotPasswordSchema = z.object({
16-
email: z.string().email('Must be a valid email').min(1, 'Email is required'),
16+
email: z.string().min(1, 'Please provide an email address').email('Must be a valid email'),
1717
})
1818

1919
const codeSchema = z.object({

apps/studio/components/interfaces/SignIn/SignInForm.tsx

Lines changed: 91 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,44 @@
11
import HCaptcha from '@hcaptcha/react-hcaptcha'
2+
import { zodResolver } from '@hookform/resolvers/zod'
23
import * as Sentry from '@sentry/nextjs'
34
import type { AuthError } from '@supabase/supabase-js'
45
import { useQueryClient } from '@tanstack/react-query'
56
import Link from 'next/link'
67
import { useRouter } from 'next/router'
7-
import { useRef, useState, useEffect } from 'react'
8+
import { useEffect, useRef, useState } from 'react'
9+
import { type SubmitHandler, useForm } from 'react-hook-form'
810
import { toast } from 'sonner'
9-
import { object, string } from 'yup'
11+
import z from 'zod'
1012

1113
import { useAddLoginEvent } from 'data/misc/audit-login-mutation'
1214
import { getMfaAuthenticatorAssuranceLevel } from 'data/profile/mfa-authenticator-assurance-level-query'
1315
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
1416
import { useLastSignIn } from 'hooks/misc/useLastSignIn'
1517
import { auth, buildPathWithParams, getReturnToPath } from 'lib/gotrue'
16-
import { Button, Form, Input } from 'ui'
18+
import { Button, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_ } from 'ui'
19+
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
1720
import { LastSignInWrapper } from './LastSignInWrapper'
1821

19-
const signInSchema = object({
20-
email: string().email('Must be a valid email').required('Email is required'),
21-
password: string().required('Password is required'),
22+
const schema = z.object({
23+
email: z.string().min(1, 'Email is required').email('Must be a valid email'),
24+
password: z.string().min(1, 'Password is required'),
2225
})
2326

24-
const SignInForm = () => {
27+
const formId = 'sign-in-form'
28+
29+
export const SignInForm = () => {
2530
const router = useRouter()
2631
const queryClient = useQueryClient()
2732
const [_, setLastSignIn] = useLastSignIn()
2833

2934
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
3035
const captchaRef = useRef<HCaptcha>(null)
3136
const [returnTo, setReturnTo] = useState<string | null>(null)
37+
const form = useForm<z.infer<typeof schema>>({
38+
resolver: zodResolver(schema),
39+
defaultValues: { email: '', password: '' },
40+
})
41+
const isSubmitting = form.formState.isSubmitting
3242

3343
useEffect(() => {
3444
// Only call getReturnToPath after component mounts client-side
@@ -44,7 +54,7 @@ const SignInForm = () => {
4454
forgotPasswordUrl = `${forgotPasswordUrl}?returnTo=${encodeURIComponent(returnTo)}`
4555
}
4656

47-
const onSignIn = async ({ email, password }: { email: string; password: string }) => {
57+
const onSubmit: SubmitHandler<z.infer<typeof schema>> = async ({ email, password }) => {
4858
const toastId = toast.loading('Signing in...')
4959

5060
let token = captchaToken
@@ -106,77 +116,78 @@ const SignInForm = () => {
106116
}
107117

108118
return (
109-
<Form
110-
validateOnBlur
111-
id="signIn-form"
112-
initialValues={{ email: '', password: '' }}
113-
validationSchema={signInSchema}
114-
onSubmit={onSignIn}
115-
>
116-
{({ isSubmitting }: { isSubmitting: boolean }) => {
117-
return (
118-
<div className="flex flex-col gap-4">
119-
<Input
120-
id="email"
121-
name="email"
122-
type="email"
123-
label="Email"
124-
placeholder="[email protected]"
125-
disabled={isSubmitting}
126-
autoComplete="email"
127-
/>
128-
129-
<div className="relative">
130-
<Input
131-
id="password"
132-
name="password"
133-
type="password"
134-
label="Password"
135-
placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;"
136-
disabled={isSubmitting}
137-
autoComplete="current-password"
138-
/>
139-
140-
{/* positioned using absolute instead of labelOptional prop so tabbing between inputs works smoothly */}
141-
<Link
142-
href={forgotPasswordUrl}
143-
className="absolute top-0 right-0 text-sm text-foreground-lighter"
144-
>
145-
Forgot Password?
146-
</Link>
147-
</div>
148-
149-
<div className="self-center">
150-
<HCaptcha
151-
ref={captchaRef}
152-
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
153-
size="invisible"
154-
onVerify={(token) => {
155-
setCaptchaToken(token)
156-
}}
157-
onExpire={() => {
158-
setCaptchaToken(null)
159-
}}
160-
/>
161-
</div>
162-
163-
<LastSignInWrapper type="email">
164-
<Button
165-
block
166-
form="signIn-form"
167-
htmlType="submit"
168-
size="large"
169-
disabled={isSubmitting}
170-
loading={isSubmitting}
171-
>
172-
Sign In
173-
</Button>
174-
</LastSignInWrapper>
175-
</div>
176-
)
177-
}}
178-
</Form>
119+
<Form_Shadcn_ {...form}>
120+
<form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
121+
<FormField_Shadcn_
122+
key="email"
123+
name="email"
124+
control={form.control}
125+
render={({ field }) => (
126+
<FormItemLayout name="email" label="Email">
127+
<FormControl_Shadcn_>
128+
<Input_Shadcn_
129+
id="email"
130+
type="email"
131+
autoComplete="email"
132+
{...field}
133+
placeholder="[email protected]"
134+
disabled={isSubmitting}
135+
/>
136+
</FormControl_Shadcn_>
137+
</FormItemLayout>
138+
)}
139+
/>
140+
141+
<div className="relative">
142+
<FormField_Shadcn_
143+
key="password"
144+
name="password"
145+
control={form.control}
146+
render={({ field }) => (
147+
<FormItemLayout name="password" label="Password">
148+
<FormControl_Shadcn_>
149+
<Input_Shadcn_
150+
id="password"
151+
type="password"
152+
autoComplete="current-password"
153+
{...field}
154+
placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;"
155+
disabled={isSubmitting}
156+
/>
157+
</FormControl_Shadcn_>
158+
</FormItemLayout>
159+
)}
160+
/>
161+
162+
{/* positioned using absolute instead of labelOptional prop so tabbing between inputs works smoothly */}
163+
<Link
164+
href={forgotPasswordUrl}
165+
className="absolute top-0 right-0 text-sm text-foreground-lighter"
166+
>
167+
Forgot Password?
168+
</Link>
169+
</div>
170+
171+
<div className="self-center">
172+
<HCaptcha
173+
ref={captchaRef}
174+
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
175+
size="invisible"
176+
onVerify={(token) => {
177+
setCaptchaToken(token)
178+
}}
179+
onExpire={() => {
180+
setCaptchaToken(null)
181+
}}
182+
/>
183+
</div>
184+
185+
<LastSignInWrapper type="email">
186+
<Button block form={formId} htmlType="submit" size="large" loading={isSubmitting}>
187+
Sign In
188+
</Button>
189+
</LastSignInWrapper>
190+
</form>
191+
</Form_Shadcn_>
179192
)
180193
}
181-
182-
export default SignInForm

apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx

Lines changed: 70 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,41 @@
1+
import { zodResolver } from '@hookform/resolvers/zod'
12
import type { Factor } from '@supabase/supabase-js'
23
import { useQueryClient } from '@tanstack/react-query'
34
import { Lock } from 'lucide-react'
45
import Link from 'next/link'
56
import { useRouter } from 'next/router'
67
import { useEffect, useState } from 'react'
7-
import { object, string } from 'yup'
8+
import { SubmitHandler, useForm } from 'react-hook-form'
9+
import z from 'zod'
810

911
import AlertError from 'components/ui/AlertError'
1012
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
1113
import { useMfaChallengeAndVerifyMutation } from 'data/profile/mfa-challenge-and-verify-mutation'
1214
import { useMfaListFactorsQuery } from 'data/profile/mfa-list-factors-query'
1315
import { useSignOut } from 'lib/auth'
1416
import { getReturnToPath } from 'lib/gotrue'
15-
import { Button, Form, Input } from 'ui'
17+
import { Button, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_ } from 'ui'
18+
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
1619

17-
const signInSchema = object({
18-
code: string().required('MFA Code is required'),
20+
const schema = z.object({
21+
code: z.string().min(1, 'MFA Code is required'),
1922
})
2023

24+
const formId = 'sign-in-mfa-form'
25+
2126
interface SignInMfaFormProps {
2227
context?: 'forgot-password' | 'sign-in'
2328
}
2429

25-
const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
30+
export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
2631
const router = useRouter()
2732
const signOut = useSignOut()
2833
const queryClient = useQueryClient()
2934
const [selectedFactor, setSelectedFactor] = useState<Factor | null>(null)
35+
const form = useForm<z.infer<typeof schema>>({
36+
resolver: zodResolver(schema),
37+
defaultValues: { code: '' },
38+
})
3039

3140
const {
3241
data: factors,
@@ -59,7 +68,7 @@ const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
5968
await router.replace('/sign-in')
6069
}
6170

62-
const onSignIn = async ({ code }: { code: string }) => {
71+
const onSubmit: SubmitHandler<z.infer<typeof schema>> = async ({ code }) => {
6372
if (selectedFactor) {
6473
mfaChallengeAndVerify({ factorId: selectedFactor.id, code, refreshFactors: false })
6574
}
@@ -84,61 +93,68 @@ const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
8493
{isErrorFactors && <AlertError error={factorsError} subject="Failed to retrieve factors" />}
8594

8695
{isSuccessFactors && (
87-
<Form
88-
validateOnBlur
89-
id="sign-in-mfa-form"
90-
initialValues={{ code: '' }}
91-
validationSchema={signInSchema}
92-
onSubmit={onSignIn}
93-
>
94-
{() => (
95-
<>
96-
<div className="flex flex-col gap-4">
97-
<Input
98-
id="code"
96+
<Form_Shadcn_ {...form}>
97+
<form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
98+
<FormField_Shadcn_
99+
key="code"
100+
name="code"
101+
control={form.control}
102+
render={({ field }) => (
103+
<FormItemLayout
99104
name="code"
100-
type="text"
101-
autoFocus
102-
icon={<Lock />}
103-
placeholder="XXXXXX"
104-
disabled={isVerifying}
105-
autoComplete="off"
106-
spellCheck="false"
107-
autoCapitalize="none"
108-
autoCorrect="off"
109105
label={
110106
selectedFactor && factors?.totp.length === 2
111107
? `Code generated by ${selectedFactor.friendly_name}`
112108
: null
113109
}
114-
/>
110+
>
111+
<FormControl_Shadcn_>
112+
<div className="relative">
113+
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-foreground-light [&_svg]:stroke-[1.5] [&_svg]:h-[20px] [&_svg]:w-[20px]">
114+
<Lock />
115+
</div>
116+
<Input_Shadcn_
117+
id="code"
118+
className="pl-10"
119+
{...field}
120+
autoFocus
121+
autoComplete="off"
122+
autoCorrect="off"
123+
autoCapitalize="none"
124+
spellCheck="false"
125+
placeholder="XXXXXX"
126+
disabled={isVerifying}
127+
/>
128+
</div>
129+
</FormControl_Shadcn_>
130+
</FormItemLayout>
131+
)}
132+
/>
115133

116-
<div className="flex items-center justify-between space-x-2">
117-
<Button
118-
block
119-
type="outline"
120-
size="large"
121-
disabled={isVerifying || isSuccess}
122-
onClick={onClickLogout}
123-
className="opacity-80 hover:opacity-100 transition"
124-
>
125-
Cancel
126-
</Button>
127-
<Button
128-
block
129-
form="sign-in-mfa-form"
130-
htmlType="submit"
131-
size="large"
132-
disabled={isVerifying || isSuccess}
133-
loading={isVerifying || isSuccess}
134-
>
135-
{isVerifying ? 'Verifying' : isSuccess ? 'Signing in' : 'Verify'}
136-
</Button>
137-
</div>
138-
</div>
139-
</>
140-
)}
141-
</Form>
134+
<div className="flex items-center justify-between space-x-2">
135+
<Button
136+
block
137+
type="outline"
138+
size="large"
139+
disabled={isVerifying || isSuccess}
140+
onClick={onClickLogout}
141+
className="opacity-80 hover:opacity-100 transition"
142+
>
143+
Cancel
144+
</Button>
145+
<Button
146+
block
147+
form={formId}
148+
htmlType="submit"
149+
size="large"
150+
disabled={isVerifying || isSuccess}
151+
loading={isVerifying || isSuccess}
152+
>
153+
{isVerifying ? 'Verifying' : isSuccess ? 'Signing in' : 'Verify'}
154+
</Button>
155+
</div>
156+
</form>
157+
</Form_Shadcn_>
142158
)}
143159

144160
<div className="my-8">
@@ -176,5 +192,3 @@ const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
176192
</>
177193
)
178194
}
179-
180-
export default SignInMfaForm

0 commit comments

Comments
 (0)