06.NextAuth:创建自定义登录页面

到目前为止,我们一直在使用 NextAuth 提供的默认登录页面。但我们遇到了两个问题:

  1. 这个页面不美观,无法自定义。
  2. 页面会重新加载,影响用户体验。

为了解决这些问题,我们将实现一个自定义的登录页面。为此,我们需要完成以下几步:

  1. 创建一个登录页面(一个表单组件)。
  2. 创建一个 NextAuth 处理器。
  3. 配置一些 NextAuth 设置。

本章的代码可以在 GitHub 上找到:分支 customlogin

创建登录页面

我们首先创建一个新的页面:

// frontend/src/app/(auth)/signin/page.tsx

import SignIn from '@/components/auth/signIn/SignIn';

export default function SignInPage() {
  return <SignIn />;
}

请注意,我们在这里使用了一个路由组 (auth)。我们将来可能会有更多的身份验证页面,例如注册页面,因此我希望将它们分组,而不在实际路由中体现。这意味着我们的登录页面将位于 http://localhost:3000/signin

我们在这个页面中除了导入 <SignIn /> 组件外,不做其他操作。我喜欢将 app 文件夹专用于路由,并将其他内容移动到组件中。

<SignIn /> 组件

components 文件夹中,我们创建一个 auth 文件夹来分组所有身份验证相关的组件。以下是我们的 <SignIn /> 组件:

// frontend/src/components/auth/signIn/SignIn.tsx

import Link from 'next/link';

export default function SignIn() {
  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'>
        Sign in
      </h2>
      <div>
        <p className='mb-4'>
          Sign in to your account or{' '}
          <Link href='/register' className='underline'>
            create a new account.
          </Link>
        </p>
        [form]
        <div className='text-center relative my-8 after:content-[""] after:block after:w-full after:h-[1px] after:bg-zinc-300 after:relative after:-top-3 after:z-0'>
          <span className='bg-zinc-100 px-4 relative z-10 text-zinc-400'>
            or
          </span>
        </div>
        <button className='bg-white border border-zinc-300 py-1 rounded-md w-full text-zinc-700'>
          <span className='text-red-700 mr-2'>G</span> Sign in with Google
        </button>
      </div>
    </div>
  );
}

它看起来像这样:

image.png

几个注意点:我知道它的样式不太好看,但重点是你可以自行定制它。由于我们稍后会使用凭据登录,我已经为表单和尚不存在的注册页面添加了一些占位符。

使用 Google 登录按钮

我们已经在 <SignInButton /> 组件中使用了 NextAuth 提供的 signIn 函数。调用 signIn() 只会将我们重定向到默认的 NextAuth 登录页面。

但是,signIn 函数有第二种用法。当我们调用它并传递一个提供商名称时,它会为该提供商启动身份验证流程:

signIn('google');

它可以接受一个可选的第二个参数,即选项对象。我们将在稍后详细介绍。

我们在按钮点击时调用 signIn,因此需要一个客户端组件。我们将 Google 登录按钮移到一个单独的客户端组件中,附上我们的 signIn 函数,并将其导入到 <Login /> 组件中:

// frontend/src/components/signIn/GoogleSignInButton.tsx

'use client';

import { signIn } from 'next-auth/react';

export default function GoogleSignInButton() {
  return (
    <button
      className='bg-white border border-zinc-300 py-1 rounded-md w-full text-zinc-700'
      onClick={() => signIn('google')}
    >
      <span className='text-red-700 mr-2'>G</span> Sign in with Google
    </button>
  );
}

注册我们的自定义登录页面

我们还不能进行测试。如果我们现在运行应用程序(未登录)并点击导航栏中的登录按钮,它仍会将我们带到默认的 NextAuth 登录页面。我们需要告诉 NextAuth 我们创建了一个自定义页面。我们在 authOptions 对象中进行设置。

我们向 authOptions 对象添加一个新的 pages 属性,其中我们将 signin 属性设置为我们的登录页面:

// frontend/src/app/api/auth/[...nextauth]/authOptions.ts

export const authOptions: NextAuthOptions = {
  //...
  pages: {
    signIn: '/signin',
  },
};

