Wasp 邮箱认证自定义 UI 实战:用wasp/client/auth从零构建登录、注册、邮箱验证与密码重置页面
【免费下载链接】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 全栈框架中邮箱(Email)认证的自定义 UI 方案:当你不再满足于框架预置的 Auth UI,希望完全掌控登录、注册、邮箱验证、忘记密码等页面的外观与交互时,可以直接在自己的 React 代码中调用wasp/client/auth暴露的login、signup、verifyEmail、requestPasswordReset、resetPassword五个认证动作,像 Auth UI 内部所做的那样手动驱动整套认证流程。读完本文,你将掌握这五个动作的完整签名、token 在 URL 中的传递约定、自定义认证页面的前后端配置,以及如何在此基础上扩展注册字段与邮件内容。
为什么需要自定义认证 UI
Wasp 为邮箱认证提供了开箱即用的 Auth UI 组件(LoginForm、SignupForm、VerifyEmailForm、ForgotPasswordForm、ResetPasswordForm),几行代码就能铺好完整的登录/注册页面。但真实业务中经常出现以下诉求:
- 登录表单需要与产品视觉体系完全一致,而不是受限于预制组件的样式;
- 注册流程需要额外的业务字段(如地址、昵称)或自定义校验提示;
- 邮箱验证、密码重置页面需要特殊的落地文案与交互;
- 需要在登录/注册动作前后插入自定义逻辑(埋点、跳转、二次确认)。
此时可以放弃预制组件,改为在客户端代码中自行构建 UI,并直接调用 Wasp 生成并暴露的认证动作。Wasp 的服务端实现、邮件模板与安全逻辑(验证邮箱后才能登录、密码重置、防邮箱泄露等)完全保留,你只需要负责“壳”。
提示:在使用邮箱认证时,Wasp 默认有一套邮箱与密码校验规则,自定义 UI 同样遵循这些规则,详见 认证总览中的默认校验规则。
前置条件:在main.wasp中启用邮箱认证
自定义 UI 不改变服务端配置方式。要在项目中使用邮箱认证并让上述动作可用,需要先在main.wasp中完成如下声明(以 0.19 版本文档 email.md 中的配置为准):
app myApp { wasp: { version: "^0.19.0" }, title: "My App", auth: { // 1. 指定用户实体(需在 schema.prisma 中定义 User) userEntity: User, methods: { // 2. 启用邮箱认证 email: { // 3. 指定发件人信息 fromField: { name: "My App Postman", email: "hello@itsme.com" }, // 4. 指定邮箱验证与密码重置要跳转的客户端路由 emailVerification: { clientRoute: EmailVerificationRoute, }, passwordReset: { clientRoute: PasswordResetRoute, }, }, }, onAuthFailedRedirectTo: "/login", onAuthSucceededRedirectTo: "/" }, // 5. 配置邮件发送器(Dummy 只把邮件打到控制台,方便本地开发) emailSender: { provider: Dummy, } }其中clientRoute字段指向你在main.wasp中定义的路由,这些路由将承载我们稍后自建的认证页面:
route LoginRoute { path: "/login", to: LoginPage } page LoginPage { component: import { Login } from "@src/pages/auth" } route SignupRoute { path: "/signup", to: SignupPage } page SignupPage { component: import { Signup } from "@src/pages/auth" } route RequestPasswordResetRoute { path: "/request-password-reset", to: RequestPasswordResetPage } page RequestPasswordResetPage { component: import { RequestPasswordReset } from "@src/pages/auth", } route PasswordResetRoute { path: "/password-reset", to: PasswordResetPage } page PasswordResetPage { component: import { PasswordReset } from "@src/pages/auth", } route EmailVerificationRoute { path: "/email-verification", to: EmailVerificationPage } page EmailVerificationPage { component: import { EmailVerification } from "@src/pages/auth", }完成上述声明后,执行wasp db migrate-dev再运行wasp start,即可在本地跑通整套邮箱认证流程。开发期间若不想每次注册都走邮箱验证,可在.env.server中设置SKIP_EMAIL_VERIFICATION_IN_DEV=true跳过验证步骤。
完整示例:自建五个认证页面
下面的代码覆盖登录、注册、邮箱验证、请求重置密码、重置密码五个完整流程,是官方文档提供的自建 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' // 用户登录页面 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> ) } // 用户注册页面 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> ) } // 用户点击邮件中的验证链接后展示的页面 export function EmailVerification() { const [error, setError] = useState(null) const navigate = useNavigate() async function handleClick() { setError(null) try { // token 以 URL 查询参数的形式传入 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> </> ) } // 用户请求重置密码页面 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> ) } // 用户点击邮件中的重置链接后展示的页面 export function PasswordReset() { const [error, setError] = useState(null) const [newPassword, setNewPassword] = useState('') const navigate = useNavigate() async function handleSubmit(event) { event.preventDefault() setError(null) try { // token 以 URL 查询参数的形式传入 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' // 用户登录页面 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> ) } // 用户注册页面 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> ) } // 用户点击邮件中的验证链接后展示的页面 export function EmailVerification() { const [error, setError] = useState<Error | null>(null) const navigate = useNavigate() async function handleClick() { setError(null) try { // token 以 URL 查询参数的形式传入 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> </> ) } // 用户请求重置密码页面 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> ) } // 用户点击邮件中的重置链接后展示的页面 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 { // token 以 URL 查询参数的形式传入 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> ) }五个认证动作逐一拆解
从wasp/client/auth可以导入五个认证动作,它们的共同点是:都返回 Promise,失败时抛出异常(示例代码统一用 try/catch 捕获并渲染error.message);成功后的页面跳转(navigate(...))必须由你自行处理。
login({ email, password })
登录动作。唯一参数data对象包含两个必填字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
email | string | 是 | 用户注册时使用的邮箱 |
password | string | 是 | 用户密码 |
成功后 Wasp 会建立会话,此时需要主动重定向(示例中跳转到主页/)。注意:如果邮箱尚未验证,登录会被拒绝,这与 Wasp“默认要求邮箱验证后才能登录”的策略一致。
signup({ email, password })
注册动作,同时触发邮箱验证流程。参数同样为{ email, password }两个必填字段。与直觉不同,注册成功后用户并不会处于登录状态,因为账户还未验证,仍需用户点击验证邮件中的链接。示例代码在成功后切换为“请查收邮件”的确认提示(needsConfirmation状态)。
Wasp 默认只保存email和password两个字段。若需在注册时收集额外字段(如昵称、地址),需要结合userSignupFields自定义注册流程,详见 认证总览的注册流程定制。
verifyEmail({ token })
邮箱验证动作。参数data包含一个必填字段token: string。这个 token 是注册时由服务端生成的,并会被放入验证邮件链接的 URL 查询参数中(参数名固定为token),因此客户端需要从window.location.search解析出来再传给动作。验证成功后账户被激活,示例代码随后跳转回主页。
requestPasswordReset({ email })
请求密码重置动作。它只负责“发送重置邮件”,并不会立即重置密码。参数为必填的email: string。发送成功后同样建议展示“请查收邮件”之类的确认文案。出于防泄露考虑,即使邮箱不存在,服务端也会返回与成功一致的响应。
resetPassword({ token, password })
确认密码重置并设置新密码。参数包含两个必填字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
token | string | 是 | 请求重置时生成、放在邮件链接 URL 查询参数token中的令牌 |
password | string | 是 | 用户设置的新密码 |
成功后应重定向到登录页,让用户用新密码登录。
从仓库示例看两种实现路线
官方仓库提供了两套风格迥异的认证页面实现,可作为自建 UI 的参考样板:
路线一:基于预制组件的“轻定制”。仓库 examples/kitchen-sink/src/features/auth/pages/Login.tsx 与 Signup.tsx 仍然使用LoginForm、SignupForm等组件,但通过customisationProps注入自定义 props,并在表单下方用Link自由编排“去注册/找回密码”的入口;注册表单甚至通过additionalFields追加了地址字段与自定义校验提示,展示了“不重写全部代码”的折中方案。
路线二:完全手动调用动作。仓库 examples/kitchen-sink/src/features/auth/pages/ManualSignupPage.tsx 展示了不使用任何预制表单组件的写法:用react-hook-form接管表单状态,wasp/client/auth只导入signup动作与FormInput、FormLabel、FormError、FormItemGroup等底层表单构件,提交时直接await signup(data),并按返回结果区分成功/失败提示:
const onSubmit = handleSubmit(async (data) => { try { const result = await signup(data); if (result.success) { setMessage({ type: "success", text: "You've signed up successfully! Check your email for the confirmation link.", }); } else { setMessage({ type: "error", text: "Signup failed. Please try again." }); } } catch (error) { const { message, data } = error.data; setMessage({ type: "error", text: `${message}: ${data.message}` }); } });同样的模式也体现在 examples/kitchen-sink/src/features/auth/pages/EmailVerification.tsx、RequestPasswordReset.tsx 与 PasswordReset.tsx 中:这几个页面仍以VerifyEmailForm、ForgotPasswordForm、ResetPasswordForm为骨架,但配合自建的布局容器与“去登录”链接完成了对默认体验的定制。此外,examples/tutorials/TodoApp/src/LoginPage.jsx 给出了最精简的预制组件接入方式,适合快速对比理解。
进阶:让自建 UI 与框架能力完全对齐
自建 UI 只是替换了“表现层”,Wasp 服务端的认证能力与安全默认行为全部保留,理解这些行为有助于你写出体验一致的页面:
- 注册限流:服务端对每个邮箱地址的注册请求限流为 1 次/分钟,防止垃圾注册;
- 防邮箱泄露:用已存在且已验证的邮箱注册时,服务端会“假装”创建成功而不是提示账户已存在;请求未知邮箱重置密码时也返回同样的成功响应;
- 未验证邮箱可重复注册:若邮箱存在但未验证,允许再次注册,防止恶意用户用他人邮箱抢占注册;
- 密码校验:默认密码强度规则由认证总览统一定义,可覆盖,参见 认证总览。
定制注册字段:若signup()需要携带额外字段,先在main.wasp中声明auth.methods.email.userSignupFields指向@src/auth下的导出,再在src/auth/signup.{js,ts}中用defineUserSignupFields定义每个字段的取值函数,详见 认证总览的注册字段定制。
定制邮件内容:验证邮件与重置邮件的文案/HTML 可通过emailVerification.getEmailContentFn与passwordReset.getEmailContentFn自定义。两个函数分别接收{ verificationLink }与{ passwordResetLink },返回{ subject, text, html },完整的配置与代码示例见 email.md 的邮箱验证流程。
小结
自建邮箱认证 UI 的关键,是把wasp/client/auth的五个动作当作“提交按钮背后的黑盒”:你负责表单、校验提示、错误展示与跳转,Wasp 负责密码散列、token 生成、邮件发送与账户激活等安全细节。结合本文的完整示例代码、API 参数表与仓库中的两套参考实现,你可以快速打造一套既贴合产品设计、又不丢失框架安全能力的认证体验。
【免费下载链接】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),仅供参考