Wasp 邮箱认证自定义 UI 完整指南:用 wasp/client/auth 打造登录、注册与密码重置流程
邮件认证(email auth)是 Wasp 框架中最常用的认证方式之一:用户使用邮箱和密码注册,Wasp 校验数据并发送验证邮件,账户在用户点击验证链接之前保持非激活状态;忘记密码时,则通过类似的流程重置密码。虽然 Wasp 提供了开箱即用的 Auth UI,但在实际项目中你往往需要与品牌一致的表单样式、定制化的交互反馈,或是把认证流程嵌入已有的页面设计中。本指南将以 Wasp 0.17 版本官方文档为主体,带你从零实现一套完全自控的邮箱认证 UI:覆盖登录、注册、邮箱验证、请求重置密码与重置密码五个完整流程,并通过仓库中的 SDK 与服务端源码,讲清每个函数背后的真实调用链与安全细节。
邮箱认证的整体工作流
在深入代码之前,先理解 Wasp 邮箱认证的状态机,这决定了 UI 的每一个分支应该如何设计:
- 注册(Signup):用户提交邮箱与密码,Wasp 在服务端校验数据合法性,随后向该邮箱发送一封验证邮件。此时账户已创建,但处于未激活状态,用户也不会被登录。
- 邮箱验证(Email Verification):用户点击邮件中的链接,链接携带
token查询参数;前端调用verifyEmail({ token }),服务端验证 token 后将账户标记为激活。在此之前,用户无法正常使用该账户登录。 - 登录(Login):账户激活后,用户用邮箱与密码登录,成功后 Wasp 会建立会话(session),前端应据此进行页面跳转。
- 请求密码重置(Request Password Reset):用户忘记密码时输入邮箱,Wasp 发送一封密码重置邮件。注意:此动作不会立即重置密码,只是发送邮件。
- 重置密码(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-dom 的 useNavigate 在成功后跳转页面。
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()自动接受这些扩展字段。默认情况下只保存email与password,如需自定义注册流程,参见 overview 文档中的 “Customizing the signup process”。 - 返回值语义:
verifyEmail、requestPasswordReset、resetPassword、signup返回{ success: boolean }(verifyEmail还可能带有reason字段),而login返回void——判断登录成功与否靠的是是否抛异常。
仓库中已有真实的自定义 UI 调用范例:例如 examples/ask-the-documents 的主页 与 Layout 就导入了 wasp/client/auth 的认证函数来驱动自己的界面,你可以直接阅读这些示例了解在完整应用中的组织方式。
服务端视角:验证与安全细节
理解服务端行为有助于你在 UI 层做出正确的错误处理与状态反馈。相关模板位于 server 端 email provider 目录(含 login.ts、signup.ts、requestPasswordReset.ts、resetPassword.ts、verifyEmail.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 只保存
password字段。如果要在注册流程中加入额外字段,请阅读 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 校验与安全性上提供的完整保障。
延伸阅读
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust4.24 K638- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python670
SlideSCIPPT插件,支持素材库、AI助手、一键添加图片标题,复制粘贴位置、一键图片对齐、一键插入Markdown(加粗、超链接等行内样式、代码块、LaTeX等块级样式)、便捷导出图片!C#230
hello-agents📚 《从零开始构建智能体》——从零开始的智能体原理与实践教程Python52874
new-apiAI模型聚合管理中转分发系统,一个应用管理您的所有AI模型,支持将多种大模型转为统一格式调用,支持OpenAI、Claude、Gemini等格式,可供个人或者企业内部管理与分发渠道使用。🍥 A Unified AI Model Management & Distribution System. Aggregate all your LLMs into one app and access them via an OpenAI-compatible API, with native support for Claude (Messages) and Gemini formats.Go22545
JeecgBoot🔥企业级低代码平台集成了AI应用平台,帮助企业快速实现低代码开发和构建AI应用!前后端分离架构 SpringBoot,SpringCloud、Mybatis,Ant Design4、 Vue3.0、TS+vite!强大的代码生成器让前后端代码一键生成,无需写任何代码! 引领AI低代码开发模式: AI生成->OnlineCoding-> 代码生成-> 手工MERGE,显著的提高效率,又不失灵活~Java36351