Wasp 自建邮箱认证 UI 完整指南:从登录、注册验证到密码重置的实战实现
【免费下载链接】waspThe batteries-included full-stack framework for the AI era. Develop JS/TS web apps (React, Node.js, and Prisma) using declarative code that abstracts away complex full-stack features like auth, background jobs, RPC, email sending, end-to-end type safety, single-command deployment, and more.项目地址: https://gitcode.com/GitHub_Trending/wa/wasp
本文是一份面向 Wasp(0.18 版本)开发者的实战指南,讲解如何抛弃预置的 Auth UI,通过wasp/client/auth提供的login、signup、verifyEmail、requestPasswordReset、resetPassword五个前端 action,从零搭建一套完全自定义的邮箱认证界面,覆盖登录、注册、邮箱验证、忘记密码与重置密码五大流程。读完本文,你将掌握自建认证 UI 的全部代码骨架、每个 action 的参数契约与调用时机,并理解其在 Wasp 生成代码中的底层实现原理。
为什么需要自建认证 UI
在 Wasp 中启用邮箱认证(auth.methods.email)后,用户通过邮箱地址与密码登录。注册时 Wasp 会校验数据并发送验证邮件,用户必须点击邮件中的验证链接后账户才会被激活;此外,用户还可以通过类似的流程重置密码。
虽然 Wasp 提供了开箱即用的 Auth UI 组件(LoginForm、SignupForm、VerifyEmailForm、ForgotPasswordForm、ResetPasswordForm),但实际项目中经常需要与品牌视觉、交互细节完全一致的表单界面。此时有两种选择:
- 对预置组件做有限的样式定制(改动空间有限);
- 完全自建 UI,在自建代码中直接调用 Wasp 的 auth actions——这正是 Auth UI 组件在底层所做的,只是把控制权交还给你。
自建 UI 的核心原则只有一条:界面、样式、交互完全由你掌控,但登录/注册/验证/重置等关键动作必须通过wasp/client/auth导出的函数完成,因为这些函数封装了与 Wasp 后端通信、会话初始化的全部细节。
默认校验规则与流程概览
在动手写代码之前,先明确 Wasp 对邮箱认证的默认约束(详见 auth 概述文档):
email不能为空,且必须是合法的邮箱地址;邮箱以大小写不敏感的方式存储;password不能为空,至少 8 个字符,且必须包含数字。
这些校验既作用于 Wasp 的 Auth UI,也作用于通过wasp/client/auth调用的默认 actions。如果你使用自定义 auth actions(auth.methods.email之外的自研后端),则需要自行执行校验。
整个邮箱认证涉及 5 个客户端函数,对应 5 个页面/交互场景:
| 函数 | 触发场景 | 成功后应做的事 |
|---|---|---|
login({ email, password }) | 用户登录 | 跳转到应用主页等 |
signup({ email, password }) | 用户注册 | 提示用户查收验证邮件 |
verifyEmail({ token }) | 用户点击邮件验证链接 | 跳转到登录页 |
requestPasswordReset({ email }) | 用户忘记密码 | 提示用户查收重置邮件 |
resetPassword({ token, password }) | 用户点击重置链接 | 跳转到登录页 |
完整示例:src/pages/auth.tsx
下面这份代码是自建 UI 的完整起点,包含处理登录、注册、邮箱验证、密码重置请求与密码重置所需的全部组件。你可以随意修改外观与交互,唯一不能动的是从wasp/client/auth导入并调用的这五个函数。
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 版本
使用 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> ) }挂载到路由
以上组件需要与main.wasp.ts中声明的路由对应。参考 email 认证配置文档 中的路由声明方式:
route("LoginRoute", "/login", page(Login, { authRequired: false })) route("SignupRoute", "/signup", page(Signup, { authRequired: false })) route("RequestPasswordResetRoute", "/request-password-reset", page(RequestPasswordReset, { authRequired: false })) route("PasswordResetRoute", "/password-reset", page(PasswordReset, { authRequired: false })) route("EmailVerificationRoute", "/email-verification", page(EmailVerification, { authRequired: false }))API 参考:wasp/client/auth五个 action 详解
以下所有函数均从wasp/client/auth导入。它们的参数契约如下。
login()
用于登录用户的 action。调用参数:
data: object(必填)email: string(必填)password: string(必填)
成功后务必做重定向(例如跳转到应用主页)。注意:如果用户邮箱尚未验证,登录会被拒绝,详见下文“登录与注册的内置防护”。
signup()
用于注册用户并发起邮箱验证的 action。注册成功后用户不会被自动登录,因为还需要完成邮箱验证。
调用参数:
data: object(必填)email: string(必填)password: string(必填)
:::info 关于额外注册字段 默认情况下,Wasp 只会保存email和password两个字段。如果要在注册流程中收集额外字段(如用户名、地址等),请阅读 自定义注册流程:通过auth.methods.email.userSignupFields定义字段提取函数(如defineUserSignupFields),并在自建注册表单中一并提交这些字段。 :::
verifyEmail()
用于将邮箱标记为已验证、将用户账户标记为激活的 action。成功后务必做重定向(例如跳转到登录页)。
调用参数:
data: object(必填)token: string(必填)——注册时生成的令牌,会以名为token的 URL Query Parameter 形式出现在验证链接中。
requestPasswordReset()
用于请求发送密码重置邮件的 action。注意它不会立即重置密码,只是发送邮件。
调用参数:
data: object(必填)email: string(必填)
resetPassword()
用于确认密码重置并提供新密码的 action。成功后务必做重定向(例如跳转到登录页)。
调用参数:
data: object(必填)token: string(必填)——请求重置时生成的令牌,会以名为token的 URL Query Parameter 形式出现在重置链接中。password: string(必填)——用户的新密码,需满足默认密码校验规则。
底层实现:这些 action 在生成代码中做了什么
为了确保自建 UI 的正确用法,理解这些客户端 action 的底层实现很有帮助。Wasp 在生成阶段会为每个 action 生成对应的客户端包装代码,模板位于waspc/data/Generator/templates/sdk/wasp/auth/email/actions/。
例如 login.ts 的实现表明:login内部会向 Wasp 生成的登录端点发起POST请求,并从响应中取出sessionId,随后调用initSession(sessionId)在客户端初始化会话(即写入会话状态),最后通过handleApiError把后端错误包装为可读异常。因此,成功login之后导航到受保护页面是安全的——会话已经就绪。
再如 signup.ts:
export async function signup(data: EmailSignupData): Promise<{ success: boolean }> { try { const { success } = await api.post('{= signupPath =}', { json: data, }).json(SuccessResponseSchema); return { success }; } catch (e) { throw handleApiError(e); } }可以看到signup仅向后端提交数据并返回{ success: boolean },不会初始化会话——这印证了文档中“注册后用户不会被登录”的说明。同理,verifyEmail.ts 与 passwordReset.ts 中的requestPasswordReset/resetPassword也都是简单的POST+ 响应校验模式,resetPassword同样不会自动建立会话,因此成功后要引导用户去登录。
当这些函数被调用时,它们会抛出经过封装的错误对象(含message字段),所以自建表单中统一用try/catch捕获并展示error.message即可。
token 的来源与传递约定
两个需要token的流程(邮箱验证、密码重置)有一个共同约定:邮件中的链接会把token作为 URL 查询参数带到你在main.wasp.ts中为auth.methods.email.emailVerification.clientRoute/auth.methods.email.passwordReset.clientRoute指定的客户端路由上。
因此自建页面中需要用以下方式读取:
const token = new URLSearchParams(window.location.search).get('token')拿到token后分别调用verifyEmail({ token })或resetPassword({ token, password })。如果 URL 中没有token,示例代码会抛出'Token not found in URL'错误并显示在页面上——这在用户直接访问验证/重置页面而没有经过邮件链接时会发生,属于合理的防御性处理。
登录与注册的内置防护(自建 UI 同样生效)
自建 UI 只是替换了前端界面,后端行为保持不变。因此以下内置策略依然生效(详见 email 认证文档):
- 速率限制:注册与密码重置请求按邮箱地址限制为每分钟 1 次,防止滥用;
- 防止邮箱泄露:如果有人用已注册且已验证的邮箱再次注册,后端会假装注册成功而不是提示“该邮箱已存在”,避免泄露邮箱是否被注册;
- 允许未验证邮箱重新注册:用户用已存在但未验证的邮箱注册时,系统允许其重新注册,防止恶意用户抢占/锁定他人的邮箱;
- 密码校验:满足默认密码规则(至少 8 位且含数字),详见 auth 概述;
- 未验证邮箱禁止登录:邮箱未验证前登录会被拒绝。
自建 UI 中也要相应设计好提示文案——例如注册成功后提示“请查收验证邮件”,与后端的“防泄露”策略保持一致,避免给出“账号已存在”这类会泄露信息的错误提示。
开发调试技巧
在开发模式下,如果不希望每次注册都走完整的邮件验证流程,可以在.env.server中设置:
SKIP_EMAIL_VERIFICATION_IN_DEV=true这对日常开发调试以及编写自动化测试非常有用。生产环境中请勿设置此变量。
同时,为快速验证验证邮件/重置邮件是否发送,可在main.wasp.ts中把emailSender.provider配置为Dummy,它不会真正发信,而是把邮件内容打印到控制台,方便你复制邮件中的验证/重置链接进行端到端测试。
与预置 Auth UI 的对比与参考
Wasp 预置的 Auth UI 组件(LoginForm、SignupForm、VerifyEmailForm、ForgotPasswordForm、ResetPasswordForm)与自建 UI 是可以混用的——例如自定义注册页、但登录页继续用预置组件。
如果你想参考官方 starter 中预置组件的用法,可以查看仓库中的waspc/data/Cli/starters/basic/src/auth/email/目录,其中 LoginPage.tsx、SignupPage.tsx、EmailVerificationPage.tsx、RequestPasswordResetPage.tsx、PasswordResetPage.tsx 展示了如何用最少的代码把预置表单挂到页面上(例如SignupPage通过additionalFields给SignupForm增加自定义字段)。对比可见:自建 UI 只是把LoginForm替换为你自己的<form>,把底层调用的函数从组件内部搬到你的handleSubmit中——这正是本指南示例代码所做的事情。
小结
自建邮箱认证 UI 的本质是“界面自绘、动作复用”:把login、signup、verifyEmail、requestPasswordReset、resetPassword五个 action 作为与 Wasp 认证后端交互的唯一入口,在其上构建任意风格的 React 表单。掌握好每个 action 的参数契约、成功后重定向的时机、token的 URL 传递约定,以及后端内置的安全策略,你就能在完全自定义视觉体验的同时,安全、正确地复刻 Wasp 邮箱认证的全部能力。
【免费下载链接】waspThe batteries-included full-stack framework for the AI era. Develop JS/TS web apps (React, Node.js, and Prisma) using declarative code that abstracts away complex full-stack features like auth, background jobs, RPC, email sending, end-to-end type safety, single-command deployment, and more.项目地址: https://gitcode.com/GitHub_Trending/wa/wasp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考