本章的代码可在 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 默认发送的内容,但你绝不应该使用这个:

  1. 我们不想向前端用户暴露我们的后端 URL。
  2. 这个端点没有错误处理。当用户点击链接但失败时,用户只会看到一个纯粹的 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 有一个特定的错误:“您的帐户电子邮件未确认”。所以我们可以监听这个错误,然后显示“请求新的确认邮件”消息。

image.png

请求新确认邮件页面

这是 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:在测试这个流程时,我发现了这个错误消息:

image.png

这里发生的情况是,用户已经被验证,Strapi 然后返回一个带有消息 "Already confirmed" 的错误对象。这不好,因为它确认数据库中有一个使用该电子邮件的用户,而我们不希望这样。

为了解决这个问题,我们监听此错误并在发生时不做任何操作。然后该函数将完成 try-catch 块并继续重定向。换句话说,当用户已经被确认时,即使我们没有实际发送新的电子邮件,我们也会将用户重定向到 /confirmation/message

这不是最优的,但应该足够了。已经被确认的用户非常不可能偶然访问此页面。我们甚至可以保护此组件,以便已登录的用户收到“已经确认”的消息或类似的内容。我将这部分留给你来决定。

注意事项2:此路由可能会受到垃圾请求的攻击。有人可能会连续提交请求,导致系统发送大量电子邮件。这也是你可能需要防范的事情。

总结

这就是我们账户/电子邮件确认流程所需的一切。在注册时:

  1. 用户会收到一封带有前端 URL + 确认令牌的电子邮件:/confirmation/submit?confirmation=***
  2. 用户会被重定向到确认消息页面:“验证您的电子邮件”:/confirmation/message
  3. 点击邮件中的链接后,用户访问:/confirmation/submit?confirmation=***
  4. 此服务器组件使用令牌调用 Strapi,处理错误并在成功时要求用户登录。
  5. 我们还添加了请求新确认邮件的选项。用户输入他的电子邮件,Strapi 发送邮件(如果邮件在数据库中),然后用户再次进入步骤2。

到此为止,我们已经处理了登录、注册和电子邮件确认的流程。接下来的章节将处理忘记密码的流程。之后,我们还需要实现修改密码和编辑用户信息的流程。

首发于公众号 大迁世界,欢迎关注。📝 每周一篇实用的前端文章 🛠️ 分享值得关注的开发工具 ❓ 有疑问?我来回答

本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。


王大冶
68k 声望104.9k 粉丝