【企画から開発までを実践】Next.jsでwebアプリを作ってみよう #2

# はじめに
前回は企画、要件定義・設計までを記事にしていきました。

今回の記事では管理者の認証機能のUI作成をやっていきたいと思います。

# プロジェクトの作成
ドキュメントを見ながら進めていきましょう。
[How to set up a Next.js project](https://nextjs.org/docs/app/getting-started/installation)

①`npx create-next-app@latest`
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942257/01144efc-5863-91e5-458e-4de08833ab58.png)
プロジェクト名はplatformとして作成していきます。
今回は全てYesで進めていきます。

platformディレクトリに移動したら以下のコマンドを実行していきます。
②`npm install next@latest react@latest react-dom@latest`

以下のコマンドを実行して開発環境を立ち上げてみましょう。
③`npx next dev`
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942257/87186f22-0bd0-23d1-7af1-3d9f6f11fcf7.png)


ここまででプロジェクトの作成は完了です。

# ファイルの整理
## ①publicフォルダの中身を削除
## ②layout.tsxファイルの言語を英語から日本語に変更
```
  return (
    <html lang="ja">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
```
## ③フォントの削除
layout.tsxを以下のように変更し、fontsファイルを丸ごと削除
```
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja">
      <body>
        {children}
      </body>
    </html>
  );
}
```

## ④ファイルを削除
page.tsx

## ⑤faviconを移動
faviconをpublicディレクトリに移動させる


## ⑥ルーティングの作成
```
app
┗blog
  ┗page.tsx
┗dashboard
  ┗login
    ┗page.tsx
  ┗page.tsx
layout.tsx
```


```blog/page.tsx
import React from 'react'

 const Blog = () => {
  return (
    <div>blog</div>
  )
}

export default Blog;
```


```dashboard/page.tsx
import React from 'react'

const Dashboard = () => { 
  return (
    <div>dashboard</div>
  )
}

export default Dashboard;
```


```dashboard/login/page.tsx
import React from 'react'

const Login = () => {
  return (
    <div>login</div>
  )
}

export default Login;
```
ここまででページ遷移するためのルーティングの設定が完了しました。

# 管理者ログインページの認証フォームUI作成
## shadcnのインストール
`npx shadcn@latest init -d`を実行
あれ、エラーが出た。

No Tailwind CSS configuration found at /Users/sakuya/app/platform.
It is likely you do not have Tailwind CSS installed or have an invalid configuration.
Install Tailwind CSS then try again.

どうやらTailwind CSSの設定がうまくいっていないようです。
まずはインストールがうまくいっているかの確認を行いましょう
`npm install -D tailwindcss postcss autoprefixer`を実行

私は上記のコマンドで対応できました。
再度shadcnをインストールするコマンドを実行して無事に完了しました。

## shadcnを使ってみる
https://ui.shadcn.com/docs/components/card

`npx shadcn@latest add card`
`npx shadcn@latest add button`
`npx shadcn@latest add input`
`npx shadcn@latest add label`
`npx shadcn@latest add select`

この5つのコマンドを実行したら以下をサイトからコピペするだけ

```

    <Card className="w-[350px]">
      <CardHeader>
        <CardTitle>Create project</CardTitle>
        <CardDescription>Deploy your new project in one-click.</CardDescription>
      </CardHeader>
      <CardContent>
        <form>
          <div className="grid w-full items-center gap-4">
            <div className="flex flex-col space-y-1.5">
              <Label htmlFor="name">Name</Label>
              <Input id="name" placeholder="Name of your project" />
            </div>
            <div className="flex flex-col space-y-1.5">
              <Label htmlFor="framework">Framework</Label>
              <Select>
                <SelectTrigger id="framework">
                  <SelectValue placeholder="Select" />
                </SelectTrigger>
                <SelectContent position="popper">
                  <SelectItem value="next">Next.js</SelectItem>
                  <SelectItem value="sveltekit">SvelteKit</SelectItem>
                  <SelectItem value="astro">Astro</SelectItem>
                  <SelectItem value="nuxt">Nuxt.js</SelectItem>
                </SelectContent>
              </Select>
            </div>
          </div>
        </form>
      </CardContent>
      <CardFooter className="flex justify-between">
        <Button variant="outline">Cancel</Button>
        <Button>Deploy</Button>
      </CardFooter>
    </Card>
```

そうすると、、、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942257/bb4c00a5-9209-5175-7b06-fe420a041c3c.png)

簡単にフォームっぽいものができてしまいました！

## shadcnをカスタマイズする

まずはゴールを確認しましょう
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942257/1885ad7c-4f75-a958-b5a0-aa2eb9766b60.png)

