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

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

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

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

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

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

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


![スクリーンショット 2024-12-10 20.58.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/5fef6e52-3473-9323-b5bd-9ebaaa859cc7.png)

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

![スクリーンショット 2024-12-14 19.31.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/baaf46db-2736-e3ac-5549-a25513cb5c03.png)


この表はNext.NAVで表しています。もしよかったら使ってみてください。

https://www.next-nav.com/

また、参考にしたチュートリアルを置いておきます

https://next-auth.js.org/v3/tutorials

https://create.t3.gg/ja/usage/next-auth

# 環境準備
まずは必要なものをインストールしていきます。基本的に全てYesで構わないです。

```shell
% 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? › @/*
```

```shell
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/21abcb1c-35ae-9e05-0f3d-cad35dfabd49.png)

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

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

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


まずは、みられたくないお客さんの大事な情報が載ったページを作っていきます。
![スクリーンショット 2024-12-14 13.05.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/1e15810c-551e-08f3-b9d4-72064d2e5f9c.png)


```typescript: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/f0251221-1c00-7b5e-f97f-a14d40d4c427.png)


```typescript: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/21abcb1c-35ae-9e05-0f3d-cad35dfabd49.png)

```typescript: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>
  );
}

```

```typescript: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/e96b19db-8589-a896-e6de-0bf6c1cc0cab.png)



```typescript: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;

```






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

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

```typescript: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;

```

ログインしている場合の内容も書きたいので、ヒーローコンポーネントを作ります。
```typescript: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/2e864acb-8cb2-6b5c-3a88-526a5bf12d56.png)




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

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

ここから、データベースの設定をしていきます。
![スクリーンショット 2024-12-10 13.09.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/8e0ad3da-a1d0-d3a9-073d-a6fdb287e90e.png)

![スクリーンショット 2024-12-13 13.30.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/8df222cf-1dee-ee15-fa5e-9e6bb1a9dd80.png)

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

![スクリーンショット 2024-12-13 13.33.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/a11be1be-a9ba-22d5-922d-810f18769d21.png)

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

```shell:.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`テーブルの作成**

```sql
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`テーブルの作成**

```sql
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`テーブルの作成**

```sql
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`テーブルの作成**

```sql
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`テーブルの作成**

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

```typescript: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])
}


```



```shell
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
```

```shell
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

```

こちらをすると、マイグレーションファイルを作成せずに、直接データベースを構築できます。
```typescript
npx prisma db push
```


大丈夫そうですね。

# Nextauthの設定

続いて、nextauthファイルの設定を行なっておきます。動的ルートでnextが受け取ることで認証を行うことができます。


今回はGoogleも行っていきます。
* 構成としては2種類の役割を作るために、まず拡張させる。
* 認証が正しいのかを確認する。
* sessionはjwtを使う。

ほんとはGoogleのセッションもここでしたかったが、なんかうまくいきませんでした...

```typescript: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/87b1b0f4-4e69-4e20-cb09-b76858260eee.png)


```typescript: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;

```



```typescript: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/bebb7f38-aafa-a322-7889-575857e98bf9.png)


![スクリーンショット 2024-12-14 14.10.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/68c33f19-46b4-1edf-041b-968a6460315b.png)



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

```typescript: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からのコールバックを記載して、リダイレクトさせます。

```typescript: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;

```

# プロバイダーの構成
プロバイダーを載せていきます。
```typescript: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も必要でしたね。
```typescript: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の使い方にしていきます。

https://www.prisma.io/docs/orm/more/help-and-troubleshooting/help-articles/nextjs-prisma-client-dev-practices


```typescript:
//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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/874c4b56-fd97-6132-b1e6-5dbda8840c18.png)

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



# Googleのプロバイダ設定

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

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

https://cloud.google.com/free

![スクリーンショット 2024-12-14 10.20.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/ba729bed-7390-a958-d716-6cc6c477ff68.png)

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

![スクリーンショット 2024-12-14 10.22.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/c5388749-193d-3f98-b4cc-f5ed2e3870fe.png)

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

![スクリーンショット 2024-12-14 10.22.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/92ef00c7-a671-9806-7fc7-2387ba8a5de1.png)

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

![スクリーンショット 2024-12-14 10.25.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/a7e8d532-72fd-1cfc-1578-d610282bb59e.png)

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

![スクリーンショット 2024-12-14 10.22.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/ef34da27-be49-72a0-ea1e-0ef667c418b8.png)

![スクリーンショット 2024-12-14 10.27.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/7bcf8d48-d323-b809-711f-da3feebf224e.png)

作成すると秘密鍵が出てくるのでこちらに載せていきます。
```shell:.env
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
```






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

![スクリーンショット 2024-12-14 14.16.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/75d51c47-49d3-d6cb-cc5d-f416e818ff24.png)

```typescript: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/832931ff-97cd-a094-8479-00b91321e7c8.png)

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

![スクリーンショット 2024-12-14 19.25.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3738967/1504cdf9-012f-89e1-5e5c-99f5fe1799d9.png)

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

```typescript: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;

```


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

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

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