第四章 用户注册流程与邮箱验证过程

491 阅读6分钟

前文中我们定义了两个表,分别是user和activateToken,我们实现注册要使用两种方式,一种是短信注册,一种是账号注册,账号注册需要验证邮箱的真伪,注册账号前发送到邮箱一个code,表activate存储邮箱账号和code,提交注册时验证code,创建账号完成注册,短信注册同理,最终页面效果如图

一、安装必要的库

首先,我们需要安装一些必要的库和组件

# 表单hook
npm i react-hook-form
# 表单hook形式的验证解析器
npm i @hookform/resolvers
# 邮箱验证格式
npm i email-validator
# 密码验证
npm i bcryptjs
npm i --save-dev @types/bcryptjs
# 添加shadcn组件
npx shadcn-ui@latest add input
npx shadcn-ui@latest add tabs
npx shadcn-ui@latest add label
npx shadcn-ui@latest add card
npx shadcn-ui@latest add textarea
npx shadcn-ui@latest add toast

toast(临时显示简洁消息)在使用前需要在全局layout中引入

// src/app/layout.tsx
import { Toaster } from '@/components/ui/toaster'
<Providers>
  {children}
  <Toaster />
</Providers>

手动触发方式如下:

const { toast } = useToast()
toast({
    title: '账号注册',
    description: '注册成功',
    variant: 'default',
})

二、账号注册表单规则

定义邮箱登录注册的数据验证规则

// src/lib/validator.ts
import { z } from 'zod'
import emailValidator from 'email-validator'
const passwordSchema = z.string().refine(
  (password) =>
    password.length >= 8 &&
    password.length <= 20 &&
    /\d/.test(password) && // 包含数字
    /[a-z]/.test(password) && // 包含小写字母
    /[A-Z]/.test(password) && // 包含大写字母
    /\W|_/.test(password), // 包含符号
  {
    message: '密码必须包含数字、大写字母、小写字母和符号,长度为8 ~ 20个字符',
  }
)

export const EmailFormVaildator = z.object({
  email: z.string().refine((email) => emailValidator.validate(email), {
    message: '邮箱格式不正确',
  }),
  password: passwordSchema,
  confirmPassword: passwordSchema,
  emailCode: z.string().refine((code) => code.length > 0, {
    message: '邮箱激活码必填',
  }),
})
export type TEmailFormVaildator = z.infer<typeof EmailFormVaildator>

三、创建注册页面

以注册为例,注册页面使用的Form是客户端组件,不把这个页面定义为客户端是因为后续需要验证,如果登录以后访问时就给重定向

// src/app/register/page.tsx
import Form from './form'

export default async function RegisterPage() {
  return <Form />
}

四、创建 Form 组件

Form 组件中,使用Tab来切换短信注册和账号注册

// src/app/register/form.tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import EmailRegister from './EmailRegister'
import PhoneRegister from './PhoneRegister'

export default function Form() {
  return (
    <>
      <div className="text-3xl font-bold text-center mt-10">注册</div>
      <Tabs defaultValue="phone" className="w-full max-w-lg mx-auto p-6">
        <TabsList className="grid w-full grid-cols-2">
          <TabsTrigger value="phone">短信注册</TabsTrigger>
          <TabsTrigger value="email">账号注册</TabsTrigger>
        </TabsList>
        <TabsContent value="phone">
          <PhoneRegister />
        </TabsContent>
        <TabsContent value="email">
          <EmailRegister />
        </TabsContent>
      </Tabs>
    </>
  )
}

PhoneRegister使用以下占位

// src/app/register/PhoneRegister.tsx
import React from 'react'

function PhoneRegister() {
  return <div>PhoneRegister</div>
}

export default PhoneRegister

五、账号注册

短信注册组件后续讲,账号注册主要实现了以下功能:

  1. 邮箱激活:用户输入邮箱后,点击“激活”按钮,将向用户的邮箱发送激活邮件。
  2. 实时验证:在用户输入邮箱、密码和确认密码时,组件会进行实时验证,并在输入不符合要求时显示错误信息。
  3. 邮箱注册:用户输入正确的邮箱、密码、确认密码和邮箱激活码后,点击“注册”按钮进行注册。

组件使用了以下主要技术和库:

  • React:用于构建组件和处理用户交互。
  • React Hook Form:用于管理表单状态,包括输入验证、错误处理等。
  • Zod:用于进行数据验证。
  • tRPC:用于处理邮箱注册和邮箱激活的API请求。
  • email-validator:用于验证邮箱格式是否正确。

完整代码如下:

