首页
/ Wasp 邮箱认证自定义 UI 完整指南:用 wasp/client/auth 打造登录、注册与密码重置流程

Wasp 邮箱认证自定义 UI 完整指南:用 wasp/client/auth 打造登录、注册与密码重置流程

2026-09-14 14:04:11作者:宣海椒Queenly

邮件认证(email auth)是 Wasp 框架中最常用的认证方式之一:用户使用邮箱和密码注册,Wasp 校验数据并发送验证邮件,账户在用户点击验证链接之前保持非激活状态;忘记密码时,则通过类似的流程重置密码。虽然 Wasp 提供了开箱即用的 Auth UI,但在实际项目中你往往需要与品牌一致的表单样式、定制化的交互反馈,或是把认证流程嵌入已有的页面设计中。本指南将以 Wasp 0.17 版本官方文档为主体,带你从零实现一套完全自控的邮箱认证 UI:覆盖登录、注册、邮箱验证、请求重置密码与重置密码五个完整流程,并通过仓库中的 SDK 与服务端源码,讲清每个函数背后的真实调用链与安全细节。

邮箱认证的整体工作流

在深入代码之前,先理解 Wasp 邮箱认证的状态机,这决定了 UI 的每一个分支应该如何设计:

  1. 注册(Signup):用户提交邮箱与密码,Wasp 在服务端校验数据合法性,随后向该邮箱发送一封验证邮件。此时账户已创建,但处于未激活状态,用户也不会被登录
  2. 邮箱验证(Email Verification):用户点击邮件中的链接,链接携带 token 查询参数;前端调用 verifyEmail({ token }),服务端验证 token 后将账户标记为激活。在此之前,用户无法正常使用该账户登录。
  3. 登录(Login):账户激活后,用户用邮箱与密码登录,成功后 Wasp 会建立会话(session),前端应据此进行页面跳转。
  4. 请求密码重置(Request Password Reset):用户忘记密码时输入邮箱,Wasp 发送一封密码重置邮件。注意:此动作不会立即重置密码,只是发送邮件。
  5. 重置密码(Password Reset):用户点击邮件中的链接,携带 token,提交新密码后完成重置,随后可凭新密码登录。

关于默认的邮箱与密码校验规则,可阅读 auth overview docs 中的 “Default validations” 一节。

Wasp 的预置 Auth UI 本质上就是基于本指南将要讲到的这些函数实现的。当你需要更多自定义空间时,完全可以效仿它的做法:在你的客户端代码中直接调用 Wasp 的 auth actions

wasp/client/auth 导入认证函数

自定义 UI 的核心依赖只有一个模块:wasp/client/auth。在 SDK 的公开入口模板 中可以看到,Wasp 生成器会把五个邮箱认证函数统一从这里导出:

// PUBLIC API
export { login } from '../../auth/email/actions/login'
export { signup } from '../../auth/email/actions/signup'
export { requestPasswordReset, resetPassword } from '../../auth/email/actions/passwordReset'
export { verifyEmail } from '../../auth/email/actions/verifyEmail'

也就是说,你只需一条 import 语句即可拿到全部能力:

import {
  login,
  requestPasswordReset,
  resetPassword,
  signup,
  verifyEmail,
} from 'wasp/client/auth'

完整示例代码:五个认证组件

下面是一份可直接作为起点的客户端实现(官方文档原始示例),它包含处理登录、注册、邮箱验证与密码重置流程所需的全部组件。你可以自定义任何外观和行为,只要确保调用的是从 wasp/client/auth 导入的函数即可。示例使用 useState 管理表单状态与错误信息,使用 react-router-domuseNavigate 在成功后跳转页面。

JavaScript 版本(src/pages/auth.jsx

import {
  login,
  requestPasswordReset,
  resetPassword,
  signup,
  verifyEmail,
} from 'wasp/client/auth'

import { useState } from 'react'
import { useNavigate } from 'react-router-dom'

// This will be shown when the user wants to log in
export function Login() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState(null)
  const navigate = useNavigate()

  async function handleSubmit(event) {
    event.preventDefault()
    setError(null)
    try {
      await login({ email, password })
      navigate('/')
    } catch (error) {
      setError(error)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <p>Error: {error.message}</p>}

      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
      />
      <button type="submit">Log In</button>
    </form>
  )
}

