0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qiita全国学生対抗戦Advent Calendar 2024

Day 9

NextAuthで複数の権限を持たせたい🥰

Last updated at Posted at 2024-12-14

はじめに

Next.jsを使っていて認証何にしようかと検討した時、フロントで完結するか、バックエンドで認証を実装するか迷いますよね。

フロントだとFirebaseのFirebase authenticationだったり、SupabaseにもSupabase Authenticationがありますよね。

ただ、それらのデメリットとして、基本的に同一のデータベースに依存してしまう点や、認証できる人数が無料で限られている点、カスタマイズ性が低いことなどが挙げられます。

そんなデメリットを解決してくれるNextAuth.jsがあるのですが、イントロばかりで、応用などが書いてある記事が中々なかったので、今回書いてみようと思いました。

数種類の権限を持ったアカウントを作り、権限の下でしかログインできないようにしたいです。

まずは概要を

v5が最新ですが、まだBeta版で、v4がLTSですので、そちらを軸に話していこうと思います。(破壊的変更がありますので、お気をつけてください。)

スクリーンショット 2024-12-10 20.58.06.png

まずは今回のレイアウトです。

スクリーンショット 2024-12-14 19.31.59.png

この表は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で必要なものを各自入れていってください。

雛形となるホームページ

スクリーンショット 2024-12-14 13.05.38.png

とりあえずlayoutでHeaderを呼び、page.tsxで雛形を書いていきます。

また、完成した形でご紹介するので、ハンズオン形式ではなく、後で説明するsupabseの設定をしないとimportできませんとなってしまいます。また、アップデートなどによって変わっているところもあると思われます。あらかじめご了承くさい。

また、今回は複数権限での認証になりますので、画像に関しては、cloudflareなどをご利用することを勧め、今回はdefaultの画像を使うことにしています。

まずは、みられたくないお客さんの大事な情報が載ったページを作っていきます。
スクリーンショット 2024-12-14 13.05.16.png

auth-app/client/page.tsx
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;

同様にお店側も作っていきます。

スクリーンショット 2024-12-14 13.09.19.png

auth-app/app/shop/page.tsx
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;

これを表示させるためのホームページの全体のレイアウトを作っていきます。

スクリーンショット 2024-12-14 13.05.38.png

auth-app/app/layout.tsx
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>
  );
}

auth-app/app/page.tsx
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;

ヘッダーでログインしているかのどうかの選択に応じて、ログインセッションを出していきます。

もしセッションがない場合は、以下のようになります。

スクリーンショット 2024-12-13 13.17.04.png

auth-app/components/header/Header.tsx
"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;

もしセッションがある時、画面にアイコンと設定の画面が出るようにしたいです。

この画面を書いていきます。

auth-app/components/header/UserNavigation.tsx
"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;

ログインしている場合の内容も書きたいので、ヒーローコンポーネントを作ります。

auth-app/components/hero/Hero.tsx
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;

セッションが入っている時は以下のようになります。

スクリーンショット 2024-12-14 13.40.55.png

アカウント管理用のデータベースの設定

supabaseでデータベースを作っていきます。

ここから、データベースの設定をしていきます。
スクリーンショット 2024-12-10 13.09.28.png

スクリーンショット 2024-12-13 13.30.38.png

connectからprismaに繋げるORMも書いていきます。

スクリーンショット 2024-12-13 13.33.20.png

.envファイルに記述していきます。

.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はユニーク識別子として定義されています。
  • providerproviderAccountIdの一意性: 同じプロバイダーの中でアカウントIDが重複しないようにしています。
  • 外部キー: userIdUserテーブルの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は一意であり、セッションの有効性を保証します。
  • 外部キー: userIdUserテーブルを参照し、ユーザーが削除されると関連するセッションも削除されます。
  • 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はリセットトークンとして一意です。
  • 外部キー: userIdUserテーブルを参照します。

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で書くとこうなります。

auth-app/prisma/schema.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のセッションもここでしたかったが、なんかうまくいきませんでした...

auth-app/app/api/auth/[...nextauth]
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 };


登録画面

また、登録する時の画面を書いていきます。

お客さん側と店舗側で登録を分けたいです。

スクリーンショット 2024-12-14 14.01.56.png

auth-app/app/signup/page.tsx
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;

auth-app/components/auth/Signup.tsx
"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;

それぞれの画面はこのようになりました。

スクリーンショット 2024-12-14 14.11.11.png

スクリーンショット 2024-12-14 14.10.36.png

ここに、登録を押した時のPOSTのAPIを書いていきます。

auth-app/app/api/auth/signup/route.ts
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からのコールバックを記載して、リダイレクトさせます。

auth-app/app/signup/signup_Google/[role]/page.tsx
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;

プロバイダーの構成

プロバイダーを載せていきます。

auth-app/components/providers/AuthProvider.tsx
"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も必要でしたね。

auth-app/components/providers/ToastProvider.tsx
"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;

新規登録のお試し

スクリーンショット 2024-12-14 14.20.04.png

登録が成功すると、上記のようにできました。

Googleのプロバイダ設定

GoogleでのOAuthの設定を以下の手順でしていきます。

まずはGCPに登録し、新しいプロジェクトを開始しましょう。

スクリーンショット 2024-12-14 10.20.52.png

APIサービスに移動します。

スクリーンショット 2024-12-14 10.22.17.png

OAuthの設定を完了させていきます。

スクリーンショット 2024-12-14 10.22.31.png

クライアントIDを選択してください。

スクリーンショット 2024-12-14 10.25.34.png

クライアントIDを作成し、リダイレクト先を設定しました。

スクリーンショット 2024-12-14 10.22.09.png

スクリーンショット 2024-12-14 10.27.00.png

作成すると秘密鍵が出てくるのでこちらに載せていきます。

.env
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

ログイン処理

ログインはどちらからしても、Roleで判断することにしているのでできます。

スクリーンショット 2024-12-14 14.16.23.png

auth-app/components/auth/Login.tsx
"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;

うまくいけてますね

スクリーンショット 2024-12-14 18.57.17.png

しかし、登録していないのに押してしまう可能性があるため、念の為選択ページを作ります。

スクリーンショット 2024-12-14 19.25.54.png

登録していない場合に、選択ページに遷移させます。

auth-app/app/signup/signup_Google/page.tsx
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;

ここから追加すること

パスワード初期化、パスワードの変更、画像をアップロード、自己紹介スペースなどまだまだあります。

何かアドバイス等あれば是非コメントお願いします🙏🙏

長文でしたが有藤うございました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?