06.NextAuth:创建自定义登录页面
到目前为止,我们一直在使用 NextAuth 提供的默认登录页面。但我们遇到了两个问题:
这个页面不美观,无法自定义。 页面会重新加载,影响用户体验。
为了解决这些问题,我们将实现一个自定义的登录页面。为此,我们需要完成以下几步:
创建一个登录页面(一个表单组件)。 创建一个 NextAuth 处理器。 配置一些 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>
);
}
它看起来像这样:
几个注意点:我知道它的样式不太好看,但重点是你可以自行定制它。由于我们稍后会使用凭据登录,我已经为表单和尚不存在的注册页面添加了一些占位符。
使用 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” 按钮,会发生以下情况:
页面重新加载。 我们没有被重定向。 但我们确实登录了!
所以,它起作用了,但我们还有一些问题需要解决。
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>
);
}
处理页面重新加载
即使使用我们的自定义登录页面,当点击以下按钮时,页面仍会完全重新加载:
登录按钮 使用 Google 登录按钮 登出按钮
这有什么问题吗?有点问题。整个应用程序会重新加载,你必须重新下载所有内容,屏幕在组件挂载时会闪烁,加载时间也会增加。这不是一个理想的流程。但除了这些问题,也不是特别严重。我们可以修复它们吗?我花了很多时间研究和尝试各种方法。
点击 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;
}
一些注意事项:
我们使用了相对 URL。 不需要 encodeURIComponent
,因为 Next 在usePathname
和useSearchParams
hooks 中已经处理了这点。这里有一个缺陷。如果我们从 /test
页面通过新链接进入登录页面,我们将得到以下路由:/signin?callbackUrl=/test
。如果我们再次点击导航栏中的登录按钮,我们的路由将变成:/signin?callbackUrl=/signin?callbackUrl=%2Ftest
。callbackUrl
变成了当前页面的 URL 加上callbackUrl
参数。这就像一个反馈回路。有趣的是,NextAuth 的signIn
函数也有同样的问题。所以,我决定忽略这个问题。
好的一面是:我们可以运行这个功能并且它有效!进入登录页面不再重新加载页面。进一步测试,callbackUrl
也能正常工作。登录后,我们正确地重定向到了对应的页面(但会有一次完整的页面重新加载,这是预期的行为)。
总结
我们制作了一个自定义登录页面。这是绝对必要的,且实现起来并不困难。我们还学习了如何使用 signIn
函数与提供商及选项参数。通过此功能,我们可以手动启动 NextAuth 的身份验证流程。选项对象包括 callbackUrl
查询参数。
最后,我们解决了部分登录和登出流程中的页面重新加载问题。我们只解决了导航到登录页面的问题。这引发了一个新的复杂性,即我们需要手动将 callbackUrl
参数传递给 URL。
这样做值得吗?自定义登录页面是必须的!而且硬性重新加载在我看来很令人不悦。我很高兴我们至少解决了其中一个问题。此外,我们用最少的代码解决了这个问题。本章内容相对较多,但最终代码是可管理的,解决方案也很不错。
在下一章中,我们将开始与 Strapi 的集成。