太好了!运行应用程序,登出。点击登录,我们将被重定向到我们自定义的登录页面。但是,页面发生了完整的重新加载。此外,我们的 URL 现在是:http://localhost:3000/signin?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2F。它将一个 callbackUrl 参数添加回我们的首页。

点击 “Sign in with Google” 按钮,会发生以下情况:

  1. 页面重新加载。
  2. 我们没有被重定向。
  3. 但我们确实登录了!

image.png

所以,它起作用了,但我们还有一些问题需要解决。

NextAuth 重定向

signIn 函数的第二个参数是一个选项对象。其中一个选项是 callbackUrl

signIn('google', { callbackUrl: 'someUrl' });

我们之前遇到过这个问题。NextAuth 自动将 callbackUrl 作为参数添加到 URL 中。老实说,我期望在我们当前的设置下(没有选项对象)被重定向到主页。NextAuth 文档中提到:

callbackUrl 指定用户登录后将被重定向到的 URL。默认为启动登录的页面 URL。

基于此,我本来以为会默认重定向到主页。但事实并非如此。因此,让我们更新 signIn 函数,将 callbackUrl 设置为主页并进行测试:

// frontend/src/components/signIn/GoogleSignInButton.tsx

onClick={() => signIn('google', { callbackUrl: '/' })}

它起作用了,我们得到了重定向。需要注意的是,NextAuth 只接受相对路径或与应用程序相同域上的绝对 URL。始终重定向到主页是可能的,但在我们的情况下,我们希望将用户重定向回他或她之前所在的页面。我们使用 useSearchParams hook 更新函数:

// frontend/src/components/signIn/GoogleSignInButton.tsx

'use client';

import { signIn } from 'next-auth/react';
import { useSearchParams } from 'next/navigation';

export default function GoogleSignInButton() {
  const searchParams = useSearchParams();
  const callbackUrl = searchParams.get('callbackUrl') || '/';
  return (
    <button
      className='bg-white border border-zinc-300 py-1 rounded-md w-full text-zinc-700'
      onClick={() => signIn('google', { callbackUrl })}
    >
      <span className='text-red-700 mr-2'>G</span> Sign in with Google
    </button>
  );
}

我们从 searchParams 获取 callbackUrl 并简单地将其传递给 signIn 选项。我们还为我们的主页路由提供了一个备用 '/'searchParams 中的 callbackUrl 可能为空,例如当用户直接访问 localhost:3000/signin 时。

要测试这一点,我们创建了一个小测试页面,这样我们就可以从那里登录:

// frontend/src/app/test/page.tsx

export default function page() {
  return <div>test page</div>;
}

我们打开 localhost:3000/test,登出,登录,它将我们再次重定向到 /test。非常好!我尝试添加一些查询参数(/test?foo=bar),这些参数也没有问题地传递下来。最后,我直接打开了 /signin,登出并重新登录,正如预期的那样,我们被发送到了首页。

注意:还有另一个 signIn 选项,称为 redirect,它接受一个布尔值。但这个选项只对邮箱和凭据提供商有效。我们稍后会使用这个选项。

保护登录页面

最后一个细节。我们将更新我们的 <SignIn /> 组件。如果用户已经登录,我们将显示一条消息,告知用户“您已登录”,而不是显示 Google 登录按钮。为什么?为了不让用户感到困惑。



// frontend/src/components/signIn/SignIn.tsx

import Link from 'next/link';
import GoogleSignInButton from './GoogleSignInButton';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/authOptions';

export default async function SignIn() {
  const session = await getServerSession(authOptions);
  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'>
        Sign in
      </h2>
      {session ? (
        <p className='text-center'>You are already signed in.</p>
      ) : (
        <div>...</div>
      )}
    </div>
  );
}

image.png

处理页面重新加载

即使使用我们的自定义登录页面,当点击以下按钮时,页面仍会完全重新加载:

  1. 登录按钮
  2. 使用 Google 登录按钮
  3. 登出按钮

这有什么问题吗?有点问题。整个应用程序会重新加载,你必须重新下载所有内容,屏幕在组件挂载时会闪烁,加载时间也会增加。这不是一个理想的流程。但除了这些问题,也不是特别严重。我们可以修复它们吗?我花了很多时间研究和尝试各种方法。