// src/app/register/EmailRegister.tsx
'use client'

import { useCallback, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import emailValidator from 'email-validator'
import { EmailFormVaildator, TEmailFormVaildator } from '@/lib/validator'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { trpc } from '@/app/_trpc/client'
import { Loader2 } from 'lucide-react'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
} from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'

function EmailRegister() {
  const { toast } = useToast()
  const {
    register,
    handleSubmit,
    formState: { errors },
    setValue,
    setError,
    clearErrors,
    getValues,
    watch,
  } = useForm<TEmailFormVaildator>({
    defaultValues: {},
    resolver: zodResolver(EmailFormVaildator),
    mode: 'all',
  })

  const confirmPassword = watch('confirmPassword')
  const password = watch('password')

  // 使用 useEffect 在 password, confirmPassword 变化时进行验证
  useEffect(() => {
    if (password && confirmPassword && password !== confirmPassword) {
      setTimeout(() => {
        setError('confirmPassword', {
          type: 'manual',
          message: '两次输入密码不一致',
        })
      })
    } else {
      clearErrors('confirmPassword')
    }
  }, [password, confirmPassword, setError, clearErrors])
  const { mutate: startEmailRegister, isLoading: emailRegisterLoading } =
    trpc.emailRegister.useMutation({
      onSuccess: (user) => {
        if (user && user.id) {
          // 注册成功
          toast({
            title: '账号注册',
            description: '注册成功',
            variant: 'default',
          })
        }
      },
      onError: (error) => {
        toast({
          title: '账号注册',
          description: error.message,
          variant: 'destructive',
        })
      },
    })

  const handleSubmitEmail = useCallback(
    ({ email, password, confirmPassword, emailCode }: TEmailFormVaildator) => {
      if (password !== confirmPassword) {
        setError('confirmPassword', {
          type: 'manual',
          message: '两次输入密码不一致',
        })
        return
      }
      startEmailRegister({ email, password, emailCode })
    },
    [setError, startEmailRegister]
  )

  const { mutate: startActiveEmail, isLoading: emailActiveLoading } =
    trpc.emailActive.useMutation({
      onSuccess: (data) => {
        if (data && data.status === 'success') {
          toast({
            title: '激活邮箱',
            description: '激活邮件已发送,请前往邮箱查看',
            variant: 'default',
          })
        }
      },
      onError: (error) => {
        toast({
          title: '激活邮箱',
          description: error.message,
          variant: 'destructive',
        })
      },
    })
  const handleActiveEmail = useCallback(() => {
    clearErrors('email')
    const email = getValues('email')?.trim()
    if (!email || !emailValidator.validate(email)) {
      setError('email', {
        type: 'manual',
        message: '请输入正确的邮箱,然后激活',
      })
      return
    }
    startActiveEmail({ email })
  }, [clearErrors, getValues, setError, startActiveEmail])
  return (
    <Card>
      <CardHeader>
        <CardDescription>
          邮箱注册后可绑定手机号登录,先点激活更快获取激活码
        </CardDescription>
      </CardHeader>
      <form
        id="emailRegister"
        onSubmit={(e) => {
          e.preventDefault()
          handleSubmit(handleSubmitEmail)()
        }}
      >
        <CardContent className="space-y-2">
          <div className="space-y-1">
            <Label className="text-zinc-600" htmlFor="email">
              邮箱:
            </Label>
            <div className="flex space-x-2">
              <Input
                {...register('email')}
                onBlur={(e) => {
                  setValue('email', e.target.value)
                }}
                className={cn(errors.email && 'focus-visible:ring-red-500')}
                id="email"
                placeholder={'输入邮箱'}
                autoComplete="username"
                type="email"
              />
              <Button
                onClick={handleActiveEmail}
                className="min-w-max text-zinc-500"
                variant={'outline'}
                type="button"
                disabled={emailActiveLoading}
              >
                {emailActiveLoading && (
                  <Loader2 className="mr-1 h-4 w-4 animate-spin" />
                )}
                激活{emailActiveLoading ? '中' : ''}
              </Button>
            </div>

            <div className="text-destructive text-xs mt-1">
              {errors.email ? errors.email.message : null}
            </div>
          </div>
          <div className="space-y-1">
            <Label className="text-zinc-600" htmlFor="password">
              密码:
            </Label>
            <Input
              {...register('password')}
              className={cn(errors.password && 'focus-visible:ring-red-500')}
              onBlur={(e) => {
                setValue('password', e.target.value)
              }}
              id="password"
              placeholder={'输入登录密码'}
              autoComplete="new-password"
              type="password"
            />
            <div className="text-destructive text-xs mt-1">
              {errors.password ? errors.password.message : null}
            </div>
          </div>
          <div className="space-y-1">
            <Label className="text-zinc-600" htmlFor="rePassword">
              确认密码:
            </Label>
            <Input
              {...register('confirmPassword')}
              className={cn(
                errors.confirmPassword && 'focus-visible:ring-red-500'
              )}
              onBlur={(e) => {
                setValue('confirmPassword', e.target.value)
              }}
              id="rePassword"
              placeholder={'再次输入登录密码'}
              autoComplete="new-password"
              type="password"
            />
            <div className="text-destructive text-xs mt-1">
              {errors.confirmPassword ? errors.confirmPassword.message : null}
            </div>
          </div>
          <div className="space-y-1">
            <Label className="text-zinc-600" htmlFor="emailCode">
              激活码:
            </Label>
            <Textarea
              {...register('emailCode')}
              className={cn(errors.emailCode && 'focus-visible:ring-red-500')}
              placeholder={'输入邮箱激活码'}
              onBlur={(e) => {
                setValue('emailCode', e.target.value || '')
              }}
              id="emailCode"
            />
            <div className="text-destructive text-xs mt-1">
              {errors.emailCode ? errors.emailCode.message : null}
            </div>
          </div>
        </CardContent>
        <CardFooter>
          <Button
            className="w-full"
            type="submit"
            disabled={emailRegisterLoading}
          >
            {emailRegisterLoading && (
              <Loader2 className="mr-4 h-4 w-4 animate-spin text-white" />
            )}
            注册
          </Button>
        </CardFooter>
      </form>
    </Card>
  )
}

