我们需要做什么?

  1. 创建一个页面,用户可以请求重置密码,并输入他们的电子邮件地址。
  2. 在登录页面添加一个链接,指向该重置密码页面。
  3. 向用户的电子邮件地址发送包含令牌的邮件。
  4. 创建一个页面,用户可以设置新密码。

注意:我们在这里不会使用 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 中的成功状态,并显示成功消息。否则,返回表单。还请注意,我们更新了类型,以便处理可能的成功对象。其余部分应该比较清楚。

image.png

// 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. 添加重置密码链接

用户在登出状态下需要能够访问我们刚创建的页面。我们保持简单,在登录表单的提交按钮旁边添加一个忘记密码的链接。

image.png

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. 构建密码重置页面

现在我们需要实际构建此页面。要重置密码,我们需要一个包含两个字段的表单:passwordconfirm password。但是,Strapi 端点需要三个值:passwordconfirm 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,我们的用户只会收到一条错误消息:密码不匹配。他修复了此错误并重新提交表单。表单调用 formActionuseFormState 捕获该调用并使用其状态和 formData 调用 resetPasswordAction。此时的状态是什么:{ error: true, message: '有些地方不对' }(没有 code 属性)。

我们的服务器操作将继续进行并尝试调用 Strapi。从 formData 中我们可以获取 passwordconfirm 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 令牌。哦,那我们能自动登录吗?也许可以。但存在一些问题:

  1. 我们省略了 NextAuth。因此,我们需要将 NextAuth 集成进来。
  2. 如何登录?使用 NextAuth 的 signIn。但 signIn 是一个仅限客户端的函数。我们有了用户对象,但在服务器端的服务器操作中。那么如何将用户对象从服务器传递到客户端呢?

我们将在最后一章中回到这个问题。

目前,在成功时,我们只是通过成功消息要求用户登录。这种模式可能不是最优的,但也是常见的。好的一面是,它确实有效!

总结

我们刚刚使用 Strapi 设置了忘记密码流程,并省略了 NextAuth。我们添加了一个请求密码重置页面,处理了发送邮件,最后创建了实际的密码重置页面。

一些小问题被轻松处理,整体过程相对简单。我们还有三个方面需要处理:

  1. 创建用户账户页面。
  2. 让用户在该页面内更改密码。
  3. 让用户在该页面内编辑他们的数据。

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

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


王大冶
68.1k 声望105k 粉丝