本章的代码可在 GitHub 上找到:分支 emailconfirmation
。
https://github.com/peterlidee/NNAS/tree/emailconfirmation
我们已经完成了一个完整的注册表单。当用户使用该表单注册时,他们会被添加到 Strapi 中。由于我们在本系列开始时的设置,Strapi 实际上已经在发送邮件,但我们需要对其进行配置。但首先,我们需要创建一个页面,用户在成功注册后会被发送到该页面。
确认消息
我们创建一个页面和一个组件:
// frontend/src/app/(auth)/confirmation/message/page.tsx
import ConfirmationMessage from '@/components/auth/confirmation/ConfirmationMessage';
export default function page() {
return <ConfirmationMessage />;
}
// frontend/src/components/auth/confirmation/ConfirmationMessage.tsx
export default function ConfirmationMessage() {
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>
);
}
在 Strapi 中设置邮件
Strapi 预设了一些用于帐户/邮件验证的邮件模板。前往 Strapi 管理界面:
Settings > Users & Permissions plugin > Email templates
然后点击“email address verification”。在实际应用中,我们应该设置所有的字段,如发送者和名称等,但我们现在关注的是消息文本框。默认情况下,它的内容是:
<p>Thank you for registering!</p>
<p>You have to confirm your email address. Please click on the link below.</p>
<p><%= URL %>?confirmation=<%= CODE %></p>
<p>Thanks.</p>
显然,这就是我们的邮件正文。我们想更新这行 <%= URL %>?confirmation=<%= CODE %>
,它解析成这个 URL:
http://localhost:1337/api/auth/email-confirmation?confirmation=3708c50f7ca98ef2654s89z46
URL
是我们的后端 Strapi URL 加上 Strapi 邮件确认端点:/api/auth/email-confirmation
。Strapi 向这个 URL 添加了一个查询参数:?confirmation=123
。这是邮件确认令牌。所以这是 Strapi 默认发送的内容,但你绝不应该使用这个:
- 我们不想向前端用户暴露我们的后端 URL。
- 这个端点没有错误处理。当用户点击链接但失败时,用户只会看到一个纯粹的 JSON 错误对象。
尽管如此,如果用户点击链接,它将会确认用户的电子邮件!让我们试试吧。我们打开了链接,被重定向到路由 /confirmEmail
。这个页面不存在,所以我们得到了 404 错误。但检查我们的 Strapi 后端时,我们发现用户现在的确认状态为 true
,之前是 false
。
因此,看起来我们有一个可以工作的 Strapi 端点来确认用户的电子邮件,但它的行为有些奇怪。成功时,它会重定向;错误时,它会返回 Strapi 错误消息。无论如何,我们不希望用户直接访问这个 URL!
计划
我们如何解决这个问题?我们将在前端创建一个新页面:/confirmation/submit
。我们会更新 Strapi 中的确认链接,使其指向这个页面。我们还会将确认令牌作为查询参数添加到这个页面。在此页面中,我们将处理端点调用。
在 Strapi 中更新邮件。我们只需要更新邮件模板中的实际链接为:
<p><a href="http://localhost:3000/confirmation/submit?confirmation=<%= CODE %>">确认您的电子邮件链接</a></p>
然后保存。还有一个设置需要调整:
Settings > Users & Permissions plugin > Advanced Settings > Redirection url
这控制了 Strapi 端点的重定向位置。我们不会使用这个设置,但我们会将其设置为 http://localhost:3000
。
邮件部分已经处理完毕。在注册时,我们向用户发送一封包含前端 URL 和确认令牌的邮件。快速测试确认一切正常。很好,现在我们来创建这个前端页面。
提交邮件确认
我们创建一个新页面和一个组件:
// frontend/src/app/(auth)/confirmation/submit/page.tsx
import ConfirmationSubmit from '@/components/auth/confirmation/ConfirmationSubmit';
type Props = {
searchParams: {
confirmation?: string,
},
};
export default async function page({ searchParams }: Props) {
return <ConfirmationSubmit confirmationToken={searchParams?.confirmation} />;
}
注意,我们从页面中获取 confirmation
查询参数并将其传递给组件!我们的组件代码如下:
// frontend/src/components/auth/confirmation/ConfirmationSubmit.tsx
import Link from 'next/link';
type Props = {
confirmationToken?: string;
};
export function Wrapper({ children }: { children: React.ReactNode }) {
return (
<div className='bg-zinc-100 rounded-sm px-4 py-8 mb-8'>{children}</div>
);
}
export default async function ConfirmationSubmit({ confirmationToken }: Props) {
if (!confirmationToken || confirmationToken === '') {
return (
<Wrapper>
<h2 className='font-bold text-lg mb-4'>错误</h2>
<p>令牌无效。</p>
</Wrapper>
);
}
// 发送邮件验证请求到 strapi 并等待响应
try {
const strapiResponse = await fetch(
`${process.env.STRAPI_BACKEND_URL}/api/auth/email-confirmation?confirmation=${confirmationToken}`
);
if (!strapiResponse.ok) {
let error = '';
const contentType = strapiResponse.headers.get('content-type');
if (contentType === 'application/json; charset=utf-8') {
const data = await strapiResponse.json();
error = data.error.message;
} else {
error = strapiResponse.statusText;
}
return (
<Wrapper>
<h2 className='font-bold text-lg mb-4'>错误</h2>
<p>错误:{error}</p>
</Wrapper>
);
}
// 成功,不做任何处理
} catch (error: any) {
return (
<Wrapper>
<h2 className='font-bold text-lg mb-4'>错误</h2>
<p>{error.message}</p>
</Wrapper>
);
}
return (
<Wrapper>
<h2 className='font-bold text-lg mb-4'>电子邮件已确认。</h2>
<p>
您的电子邮件已成功验证。您现在可以{' '}
<Link href='/login' className='underline'>
登录
</Link>
了。
</p>
</Wrapper>
);
}
这是一个非常简单的组件。首先我们检查是否存在 confirmationToken
。如果没有,我们直接返回一个简单的错误消息。接下来,我们使用我们的令牌调用 Strapi 端点。由于这是一个服务器组件,我们可以在函数组件内部完成此操作。
我们监听错误:!strapiResponse.ok
并将错误返回给用户。我们还将 API 调用包装在 try-catch
块中,以便再次返回错误。
成功时,Strapi 端点仍会重定向。但在我们的 strapiResponse
中,状态将为 ok
。因此,成功调用此端点后,strapiResponse.ok
仍然为真。当我们跳出 try-catch
块时,我们知道没有错误,因此返回成功消息。
成功消息确认用户的电子邮件已验证,并提示他们登录。这就是全部内容。我们现在已经完成了用户电子邮件的确认和错误处理。虽然这个 Strapi 端点的行为不太理想,但这就是 Strapi 提供的流程,我们只能使用它。
请求新确认邮件
事情可能会出错。也许用户没有找到邮件(可能在垃圾邮件文件夹中?),或者确认令牌已过期(我不知道它有效多久)。但这无关紧要。在某些情况下,你需要给用户一个机会,重新请求确认邮件。
这是一个用户体验问题。如何实现取决于你的设计。问题在于你何时何地给用户机会请求新的确认邮件。这很棘手,因为它可能会让用户感到困惑。我选择在两个地方显示此选项:
第一个是在确认消息中。当用户成功注册时,我们会将其重定向到一个确认页面(我们已向您发送一封邮件...)。让我们在那里添加一个链接,用户可以通过该链接请求新的确认邮件。我们稍后会构建此页面。
// frontend/src/components/auth/confirmation/ConfirmationMessage.tsx
import Link from 'next/link';
export default function ConfirmationMessage() {
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>
<h3 className='font-bold my-4'>没有找到邮件?</h3>
<p>
如果您没有收到确认链接的邮件,请检查您的垃圾邮件文件夹或等待几分钟。
</p>
<p>
仍未收到邮件?{' '}
<Link href='/confirmation/newrequest' className='underline'>
请求新的确认邮件。
</Link>
</p>
</div>
);
}
这还不够。可能用户已经关闭了此页面。所以我在登录页面添加了一个额外选项。假设用户没有找到或打开确认邮件,但尝试登录。Strapi 有一个特定的错误:“您的帐户电子邮件未确认”。所以我们可以监听这个错误,然后显示“请求新的确认邮件”消息。
请求新确认邮件页面
这是 Strapi 的端点:
const strapiResponse: any = await fetch(
process.env.STRAPI_BACKEND_URL + '/api/auth/send-email-confirmation',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
cache: 'no-cache',
}
);
这是一个 POST 请求,我们需要在 body 对象中发送电子邮件。响应非常有趣。Strapi 永远不会确认数据库中是否存在该电子邮件的用户。如果在请求中发送了电子邮件,则响应如下:
{
"email": "bob@example.com",
"sent": true
}
无论电子邮件是否存在于 Strapi 数据库中,响应都会是这样。这很好。如果请求中没有电子邮件或电子邮件无效,Strapi 会返回错误。所以仍有可能收到错误消息。
我们需要一个表单,让用户输入电子邮件并提交。它需要错误处理和加载状态。成功后,它会重定向到我们在注册时创建的 confirm/message
页面。我们不需要 NextAuth,所以可以再次使用服务器操作和 useFormState
。我们需要一个页面、一个组件和一个服务器操作:
// frontend/src/app/(auth)/confirmation/newrequest/page.tsx
import ConfirmationNewRequest from '@/components/auth/confirmation/ConfirmationNewRequest';
export default function page() {
return <ConfirmationNewRequest />;
}
// frontend/src/components/auth/confirmation/NewRequest.tsx
'use client';
import { useFormState } from 'react-dom';
import confirmationNewRequestAction from './confirmationNewRequestAction';
import PendingSubmitButton from '../PendingSubmitButton';
type InputErrorsT = {
email?: string[];
};
type InitialFormStateT = {
error: false;
};
type ErrorFormStateT = {
error: true;
message: string;
inputErrors?: InputErrorsT;
};
export type ConfirmationNewRequestFormStateT =
| InitialFormStateT
| ErrorFormStateT;
const initialState: InitialFormStateT = {
error: false,
};
export default function ConfirmationNewRequest() {
const [state, formAction] = useFormState<
ConfirmationNewRequestFormStateT,
FormData
>(confirmationNewRequestAction, initialState);
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>
);
}
最后是我们的服务器操作:
// frontend/src/components/auth/confirmation/ConfirmationNewrequestAction.ts
'use server';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { ConfirmationNewRequestFormStateT } from './ConfirmationNewRequest';
const formSchema = z.object({
email: z.string().email('请输入有效的电子邮件。').trim(),
});
export default async function confirmNewRequestAction(
prevState: ConfirmationNewRequestFormStateT,
formData: FormData
) {
const validatedFields = formSchema.safeParse({
email: formData.get('email'),
});
if (!validatedFields.success) {
return {
error: true,
inputErrors: validatedFields.error.flatten().fieldErrors,
message: '请核实您的数据。',
};
}
const { email } = validatedFields.data;
try {
const strapiResponse: any = await fetch(
process.env.STRAPI_BACKEND_URL + '/api/auth/send-email-confirmation',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
cache: 'no-cache',
}
);
// 处理 strapi 错误
if (!strapiResponse.ok) {
const response = {
error: true,
message: '',
};
// 检查响应是否为 JSON 格式
const contentType = strapiResponse.headers.get('content-type');
if (contentType === 'application/json; charset=utf-8') {
const data = await strapiResponse.json();
// 我们不希望确认电子邮件存在于 Strapi DB 中
// 但我们不能在 try catch 块内重定向
// 只有在这种情况下才返回响应
// 如果是这种情况,我们将继续重定向
if (data.error.message !== 'Already confirmed') {
response.message = data.error.message;
return response;
}
} else {
response.message = strapiResponse.statusText;
return response;
}
}
// 成功时我们在 try catch 块外重定向
} catch (error: any) {
// 网络错误或其他问题
return {
error: true,
message: 'message' in error ? error.message : error.statusText,
};
}
redirect('/confirmation/message');
}
这里没有新东西。我在上一章中解释了如何使用服务器操作和 useFormState
。
注意事项1:在测试这个流程时,我发现了这个错误消息:
这里发生的情况是,用户已经被验证,Strapi 然后返回一个带有消息 "Already confirmed" 的错误对象。这不好,因为它确认数据库中有一个使用该电子邮件的用户,而我们不希望这样。
为了解决这个问题,我们监听此错误并在发生时不做任何操作。然后该函数将完成 try-catch
块并继续重定向。换句话说,当用户已经被确认时,即使我们没有实际发送新的电子邮件,我们也会将用户重定向到 /confirmation/message
。
这不是最优的,但应该足够了。已经被确认的用户非常不可能偶然访问此页面。我们甚至可以保护此组件,以便已登录的用户收到“已经确认”的消息或类似的内容。我将这部分留给你来决定。
注意事项2:此路由可能会受到垃圾请求的攻击。有人可能会连续提交请求,导致系统发送大量电子邮件。这也是你可能需要防范的事情。
总结
这就是我们账户/电子邮件确认流程所需的一切。在注册时:
- 用户会收到一封带有前端 URL + 确认令牌的电子邮件:
/confirmation/submit?confirmation=***
- 用户会被重定向到确认消息页面:“验证您的电子邮件”:
/confirmation/message
- 点击邮件中的链接后,用户访问:
/confirmation/submit?confirmation=***
- 此服务器组件使用令牌调用 Strapi,处理错误并在成功时要求用户登录。
- 我们还添加了请求新确认邮件的选项。用户输入他的电子邮件,Strapi 发送邮件(如果邮件在数据库中),然后用户再次进入步骤2。
到此为止,我们已经处理了登录、注册和电子邮件确认的流程。接下来的章节将处理忘记密码的流程。之后,我们还需要实现修改密码和编辑用户信息的流程。
首发于公众号 大迁世界,欢迎关注。📝 每周一篇实用的前端文章 🛠️ 分享值得关注的开发工具 ❓ 有疑问?我来回答
本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。