export default EmailRegister

六、验证策略

www.react-hook-form.com/api/useform…

此选项允许您在用户提交表单之前配置验证策略。验证发生在事件期间,该事件是通过调用函数触发的

所以为了实时验证,我们使用mode: 'all',验证在模糊事件和更改事件上都被触发。

七、完成激活注册

首先移除原来的test API,复原dashboard页面

// src/app/dashboard/page.tsx
function DashboardPage() {
  return <div>DashboardPage</div>
}

export default DashboardPage

完成激活邮箱和注册账号的逻辑

// src/trpc/index.ts
import { db } from '@/db'
import { publicProcedure, router } from './trpc'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { hash } from 'bcryptjs'
export const appRouter = router({
  emailRegister: publicProcedure
    .input(
      z.object({
        email: z.string(),
        password: z.string(),
        emailCode: z.string(),
      })
    )
    .mutation(async ({ input }) => {
      const { email, password, emailCode } = input
      // 验证参数
      if (!email || !password || !emailCode) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: '客户端缺少参数',
        })
      }
      // 验证邮箱是否已经注册
      const user = await db.user.findFirst({
        where: {
          email,
        },
      })
      if (user) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: '邮箱已经注册,请直接登录',
        })
      }
      // 验证激活码是否存在
      const emailCodeRecord = await db.activateToken.findFirst({
        where: {
          account: email,
        },
      })
      if (!emailCodeRecord) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: '激活码不存在,请重新发送获取',
        })
      }
      // 验证激活码是否过期
      if (emailCodeRecord.expiredAt.getTime() < Date.now()) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: '激活码已过期,请重新发送获取',
        })
      }
      // 验证激活码是否正确
      if (emailCodeRecord.code !== emailCode) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: '激活码不正确,请重新输入',
        })
      }
      // 创建用户
      const hashedPassword = await hash(password, 10)
      const newUser = await db.user.create({
        data: {
          email,
          password: hashedPassword,
        },
      })
      return { id: newUser.id, email }
    }),
  emailActive: publicProcedure
    .input(z.object({ email: z.string() }))
    .mutation(async ({ input }) => {
      const { email } = input
      // 创建激活码
      const hashedEmail = await hash(email, 10)
      // 保存激活码=》不存在email就创建,存在就更新
      await db.activateToken.upsert({
        where: {
          account: email,
        },
        create: {
          account: email,
          code: hashedEmail,
          expiredAt: new Date(Date.now() + 1000 * 60 * 60 * 1), // 1小时过期
        },
        update: {
          code: hashedEmail,
          expiredAt: new Date(Date.now() + 1000 * 60 * 60 * 1), // 1小时过期
        },
      })
      // 发送邮件
      // 返回状态
      return { status: 'success' }
    }),
})

// export type definition of API
export type AppRouter = typeof appRouter

下一节会进行回顾和优化代码,如邮箱发送邮件,提示样式,文案,错误异常处理等。