Remix + Prisma + MongoDB 全栈应用开发(二):用户验证

490 阅读22分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情 >> 欢迎阅读本系列教程的第二篇文章,在该系列中你可以学到如何使用 MongoDB、Prisma 和 Remix 从零开始构建一个全栈应用。在本节中,你将为 Remix 应用设置基于会话的身份验证。

fullstack-mongodb-2.png

介绍

在本系列上一节内容中你已经设置好了 Remix 项目,启动并运行 MongoDB 数据库。也配置好了 TailwindCSS 和 Prisma,并开始在 schema.prisma 文件中给 User 数据集建模。

在本节你将在应用中实现身份验证,允许用户通过注册和登录表单来创建账号和登录账号。

提示:该项目的起点位于 GitHub 仓库的 part-1 分支中。如果你想看这部分的最终结果,请前往 part-2 分支。

开发环境

为了能够跟上提供的示例,你需要

  • 已安装好 Node.js。
  • 已安装好 Git。
  • 已安装好 TailwindCSS VSCode 插件(可选)。
  • 已安装好 Prisma VSCode 插件(可选)。

提示:可选装的插件会给 Tailwind 和 Prisma 带来非常优秀的智能感知和语法高亮功能。

设置登录路由

你需要做的第一件事就是设置 /login 路由,因为你的登录和注册表单就在这里。

要在 Remix 框架中创建路由,向 app/routes 文件夹中添加一个文件即可。文件名将会被用作路由的名称。要了解更多Remix 中路由的工作原理,请查阅他们的文档

app/routes 目录下创建一个名为 login.tsx 的新文件,内容如下:

// app/routes/login.tsx
export default function Login() {
  return (
    <div className="h-screen flex justify-center items-center">
      <h2 className="text-yellow-300 font-extrabold text-5xl">Login Route</h2>
    </div>
  )
}

路由文件的默认导出是 Remix 渲染到浏览器中的组件。

npm run dev 启动本地开发服务,浏览器打开 http://localhost:3000/login,你应该看到路由被渲染为:

initial-login.png

这时服务已经工作,但是看起来仍然不是很好看。。。接下来,你将通过添加一个实际的登录表单来稍微美化一下。

创建一个可复用的布局组件

首先,创建一个组件,你将在其中包装好路由,来提供一些共有的格式和样式。你将使用 composition 模式来创建此 Layout 组件。

Composition

Composition 是一种模式,你可以通过其 props 为组件提供一组自元素。children 属性表示定义在父组件的开始标签和结束标签之间的元素:

<Parent>
  <p>The child</p>
</Parent>

在本例中,<p> 标签是 Parent 组件的自元素,无论你是否决定渲染 children 属性值,它都将被渲染到 Parent 组件中。

要看到这一点,在 app 文件夹下创建一个名为 components 新文件夹。并在文件夹里创建一个名为 layout.tsx 的新文件。

在该文件中,导出如下的函数式组件

// app/components/layout.tsx
export function Layout({ children }: { children: React.ReactNode }) {
  return <div className="h-screen w-full bg-blue-600 font-mono">{children}</div>
}

此组件使用Tailwind 类来指定你组件中包含的任何内容都将占据整个屏幕的宽度和高度,使用 mono 字体,并使用一个适度的深蓝色作为背景。

注意 children 属性是被渲染在 <div> 中。要想了解使用时它将被如何渲染,请查看以下代码:

JSX

<Layout>
  <p>Child Element</p>
</Layout>

RenderedHTML

<div className="h-screen w-full bg-blue-600 font-mono">
  <p>Child Element</p>
</div>

创建登录表单

现在你可以把组件导入到 app/routes/login.tsx 文件中,并使用一个 Layout 组件替代 <div><h2> 标签所在的位置包裹起来:

// app/routes/login.tsx
import { Layout } from '~/components/layout'

export default function Login() {
  return (
    <Layout>
      <h2 className="text-yellow-300 font-extrabold text-5xl">Login Route</h2>
    </Layout>
  )
}

构建表单

接着在注册表单中添加 emailpassword 输入框并展示一个提交按钮。

在顶部添加一句友好的欢迎信息,这样当用户进入站点时就可以向他们打招呼,并使用 Tailwind flex 类来让整个表单居中。

// app/routes/login.tsx
import { Layout } from '~/components/layout'