// This will be shown when the user wants to sign up
export function Signup() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState(null)
  const [needsConfirmation, setNeedsConfirmation] = useState(false)

  async function handleSubmit(event) {
    event.preventDefault()
    setError(null)
    try {
      await signup({ email, password })
      setNeedsConfirmation(true)
    } catch (error) {
      console.error('Error during signup:', error)
      setError(error)
    }
  }

  if (needsConfirmation) {
    return (
      <p>
        Check your email for the confirmation link. If you don't see it, check
        spam/junk folder.
      </p>
    )
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <p>Error: {error.message}</p>}

      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
      />
      <button type="submit">Sign Up</button>
    </form>
  )
}

// This will be shown has clicked on the link in their
// email to verify their email address
export function EmailVerification() {
  const [error, setError] = useState(null)
  const navigate = useNavigate()

  async function handleClick() {
    setError(null)
    try {
      // The token is passed as a query parameter
      const token = new URLSearchParams(window.location.search).get('token')
      if (!token) throw new Error('Token not found in URL')
      await verifyEmail({ token })
      navigate('/')
    } catch (error) {
      console.error('Error during email verification:', error)
      setError(error)
    }
  }

  return (
    <>
      {error && <p>Error: {error.message}</p>}

      <button onClick={handleClick}>Verify email</button>
    </>
  )
}

// This will be shown when the user wants to reset their password
export function RequestPasswordReset() {
  const [email, setEmail] = useState('')
  const [error, setError] = useState(null)
  const [needsConfirmation, setNeedsConfirmation] = useState(false)

  async function handleSubmit(event) {
    event.preventDefault()
    setError(null)
    try {
      await requestPasswordReset({ email })
      setNeedsConfirmation(true)
    } catch (error) {
      console.error('Error during requesting reset:', error)
      setError(error)
    }
  }

  if (needsConfirmation) {
    return (
      <p>
        Check your email for the confirmation link. If you don't see it, check
        spam/junk folder.
      </p>
    )
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <p>Error: {error.message}</p>}

      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />

      <button type="submit">Send password reset</button>
    </form>
  )
}

// This will be shown when the user clicks on the link in their
// email to reset their password
export function PasswordReset() {
  const [error, setError] = useState(null)
  const [newPassword, setNewPassword] = useState('')
  const navigate = useNavigate()

  async function handleSubmit(event) {
    event.preventDefault()
    setError(null)
    try {
      // The token is passed as a query parameter
      const token = new URLSearchParams(window.location.search).get('token')
      if (!token) throw new Error('Token not found in URL')
      await resetPassword({ token, password: newPassword })
      navigate('/')
    } catch (error) {
      console.error('Error during password reset:', error)
      setError(error)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <p>Error: {error.message}</p>}

      <input
        type="password"
        autoComplete="new-password"
        value={newPassword}
        onChange={(e) => setNewPassword(e.target.value)}
        placeholder="New password"
      />

      <button type="submit">Reset password</button>
    </form>
  )
}

TypeScript 版本(src/pages/auth.tsx

import {
  login,
  requestPasswordReset,
  resetPassword,
  signup,
  verifyEmail,
} from 'wasp/client/auth'

import { useState } from 'react'
import { useNavigate } from 'react-router-dom'

// This will be shown when the user wants to log in
export function Login() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState<Error | null>(null)
  const navigate = useNavigate()

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setError(null)
    try {
      await login({ email, password })
      navigate('/')
    } catch (error: unknown) {
      setError(error as Error)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <p>Error: {error.message}</p>}

      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
      />
      <button type="submit">Log In</button>
    </form>
  )
}

// This will be shown when the user wants to sign up
export function Signup() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState<Error | null>(null)
  const [needsConfirmation, setNeedsConfirmation] = useState(false)

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setError(null)
    try {
      await signup({ email, password })
      setNeedsConfirmation(true)
    } catch (error: unknown) {
      console.error('Error during signup:', error)
      setError(error as Error)
    }
  }

  if (needsConfirmation) {
    return (
      <p>
        Check your email for the confirmation link. If you don't see it, check
        spam/junk folder.
      </p>
    )
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <p>Error: {error.message}</p>}

      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
      />
      <button type="submit">Sign Up</button>
    </form>
  )
}

// This will be shown has clicked on the link in their
// email to verify their email address
export function EmailVerification() {
  const [error, setError] = useState<Error | null>(null)
  const navigate = useNavigate()

  async function handleClick() {
    setError(null)
    try {
      // The token is passed as a query parameter
      const token = new URLSearchParams(window.location.search).get('token')
      if (!token) throw new Error('Token not found in URL')
      await verifyEmail({ token })
      navigate('/')
    } catch (error: unknown) {
      console.error('Error during email verification:', error)
      setError(error as Error)
    }
  }

  return (
    <>
      {error && <p>Error: {error.message}</p>}

      <button onClick={handleClick}>Verify email</button>
    </>
  )
}