以下のように修正していきます。
```login/page.tsx
import React from 'react'
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"


const Login = () => {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <Card className="w-full max-w-md">
        <CardHeader>
          <CardTitle className="text-2xl text-center">管理者ログイン</CardTitle>
        </CardHeader>
        <CardContent>
          <form>
            <div className="grid w-full items-center gap-4">
              <div className="flex flex-col space-y-1.5">
                <Label htmlFor="name">メールアドレス</Label>
                <Input id="name" />
              </div>
            </div>
          </form>
        </CardContent>
        <CardContent>
          <form>
            <div className="grid w-full items-center gap-4">
              <div className="flex flex-col space-y-1.5">
                <Label htmlFor="name">パスワード</Label>
                <Input id="name" />
              </div>
            </div>
          </form>
        </CardContent>
        <CardFooter className="flex justify-between">
          <Button type="submit" className="w-full">ログイン</Button>
        </CardFooter>
      </Card>
    </div>
  )
}

export default Login;
```

完成系はこちら
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942257/1d297723-5fb8-096d-519c-e030be0033fb.png)

いい感じに修正できたと思います。

*ディレクトリ構造の修正を以下のように行いました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942257/36da173b-5654-207d-4d26-1b41a6de62a3.png)

# DBの作成
次は認証を行うためにdbを作っていきましょう。
DBは以下のようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942257/1ea87851-aef1-2830-60e1-63e562648479.png)


## supabase
### プロジェクトの作成
new projectからプロジェクトを作成する。
リージョンは日本を選択してください。
### apiディレクトリと.envファイルの作成
appディレクトリと同階層にapiディレクトリを作成。
その後.envファイルを作成して環境変数を記述していきます。
自分自身のProject URLとAPI Keyを入力してください。
```.env
NEXT_PUBLIC_SUPABASE_URL=Project URL
NEXT_PUBLIC_API_ANON_KEY=API Key
```
これでsupabaseと接続・操作ができるようになりました。
## Prisma
### Prismaの初期設定
1. api/libフォルダーを作成しprismaClient.tsファイルを作成しましょう。
2. `npm install -D prisma`を実行してprismaを入れていきます。
3. apiディレクトリに移動して`npx prisma init`を実行します。
    そうするとapiディレクトリにprismaフォルダーが作成され、.envファイルに新たに環境変数が追加されていると思います。

4. supabaseの設定からdatabaseを選択してdatabaseURLの環境変数をコピーして、先ほど.envファイルに作成された環境変数にコピペします。

ここまででprismaの初期設定が終わったのでdbを作っていきます。
### データベースのモデル作成
[公式ドキュメント](https://www.prisma.io/docs/getting-started/quickstart-sqlite#2-model-your-data-in-the-prisma-schema)にmodelがあるのでこれを編集して作成していきます。

```schema.prisma
model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String
  password String
  articles article[]
  createAt DateTime @default(now())
  updateAt DateTime @updatedAt
}

model article {
  id        Int     @id @default(autoincrement())
  title     String
  content   String
  status Boolean @default(true)
  author User @relation(fields: [authorId], references: [id])
  authorId  Int 
  createAt DateTime @default(now())
  updateAt DateTime @updatedAt
}
```
当初想定していた設計を少し変更しました。
ちょっとした余談なのですが、設計を作り切るのは難しいですね。

修正後はこんな感じ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942257/c82f6ef7-8019-7d3d-9b40-aa7eac87ac57.png)

### マイグレーション
1. apiディレクトリで以下のコマンドを実行
`npx prisma migrate dev --name init`

2. prisma配下にmigrationsが作成されていることを確認,supabaseの方も確認するとdbが作成されていると思います。

3. supabaseから直接管理者アカウントのデータを追加
3. ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3942257/046dd019-f563-a60a-4997-06a785221cb5.png)

ここまででprismaの全体の設定が完了！

## Hono
次はバックエンドの処理を追加していきます。
具体的には、
メールアドレス、パスワードが一致していた場合、ログインができて/applications/dashboardに遷移できる
また、ログインできていないのにapplications/dashboardに遷移できないようにする。
この二つの処理を実装していきましょう。
*今回ではセキュリティーの実装はしません。今後追加します。

### Honoと必要なものを追加

`npm add hono`
`npm install @hono/node-server`
`npm install --save bcrypt`
`npm install --save-dev ts-node`