export default function Login() {
  return (
    <Layout>
      <div className="h-full justify-center items-center flex flex-col gap-y-4">
        <h2 className="text-5xl font-extrabold text-yellow-300">Welcome to Kudos!</h2>
        <p className="font-semibold text-slate-300">Log In To Give Some Praise!</p>

        <form method="post" className="rounded-2xl bg-gray-200 p-6 w-96">
          <label htmlFor="email" className="text-blue-600 font-semibold">
            Email
          </label>
          <input type="text" id="email" name="email" className="w-full p-2 rounded-xl my-2" />

          <label htmlFor="password" className="text-blue-600 font-semibold">
            Password
          </label>
          <input type="password" id="password" name="password" className="w-full p-2 rounded-xl my-2" />

          <div className="w-full text-center">
            <input
              type="submit"
              className="rounded-xl mt-2 bg-yellow-300 px-3 py-2 text-blue-600 font-semibold transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1"
              value="Sign In"
            />
          </div>
        </form>
      </div>
    </Layout>
  )
}

login-form.png

此时,你无需担心

的操作指向何处,只需关心它有一个值为 “post”method 。稍后你将看到一些很酷的 Remix 魔法, 它们已为我们设置好了操作!

创建表单字段组件

当你添加更多表单时,输入框字段及其标签将在整个应用程序中进行相当多的重写。所以将它们分解成一个名为 FormField可控组件,比避免代码重复。

app/components 中新建一个名为form-field.tsx 的文件,来构建 FormField 组件。然后,添加以下代码:

// app/components/form-field.tsx
interface FormFieldProps {
  htmlFor: string
  label: string
  type?: string
  value: any
  onChange?: (...args: any) => any
}

export function FormField({ htmlFor, label, type = 'text', value, onChange = () => {} }: FormFieldProps) {
  return (
    <>
      <label htmlFor={htmlFor} className="text-blue-600 font-semibold">
        {label}
      </label>
      <input
        onChange={onChange}
        type={type}
        id={htmlFor}
        name={htmlFor}
        className="w-full p-2 rounded-xl my-2"
        value={value}
      />
    </>
  )
}

这将定义并导出与你之前在登录表单中完全相同的标签和输入框组合,除了此组件将具有可配置项:

  • htmlFor:用于输入框的 idname 属性,以及标签的 htmlFor 属性的值。
  • label:标签显示文字。
  • value:输入框的当前控制值。
  • type可选项,允许你设置输入框的 type 属性,默认值时 ‘text’
  • onChange可选项,允许你提供一个函数,当输入框的值改变时来执行。默认为空函数调用。

你现在可以用此组件替换之前的标签和输入框:

// app/routes/login.tsx
import { useState } from 'react'
import { Layout } from '~/components/layout'
import { FormField } from '~/components/form-field'

export default function Login() {
  const [formData, setFormData] = useState({
    email: '',
    password: '',
  })

  // Updates the form data when an input changes
  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>, field: string) => {
    setFormData(form => ({ ...form, [field]: event.target.value }))
  }

  return (
    <Layout>
      <div className="h-full justify-center items-center flex flex-col gap-y-4">
        <h2 className="text-5xl font-extrabold text-yellow-300">Welcome to Kudos!</h2>
        <p className="font-semibold text-slate-300">Log In To Give Some Praise!</p>

        <form method="POST" className="rounded-2xl bg-gray-200 p-6 w-96">
          <FormField
            htmlFor="email"
            label="Email"
            value={formData.email}
            onChange={e => handleInputChange(e, 'email')}
          />
          <FormField
            htmlFor="password"
            type="password"
            label="Password"
            value={formData.password}
            onChange={e => handleInputChange(e, 'password')}
          />
          <div className="w-full text-center">
            <input
              type="submit"
              className="rounded-xl mt-2 bg-yellow-300 px-3 py-2 text-blue-600 font-semibold transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1"
              value="Sign In"
            />
          </div>
        </form>
      </div>
    </Layout>
  )
}

这将导入一个新的 FormField 组件,该组件的状态将由它的父组件(这里指注册表单)管理。

任何值的改变都会被 handleInputChange 函数追踪到。

你可以稍后再返回到 FormField 组件以添加错误信息处理,但是现在的功能已经满足需要了!

添加注册表单

你还需要一个路径让用户注册账户,这意味着你需要另外一个表单。此表单包含四个值:

  • email
  • password
  • firstName
  • lastName

为避免创建一个新的看起来和 /login 路由几乎一摸一样的 /signup 路由,需要重新调整登录表单,以便可以在两种不同的操作之间切换:登录和注册。

Login 组件的顶部,创建另外一个变量来保存你的 action状态。

// app/routes/login.tsx
export default function Login() {
  const [action, setAction] = useState('login')
  // ...
}

注意:默认的状态是登录页。

接下来,你需要一种方式来切换你要浏览哪种状态。在“Welcome to Kudos” 信息上面,添加以下按钮:

// app/routes/login.tsx
return (
  <Layout>
    <div className="h-full justify-center items-center flex flex-col gap-y-4">
      <button
        onClick={() => setAction(action == 'login' ? 'register' : 'login')}
        className="absolute top-8 right-8 rounded-xl bg-yellow-300 font-semibold text-blue-600 px-3 py-2 transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1"
      >
        {action === 'login' ? 'Sign Up' : 'Sign In'}
      </button>
      {/* ... */}
    </div>
  </Layout>
)

按钮文字会根据 action 的状态改变。onClick 方法将会把状态值在 ‘login’‘register’ 之间来回切换。

在这个页面上还有一些静态文字需要根据你所看到的表单进行调整。具体来说就是 “Log In To Give Some Praise!”副标题和表单本身的 “Sign In” 按钮。

更改表单的副标题以在每个表单上显示不同的信息:

// app/routes/login.tsx
- <p className="font-semibold text-slate-300">Log In To Give Some Praise!</p>
+ <p className="font-semibold text-slate-300">
+  {action === 'login' ? 'Log In To Give Some Praise!' : 'Sign Up To Get Started!'}
+ </p>

完全删除登录按钮并使用以下 <button> 替换:

// app/routes/login.tsx
- <input
-   type="submit"
-   className="rounded-xl mt-2 bg-yellow-300 px-3 py-2 text-blue-600 font-semibold transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1"
-   value="Sign In"
- />
+ <button type="submit" name="_action" value={action} className="rounded-xl mt-2 bg-yellow-300 px-3 py-2 text-blue-600 font-semibold transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1">
+    {
+        action === 'login' ? "Sign In" : "Sign Up"
+    }
+ </button>

新按钮有一个 namevalue 属性。该值的设置取决于状态的 action 是什么。当表单提交时,该值将被作为 _action 和表单数据一起被传递。

注意:此技巧仅适用于 name 属性是以下划线开头的 <button>

根据你选择的表单,你现在应该会看到更新后的信息。试着点击几次“注册”和“登录”按钮。

AnimatedImage.gif

添加可切换字段

页面上的文字看起来不错,但是两个表单里的输入字段看来还是一样的。最后,当注册表单展示时,你需要添加更多字段。

password 字段后面添加以下字段,并确保将新字段添加到 formData 对象。

// app/routes/login.tsx

// ...

export default function Login() {
  // ...
  // 1
  const [formData, setFormData] = useState({
    email: '',
    password: '',
    // 👇 New fields
    firstName: '',
    lastName: '',
  })
  // ...

  return (
    <Layout>
      <div className="h-full justify-center items-center flex flex-col gap-y-4">
        {/* ... */}
        <form method="POST" className="rounded-2xl bg-gray-200 p-6 w-96">
          {/* Other input fields ... */}
          {/* 2 */}
          {action === 'register' && (
            <>
              <FormField
                htmlFor="firstName"
                label="First Name"
                onChange={e => handleInputChange(e, 'firstName')}
                value={formData.firstName}
              />
              <FormField
                htmlFor="lastName"
                label="Last Name"
                onChange={e => handleInputChange(e, 'lastName')}
                value={formData.lastName}
              />
            </>
          )}
          {/* ... */}
        </form>
      </div>
    </Layout>
  )
}

这里有两处变化:

  1. 添加了两个新的键到 formData 状态。
  2. 添加了两个新的字段,它们根据你浏览的是登录还是注册表单有条件地渲染。

登录和注册表单现在看起来是完整的!是时候进行下一部分了:使表单功能化。

AnimatedImage.gif

身份验证流程

本节是最有趣的部分,将使你一直在设计和构建的所有内容真正发挥作用。

但是,在继续之前,你需要在项目中添加一些新的依赖项。运行以下命令:

npm i bcryptjs && npm i -D @types/bcryptjs

这样就安装了 bcryptjs 库及它的类型定义。稍后你将用它来加密和对比密码。

身份验证将会基于会话,和 Remix 的 Jokes App 教程中使用的身份验证模式相同。

为了更好地可视化你的应用的身份验证流程,请查看下图。

auth-flow.png

为了验证用户身份,需要采取一系列步骤,有两种可能的途径(登录和注册):

  1. 用户将尝试登录或注册。
  2. 表单将会被校验。
  3. login 或 register 函数将会被调用。
  4. 如果登录,服务端代码将使用登录详细信息确保用户存在。如果注册一个账号,将确保提供的邮箱没有被注册过。
  5. 如果以上步骤都通过了,将创建一个新的 cookie 会话,并将用户重定向到主页。
  6. 如果一个没有通过并存在一些问题,用户将被送回登录或注册页,并显示错误。

首先,在 app 目录下创建一个名为 utils 的文件夹。这里存放的是一些 helpers,services 及配置文件。