// This will be shown when the user wants to reset their password
export function RequestPasswordReset() {
  const [email, setEmail] = useState('')
  const [error, setError] = useState<Error | null>(null)
  const [needsConfirmation, setNeedsConfirmation] = useState(false)

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setError(null)
    try {
      await requestPasswordReset({ email })
      setNeedsConfirmation(true)
    } catch (error: unknown) {
      console.error('Error during requesting reset:', error)
      setError(error as Error)
    }
  }

  if (needsConfirmation) {
    return (
      <p>
        Check your email for the confirmation link. If you don't see it, check
        spam/junk folder.
      </p>
    )
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <p>Error: {error.message}</p>}

      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />

      <button type="submit">Send password reset</button>
    </form>
  )
}

// This will be shown when the user clicks on the link in their
// email to reset their password
export function PasswordReset() {
  const [error, setError] = useState<Error | null>(null)
  const [newPassword, setNewPassword] = useState('')
  const navigate = useNavigate()

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setError(null)
    try {
      // The token is passed as a query parameter
      const token = new URLSearchParams(window.location.search).get('token')
      if (!token) throw new Error('Token not found in URL')
      await resetPassword({ token, password: newPassword })
      navigate('/')
    } catch (error: unknown) {
      console.error('Error during password reset:', error)
      setError(error as Error)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <p>Error: {error.message}</p>}

      <input
        type="password"
        autoComplete="new-password"
        value={newPassword}
        onChange={(e) => setNewPassword(e.target.value)}
        placeholder="New password"
      />

      <button type="submit">Reset password</button>
    </form>
  )
}

示例代码要点解读

  • 登录成功必须跳转login() 成功后 Wasp 已在浏览器建立了会话,此时应立即用 navigate('/') 之类的跳转把用户带到应用主页。
  • 注册后不登录signup() 返回后用户处于“待验证”状态,示例通过 needsConfirmation 状态切换到提示页面,引导用户去邮箱点击确认链接。
  • token 来自 URL 查询参数:验证邮件与重置密码邮件中的链接会携带 token 查询参数,因此两个“点击邮件链接后到达”的组件都用 new URLSearchParams(window.location.search).get('token') 来读取,并显式处理 token 缺失的情况。
  • autoComplete="new-password":重置密码输入框应设置该属性,避免浏览器自动填充旧的登录凭据,同时便于密码管理器正确识别。

深入底层:这些函数究竟做了什么

自定义 UI 并不神秘——你调用的每个函数最终都会由 Wasp 生成器编译为一次对服务端 REST 接口的调用。以 login 的 SDK 实现 为例:

export async function login(data: { email: string; password: string }): Promise<void> {
    try {
        const { sessionId } = await api.post('{= loginPath =}', {
            json: data,
        }).json(SessionResponseSchema);
        await initSession(sessionId);
    } catch (e) {
        throw handleApiError(e);
    }
}

模板中的 {= loginPath =} 等占位符会在 wasp build / wasp start 时由代码生成器替换为真实的路由路径。几个值得注意的细节:

  • 错误统一包装:所有函数都通过 handleApiError(e) 把网络或服务端错误转换为统一的错误对象,因此你的 UI 可以直接读取 error.message 展示给用户。
  • 会话初始化login() 成功后,SDK 拿到 sessionId 并调用 initSession 完成会话的持久化,这是“登录成功”在客户端层面的本质。
  • 注册数据类型的可扩展性signup 的实现 中有一个条件类型 EmailSignupData:当你在 Wasp 配置中定义了额外的注册字段时,生成器会在签名中并入 UserEmailSignupFields,使 signup() 自动接受这些扩展字段。默认情况下只保存 emailpassword,如需自定义注册流程,参见 overview 文档中的 “Customizing the signup process”
  • 返回值语义verifyEmailrequestPasswordResetresetPasswordsignup 返回 { success: boolean }verifyEmail 还可能带有 reason 字段),而 login 返回 void——判断登录成功与否靠的是是否抛异常。

仓库中已有真实的自定义 UI 调用范例:例如 examples/ask-the-documents 的主页Layout 就导入了 wasp/client/auth 的认证函数来驱动自己的界面,你可以直接阅读这些示例了解在完整应用中的组织方式。

服务端视角:验证与安全细节

理解服务端行为有助于你在 UI 层做出正确的错误处理与状态反馈。相关模板位于 server 端 email provider 目录(含 login.tssignup.tsrequestPasswordReset.tsresetPassword.tsverifyEmail.ts)。

邮箱验证:JWT + 状态翻转

