SignInSSOForm.tsx 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import HCaptcha from '@hcaptcha/react-hcaptcha'
  2. import { zodResolver } from '@hookform/resolvers/zod'
  3. import { useQueryClient } from '@tanstack/react-query'
  4. import { useRef, useState } from 'react'
  5. import { useForm, type SubmitHandler } from 'react-hook-form'
  6. import { toast } from 'sonner'
  7. import { Button, Form, FormControl, FormField, Input } from 'ui'
  8. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  9. import z from 'zod'
  10. import { useLastSignIn } from '@/hooks/misc/useLastSignIn'
  11. import { BASE_PATH } from '@/lib/constants'
  12. import { captureCriticalError } from '@/lib/error-reporting'
  13. import { auth, buildPathWithParams } from '@/lib/gotrue'
  14. const schema = z.object({
  15. email: z.string().min(1, 'Email is required').email('Must be a valid email'),
  16. })
  17. const formId = 'sso-sign-in-form'
  18. export const SignInSSOForm = () => {
  19. const queryClient = useQueryClient()
  20. const captchaRef = useRef<HCaptcha>(null)
  21. const [captchaToken, setCaptchaToken] = useState<string | null>(null)
  22. const [_, setLastSignInUsed] = useLastSignIn()
  23. const form = useForm<z.infer<typeof schema>>({
  24. resolver: zodResolver(schema as any),
  25. defaultValues: { email: '' },
  26. })
  27. const isSubmitting = form.formState.isSubmitting
  28. const onSubmit: SubmitHandler<z.infer<typeof schema>> = async ({ email }) => {
  29. const toastId = toast.loading('Signing in...')
  30. let token = captchaToken
  31. if (!token) {
  32. const captchaResponse = await captchaRef.current?.execute({ async: true })
  33. token = captchaResponse?.response ?? null
  34. }
  35. // redirects to /sign-in to check if the user has MFA setup (handled in SignInLayout.tsx)
  36. const redirectTo = buildPathWithParams(
  37. `${
  38. process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
  39. ? location.origin
  40. : process.env.NEXT_PUBLIC_SITE_URL
  41. }${BASE_PATH}/sign-in-mfa?method=sso`
  42. )
  43. const { data, error } = await auth.signInWithSSO({
  44. domain: email.split('@')[1],
  45. options: {
  46. captchaToken: token ?? undefined,
  47. redirectTo,
  48. },
  49. })
  50. if (!error) {
  51. await queryClient.resetQueries()
  52. setLastSignInUsed('sso')
  53. if (data) {
  54. // redirect to SSO identity provider page
  55. window.location.href = data.url
  56. }
  57. } else {
  58. setCaptchaToken(null)
  59. captchaRef.current?.resetCaptcha()
  60. toast.error(`Failed to sign in: ${error.message}`, { id: toastId })
  61. captureCriticalError(error, 'sign in via SSO')
  62. }
  63. }
  64. return (
  65. <Form {...form}>
  66. <form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
  67. <FormField
  68. key="email"
  69. name="email"
  70. control={form.control}
  71. render={({ field }) => (
  72. <FormItemLayout name="email" label="Email">
  73. <FormControl>
  74. <Input
  75. id="email"
  76. type="email"
  77. autoComplete="email"
  78. {...field}
  79. placeholder="gavin@hooli.com"
  80. />
  81. </FormControl>
  82. </FormItemLayout>
  83. )}
  84. />
  85. <div className="self-center">
  86. <HCaptcha
  87. ref={captchaRef}
  88. sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
  89. size="invisible"
  90. onVerify={(token) => {
  91. setCaptchaToken(token)
  92. }}
  93. onExpire={() => {
  94. setCaptchaToken(null)
  95. }}
  96. />
  97. </div>
  98. <Button block form={formId} htmlType="submit" size="large" loading={isSubmitting}>
  99. Sign in
  100. </Button>
  101. </form>
  102. </Form>
  103. )
  104. }