在这个新文件夹里,创建一个名为 auth.service.ts 的文件,你将在其中编写身份验证和与会话相关的方法。

注意:Remix 不会在发送到浏览器的文件写入代码之前将文件和 .server 绑定在一起。

构建注册函数

你要构建的第一个函数就是注册函数,它允许用户创建一个新账号。

app/utils/auth.server.ts 中导出名为 register 的异步函数:

// app/utils/auth.server.ts
export async function register() {}

app/utils 里一个名为 types.server.ts 的文件中,创建并其导出一个 type ,该类型定义了注册表单将提供的字段。

// app/utils/types.server.ts
export type RegisterForm = {
  email: string
  password: string
  firstName: string
  lastName: string
}

type 导入到 app/utils/auth.server.ts 中,并在 register 函数里用它来描述 user 参数,该参数将包含注册表单的数据:

// app/utils/auth.server.ts
import type { RegisterForm } from './types.server'
export async function register(user: RegisterForm) {}

register 函数被调用并提供一个 user 时,你要做的第一件事就是用提供的邮件来检查这个用户是否存在。

注意:记住,email 字段在你的 schema 中定义是唯一的。

创建 Prisma Client 实例

你将使用 PrismaClient 来执行数据库查询,但现在你的应用里还没有一个可用的实例。

app/utils 文件夹里新建一个名为 prisma.server.ts 的文件,在该文件中创建并导出一个 PrismaClient 实例:

// app/utils/prisma.server.ts
import { PrismaClient } from '@prisma/client'

let prisma: PrismaClient
declare global {
  var __db: PrismaClient | undefined
}

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient()
  prisma.$connect()
} else {
  if (!global.__db) {
    global.__db = new PrismaClient()
    global.__db.$connect()
  }
  prisma = global.__db
}

export { prisma }

注意:上面已经采取了预防措施,以防止在开发环境实时重载连接数据库超饱和。

现在你已经可以连接数据库了。在 app/utils/auth.server.ts 中,导入实例化的 PrismaClient 并添加以下代码到 register 函数:

// app/utils/auth.server.ts

import type { RegisterForm } from './types.server'
import { prisma } from './prisma.server'
import { json } from '@remix-run/node'

export async function register(user: RegisterForm) {
  const exists = await prisma.user.count({ where: { email: user.email } })
  if (exists) {
    return json({ error: `User already exists with that email` }, { status: 400 })
  }
}

注册函数现在将使用提供的电子邮件来查询数据库中的任何用户。

之所以在这里使用 count 函数是因为它可以返回一个数字。如果没有查询到任何记录,它将返回 0,其对应的布尔值为 false

更新数据模型

现在,你可以确定当用户尝试注册时,不会存在其他用户使用过所提供的电子邮件。接着,register 函数应该创建一个新用户。我们要存储的字段不多,但是,Prisma schema 中还没有这两个(firstNamelastName)。

你将在 User 模型中把这些数据存储为一个包含内嵌文档的字段,名为 profile

打开 prisma/schema.prisma 文件,并添加以下 type 代码块:

// ./prisma/schema.prisma

// ...

type Profile {
  firstName String
  lastName  String
}

type 关键字是用来定义复合类型 - 允许你在文档中定义文档。使用复合类型而不是 JSON 类型的好处是当你查询文档时获得类型安全检测。

这非常有用,因为它使你能够显示地定义数据的结构,否则由于 MongoDB 的灵活性,这些数据本来就是流动的,就可以包含任何内容。

你还未使用这个新的复合类型(嵌入文档的另一个名字)来描述字段。在 User 模型中,添加一个 profile 字段,并使用 Profile 类型作为其数据类型:

// ./prisma/schema.prisma

// ...

model User {
  id        String   @id @default(auto()) @map("_id") @db.ObjectId
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  email     String   @unique
  password  String
  profile   Profile  // 👈
}

// ...

太棒了,你的 User 模型现在已经包含了一个 profile 嵌入文档。重新生成 Prisma Client 来检查下这些新变化:

npx prisma generate

注意:你不需要运行 prisma db push,因为你没有添加任何新的数据集或索引。

添加用户服务

app/utils 中创建另外一个名为 users.server.ts 的文件,其中可以编写任何用户特定的函数。在该文件中添加以下函数并且导入:

// app/utils/user.server.ts
import bcrypt from 'bcryptjs'
import type { RegisterForm } from './types.server'
import { prisma } from './prisma.server'

export const createUser = async (user: RegisterForm) => {
  const passwordHash = await bcrypt.hash(user.password, 10)
  const newUser = await prisma.user.create({
    data: {
      email: user.email,
      password: passwordHash,
      profile: {
        firstName: user.firstName,
        lastName: user.lastName,
      },
    },
  })
  return { id: newUser.id, email: user.email }
}