服务端 verifyEmail 的流程是:从请求体中取出 token → 用 validateJWT 校验并解析出邮箱 → 按 providerId 找到 AuthIdentity → 把 isEmailVerified 置为 true → 触发 onAfterEmailVerifiedHook(如果你配置了该 hook)。token 无效时抛出 400,并统一返回 “Email verification failed, invalid token” 而不泄露具体原因。

密码重置:先验 token,再验密码

服务端 resetPassword 有一段值得注意的安全设计(源码注释也明确说明):

The token is validated before the password so that an unauthenticated caller with an invalid token can't learn the deployment's password policy.

先校验 token,再校验新密码的强度规则,避免未携带有效 token 的攻击者通过接口探知你部署环境中的密码策略。此外它还有两个关键行为:

  • 重置即验证:重置密码成功时会把 isEmailVerified 一并置为 true,因为能通过邮件链接重置密码本身就证明了对邮箱的控制权。
  • 会话全失效:修改密码后会调用 invalidateAllSessionsForAuthId 使该用户的所有既有会话失效,防止拿到旧会话的人继续使用。

API 参考:wasp/client/auth 五大函数

以下为官方文档给出的完整 API 说明,可直接作为自定义 UI 的接口契约。

login()

用于登录用户的 action。成功后务必做页面跳转(例如跳转到应用主页)。

它接收一个参数:

  • data: object(必填),字段如下:
    • email: string(必填)
    • password: string(必填)

signup()

用于注册用户并启动邮箱验证流程的 action。注册成功后用户不会被登录,因为其邮箱仍需验证。

它接收一个参数:

  • data: object(必填),字段如下:
    • email: string(必填)
    • password: string(必填)

默认情况下,Wasp 只保存 emailpassword 字段。如果要在注册流程中加入额外字段,请阅读 overview 文档中的 “Customizing the signup process”

verifyEmail()

用于将邮箱标记为有效、将用户账户标记为激活的 action。成功后务必做页面跳转(例如跳转到登录页)。

它接收一个参数:

  • data: object(必填),字段如下:
    • token: string(必填)—— 注册时生成的 token,会以名为 token 的 URL 查询参数形式出现在验证链接中。

requestPasswordReset()

用于请求发送密码重置邮件的 action。该动作不会立即重置密码,只是发送邮件

它接收一个参数:

  • data: object(必填),字段如下:
    • email: string(必填)

resetPassword()

用于确认密码重置并提供新密码的 action。成功后务必做页面跳转(例如跳转到登录页)。

它接收一个参数:

  • data: object(必填),字段如下:
    • token: string(必填)—— 请求密码重置时生成的 token,会以名为 token 的 URL 查询参数形式出现在重置链接中。
    • password: string(必填)—— 用户的新密码。

把组件接入路由

五个组件就位后,还需要在路由层把它们串起来。一个典型的做法是为验证邮件与重置邮件链接创建专门的路由(例如 /verify-email/reset-password),其余表单挂载在登录/注册页面下。由于示例组件都导出了具名函数,你可以在自己的路由配置中按需引入:

<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/verify-email" element={<EmailVerification />} />
<Route path="/request-password-reset" element={<RequestPasswordReset />} />
<Route path="/reset-password" element={<PasswordReset />} />

这样,验证邮件与重置邮件里的链接地址就能直接命中对应的组件,组件内部再通过 URLSearchParams 解析 token 完成后续调用。

小结

自定义邮箱认证 UI 的本质并不复杂:五个组件 + 五个来自 wasp/client/auth 的函数。真正需要把握的是每个函数在成功与失败时的语义差异——login 成功即建会话、signup 成功但需等待邮箱验证、verifyEmail/resetPassword 成功后应当跳转登录页、requestPasswordReset 只发信不重置。结合本文对 SDK 模板与服务端实现的剖析,你可以放心地把认证流程完全纳入自己的设计体系,同时仍然享受 Wasp 在会话管理、token 校验与安全性上提供的完整保障。

延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
34
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.21 K
2.81 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
945
1.86 K
docsdocs
暂无描述
Markdown
906
5.84 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
537
607
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
864
1.36 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
4.28 K
1.03 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.39 K
1.48 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
550
401
flutter_flutterflutter_flutter
本仓库是 Flutter SDK 与 Flutter Engine 的 OpenHarmony 适配版本,由 CPF-Flutter 团队维护。开发者可使用熟悉的 Flutter 技术栈开发 OpenHarmony 应用,3.35.7 及以后的适配版本可基于本仓库源码构建支持 OpenHarmony 的 Flutter Engine。
Dart
1.19 K
347