我们需要做什么?
- 创建一个页面,用户可以请求重置密码,并输入他们的电子邮件地址。
- 在登录页面添加一个链接,指向该重置密码页面。
- 向用户的电子邮件地址发送包含令牌的邮件。
- 创建一个页面,用户可以设置新密码。
注意:我们在这里不会使用 NextAuth,因为不需要它。
注意2:我们将确保用户只能在登出状态下请求密码重置。
本章代码可在 GitHub 上找到,分支为 forgotpassword
。
https://github.com/peterlidee/NNAS/tree/forgotpassword
1. 请求密码重置
这与我们在上一章中创建的请求电子邮件确认页面非常相似。下面是 Strapi 的端点:
const strapiResponse: any = await fetch(
process.env.STRAPI_BACKEND_URL + '/api/auth/forgot-password',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
cache: 'no-cache',
}
);
顺便提一下,这些端点从哪里来?Strapi 是开源的,我们可以查看源代码。这些端点都来自“Users and Permissions”插件。如果你在 GitHub 上浏览 Strapi 的代码,最终你会找到 auth.js
文件,其中包含了所有的路由。如果你有兴趣,还可以找到 Strapi 的控制器。
接下来我们创建一个页面、一个组件和一个服务器操作:
// frontend/src/app/(auth)/password/requestreset/page.tsx
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
import RequestPasswordReset from '@/components/auth/password/RequestPasswordReset';
import { authOptions } from '@/app/api/auth/[...nextauth]/authOptions';
export default async function RequestResetPage() {
const session = await getServerSession(authOptions);
if (session) redirect('/account');
return <RequestPasswordReset />;
}
注意,我们在页面层面上进行了保护。如果用户已登录,我们将其重定向到 /account
。我们将在后面构建该页面。为什么我们在页面层面进行操作?因为我在组件内尝试时出现了一些错误,例如:Warning: Cannot update a component ('Router') while rendering a different component.
在我们的服务器操作中,我们使用 Zod 验证 formData
,向 Strapi 发送请求并处理错误。在这种情况下,我们不会在成功时重定向,而是返回一个成功对象 { error: false, message: 'Success' }
,并在表单组件中处理该成功状态。以下是 requestPasswordResetAction
的代码:
// frontend/src/components/auth/password/requestPasswordResetAction.ts
'use server';
import { z } from 'zod';
import { RequestPasswordResetFormStateT } from './RequestPasswordReset';
const formSchema = z.object({
email: z.string().email('请输入有效的电子邮件地址。').trim(),
});
export default async function requestPasswordResetAction(
prevState: RequestPasswordResetFormStateT,
formData: FormData
) {
const validatedFields = formSchema.safeParse({
email: formData.get('email'),
});
if (!validatedFields.success) {
return {
error: true,
message: '请检查您的输入。',
fieldErrors: validatedFields.error.flatten().fieldErrors,
};
}
const { email } = validatedFields.data;
try {
const strapiResponse: any = await fetch(
process.env.STRAPI_BACKEND_URL + '/api/auth/forgot-password',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
cache: 'no-cache',
}
);
const data = await strapiResponse.json();
if (!strapiResponse.ok) {
const response = {
error: true,
message: '',
};
const contentType = strapiResponse.headers.get('content-type');
if (contentType === 'application/json; charset=utf-8') {
const data = await strapiResponse.json();
response.message = data.error.message;
} else {
response.message = strapiResponse.statusText;
}
return response;
}
return {
error: false,
message: 'Success',
};
} catch (error: any) {
return {
error: true,
message: 'message' in error ? error.message : error.statusText,
};
}
}
最后,在实际的表单组件中,我们监听 useFormState
中的成功状态,并显示成功消息。否则,返回表单。还请注意,我们更新了类型,以便处理可能的成功对象。其余部分应该比较清楚。
// frontend/src/components/auth/password/ForgotPassword.tsx
'use client';
import { useFormState } from 'react-dom';
import PendingSubmitButton from '../PendingSubmitButton';
import requestPasswordResetAction from './requestPasswordResetAction';
type InputErrorsT = {
email?: string[];
};
type NoErrorFormStateT = {
error: false;
message?: string;
};
type ErrorFormStateT = {
error: true;
message: string;
inputErrors?: InputErrorsT;
};
export type RequestPasswordResetFormStateT =
| NoErrorFormStateT
| ErrorFormStateT;
const initialState: NoErrorFormStateT = {
error: false,
};
export default function ForgotPassword() {
const [state, formAction] = useFormState<
RequestPasswordResetFormStateT,
FormData
>(requestPasswordResetAction, initialState);
if (!state.error && state.message === 'Success') {
return (
<div className='bg-zinc-100 rounded-sm px-4 py-8 mb-8'>
<h2 className='font-bold text-lg mb-4'>检查您的电子邮件</h2>
<p>
我们向您发送了一封包含链接的邮件。打开此链接以重置您的密码。请注意,链接会过期...
</p>
</div>
);
}
return (
<div className='mx-auto my-8 p-8 max-w-lg bg-zinc-100 rounded-sm'>
<h2 className='text-center text-2xl text-blue-400 mb-8 font-bold'>
请求重置密码
</h2>
<p className='mb-4'>
忘记密码了?在此输入您的账户邮箱,我们将向您发送一个用于重置密码的链接。
</p>
<form action={formAction} className='my-8'>
<div className='mb-3'>
<label htmlFor='email' className='block mb-1'>
电子邮件 *
</label>
<input
type='email'
id='email'
name='email'
required
className='bg-white border border-zinc-300 w-full rounded-sm p-2'
/>
{state.error && state?.inputErrors?.email ? (
<div className='text-red-700' aria-live='polite'>
{state.inputErrors.email[0]}
</div>
) : null}
</div>
<div className='mb-3'>
<PendingSubmitButton />
</div>
{state.error && state.message ? (
<div className='text-red-700' aria-live='polite'>
{state.message}
</div>
) : null}
</form>
</div>
);
}
2. 添加重置密码链接
用户在登出状态下需要能够访问我们刚创建的页面。我们保持简单,在登录表单的提交按钮旁边添加一个忘记密码的链接。
3. 从 Strapi 发送忘记密码邮件
要发送邮件,我们首先需要添加一个设置:
Settings > Users & Permissions plugin > Advanced settings > Reset password page
我们将此字段设置为 http://localhost:3000/password/reset
。
接着,我们需要更新 Strapi 邮件模板:
Settings > Users & Permissions plugin > Email templates > Reset password
你应该修改发送者名称、电子邮件和主题。邮件的正文默认如下:
<p>我们听说您忘记了密码。对不起!</p>
<p>但是别担心!您可以使用以下链接重置密码:</p>
<p><%= URL %>?code=<%= TOKEN %></p>
<p>谢谢。</p>
其中 <%= URL %>?code=<%= TOKEN %>
解析为 http://localhost:3000/password/reset?code=***somecode***
,正如我们所预期的。我们只需要更新这一行,使其实际成为一个链接:
<p><a href="<%= URL %>?code=<%= TOKEN %>">重置密码链接</a></p>
然后保存。
4. 构建密码重置页面
现在我们需要实际构建此页面。要重置密码,我们需要一个包含两个字段的表单:password
和 confirm password
。但是,Strapi 端点需要三个值:password
、confirm password
以及令牌(code
查询参数)。成功后,Strapi 将返回一个用户
对象和一个新的 Strapi 令牌。我们稍后会处理这个问题。首先构建页面:
// frontend/src/app/(auth)/password/reset/page.tsx
import { authOptions } from '@/app/api/auth/[...nextauth]/authOptions';
import { getServerSession } from 'next-auth';
import ResetPassword from '@/components/auth/password/ResetPassword';
import { redirect } from 'next/navigation';
type Props = {
searchParams: {
code?: string,
},
};
export default async function page({ searchParams }: Props) {
const session = await getServerSession(authOptions);
if (session) redirect('/account');
return <ResetPassword code={searchParams.code} />;
}
注意,我们不允许已登录用户访问此页面,并且将 code
(重置密码令牌)传递给实际组件。
我们将使用一个服务器操作,该操作返回成功或错误对象,但不会重定向。然而,这带来了一个直接的问题。我们如何将 code
prop 从表单组件传递到服务器操作?这很容易解决。我们只需将其放入 useFormState
的初始状态中:
const initialState{
error: false,
code: code || '',
};
const [state, formAction] = useFormState(
resetPasswordAction,
initialState
);
我们的服务器操作 resetPasswordAction
可以通过 prevState
参数访问该状态:
export default async function resetPasswordAction(prevState, formData) {
// make strapi request passing prevState.code
}
但是,这又带来了另一个问题。假设用户输入错误,密码确认值不匹配。Strapi 会检测到这一点并返回错误对象。我们在服务器操作中捕获此错误(!strapiResponse.ok
),然后从服务器操作返回一个错误对象到我们的表单中。
useFormState
的状态等于服务器操作的返回值。此时 code
值在哪里?它消失了,除非我们从服务器操作返回它。如果我们从服务器操作返回这个:
return {
error: true,
message: '有些地方不对',
code: prevState.code,
};
那么表单中的状态将会被重置为包含这个 code
值。
想象一下,如果我们没有从服务器操作返回 code
,我们的用户只会收到一条错误消息:密码不匹配。他修复了此错误并重新提交表单。表单调用 formAction
,useFormState
捕获该调用并使用其状态和 formData
调用 resetPasswordAction
。此时的状态是什么:{ error: true, message: '有些地方不对' }
(没有 code
属性)。
我们的服务器操作将继续进行并尝试调用 Strapi。从 formData
中我们可以获取 password
和 confirm password
,但 Strapi 还需要 code
,我们无法提供它。因为它不再在状态中了!Strapi 将报错:需要 code
。
因此,我们需要在每次返回错误时从服务器操作传递 code
。当没有错误时,表单将不会被再次调用,因此我们不再需要它。但我们确实需要在每次返回错误时包含它!包括例如 Zod 错误。希望这能理解。
继续看表单组件:
// frontend/src/components/auth/password/resetPassword.tsx
'use client';
import { useFormState } from 'react-dom';
import resetPasswordAction from './resetPasswordAction';
import Link from 'next/link';
import PendingSubmitButton from '../PendingSubmitButton';
type Props = {
code: string | undefined;
};
type InputErrorsT = {
password?: string[];
passwordConfirmation?: string[];
};
export type ResetPasswordFormStateT = {
error: boolean;
message?: string;
inputErrors?: InputErrorsT;
code?: string;
};
export default function ResetPassword({ code }: Props) {
const initialState: ResetPasswordFormStateT = {
error: false,
code: code || '',
};
const [state, formAction] = useFormState<ResetPasswordFormStateT, FormData>(
resetPasswordAction,
initialState
);
if (!code) return <p>错误,请使用我们发送给您的链接。</p>;
if (!state.error && 'message' in state && state.message === 'Success') {
return (
<div className='bg-zinc-100 rounded-sm px-4 py-8 mb-8'>
<h2 className='font-bold text-lg mb-4'>密码已重置</h2>
<p>
您的密码已重置。现在可以使用新密码{' '}
<Link href='/signin' className='underline'>
登录
</Link>。
</p>
</div>
);
}
return (
<div className='mx-auto my-8 p-8 max-w-lg bg-zinc-100 rounded-sm'>
<h2 className='text-center text-2xl text-blue-400 mb-8 font-bold'>
重置您的密码
</h2>
<p className='mb-4'>
要重置您的密码,请输入新密码,再次确认输入后点击发送。
</p>
<form action={formAction} className='my-8'>
<div className='mb-3'>
<label htmlFor='password' className='block mb-1'>
密码 *
</label>
<input
type='password'
id='password'
name='password'
required
className='bg-white border border-zinc-300 w-full rounded-sm p-2'
/>
{state.error && state?.inputErrors?.password ? (
<div className='text-red-700' aria-live='polite'>
{state.inputErrors.password[0]}
</div>
) : null}
</div>
<div className='mb-3'>
<label htmlFor='passwordConfirmation' className='block mb-1'>
确认密码 *
</label>
<input
type='password'
id='passwordConfirmation'
name='passwordConfirmation'
required
className='bg-white border border-zinc-300 w-full rounded-sm p-2'
/>
{state.error && state?.inputErrors?.passwordConfirmation ? (
<div className='text-red-700' aria-live='polite'>
{state.inputErrors.passwordConfirmation[0]}
</div>
) : null}
</div>
<div className='mb-3'>
<PendingSubmitButton />
</div>
{state.error && state.message ? (
<div className='text-red-700' aria-live='polite'>
错误:{state.message}
</div>
) : null}
</form>
</div>
);
}
需要注意的是,我们检查是否有 code
(令牌),以及 state
是否包含成功消息。如果没有,我们就显示表单。唯一不同的是,这次我们没有使用辨别联合类型(discriminated union Type)。由于 code
属性的问题,这样做比较困难。因此,我们选择了一个更简单的类型,其中大多数属性都是可选的。这样做是正确的,但不如之前的类型那么具体。
我们的服务器操作:
// frontend/src/component/auth/password/resetPasswordAction.ts
'use server';
import { z } from 'zod';
import { ResetPasswordFormStateT } from './ResetPassword';
import { StrapiErrorT } from '@/types/strapi/StrapiError';
const formSchema = z.object({
password: z.string().min(6).max(30).trim(),
passwordConfirmation: z.string().min(6).max(30).trim(),
});
export default async function resetPasswordAction(
prevState: ResetPasswordFormStateT,
formData: FormData
) {
const validatedFields = formSchema.safeParse({
password: formData.get('password'),
passwordConfirmation: formData.get('passwordConfirmation'),
});
if (!validatedFields.success) {
return {
error: true,
message: '请检查您的输入。',
inputErrors: validatedFields.error.flatten().fieldErrors,
code: prevState.code,
};
}
const { password, passwordConfirmation } = validatedFields.data;
try {
const strapiResponse: any = await fetch(
process.env.STRAPI_BACKEND_URL + '/api/auth/reset-password',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
password,
passwordConfirmation,
code: prevState.code,
}),
cache: 'no-cache',
}
);
if (!strapiResponse.ok) {
const response = {
error: true,
message: '',
code: prevState.code,
};
const contentType = strapiResponse.headers.get('content-type');
if (contentType === 'application/json; charset=utf-8') {
const data: StrapiErrorT = await strapiResponse.json();
response.message = data.error.message;
} else {
response.message = strapiResponse.statusText;
}
return response;
}
return {
error: false,
message: 'Success',
};
} catch (error
: any) {
return {
error: true,
message: 'message' in error ? error.message : error.statusText,
code: prevState.code,
};
}
}
这一切都应该能理解,我们之前已经解释了在返回对象中包含 code
属性的重要性。
strapiResponse.ok
还有一件事。在成功时,我们并未实际使用 strapiResponse
。那么什么是成功的 strapiResponse
呢?一个用户对象和一个 Strapi JWT 令牌。哦,那我们能自动登录吗?也许可以。但存在一些问题:
- 我们省略了 NextAuth。因此,我们需要将 NextAuth 集成进来。
- 如何登录?使用 NextAuth 的
signIn
。但signIn
是一个仅限客户端的函数。我们有了用户对象,但在服务器端的服务器操作中。那么如何将用户对象从服务器传递到客户端呢?
我们将在最后一章中回到这个问题。
目前,在成功时,我们只是通过成功消息要求用户登录。这种模式可能不是最优的,但也是常见的。好的一面是,它确实有效!
总结
我们刚刚使用 Strapi 设置了忘记密码流程,并省略了 NextAuth。我们添加了一个请求密码重置页面,处理了发送邮件,最后创建了实际的密码重置页面。
一些小问题被轻松处理,整体过程相对简单。我们还有三个方面需要处理:
- 创建用户账户页面。
- 让用户在该页面内更改密码。
- 让用户在该页面内编辑他们的数据。
首发于公众号 大迁世界,欢迎关注。📝 每周一篇实用的前端文章 🛠️ 分享值得关注的开发工具 ❓ 有疑问?我来回答
本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。