createUser 函数做了这些事:

  1. 哈希化注册表单提供的密码,因为你不应该把密码存储为纯文本。
  2. 使用 Prisma 存储新的User 文档。
  3. 返回新用户的 idemail

注意:你可以通过传入 JSON 对象,直接在此查询中填写 profile 嵌入文档的详细信息,由于 Prisma 生成了类型,你会看到一些很棒的自动完成功能。

embedded-doc-helpers.png

此函数将会被用在 register 函数中,来处理用户的实际创建。在 app/utils/auth.server.ts 中导入 createUser 函数并在 register 函数中调用它。

// app/utils/auth.server.ts

import { json } from '@remix-run/node'
import type { RegisterForm } from './types.server'
import { prisma } from './prisma.server'
+ import { createUser } from './user.server'


export async function register(user: RegisterForm) {
  const exists = await prisma.user.count({ where: { email: user.email } })
  if (exists) {
    return json({ error: `User already exists with that email` }, { status: 400 })
  }


+  const newUser = await createUser(user)
+  if (!newUser) {
+    return json(
+      {
+        error: `Something went wrong trying to create a new user.`,
+        fields: { email: user.email, password: user.password },
+      },
+      { status: 400 },
+    )
+  }
}

现在,当一个用户注册时,如果其提供的电子邮件没有被其他用户注册过,那么就会创建一个新的用户。如果在创建用户时出错,错误将连同 emailpassword 的值一起返回给客户端。

构建登录功能

login 函数将接收emailpassword 参数,所以要启动这个函数,首先需要在 app/utils/types.server.ts 中创建一个新的 LoginForm 类型来描述这些数据:

// app/utils/types.server.ts

// ...

export type LoginForm = {
  email: string
  password: string
}

然后通过添加以下代码到 app/utils/auth.server.ts 中来创建 login 函数:

// app/utils/auth.server.ts

// 1
import { RegisterForm, LoginForm } from './types.server'
import bcrypt from 'bcryptjs'

//...

export async function login({ email, password }: LoginForm) {
  // 2
  const user = await prisma.user.findUnique({
    where: { email },
  })

  // 3
  if (!user || !(await bcrypt.compare(password, user.password)))
    return json({ error: `Incorrect login` }, { status: 400 })

  // 4
  return { id: user.id, email }
}

以上代码…

  1. ... 导入了新的 typebcryptjs 库。
  2. … 使用匹配的电子邮箱来查询一个用户。
  3. … 如果在数据库中没有找到用户或者提供的密码没有匹配哈希值,则返回 null 值。
  4. … 如果一些顺利则返回用户的 idemail

这将确保提供正确的凭证,并将返回创建新 cookie 会话所需的数据。

添加会话管理

当用户登录或注册时,你需要为它们的账号生成一个 cookie 会话。Remix 的 createCookieSessionStorate 函数提供了一种简单的方法来储存这些 cookie 会话。

app/utils/auth.server.ts 中导入那个函数,之后直接添加一个新的 cookie 会话存储器:

// app/utils/auth.server.ts

// Added the createCookieSessionStorage function 👇
import { json, createCookieSessionStorage } from '@remix-run/node'

// ...

const sessionSecret = process.env.SESSION_SECRET
if (!sessionSecret) {
  throw new Error('SESSION_SECRET must be set')
}

const storage = createCookieSessionStorage({
  cookie: {
    name: 'kudos-session',
    secure: process.env.NODE_ENV === 'production',
    secrets: [sessionSecret],
    sameSite: 'lax',
    path: '/',
    maxAge: 60 * 60 * 24 * 30,
    httpOnly: true,
  },
})

// login & register functions...

以上代码创建了一个带有几个配置的会话存储器:

  1. name:cookie 的名称。
  2. secure:如果为 true,cookie 的传输只允许使用 HTTPS。
  3. secrets:会话的密码。
  4. sameSite:特指 cookie 的传输是否允许通过跨站请求。
  5. path:传输 cookie 时 URL 中必须包含的路径。
  6. maxAge:定义 cookie 在被自动删除之前允许的时间段。
  7. httpOnly:如果为 true,不允许 JavaScript 访问 cookie。

注意:在此处学习更多关于 cookie 的不同配置项。

你还需要在 .env 文件中设置一个会话密码。添加一个名为 SESSION_SECRET 的变量和密码值。例如:

// .env
SESSION_SECRET="supersecretvalue"

会话存储现在已经设置好了。在 app/utils/auth.server.ts 中再创建一个实际创建 cookie 会话的函数:

// app/utils/auth.server.ts