### ディレクトリの追加
以下のように作成してください。
```
src/
├── api/
│   ├── lib/
│   │   └── prismaClient.ts
│   ├── middleware/
│   │   ├── auth/
│   │   │   ├── index.ts
│   │   │   └── types.ts
│   │   └── index.ts
│   ├── prisma/
│   │   ├── migrations/
│   │   │   └── 20241124063915_init/
│   │   │       └── migration.sql
│   │   └── schema.prisma
│   ├── routes/
│   │   └── dashboard.ts
│   ├── utils/
│   │   └── jwt.ts
│   ├── index.ts
│   └── tsconfig.json
│
└── app/
    ├── applications/
    │   ├── blog/
    │   │   └── page.tsx
    │   └── dashboard/
    │       ├── login/
    │       │   └── page.tsx
    │       └── page.tsx
    ├── components/
    │   └── ui/
    │       ├── card.tsx
    │       └── select.tsx
    ├── middleware/
    │   ├── auth/
    │   │   ├── hooks.ts
    │   │   ├── index.tsx
    │   │   └── types.ts
    │   └── index.ts
    ├── styles/
    │   └── globals.css
    └── layout.tsx
```

### 認証のイベント追加
```dashboard/login/page.tsx
'use client'
import React, { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from "@/app/components/ui/button"
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/app/components/ui/card"
import { Input } from "@/app/components/ui/input"
import { Label } from "@/app/components/ui/label"

const Login = () => {
  const router = useRouter()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError('')

    try {
      const res = await fetch('http://localhost:4000/dashboard/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ email, password }),
      })
      
      const data = await res.json()

      if (!res.ok) {
        throw new Error(data.message || 'ログインに失敗しました')
      }

      if (data.success && data.token) {
        localStorage.setItem('token', data.token)
        router.push('/applications/dashboard')
      } else {
        throw new Error('認証に失敗しました')
      }

    } catch (err) {
      if (err instanceof Error) {
        setError(err.message)
      } else {
        setError('ログインに失敗しました')
      }
      console.error('Login error:', err)
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <Card className="w-full max-w-md">
        <CardHeader>
          <CardTitle className="text-2xl text-center">管理者ログイン</CardTitle>
        </CardHeader>
        {error && (
          <div className="px-6 py-2 text-red-500 text-center">{error}</div>
        )}
        <form onSubmit={handleSubmit}>
          <CardContent>
            <div className="grid w-full items-center gap-4">
              <div className="flex flex-col space-y-1.5">
                <Label htmlFor="email">メールアドレス</Label>
                <Input
                  id="email"
                  type="email"
                  value={email}
                  onChange={(e) => setEmail(e.target.value)}
                  required
                />
              </div>
            </div>
          </CardContent>
          <CardContent>
            <div className="grid w-full items-center gap-4">
              <div className="flex flex-col space-y-1.5">
                <Label htmlFor="password">パスワード</Label>
                <Input
                  id="password"
                  type="password"
                  value={password}
                  onChange={(e) => setPassword(e.target.value)}
                  required
                />
              </div>
            </div>
          </CardContent>
          <CardFooter className="flex justify-between">
            <Button type="submit" className="w-full">ログイン</Button>
          </CardFooter>
        </form>
      </Card>
    </div>
  )
}

export default Login
```

### honoでapiサーバー構築
```api/index.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { dashboard } from './routes/dashboard'

const app = new Hono()

// CORSの設定
app.use('/*', cors({
  origin: ['http://localhost:3000'],
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowHeaders: ['Content-Type', 'Authorization'],
  exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
  maxAge: 600,
  credentials: true,
}))

// ルートの設定
app.route('/dashboard', dashboard)

// サーバーの起動
const port = 4000
console.log(`Server is running on port ${port}`)

serve({
  fetch: app.fetch,
  port
})

```

次にルート先を設定します。
```routes/dashboard.ts
import { Hono } from 'hono'
// import bcrypt from 'bcrypt'
import { generateToken } from '../utils/jwt'
import { authMiddleware } from '../middleware/auth'
import { prisma } from '../lib/prismaClient'

type Variables = {
  userId: number;
};

const dashboard = new Hono<{ Variables: Variables }>()

dashboard.post('/login', async (c) => {
  try {
    const { email, password } = await c.req.json()

    const user = await prisma.user.findUnique({
      where: { email },
      select: {
        id: true,
        email: true,
        password: true
      }
    })


    if (!user) {
      return c.json({ 
        success: false,
        message: 'メールアドレスまたはパスワードが間違っています' 
      }, 401)
    }

    // const isValid = await bcrypt.compare(password, user.password)
    const isValid = password === user.password

    if (!isValid) {
      return c.json({ 
        success: false,
        message: 'メールアドレスまたはパスワードが間違っています' 
      }, 401)
    }

    const token = generateToken(user.id)
    return c.json({ 
      success: true,
      token, 
      userId: user.id 
    })

  } catch (error) {
    console.error('Login error:', error)
    return c.json({ 
      success: false,
      message: 'ログイン処理中にエラーが発生しました' 
    }, 500)
  }
})

dashboard.use('/protected/*', authMiddleware)

dashboard.get('/protected/user', async (c) => {
  const userId = c.get('userId')
  
  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: {
      id: true,
      email: true,
      name: true,
      createAt: true
    }
  })

  if (!user) {
    return c.json({ message: 'ユーザーが見つかりません' }, 404)
  }

  return c.json(user)
})

export { dashboard }
```

