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?

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

Posted at

はじめに

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

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

プロジェクトの作成

ドキュメントを見ながら進めていきましょう。
How to set up a Next.js project

npx create-next-app@latest
image.png
プロジェクト名はplatformとして作成していきます。
今回は全てYesで進めていきます。

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

以下のコマンドを実行して開発環境を立ち上げてみましょう。
npx next dev
image.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を使ってみる

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

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

shadcnをカスタマイズする

まずはゴールを確認しましょう
image.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

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

*ディレクトリ構造の修正を以下のように行いました。
image.png

DBの作成

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

image.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を作っていきます。

データベースのモデル作成

公式ドキュメントに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

マイグレーション

  1. apiディレクトリで以下のコマンドを実行
    npx prisma migrate dev --name init

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

  3. supabaseから直接管理者アカウントのデータを追加

  4. image.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' 

こんな感じの認証になりました。
管理者認証の動画

終わりに

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

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

0
0
1

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?