// 👇 Added the redirect function
import { redirect, json, createCookieSessionStorage } from '@remix-run/node'

// ...

export async function createUserSession(userId: string, redirectTo: string) {
  const session = await storage.getSession()
  session.set('userId', userId)
  return redirect(redirectTo, {
    headers: {
      'Set-Cookie': await storage.commitSession(session),
    },
  })
}

该函数…

  1. ... 创建一个新会话。
  2. … 设置该会话的 userId 为登录用户的 id
  3. … 被调用时重定向用户到你指定的路由。
  4. … 在设置 cookie 响应头时提交会话。

当用户成功注册或者登录时,可以在 registerlogin 函数中使用 createUserSession 函数。

// app/utils/auth.server.ts
export async function register(user: RegisterForm) {
  const exists = await prisma.user.count({ where: { email: user.email } });
  if (exists) {
    return json(
      { error: `User already exists with that email` },
      { status: 400 }
    );
  }

  const newUser = await createUser(user);
  if (!newUser) {
    return json(
      {
        error: `Something went wrong trying to create a new user.`,
        fields: { email: user.email, password: user.password },
      },
      { status: 400 }
    );
  }

+ return createUserSession(newUser.id, '/');
}

export async function login({ email, password }: LoginForm) {
  const user = await prisma.user.findUnique({
    where: { email },
  });


  if (!user || !(await bcrypt.compare(password, user.password)))
    return json({ error: `Incorrect login` }, { status: 400 });


-  return { id: user.id, email }
+  return createUserSession(user.id, "/");
}

处理登录和注册表单的提交

你已经写好了创建新用户或登录所需的所有函数。现在你将把他们用于你构建的表单中。

app/routes/login.tsx 中,导出一个 action 函数。

// app/routes/login.tsx

// ...

import { ActionFunction } from '@remix-run/node'
export const action: ActionFunction = async ({ request }) => {}

// ...

注意:Remix 会查找名为 action 的导出函数,以在你定义的路由上设置一个 POST 请求。

现在,在 app/utils 里一个名为 validators.server.ts 的文件中新建一些验证函数,用以验证表单的输入项。

// app/utils/validators.server.ts

export const validateEmail = (email: string): string | undefined => {
  var validRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
  if (!email.length || !validRegex.test(email)) {
    return "Please enter a valid email address"
  }
}

export const validatePassword = (password: string): string | undefined => {
  if (password.length < 5) {
    return "Please enter a password that is at least 5 characters long"
  }
}

export const validateName = (name: string): string | undefined => {
  if (!name.length) return `Please enter a value`
}

app/routes/login.tsxaction 函数中,获取请求中的表单数据并验证其格式是否正确。

// app/routes/login.tsx
// ...
// Added the json function 👇
import { ActionFunction, json } from '@remix-run/node'
import { validateEmail, validateName, validatePassword } from '~/utils/validators.server'

export const action: ActionFunction = async ({ request }) => {
  const form = await request.formData()
  const action = form.get('_action')
  const email = form.get('email')
  const password = form.get('password')
  let firstName = form.get('firstName')
  let lastName = form.get('lastName')

  if (typeof action !== 'string' || typeof email !== 'string' || typeof password !== 'string') {
    return json({ error: `Invalid Form Data`, form: action }, { status: 400 })
  }

  if (action === 'register' && (typeof firstName !== 'string' || typeof lastName !== 'string')) {
    return json({ error: `Invalid Form Data`, form: action }, { status: 400 })
  }

  const errors = {
    email: validateEmail(email),
    password: validatePassword(password),
    ...(action === 'register'
      ? {
          firstName: validateName((firstName as string) || ''),
          lastName: validateName((lastName as string) || ''),
        }
      : {}),
  }

  if (Object.values(errors).some(Boolean))
    return json({ errors, fields: { email, password, firstName, lastName }, form: action }, { status: 400 })
}

// ...

上面的代码可能看起来有点吓人,但简而言之…

  • ... 从请求对象中提取表单数据。
  • … 确保提供了 emailpassword
  • … 如果 _action 的值为 “register”, 确保提供了 firstNamelastName
  • … 如果发生任何问题,都将返回一个带有表单字段值错误信息,因此,如果这些字段中的任何一个字段无效,你还可以稍厚再使用用户的输入重新填充表单。

你需要做的最后一件事就是,如果输入看起都是好的,则实际运行 registerlogin 函数。

