はじめに
Next.jsを使っていて認証何にしようかと検討した時、フロントで完結するか、バックエンドで認証を実装するか迷いますよね。
フロントだとFirebaseのFirebase authenticationだったり、SupabaseにもSupabase Authenticationがありますよね。
ただ、それらのデメリットとして、基本的に同一のデータベースに依存してしまう点や、認証できる人数が無料で限られている点、カスタマイズ性が低いことなどが挙げられます。
そんなデメリットを解決してくれるNextAuth.jsがあるのですが、イントロばかりで、応用などが書いてある記事が中々なかったので、今回書いてみようと思いました。
数種類の権限を持ったアカウントを作り、権限の下でしかログインできないようにしたいです。
まずは概要を
v5が最新ですが、まだBeta版で、v4がLTSですので、そちらを軸に話していこうと思います。(破壊的変更がありますので、お気をつけてください。)
まずは今回のレイアウトです。
この表はNext.NAVで表しています。もしよかったら使ってみてください。
また、参考にしたチュートリアルを置いておきます
環境準備
まずは必要なものをインストールしていきます。基本的に全てYesで構わないです。
% npx create-next-app@14 auth-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
? What import alias would you like configured? › @/*
npm i @prisma/client next-auth @next-auth/prisma-adapter bcrypt react-hot-toast react-icons lucide-react
npm i --save-dev prisma @types/bcrypt
npx shadcn@latest init
npx prisma init --datasource-provider postgresql
基本に使うものは
- prisma→データベースのORM(Object-Relational Mapping)。SQLクエリの作成することなく、ORMを通じてデータベースと相互作用できるようになる。
- bcrypt→暗号化に使います。
- shadcn→UIライブラリです。シンプルで変形しやすくて使いやすいです
- lucide-react→アイコンなどが入ってい流パッケージです。
shadcnで必要なものを各自入れていってください。
雛形となるホームページ
とりあえずlayoutでHeaderを呼び、page.tsxで雛形を書いていきます。
また、完成した形でご紹介するので、ハンズオン形式ではなく、後で説明するsupabseの設定をしないとimportできませんとなってしまいます。また、アップデートなどによって変わっているところもあると思われます。あらかじめご了承くさい。
また、今回は複数権限での認証になりますので、画像に関しては、cloudflareなどをご利用することを勧め、今回はdefaultの画像を使うことにしています。
まずは、みられたくないお客さんの大事な情報が載ったページを作っていきます。
import { redirect } from "next/navigation";
import { getAuthSession } from "@/lib/nextauth";
const ClientPage = async () => {
const user = await getAuthSession();
if (!user) {
redirect("/login");
}
if (user.role !== "client") {
redirect("/login");
}
return (
<div className='flex flex-col items-center gap-2'>
<h1 className='text-2xl font-bold'>ClientPage</h1>
<h1>むっちゃ大事な情報</h1>
<h1>↓↓↓</h1>
<h1>タコの心臓は3つある!!!</h1>
</div>
);
};
export default ClientPage;
同様にお店側も作っていきます。
import { redirect } from "next/navigation";
import { getAuthSession } from "@/lib/nextauth";
const ShopPage = async () => {
const user = await getAuthSession();
if (!user) {
redirect("/login");
}
if (user.role !== "shop") {
redirect("/login");
}
return (
<div className='flex flex-col items-center gap-2'>
<h1 className='text-2xl font-bold'>ShopPage</h1>
<h1>むっちゃ大事な情報</h1>
<h1>↓↓↓</h1>
<h1>シロクマの皮膚は黒い!!!</h1>
</div>
);
};
export default ShopPage;
これを表示させるためのホームページの全体のレイアウトを作っていきます。
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import Header from "@/components/header/Header";
import AuthProvider from "@/components/providers/AuthProvider";
import ToastProvider from "@/components/providers/ToastProvider";
import { getAuthSession } from "@/lib/nextauth";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const user = await getAuthSession();
return (
<html lang='ja'>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<div>
<AuthProvider>
<Header user={user} />
<ToastProvider />
{children}
</AuthProvider>
</div>
</body>
</html>
);
}
import React from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { getAuthSession } from "@/lib/nextauth";
import Hero from "@/components/hero/Hero";
const page = async () => {
const user = await getAuthSession();
return (
<div className='flex justify-center '>
<div className='flex flex-col items-center gap-2 justify-center'>
{user ? (
<Hero user={user} />
) : (
<h1 className='text-2xl font-bold'>ログインしていません</h1>
)}
<br />
<Button
className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mb-10'
size='lg'
>
<Link href='/client'>お客さん</Link>
</Button>
<Button
className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded'
size='lg'
>
<Link href='/shop'>ショップ</Link>
</Button>
</div>
</div>
);
};
export default page;
ヘッダーでログインしているかのどうかの選択に応じて、ログインセッションを出していきます。
もしセッションがない場合は、以下のようになります。
"use client";
import { Button } from "@/components/ui/button";
import UserNavigation from "@/components/header/UserNavigation";
import Link from "next/link";
interface HeaderProps {
user: User | null;
}
// ナビゲーション
const Header = ({ user }: HeaderProps) => {
return (
<header className='w-full bg-blue-100 shadow-lg mb-10'>
<div className='container mx-auto flex max-w-screen-md items-center justify-center px-2 py-3'>
<Link href='/' className='cursor-pointer text-xl font-bold'>
Authentication
</Link>
</div>
<div className='container mx-auto flex max-w-screen-md items-center justify-center px-2 py-3'>
{user ? (
<UserNavigation user={user} />
) : (
<div className='flex items-center space-x-1'>
<Button asChild variant='outline' className='font-bold'>
<Link href='/login'>ログイン</Link>
</Button>
<Button asChild variant='default' className='font-bold'>
<Link href='/signup'>新規登録</Link>
</Button>
</div>
)}
</div>
</header>
);
};
export default Header;
もしセッションがある時、画面にアイコンと設定の画面が出るようにしたいです。
この画面を書いていきます。
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { signOut } from "next-auth/react";
import Image from "next/image";
import { User } from "@prisma/client";
interface UserNavigationProps {
user: User;
}
// ユーザーナビゲーション
const UserNavigation = ({ user }: UserNavigationProps) => {
return (
<div className='flex-col container w-full mx-auto flex max-w-screen-md items-center justify-center'>
<DropdownMenu>
<DropdownMenuTrigger>
<div className='relative w-10 h-10 flex-shrink-0'>
<Image
src={user.image || "/default.png"}
className='rounded-full object-cover'
alt={user.name || "avatar"}
fill
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className='bg-white p-2 w-[300px]' align='center'>
<DropdownMenuItem
onSelect={async (event) => {
event.preventDefault();
await signOut({ callbackUrl: "/" });
}}
className='text-red-600 cursor-pointer'
>
ログアウト
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
export default UserNavigation;
ログインしている場合の内容も書きたいので、ヒーローコンポーネントを作ります。
import { User } from "@prisma/client";
const Hero = ({ user }: { user: User }) => {
return (
<div className='flex flex-col items-start gap-2'>
<h1 className='text-2xl font-bold'>現在のログイン名:{user.name}</h1>
<h1 className='text-2xl font-bold'>現在のログインロール:{user.role}</h1>
<h1 className='text-2xl font-bold'>現在のid:{user.id}</h1>
</div>
);
};
export default Hero;
セッションが入っている時は以下のようになります。
アカウント管理用のデータベースの設定
supabaseでデータベースを作っていきます。
connectからprismaに繋げるORMも書いていきます。
.env
ファイルに記述していきます。
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Connect to Supabase via connection pooling with Supavisor.
DATABASE_URL=
# Direct connection to the database. Used for migrations.
DIRECT_URL=
# 秘密鍵
# openssl rand -base64 32を使って、ランダムな鍵を生成してください。
NEXTAUTH_SECRET=
NEXTAUTH_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=http://localhost:3000
また、prismaの方にmigrateするものを記述していきます。
今回は、自己紹介などは扱いませんが、もしかしたらご紹介するかもしれないので、一応書いておきます。
prismaは、SQL文法を記述することなく、SQL文を書いていけます。
作りたいテーブルなどは以下のようになります。
1. Account
テーブルの作成
CREATE TABLE Account (
id SERIAL PRIMARY KEY,
userId VARCHAR(255) NOT NULL,
type VARCHAR(255) NOT NULL,
provider VARCHAR(255) NOT NULL,
providerAccountId VARCHAR(255) NOT NULL,
refresh_token TEXT,
access_token TEXT,
expires_at INT,
token_type VARCHAR(255),
scope TEXT,
id_token TEXT,
session_state TEXT,
CONSTRAINT unique_provider_account UNIQUE (provider, providerAccountId),
CONSTRAINT fk_user FOREIGN KEY (userId) REFERENCES User (id) ON DELETE CASCADE
);
解説
-
主キー:
id
はユニーク識別子として定義されています。 -
provider
とproviderAccountId
の一意性: 同じプロバイダーの中でアカウントIDが重複しないようにしています。 -
外部キー:
userId
はUser
テーブルのid
を参照し、CASCADE
により関連するユーザーが削除されるとこのテーブルの関連データも削除されます。
2. Session
テーブルの作成
CREATE TABLE Session (
id SERIAL PRIMARY KEY,
sessionToken VARCHAR(255) UNIQUE NOT NULL,
userId VARCHAR(255) NOT NULL,
expires TIMESTAMP NOT NULL,
role VARCHAR(255) NOT NULL DEFAULT 'user',
CONSTRAINT fk_user_session FOREIGN KEY (userId) REFERENCES User (id) ON DELETE CASCADE
);
解説
-
主キー:
id
はセッションを一意に識別します。 -
一意性:
sessionToken
は一意であり、セッションの有効性を保証します。 -
外部キー:
userId
はUser
テーブルを参照し、ユーザーが削除されると関連するセッションも削除されます。 -
roleカラム:
roleによってshopとeventによってログインを判断します。
3. User
テーブルの作成
CREATE TABLE User (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE,
emailVerified TIMESTAMP,
name VARCHAR(255),
introduction TEXT,
image TEXT,
hashedPassword TEXT,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
解説
-
主キー:
id
はユーザーを一意に識別します。 -
ユニーク制約:
email
フィールドは一意であり、同じメールアドレスの登録を防ぎます。 -
タイムスタンプ:
createdAt
は作成日時を、updatedAt
は更新日時を自動的に記録します。
4. PasswordResetToken
テーブルの作成
CREATE TABLE PasswordResetToken (
id SERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expiry TIMESTAMP NOT NULL,
userId VARCHAR(255) NOT NULL,
CONSTRAINT fk_user_password_reset FOREIGN KEY (userId) REFERENCES User (id) ON DELETE CASCADE
);
解説
-
主キー:
id
はトークンを一意に識別します。 -
一意性:
token
はリセットトークンとして一意です。 -
外部キー:
userId
はUser
テーブルを参照します。
5. VerificationToken
テーブルの作成
CREATE TABLE VerificationToken (
identifier VARCHAR(255) NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
expires TIMESTAMP NOT NULL,
CONSTRAINT unique_identifier_token UNIQUE (identifier, token)
);
解説
-
主キー: このテーブルでは複合ユニーク制約(
identifier
,token
)を主キーの代わりに使用しています。 -
一意性:
token
は検証トークンとして一意である必要があります。
これを、prismaで書くとこうなります。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
role String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
role String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
email String? @unique
emailVerified DateTime?
role String?
name String?
introduction String?
image String?
hashedPassword String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
PasswordResetToken PasswordResetToken[]
sessions Session[]
}
model PasswordResetToken {
id String @id @default(cuid())
token String @unique
createdAt DateTime @default(now())
expiry DateTime
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
auth-app % npx prisma migrate dev --name init
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "postgres", schema "public" at "aws-0-ap-northeast-1.pooler.supabase.com:5432"
Applying migration `20241213070425_init`
The following migration(s) have been created and applied from new schema changes:
migrations/
└─ 20241213070425_init/
└─ migration.sql
Your database is now in sync with your schema.
✔ Generated Prisma Client (v6.0.1) to ./node_modules/@prisma/client in 60ms
auth-app % npx prisma generate
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
✔ Generated Prisma Client (v6.0.1) to ./node_modules/@prisma/client in 48ms
Start by importing your Prisma Client (See: https://pris.ly/d/importing-client)
Tip: Want real-time updates to your database without manual polling? Discover how with Pulse: https://pris.ly/tip-0-pulse
こちらをすると、マイグレーションファイルを作成せずに、直接データベースを構築できます。
npx prisma db push
大丈夫そうですね。
Nextauthの設定
続いて、nextauthファイルの設定を行なっておきます。動的ルートでnextが受け取ることで認証を行うことができます。
今回はGoogleも行っていきます。
- 構成としては2種類の役割を作るために、まず拡張させる。
- 認証が正しいのかを確認する。
- sessionはjwtを使う。
ほんとはGoogleのセッションもここでしたかったが、なんかうまくいきませんでした...
import NextAuth from "next-auth/next";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { getServerSession, type NextAuthOptions } from "next-auth";
import prisma from "@/lib/prisma";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcrypt";
declare module "next-auth" {
interface User {
id: string; // User モデルの ID を追加
role?: string; // 必要であれば他のカスタムフィールドも追加
}
interface Session {
user: {
id: string; // セッションの User に ID を追加å
name?: string | null;
email?: string | null;
image?: string | null;
role?: string; // ユーザーのロールをセッションに追加
};
}
}
// 環境変数の検証
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
throw new Error("Google認証に必要な環境変数が設定されていません");
}
// ユーザー認証ヘルパー関数
async function authenticateUser(email: string, password: string) {
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.hashedPassword) {
throw new Error("ユーザーが見つからないか、パスワードが設定されていません");
}
const isCorrectPassword = await bcrypt.compare(password, user.hashedPassword);
if (!isCorrectPassword) {
throw new Error("パスワードが一致しません");
}
return user;
}
// NextAuth設定
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error("メールアドレスとパスワードを入力してください");
}
return authenticateUser(credentials.email, credentials.password);
},
}),
],
session: {
strategy: "jwt",
},
callbacks: {
async session({ session, token }) {
if (token?.sub) {
if (session.user) {
session.user.id = token.sub; // ユーザーIDをセッションに追加
}
}
return session;
},
async jwt({ token, user }) {
if (user) {
token.sub = user.id; // JWTトークンにユーザーIDを保存
}
return token;
},
},
};
// 認証情報取得
export const getAuthSession = async () => {
const session = await getServerSession(authOptions);
if (!session || !session.user?.email) {
return null;
}
// 1件のレコードを取得、見つからない場合は例外を投げる
const user = await prisma.user.findFirstOrThrow({
where: {
email: session.user.email,
},
});
return user;
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
登録画面
また、登録する時の画面を書いていきます。
お客さん側と店舗側で登録を分けたいです。
import { redirect } from "next/navigation";
import { getAuthSession } from "@/lib/nextauth";
import Link from "next/link";
import { Button } from "@/components/ui/button";
// サインアップページ
const SignupPage = async () => {
// 認証情報取得
const user = await getAuthSession();
if (user) {
redirect("/");
}
return (
<div className='flex flex-col items-center gap-5 h-screen'>
<div className='text-2xl font-bold text-center '>新規登録</div>
<Link href='/signup/client'>
<Button variant='outline' size='lg' className='w-full p-10'>
お客さんとして登録
</Button>
</Link>
<Link href='/signup/shop'>
<Button variant='outline' size='lg' className='w-full p-10'>
店舗として登録
</Button>
</Link>
</div>
);
};
export default SignupPage;
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { FcGoogle } from "react-icons/fc";
import { signIn } from "next-auth/react";
import { toast } from "react-hot-toast";
import Link from "next/link";
type SignupProps = {
role: "client" | "shop";
};
const Signup = ({ role }: SignupProps) => {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [errors, setErrors] = useState({ name: "", email: "", password: "" });
const validateForm = () => {
let isValid = true;
const newErrors = { name: "", email: "", password: "" };
if (name.length < 2) {
newErrors.name = "2文字以上入力する必要があります";
isValid = false;
}
if (!email) {
newErrors.email = "メールアドレスを入力してください";
isValid = false;
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = "メールアドレスの形式が正しくありません";
isValid = false;
}
if (password.length < 8) {
newErrors.password = "8文字以上入力する必要があります";
isValid = false;
}
setErrors(newErrors);
return isValid;
};
const handleGoogleSignup = async () => {
try {
console.log("Googleアカウントでサインアップ");
const result = await signIn("google", {
callbackUrl: `/signup/signup_Google/${role}`,
});
if (result?.error) {
toast.error("Googleアカウントのサインアップに失敗しました");
}
} catch (error) {
toast.error("Googleアカウントのサインアップに失敗しました");
console.error(error);
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validateForm()) return;
try {
const response = await fetch("/api/auth/signup", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name, email, password, role }),
});
if (!response.ok) {
const error = await response.json();
toast.error(error.message || "アカウント作成に失敗しました");
return;
}
toast.success("アカウントを作成しました!");
// ログイン
await signIn("credentials", {
email,
password,
callbackUrl: "/",
});
router.refresh();
} catch (error) {
console.error(error);
toast.error("アカウント作成中にエラーが発生しました");
}
};
return (
<div className='flex justify-center items-center min-h-screen bg-gray-100'>
<Card className='w-full max-w-md'>
<CardHeader>
<CardTitle className='text-2xl font-bold text-center'>
{role === "client" ? "お客さん" : "店舗"}として新規登録
</CardTitle>
</CardHeader>
<CardContent>
<Button
variant='outline'
className='w-full'
onClick={handleGoogleSignup}
>
<FcGoogle className='mr-2 h-4 w-4' />
Googleアカウントで登録
</Button>
<div className='relative my-5'>
<div className='absolute inset-0 flex items-center'>
<span className='w-full border-t' />
</div>
<div className='relative flex justify-center text-xs uppercase'>
<span className='bg-background px-2 text-muted-foreground'>
または
</span>
</div>
</div>
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='name'>名前</Label>
<Input
id='name'
value={name}
onChange={(e) => setName(e.target.value)}
placeholder='名前'
/>
{errors.name && (
<p className='text-sm text-red-500'>{errors.name}</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='email'>メールアドレス</Label>
<Input
id='email'
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{errors.email && (
<p className='text-sm text-red-500'>{errors.email}</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='password'>パスワード</Label>
<Input
id='password'
type='password'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{errors.password && (
<p className='text-sm text-red-500'>{errors.password}</p>
)}
</div>
<Button type='submit' className='w-full'>
アカウント作成
</Button>
</form>
<div className='text-center mt-4'>
<Link
href='/login'
className='text-sm text-blue-500 hover:underline'
>
すでにアカウントをお持ちの方
</Link>
</div>
</CardContent>
</Card>
</div>
);
};
export default Signup;
それぞれの画面はこのようになりました。
ここに、登録を押した時のPOSTのAPIを書いていきます。
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import bcrypt from "bcrypt";
export async function POST(request: Request) {
try {
const { name, email, password, role } = await request.json();
const userRole = role;
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
return NextResponse.json(
{ message: "既に登録されているメールアドレスです" },
{ status: 400 }
);
}
const hashedPassword = await bcrypt.hash(password, 12);
const newUser = await prisma.user.create({
data: { name, email, hashedPassword, role: userRole }, // roleを保存
});
return NextResponse.json({ message: "サインアップ成功" }, { status: 201 });
} catch (error) {
console.error(error);
return NextResponse.json(
{ message: "サーバーエラーが発生しました" },
{ status: 500 }
);
}
}
Googleからのコールバックを記載して、リダイレクトさせます。
import { redirect } from "next/navigation";
import { getAuthSession } from "@/lib/nextauth";
import prisma from "@/lib/prisma";
const SignupGooglePage = async ({ params }: { params: { role: string } }) => {
const session = await getAuthSession();
if (!session || session.role !== null) {
redirect("/");
}
await prisma.user.update({
where: { email: session?.email as string },
data: { role: params.role as "client" | "shop" },
});
redirect("/");
};
export default SignupGooglePage;
プロバイダーの構成
プロバイダーを載せていきます。
"use client";
import { SessionProvider } from "next-auth/react";
interface AuthProviderProps {
children: React.ReactNode;
}
const AuthProvider = ({ children }: AuthProviderProps) => {
return <SessionProvider>{children}</SessionProvider>;
};
export default AuthProvider;
Toasterも必要でしたね。
"use client";
import { Toaster } from "react-hot-toast";
const ToastProvider = () => {
return <Toaster />;
};
export default ToastProvider;
prismaインスタンスの重複を防ぐ
Next.jsに合わせたprismaの使い方にしていきます。
//prismaのためのおまじない
import { PrismaClient } from "@prisma/client";
const prismaClientSingleton = () => {
return new PrismaClient();
};
declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();
export default prisma;
if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma;
新規登録のお試し
登録が成功すると、上記のようにできました。
Googleのプロバイダ設定
GoogleでのOAuthの設定を以下の手順でしていきます。
まずはGCPに登録し、新しいプロジェクトを開始しましょう。
APIサービスに移動します。
OAuthの設定を完了させていきます。
クライアントIDを選択してください。
クライアントIDを作成し、リダイレクト先を設定しました。
作成すると秘密鍵が出てくるのでこちらに載せていきます。
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
ログイン処理
ログインはどちらからしても、Roleで判断することにしているのでできます。
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { FcGoogle } from "react-icons/fc";
import { Loader2 } from "lucide-react";
import { signIn } from "next-auth/react";
import { toast } from "react-hot-toast";
import Link from "next/link";
const Login = () => {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [errors, setErrors] = useState({ email: "", password: "" });
const validateForm = () => {
let isValid = true;
const newErrors = { email: "", password: "" };
if (!email) {
newErrors.email = "メールアドレスを入力してください";
isValid = false;
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = "メールアドレスの形式が正しくありません";
isValid = false;
}
if (!password) {
newErrors.password = "パスワードを入力してください";
isValid = false;
} else if (password.length < 8) {
newErrors.password = "パスワードは8文字以上である必要があります";
isValid = false;
}
setErrors(newErrors);
return isValid;
};
// Googleアカウントでログイン
const handleGoogleLogin = async () => {
try {
console.log("Googleアカウントでログイン");
const result = await signIn("google", {
callbackUrl: `/signup/signup_Google/`,
});
if (result?.error) {
toast.error("Googleアカウントのサインアップに失敗しました");
}
} catch (error) {
toast.error("Googleアカウントのサインアップに失敗しました");
console.error(error);
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validateForm()) return;
setIsLoading(true);
try {
const res = await signIn("credentials", {
email,
password,
redirect: false,
});
if (res?.error) {
toast.error("ログインに失敗しました");
return;
}
toast.success("ログインしました!");
router.push("/");
router.refresh();
} catch (error) {
console.error(error);
toast.error("ログインに失敗しました");
} finally {
setIsLoading(false);
}
};
return (
<div className='flex justify-center items-center min-h-screen bg-gray-100'>
<Card className='w-full max-w-md'>
<CardHeader className='space-y-1'>
<CardTitle className='text-2xl font-bold text-center'>
ログイン
</CardTitle>
</CardHeader>
<CardContent>
<Button
variant='outline'
className='w-full'
onClick={handleGoogleLogin}
>
<FcGoogle className='mr-2 h-4 w-4' />
Googleアカウントでログイン
</Button>
<div className='relative my-5'>
<div className='absolute inset-0 flex items-center'>
<span className='w-full border-t' />
</div>
<div className='relative flex justify-center text-xs uppercase'>
<span className='bg-background px-2 text-muted-foreground'>
または
</span>
</div>
</div>
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='email'>メールアドレス</Label>
<Input
id='email'
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{errors.email && (
<p className='text-sm text-red-500'>{errors.email}</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='password'>パスワード</Label>
<Input
id='password'
type='password'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{errors.password && (
<p className='text-sm text-red-500'>{errors.password}</p>
)}
</div>
<Button disabled={isLoading} type='submit' className='w-full'>
{isLoading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
ログイン
</Button>
</form>
<div className='text-center mt-4 space-y-2'>
<Link
href='/reset-password'
className='text-sm text-blue-500 hover:underline block'
>
パスワードをお忘れですか?
</Link>
<Link
href='/signup'
className='text-sm text-blue-500 hover:underline block'
>
アカウントを作成
</Link>
</div>
</CardContent>
</Card>
</div>
);
};
export default Login;
うまくいけてますね
しかし、登録していないのに押してしまう可能性があるため、念の為選択ページを作ります。
登録していない場合に、選択ページに遷移させます。
import { redirect } from "next/navigation";
import { getAuthSession } from "@/lib/nextauth";
import Link from "next/link";
const SignupGooglePage = async () => {
const session = await getAuthSession();
if (!session || session.role !== null) {
redirect("/");
}
return (
<div className='flex flex-col items-center h-screen'>
<h1 className='text-2xl font-bold mb-5'>ようこそ、{session.name}さん</h1>
<p className='text-bold mb-5'>あなたのロールを選択してください</p>
<div className='flex gap-2'>
<Link
href={`/signup/signup_Google/client`}
className='bg-blue-500 text-white px-4 py-2 rounded-md'
>
クライアントでサインアップ
</Link>
<Link
href={`/signup/signup_Google/shop`}
className='bg-green-500 text-white px-4 py-2 rounded-md'
>
ショップでサインアップ
</Link>
</div>
</div>
);
};
export default SignupGooglePage;
ここから追加すること
パスワード初期化、パスワードの変更、画像をアップロード、自己紹介スペースなどまだまだあります。
何かアドバイス等あれば是非コメントお願いします🙏🙏
長文でしたが有藤うございました。