次に認証が成功した後のトークン作成する処理を実装しましょう。
```utils/jwt.ts
import jwt from 'jsonwebtoken'

const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'

interface JWTPayload {
  userId: number
}

export const generateToken = (userId: number): string => {
  return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '24h' })
}

export const verifyToken = (token: string): JWTPayload | null => {
  try {
    const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload
    return decoded
  } catch {
    return null
  }
}
```

次にapiとjwtの認証チェックの処理を記述していきましょう。
```auth/index.ts
import { Context, Next } from 'hono'
import { verifyToken } from '../../utils/jwt'
import type { AuthMiddlewareContext } from './types'

export const authMiddleware = async (c: Context<{ Variables: AuthMiddlewareContext }>, next: Next) => {
  const token = c.req.header('Authorization')?.split(' ')[1]
  
  if (!token) {
    return c.json({ message: '認証が必要です' }, 401)
  }

  const decoded = verifyToken(token)
  if (!decoded) {
    return c.json({ message: 'トークンが無効です' }, 401)
  }

  c.set('userId', decoded.userId)
  await next()
} 
```

次は型定義です。
```auth/types.ts
export interface JWTPayload {
  userId: number;
}

export interface AuthMiddlewareContext {
  userId: number;
}
```

処理を外で使えるようにしましょう。
```middleware/index.ts
export { authMiddleware } from './auth'
export type { AuthMiddlewareContext, JWTPayload } from './auth/types' 
```

次はアプリ側のmiddlewareの処理を記述していきましょう。
まずは、HOCの実装です。
HOCは「コンポーネントを受け取って、新しいコンポーネントを返す関数」です。簡単に言うと、コンポーネントを包装（ラップ）して機能を追加するパターンです。

```auth/index.tsx
'use client'
import React, { useEffect } from 'react'
import { useAuth } from './hooks'

export function withAuth<P extends object>(
  WrappedComponent: React.ComponentType<P>
) {
  return function WithAuthComponent(props: P) {
    const { isAuthenticated, isLoading, checkAuth } = useAuth()

    useEffect(() => {
      checkAuth()
    }, [checkAuth])

    if (isLoading) return <div>Loading...</div>
    if (!isAuthenticated) return null

    return <WrappedComponent {...props} />
  }
} 
```

次に認証ロジックとAPI通信の追加です。
```auth/hooks.ts
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'

export const useAuth = () => {
  const router = useRouter()
  const [isAuthenticated, setIsAuthenticated] = useState(false)
  const [isLoading, setIsLoading] = useState(true)

  const verifyAuth = async (token: string): Promise<boolean> => {
    try {
      const res = await fetch('http://localhost:4000/dashboard/protected/user', {
        headers: {
          'Authorization': `Bearer ${token}`
        }
      })
      return res.ok
    } catch {
      return false
    }
  }

  const checkAuth = async () => {
    const token = localStorage.getItem('token')
    if (!token) {
      setIsAuthenticated(false)
      setIsLoading(false)
      router.push('/applications/dashboard/login')
      return false
    }

    const isValid = await verifyAuth(token)
    if (!isValid) {
      setIsAuthenticated(false)
      setIsLoading(false)
      localStorage.removeItem('token')
      router.push('/applications/dashboard/login')
      return false
    }

    setIsAuthenticated(true)
    setIsLoading(false)
    return true
  }

  return { isAuthenticated, isLoading, checkAuth }
}
```

型定義の集約を行いましょう
```auth/types.ts
export interface AuthState {
  isAuthenticated: boolean;
  isLoading: boolean;
}

export interface AuthResponse {
  success: boolean;
  token?: string;
  userId?: number;
  message?: string;
}

export interface AuthHook {
  isAuthenticated: boolean;
  isLoading: boolean;
  checkAuth: () => Promise<boolean>;
} 
```
最後に外で使えるようにしましょう。

```middleware/index.ts
export { withAuth } from './auth'
export { useAuth } from './auth/hooks'
export type { AuthState, AuthResponse, AuthHook } from './auth/types' 
```

こんな感じの認証になりました。
[管理者認証の動画](https://gyazo.com/4f8217cf503d3a815094420eaab072ca)

# 終わりに
認証機能を今回は簡単に実装しました。今後はセキュリティー面を修正する必要がありますね。
また、今回認証機能を作るにあたり認証と認可の違いだったり、さまざまな認証方法があったりととても奥深いものだと思いました。

今後いろんな認証方法を実装していき学んでいきたいと思いました。