点击 Google 登录按钮时的重新加载是无法修复的。但这是正常的情况,每个 Web 应用在使用 Google 登录时都会重新加载。登出按钮同样无法修复,会有一次重新加载,但这也是正常行为。最后,我们可以修复登录按钮的重新加载问题。

修复点击登录按钮时的重新加载问题

你可能已经猜到了。为什么不直接链接到我们的登录页面,而不是使用调用 signIn 的按钮呢?我们可以这样做,但会引发一些问题。

第一个问题是,我们失去了 callbackUrl 参数。前面提到,当调用 signIn 时,NextAuth 会自动添加一个 callbackUrl 查询参数。当我们移除按钮并使用 Link 时,显然不再有这个 callbackUrl。我们必须自己构建它。因此,我们将用一个新组件 <SignInLink /> 替换 <SignInButton />

// frontend/src/components/header/SignInLink.tsx

'use client';

import Link from 'next/link';
import useCallbackUrl from '@/hooks/useCallbackUrl';

export default function SignInLink() {
  const callbackUrl = useCallbackUrl();
  return (
    <Link
      href={`/signin?callbackUrl=${callbackUrl}`}
      className='bg-sky-400 rounded-md px-4 py-2'
    >
      sign in
    </Link>
  );
}

记住,这是我们的导航栏组件中的登录链接。因此,我们需要链接到 /signin。但我们还需要添加一个 callbackUrl 查询参数。这个参数的值是什么?我们当前的 URL 加上它自己的所有查询参数。以下是一个示例:如果我们在 /test?foo=bar 页面,我们希望 <SignInLink /> 链接到 /signIn 加上 ?callbackUrl=/test?foo=bar。为了创建 callbackUrl 值,我们编写了一个自定义 hook,以便能够重复使用。以下是该 hook:

// frontend/src/hooks/useCallbackUrl.ts

import { usePathname, useSearchParams } from 'next/navigation';

export default function useCallbackUrl() {
  const pathname = usePathname();
  const params = useSearchParams().toString();
  // 如果没有查询参数,不添加 `?`
  const callbackUrl = `${pathname}${params ? '?' : ''}${params}`;
  return callbackUrl;
}

一些注意事项:

  1. 我们使用了相对 URL。
  2. 不需要 encodeURIComponent,因为 Next 在 usePathnameuseSearchParams hooks 中已经处理了这点。
  3. 这里有一个缺陷。如果我们从 /test 页面通过新链接进入登录页面,我们将得到以下路由:/signin?callbackUrl=/test。如果我们再次点击导航栏中的登录按钮,我们的路由将变成:/signin?callbackUrl=/signin?callbackUrl=%2FtestcallbackUrl 变成了当前页面的 URL 加上 callbackUrl 参数。这就像一个反馈回路。有趣的是,NextAuth 的 signIn 函数也有同样的问题。所以,我决定忽略这个问题。

好的一面是:我们可以运行这个功能并且它有效!进入登录页面不再重新加载页面。进一步测试,callbackUrl 也能正常工作。登录后,我们正确地重定向到了对应的页面(但会有一次完整的页面重新加载,这是预期的行为)。

总结

我们制作了一个自定义登录页面。这是绝对必要的,且实现起来并不困难。我们还学习了如何使用 signIn 函数与提供商及选项参数。通过此功能,我们可以手动启动 NextAuth 的身份验证流程。选项对象包括 callbackUrl 查询参数。

最后,我们解决了部分登录和登出流程中的页面重新加载问题。我们只解决了导航到登录页面的问题。这引发了一个新的复杂性,即我们需要手动将 callbackUrl 参数传递给 URL。

这样做值得吗?自定义登录页面是必须的!而且硬性重新加载在我看来很令人不悦。我很高兴我们至少解决了其中一个问题。此外,我们用最少的代码解决了这个问题。本章内容相对较多,但最终代码是可管理的,解决方案也很不错。

在下一章中,我们将开始与 Strapi 的集成。

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

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


王大冶
68k 声望104.9k 粉丝