// app/routes/login.tsx
// ...
import { login, register } from '~/utils/auth.server'
export const action: ActionFunction = async ({ request }) => {

  // validation ...

  switch (action) {
    case 'login': {
        return await login({ email, password })
    }
    case 'register': {
        firstName = firstName as string
        lastName = lastName as string
        return await register({ email, password, firstName, lastName })
    }
    default:
        return json({ error: `Invalid Form Data` }, { status: 400 });
  }

// ...

switch 语句将允许你根据表单中的 _action 值包含的内容有条件地运行登录和注册功能。

为了真实触发这个动作,表单需要请求这个路由。幸运的是,Remix 将会处理好这些,因为当它识别到导出的 action 函数后,会自动配置 POST 请求到 /login 路由。

如果你尝试登录或创建一个账号,你应该看到之后会被重定向到主页。成功!

AnimatedImage.gif

在私有路由上授权用户

接下来你要做的来提升用户体验的事是,根据用户的会话是否有效来自动跳转到主页或登录页。

在 app/utils/auth.server.ts 中你需要添加一些辅助函数:

// app/utils/auth.server.ts
// ...

export async function requireUserId(request: Request, redirectTo: string = new URL(request.url).pathname) {
  const session = await getUserSession(request)
  const userId = session.get('userId')
  if (!userId || typeof userId !== 'string') {
    const searchParams = new URLSearchParams([['redirectTo', redirectTo]])
    throw redirect(`/login?${searchParams}`)
  }
  return userId
}

function getUserSession(request: Request) {
  return storage.getSession(request.headers.get('Cookie'))
}

async function getUserId(request: Request) {
  const session = await getUserSession(request)
  const userId = session.get('userId')
  if (!userId || typeof userId !== 'string') return null
  return userId
}

export async function getUser(request: Request) {
  const userId = await getUserId(request)
  if (typeof userId !== 'string') {
    return null
  }

  try {
    const user = await prisma.user.findUnique({
      where: { id: userId },
      select: { id: true, email: true, profile: true },
    })
    return user
  } catch {
    throw logout(request)
  }
}

export async function logout(request: Request) {
  const session = await getUserSession(request)
  return redirect('/login', {
    headers: {
      'Set-Cookie': await storage.destroySession(session),
    },
  })
}

这是很多新功能。以下是上述函数的作用:

  • requireUserId 检查用户的会话。如果存在,则表示成功,只返回 userId。如果失败,那么,直接跳转到用户登录页。
  • getUserSession 基于请求中的 cookie 获取当前用户的会话。
  • getUserId 从会话存储器中返回当前用户的 id
  • getUser 返回当前会话所关联用户的整个 user 文档。如果没找到,则用户注销。
  • logout 销毁当前的会话并跳转到用户登录页。

有了这些,你可以在私有路由上实现一些很棒的授权功能。

app/routes/index.tsx 中,如果用户未登录,则通过添加以下代码将用户返回到登录页:

// app/routes/index.tsx

import { LoaderFunction } from '@remix-run/node'
import { requireUserId } from '~/utils/auth.server'

export const loader: LoaderFunction = async ({ request }) => {
  await requireUserId(request)
  return null
}

// ...

注意:Remix 在服务页面之前先运行 loader 函数。这意味着加载器中的任何重定向都会在页面被提供之前触发。

如果你尝试在未登录时打开应用程序的根路由(/),你应该会被 URL 中的 redirectTo 参数重定向到登录页。

注意:如果你已经登录了,你可能需要先清除你的 cookie。

接下来,做相反的事情。如果已登录的用户尝试打开登录页,那么他们应该被重定向回主页,因为他们已经登录过了。添加以下代码到 app/routes/login.tsx 中:

// app/routes/login.tsx
// ...
import { ActionFunction, json, LoaderFunction, redirect } from '@remix-run/node'
import { login, register, getUser } from '~/utils/auth.server'

export const loader: LoaderFunction = async ({ request }) => {
  // If there's already a user in the session, redirect to the home page
  return (await getUser(request)) ? redirect('/') : null
}
// ...

添加表单验证

非常好!你的登录和注册表单正在运行,而且你已经设置好了私有路由的授权和重定向。你几乎已经到了终点线!

最后一件事就是要添加表单验证和展示从 action 函数返回的错误信息。

更新 FormField 组件,让它们能够处理错误信息。

// app/components/form-field.tsx

+ import { useEffect, useState } from "react"

interface FormFieldProps {
    htmlFor: string,
    label: string,
    type?: string,
    value: any,
    onChange?: (...args: any) => any,
+    error?: string
}

export function FormField({
    htmlFor,
    label,
    type = "text",
    value,
    onChange = () => { },
+    error = ""
}: FormFieldProps) {
+    const [errorText, setErrorText] = useState(error)

+    useEffect(() => {
+        setErrorText(error)
+    }, [error])

    return <>
        <label htmlFor={htmlFor} className="text-blue-600 font-semibold">{label}</label>
-        <input onChange={onChange} type={type} id={htmlFor} name={htmlFor} className="w-full p-2 rounded-xl my-2" value={value} />
+        <input onChange={e => {
+            onChange(e)
+            setErrorText('')
+        }} type={type} id={htmlFor} name={htmlFor} className="w-full p-2 rounded-xl my-2" value={value} />
+        <div className="text-xs font-semibold text-center tracking-wide text-red-500 w-full">
+            {errorText || ''}
+        </div>
    </>
}

现在该组件将接收一个错误信息。当用户开始输入字段时,如果显示任何错误信息,字段都将被清除。

在登录表单中,你需要使用 Remix 的 useActionData hook访问从 action 返回的数据,以便提取错误信息。

// app/routes/login.tsx

// ...
import { useActionData } from '@remix-run/react'
import { useRef, useEffect } from 'react'
// ...
export default function Login() {
  // ...
  // 1
  const actionData = useActionData()
  // 2
  const firstLoad = useRef(true)
  const [errors, setErrors] = useState(actionData?.errors || {})
  const [formError, setFormError] = useState(actionData?.error || '')
  // 3
  const [formData, setFormData] = useState({
    email: actionData?.fields?.email || '',
    password: actionData?.fields?.password || '',
    firstName: actionData?.fields?.lastName || '',
    lastName: actionData?.fields?.firstName || '',
  })
  // ...
}

此代码添加了以下内容:

  1. 把从 action 函数返回的数据钩入。
  2. 设置一个 errors 变量,它将在对象中保存特定字段的错误,例如 “无效邮箱”。另外也设置了一个 formError 变量,它将保存错误信息以显示诸如“登录错误”之类的表单信息。
  3. 更新 formData 状态变量的默认值为 action 函数返回的任何值(如果可用)。

如果向用户展示错误并切换表单,你将需要清除表单和显示的任何错误。使用 effects 来达成:

// app/routes/login.tsx
// ...
export default function Login() {
  // ...
  useEffect(() => {
    if (!firstLoad.current) {
      const newState = {
        email: '',
        password: '',
        firstName: '',
        lastName: '',
      }
      setErrors(newState)
      setFormError('')
      setFormData(newState)
    }
  }, [action])

  useEffect(() => {
    if (!firstLoad.current) {
      setFormError('')
    }
  }, [formData])

  useEffect(() => { firstLoad.current = false }, [])
}
// ...

有了这些,你终于可以让你的表单和字段知道要展示哪些错误了。

// app/routes/login.tsx

// ...
<form method="POST" className="rounded-2xl bg-gray-200 p-6 w-96">
+ <div className="text-xs font-semibold text-center tracking-wide text-red-500 w-full">{formError}</div>
  <FormField
    htmlFor="email"
    label="Email"
    value={formData.email}
    onChange={e => handleInputChange(e, 'email')}
+    error={errors?.email}
  />
  <FormField
    htmlFor="password"
    type="password"
    label="Password"
    value={formData.password}
    onChange={e => handleInputChange(e, 'password')}
+    error={errors?.password}
  />

  {action === 'register' && (
    <>
      {/* First Name */}
      <FormField
        htmlFor="firstName"
        label="First Name"
        onChange={e => handleInputChange(e, 'firstName')}
        value={formData.firstName}
+        error={errors?.firstName}
      />
      {/* Last Name */}
      <FormField
        htmlFor="lastName"
        label="Last Name"
        onChange={e => handleInputChange(e, 'lastName')}
        value={formData.lastName}
+        error={errors?.lastName}
      />
    </>
  )}

  {/* ... */}
</form>
// ...

现在你应该在登录和注册表单上看到错误信息,并且表单重置正常工作!

error-message.png

总结 & 下一步

Kudos(😉)感谢你能坚持到本节结束!有很多内容要介绍,但希望你能够理解以下内容:

  • 在 Remix 中如何设置路由。
  • 如何构建带有验证的登录表单和注册表单。
  • 基于会话的身份验证如何工作。
  • 如何通过实施授权来保护私有理由。
  • 当创建和认证用户时如何使用 Prisma 存储和查询数据。

在本系列的下一节中,你将构建 Kudos 主页和 Kudos-share 功能。你还将向 Kudos feed 添加搜索和过滤功能。

译者注:本文翻译自 Prisma 官方博客系列教程,整个系列很长且很详细,如有纰漏还请指正。

原文标题:Build A Fullstack App with Remix, Prisma & MongoDB: Project SetupBuild A Fullstack App with Remix, Prisma & MongoDB: Project Setup

原文作者:Sabin Adams

发布时间:2022年4月26日

原文连接:www.prisma.io/blog/fullst…