
JWTログインAPIをNext.jsで実装する
Shadcn/uiとつなぐ認証基盤の第一歩
初回公開日
最終更新日
0. はじめに
本記事は、前回の「Shadcn/uiで作るログイン画面」の続編となります。
ここでは UI だけではなく、実際にフォームから情報を送信して、JWTトークンを発行するログインAPIを実装します。
また、「JWTって何? Next.js での認証方式とトークンの仕組みを徹底解説(超入門)」で解説したように、JWTはステートレスで軟弱性もある認証トークンです。
この記事ではそれを前提に、ミニマムな構成で実装する方法を紹介します。
1. 目標と構成
今回は、ログインAPIを実装し、JWTを使ったログイン後のユーザー認証フローを構築することが目的です。
/api/login
にログイン用APIを実装し、JWTを発行localStorage
にJWTを保存し、後続ページで利用/dashboard
ページでは、JWTを使って認証チェックを行い、ユーザー情報を表示- 認証処理は
AuthenticatedArea.tsx
に共通化し、どのページでも再利用可能な設計に
下記の構成で、フロント〜バックエンドを含めた最小構成のJWT認証機能を実現します。
txt
1📁 src
2├── app
3│ ├── login/page.tsx ← UI(前回実装)
4│ ├── api/login/route.ts ← ログインAPI(今回実装)
5│ ├── api/protected/route.ts ← 認証付きAPI
6│ ├── dashboard/
7│ │ ├── page.tsx ← ダッシュボード(SSRラッパー)
8│ │ └── DashboardClient.tsx ← トークン検証+ユーザー表示
9├── components
10│ └── AuthenticatedArea.tsx ← トークン検証の共通コンポーネント
11├── lib
12│ ├── jwt.ts ← JWTの署名・検証ユーティリティ
13│ └── users.ts ← 仮ユーザーデータ(今後利用)
2. JWT生成・検証の基盤実装
まずは JWT を発行・検証する関数を
lib/jwt.ts
に実装します。ここでは jose
ライブラリを使用します。jose
のインストール
joseライブラリとは?
jose は、Node.jsとブラウザの両方で使える JWTの生成と検証に特化した暗号ライブラリです。
SignJWT
はトークンの発行に使用しますjwtVerify
は受け取ったトークンが正しい署名かを検証します
下記のコマンドでインストールを実行します。
zsh
1npm install jose
🔧 src/lib/jwt.ts のコード
認証制御を
src/lib/jwt.ts
を作成して下記のように記述します。
今回は jsonwebtoken
パッケージではなく、上記でインストールした、より軽量かつブラウザ対応も意識された jose
パッケージを利用して JWT の署名・検証を行います。ts : jwt.ts
1import { SignJWT, jwtVerify } from "jose";
2
3const secret = new TextEncoder().encode(process.env.JWT_SECRET || "secret-key");
4const alg = "HS256";
5
6// JWTの署名を行う関数(アクセストークン発行)
7export async function signToken(payload: JWTPayload): Promise<string> {
8 return await new SignJWT(payload)
9 .setProtectedHeader({ alg })
10 .setIssuedAt()
11 .setExpirationTime("1h")
12 .sign(secret);
13}
14
15// JWTの検証を行う関数(Cookieから読み取ったtokenを確認)
16export async function verifyToken(token: string): Promise<JWTPayload | null> {
17 try {
18 const { payload } = await jwtVerify(token, secret);
19 return payload as JWTPayload;
20 } catch (e) {
21 if (process.env.NODE_ENV === "development") {
22 console.error(e); // 開発中だけログ出す
23 }
24 return null;
25 }
26}
27
28// JWTペイロードの型(型安全に記述)
29export type JWTPayload = {
30 sub: string; // アカウントID(ログインに使うID)
31 email: string; // 入力されたメールアドレス
32 role?: string; // 今後RBACに使う可能性があるので任意で保持
33 iat?: number; // 発行時刻(JWT標準)
34 exp?: number; // 有効期限(JWT標準)
35};
💡 コードの解説
secret の設定方法
ts
1const secret = new TextEncoder().encode(process.env.JWT_SECRET || 'secret-key')
process.env.JWT_SECRET
: **.env
**ファイルやVercel環境変数などに定義した「秘密鍵」です。TextEncoder
でUint8Array
に変換するのはjose
ライブラリが求める形式のためです.encode(...)
:joseライブラリではUint8Array型のバイト列が必要なので、文字列をバイト配列に変換しています。
✅ 安全な
process.env.JWT_SECRET
の条件:- 英数字 + 記号(ASCII文字のみ)
- 最低32文字以上(64〜128文字推奨)
- Base64形式 or ランダム生成(
openssl rand -base64 64
など)
◯JWT_SECRETの作成と設定例:
これはmacOSなら普通に利用可能なコマンドだと思います:
zsh
1openssl rand -base64 64
dotenv : env
1JWT_SECRET=BIKG9f2KSsxj4lMT+iDl3BEgCg6PvURpqSUw8umE9g/ju/mUNi6DiEjOlM9bwz6GVo4+qhJUTUJWNxLzBkWRsQ==
🔒 SignJWT
と jwtVerify
の使い分け
SignJWT(payload)
:トークンを生成(署名)するためのクラスjwtVerify(token, secret)
:受け取ったトークンを検証し、署名と有効期限を確認
signToken()
の各処理の意味
ts
1new SignJWT(payload)
2 .setProtectedHeader({ alg })
3 .setIssuedAt()
4 .setExpirationTime('1h')
5 .sign(secret)
new SignJWT(payload)
:ペイロード(トークンの中身)をセットします。このときのpayload
は、JWTPayload
型で「sub(アカウントID)」「email」「role」などを含むオブジェクトです。.setProtectedHeader({ alg })
:使用する署名アルゴリズムを指定します(ここではHS256
)。.setIssuedAt()
:現在時刻(UNIX時間)をiat
として付加します。.setExpirationTime('1h')
:トークンの有効期限を「1時間」に設定します。.sign(secret)
:最後に秘密鍵で署名し、安全なトークンとして返します。
この関数によって、改ざん不可な1時間有効のJWTが発行されます。
verifyToken()
の役割
ts
1export async function verifyToken(token: string): Promise<JWTPayload | null> {
2 try {
3 const { payload } = await jwtVerify(token, secret);
4 return payload as JWTPayload;
5 } catch (e) {
6 return null;
7 }
8}
- この関数はトークンの整合性(署名・有効期限)を検証します。
- 失敗時は
null
を返すことで「未ログイン」扱いにできます。
⚠️ トークンが
改ざん
されていたり、有効期限切れ
の場合は catch
に入り、ログイン済み扱いにはなりません。次のステップでは、このトークンの発行と検証を行うAPIルート
/api/login
を実装し、ログイン処理を完成させていきます。3. /api/login
の実装:トークン発行API
ここからは、ログインフォームから送信された情報を受け取り、JWTを発行して返すAPIルート
src/app/api/login/route.ts
を作成します。ts : route.ts
1import { NextResponse } from "next/server";
2import { signToken } from "@/lib/jwt";
3
4// 仮のユーザーデータ(デモ用)
5const dummyUser = {
6 accountId: "demoaccount0123",
7 email: "demo@example.com",
8 password: "password0123456", // 本番ではハッシュ化必須
9 role: "admin",
10};
11
12export async function POST(req: Request) {
13 const body = await req.json();
14 const { accountId, email, password } = body;
15
16 // 入力値の検証(実際はZodやDB連携が必要)
17 if (
18 accountId !== dummyUser.accountId ||
19 email !== dummyUser.email ||
20 password !== dummyUser.password
21 ) {
22 return NextResponse.json({ error: "認証に失敗しました" }, { status: 401 });
23 }
24
25 // JWTのペイロード
26 const payload = {
27 sub: dummyUser.accountId,
28 email: dummyUser.email,
29 role: dummyUser.role,
30 };
31
32 // トークン発行
33 const token = await signToken(payload);
34
35 // クライアントに返す(今回はJSONで返却。Cookie保存ではない)
36 return NextResponse.json({ token });
37}
🔰 コードの解説
📥 POST(req: Request)
とは?
Next.js App Routerでは、
app/api/...
フォルダ以下の route.ts
に GET()
や POST()
を定義することでAPIエンドポイントを実装します。ここではログインフォームからPOSTされたデータを受け取りたいので、
POST()
を使います。🧪 dummyUser
で仮ログイン検証
ts
1const dummyUser = {
2 accountId: 'demo123',
3 email: 'demo@example.com',
4 password: 'password123',
5 role: 'admin'
6}
実際のアプリではデータベースからユーザー情報を取得して照合しますが、今回は簡易デモとして固定ユーザー情報を直接コードに記述しています。
✅ 入力値をチェックしてJWTを発行
ts
1if (accountId !== dummyUser.accountId || ...) {
2 return 401
3}
- ログイン失敗時は HTTPステータス401 Unauthorized を返します
ts
1const payload = { sub, email, role }
signToken()
関数に渡すpayload
には、ユーザーを一意に特定するためにsub
(アカウントID)を含めます
🔐 今回は Cookie 保存ではない理由
今回はトークンを
HttpOnly Cookie
に保存するのではなく、フロントエンド側で localStorage に保存する設計にしています。その理由は以下の通りです:
- 初学者が JWT の仕組みを「目で見て理解」しやすくするため
localStorage
に保存すれば、開発者ツールからトークンの中身が確認できる- Cookie + HttpOnly 方式は本番向けだが、CSRF対策やトークン無効化処理など複雑さが増す
- 今回の記事では「最低限のログイン処理」に焦点をあて、後続記事でセキュアな保存方法に拡張予定
✅ 本番環境では、セキュリティの観点から
HttpOnly Cookie
+ SameSite=Strict
+ Secure
による保存が推奨されます。次はこのAPIをフロントエンドの
LoginForm.tsx
から呼び出し、実際にフォームから認証 → トークン保存 → リダイレクトの流れを作っていきます。4. LoginForm.tsx
からトークンを受け取り保存する
フロントエンドのログインフォームから、先ほどの
/api/login
API を呼び出して、認証成功時には JWT を localStorage
に保存し、失敗時にはエラーメッセージを表示します。Shadcn/uiで作るログイン画面 で作成した
src/components/LoginForm.tsx
に追記していきます。diff : LoginForm.tsx
1"use client";
2
3import { useState } from "react";
4+ import { useRouter } from "next/navigation";
5import { useForm } from "react-hook-form";
6import { zodResolver } from "@hookform/resolvers/zod";
7import { loginSchema } from "@/lib/schema"; // ログインスキーマ
8import { z } from "zod";
9import {
10 Form,
11 FormField,
12 FormItem,
13 FormLabel,
14 FormControl,
15 FormMessage,
16} from "@/components/ui/form";
17import { Input } from "@/components/ui/input";
18import { Button } from "@/components/ui/button";
19import { Eye, EyeOff } from "lucide-react";
20
21export default function LoginForm() {
22 const form = useForm<z.infer<typeof loginSchema>>({
23 resolver: zodResolver(loginSchema),
24 defaultValues: {
25 accountId: "",
26 email: "",
27 password: "",
28 },
29 });
30
31+ const router = useRouter();
32+ const [error, setError] = useState("");
33
34 const [showPassword, setShowPassword] = useState(false);
35
36 function onSubmit(values: z.infer<typeof loginSchema>) {
37+ setError("");
38+ fetch("/api/login", {
39+ method: "POST",
40+ headers: {
41+ "Content-Type": "application/json",
42+ },
43+ body: JSON.stringify(values),
44+ })
45+ .then(async (res) => {
46+ if (!res.ok) {
47+ const { error } = await res.json();
48+ setError(error || "ログインに失敗しました");
49+ return;
50+ }
51+ const { token } = await res.json();
52+ localStorage.setItem("token", token);
53+ router.push("/dashboard");
54+ })
55+ .catch(() => {
56+ setError("通信エラーが発生しました");
57+ });
58 }
59
60 return (
61 <Form {...form}>
62 <form
63 onSubmit={form.handleSubmit(onSubmit)}
64 className="mx-auto max-w-md space-y-4 pt-4 pb-10"
65 >
66 <FormField
67 control={form.control}
68 name="accountId"
69 render={({ field }) => (
70 <FormItem>
71 <FormLabel>アカウントID</FormLabel>
72 <FormControl>
73 <Input
74 data-testid="accountId"
75 placeholder="CORP000123456"
76 autoFocus
77 {...field}
78 />
79 </FormControl>
80 <FormMessage data-testid="accountId-error" />
81 </FormItem>
82 )}
83 />
84 <FormField
85 control={form.control}
86 name="email"
87 render={({ field }) => (
88 <FormItem>
89 <FormLabel>メールアドレス</FormLabel>
90 <FormControl>
91 <Input
92 data-testid="email"
93 type="email"
94 autoComplete="email"
95 placeholder="your@email.com"
96 {...field}
97 />
98 </FormControl>
99 <FormMessage data-testid="email-error" />
100 </FormItem>
101 )}
102 />
103 <FormField
104 control={form.control}
105 name="password"
106 render={({ field }) => (
107 <FormItem>
108 <FormLabel>パスワード</FormLabel>
109 <div className="flex items-start gap-2">
110 <FormControl>
111 <Input
112 {...field}
113 data-testid="password"
114 type={showPassword ? "text" : "password"}
115 autoComplete="current-password"
116 placeholder="半角英数字15文字以上"
117 />
118 </FormControl>
119 <Button
120 data-testid="password-toggle"
121 type="button"
122 size="icon"
123 variant="outline"
124 onClick={() => setShowPassword((prev) => !prev)}
125 aria-label={
126 showPassword
127 ? "パスワードを非表示にする"
128 : "パスワードを表示する"
129 }
130 className="shrink-0 cursor-pointer"
131 >
132 {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
133 </Button>
134 </div>
135 <FormMessage data-testid="password-error" />
136 </FormItem>
137 )}
138 />
139 <Button
140 data-testid="submit"
141 type="submit"
142 className="mt-4 w-full cursor-pointer"
143 >
144 ログイン
145 </Button>
146+ {error && <p className="mt-2 text-sm text-red-500">{error}</p>}
147 </form>
148 </Form>
149 );
150}
🔰 コードの解説:この実装の各ポイント
🧪 fetch("/api/login")
の使い方
- 第2引数で
method: POST
を指定し、body
にJSON形式のログイン情報を送信 headers
にContent-Type: application/json
を指定しないと Express/Next 側が解釈できない
🧠 res.ok
とは?
- HTTPステータスが200系(成功)であれば true、それ以外(400/500系など)は false になります
- 認証失敗時には
401
を返す設計なので、ここで弾ける
💾 localStorage.setItem("token", token)
の意味
- ブラウザのローカルストレージにJWTトークンを保存します
- これにより、後続のAPIリクエストなどでトークンを付与する実装が可能になる(※今回はまだ未実装)
✅ なぜ localStorage
に保存?
- 今回は Cookie 保存を行っていないため、トークンの保持先をフロントエンド側で持つ必要がある
- localStorage に保持すれば、次回のリクエストで
fetch
ヘッダーにAuthorization
をつけるなどの操作ができる
🔁 router.push("/dashboard")
- 認証成功時にダッシュボードページ(仮)へ遷移します
- 実際の画面がまだ存在しない場合は
page.tsx
を用意してください(仮でもOK)
🧾 <LoginForm />
側の表示追加
送信ボタンの下に
エラー表示 <p>
を1行追加しています:tsx
1{error && <p className="text-sm text-red-500 mt-2">{error}</p>}
次は、この保存されたトークンを使って「保護されたAPI」を呼び出す方法や、ログイン中ユーザーの状態を表示する
Dashboard
ページの構築に進みます。5. /dashboard
ページの構築と保護APIの呼び出し
ログイン後に遷移する
/dashboard
ページでは、次の処理を行います:- localStorage から JWT トークンを取得
- API
/api/protected
にトークンを付けて送信 - トークンの有効性を検証し、ユーザー情報を表示
このようにして「ログイン済みユーザーだけがアクセスできる保護ページ」を構築します。
6. APIルート /api/protected
の実装
まずはバックエンド側から。新しいAPIエンドポイントを作成し、トークンを検証してユーザー情報を返すようにします。
src/app/api/protected/route.ts
を下記の内容で作成します。ts : route.ts
1import { NextRequest, NextResponse } from "next/server";
2import { verifyToken } from "@/lib/jwt";
3
4export async function GET(req: NextRequest) {
5 const authHeader = req.headers.get("Authorization");
6 const token = authHeader?.replace("Bearer ", "");
7
8 if (!token) {
9 return NextResponse.json(
10 { error: "トークンがありません" },
11 { status: 401 },
12 );
13 }
14
15 const payload = await verifyToken(token);
16
17 if (!payload) {
18 return NextResponse.json({ error: "無効なトークンです" }, { status: 401 });
19 }
20
21 return NextResponse.json({ user: payload });
22}
🔍 詳細解説:API 側の役割
🔸 req.headers.get("Authorization")
- クライアントから送られてくる
Authorization
ヘッダー("Bearer トークン文字列")を取得します。 - 形式は
Authorization: Bearer eyJhbGciOi...
のようになっています。
🔸 replace("Bearer ", "")
- ヘッダーの中から "Bearer " の部分を取り除き、実際のトークン文字列だけを抽出します。
- スペースや大文字・小文字を間違えると正しく動作しないため注意が必要です。
🔸 verifyToken(token)
src/lib/jwt.ts
で定義したトークン検証関数を使って、有効なトークンかどうかを確認します。- 有効であればユーザー情報(JWTのペイロード)を返し、無効であれば
null
を返します。
🔸 返却内容
- トークンが正しければ
user
情報(sub, email, roleなど)を JSON で返します。 - トークンがない、または無効な場合は 401 Unauthorized エラーを返してガードします。
7. /dashboard
ページの実装
ログイン成功後、ユーザーを
/dashboard
に遷移させる構成にします。このページでは「トークンが正しいことを検証」し、ログインユーザー情報(メールアドレスや権限など)を表示します。ただし、今回の実装ではJWTをlocalStorageに保存しているため、サーバー側(``)ではトークンを取得できません。したがって、クライアントコンポーネント側でトークンの読み取り・検証処理を行う必要があります。
この目的のため、以下のようなファイル構成にします:
txt
1📁 src
2├── app
3│ └── dashboard
4│ ├── page.tsx ← サーバーサイドは構造だけ
5│ └── DashboardClient.tsx ← トークンを検証し、ユーザー情報を表示
📄 page.tsx
(サーバーコンポーネント)
src/app/dashboard/page.tsx
は下記のようになります。tsx
1import DashboardClient from "./DashboardClient";
2
3export default function DashboardPage() {
4 return <DashboardClient />;
5}
このファイルはSSRで処理されますが、認証処理は含みません。単にクライアントコンポーネントをラップして返すだけです。
🧠 なぜこの構成?
- localStorageはサーバー側で使えない:
localStorage
はブラウザ専用APIのため、Node.js側ではアクセスできません - JWTをlocalStorageに保存している限り、認証チェックはクライアントで行う必要がある
- 将来Cookieに保存する場合でもこの構成を活かせる:APIを通じて認証確認する設計は、Cookie方式にも応用可能です
8. DashboardClient.tsx
の役割と構成
この
DashboardClient.tsx
に以下の機能を実装します:- localStorageからJWTを取得
/api/protected
にリクエストを送り、ログイン状態を検証- 成功時:メールアドレスや権限情報を表示
- 失敗時:ログイン画面へのリダイレクト、またはエラー表示
また、この処理は今後
/users
などのページでも再利用できるよう、汎用コンポーネント AuthenticatedArea.tsx
に切り出す予定です。このように、Next.js + JWT認証構成においては「サーバーで無理に認証処理をしない」ことで、構成の明快さとクライアント制御の柔軟性を最大化できます。
次は、
DashboardClient.tsx
の具体的なコード実装と、ログイン後ユーザーの状態管理UIを構築していきます。9. DashboardClient.tsx
の実装
ここでは、
/dashboard
ページでログイン済みユーザーの情報を表示するためのクライアントコンポーネント DashboardClient.tsx
を実装します。トークンの取得と認証チェックの処理は、他ページでも再利用可能にするため、共通コンポーネント
src/components/AuthenticatedArea.tsx
に切り出して利用します。txt
1📁 src
2├── components
3│ └── AuthenticatedArea.tsx ← トークンの読み取り&API検証
4├── app
5│ └── dashboard
6│ ├── page.tsx
7│ └── DashboardClient.tsx ← ここで認証結果を使ってUIを構成
✅ AuthenticatedArea.tsx の内容
トークンの読み取り&API検証を行う
src/components/AuthenticatedArea.tsx
を作成します。tsx
1import { useEffect, useState } from "react";
2import { useRouter } from "next/navigation";
3
4export type User = {
5 sub: string;
6 email: string;
7 role?: string;
8};
9
10type Props = {
11 children: (user: User) => React.ReactNode;
12};
13
14export default function AuthenticatedArea({ children }: Props) {
15 const [user, setUser] = useState<User | null>(null);
16 const [checking, setChecking] = useState(true);
17 const router = useRouter();
18
19 useEffect(() => {
20 const token = localStorage.getItem("token");
21 if (!token) {
22 router.replace("/");
23 return;
24 }
25
26 fetch("/api/protected", {
27 method: "GET",
28 headers: {
29 Authorization: `Bearer ${token}`,
30 },
31 })
32 .then(async (res) => {
33 if (!res.ok) {
34 throw new Error("Unauthorized");
35 }
36 const { user } = await res.json();
37 setUser(user);
38 setChecking(false);
39 })
40 .catch(() => {
41 localStorage.removeItem("token");
42 router.push("/");
43 });
44 }, [router]);
45
46 if (checking) return <p>読み込み中...</p>;
47 if (!user) return null; // ログインページにリダイレクト中
48
49 return <>{children(user)}</>;
50}
🔍 解説:このコンポーネントの目的
処理 | 説明 |
---|---|
localStorage.getItem("token") | ローカルストレージに保存されたトークンを読み込む |
/api/protected に fetch | JWT が有効かどうかをAPI経由で検証 |
children(user) で描画 | ログイン済みユーザーを親コンポーネントに引き渡す |
✅ DashboardClient.tsx の内容
tsx
1"use client";
2
3import AuthenticatedArea, { User } from "@/components/AuthenticatedArea";
4
5export default function DashboardClient() {
6 return (
7 <AuthenticatedArea>
8 {(user: User) => (
9 <div className="max-w-xl mx-auto pt-10">
10 <h1 className="text-2xl font-bold mb-4">ダッシュボード</h1>
11 <p>ようこそ、{user.email} さん</p>
12 <p className="text-sm text-muted-foreground mt-2">
13 アカウントID: {user.sub}
14 </p>
15 {user.role && <p>ロール: {user.role}</p>}
16 </div>
17 )}
18 </AuthenticatedArea>
19 );
20}
🧠 解説:このコンポーネントの責務
- 認証に成功したユーザーの情報を受け取り、UIに表示します
AuthenticatedArea
が認証状態を判断・管理してくれるため、ロジックは完全に分離可能User
型に基づいて安全にemail
やrole
を参照できます
このようにすることで:
- トークン検証処理を一箇所に集約
- 複数ページで使い回せる
- 認証結果を明確に props として扱える
- 今後の RBAC 対応やログインユーザーの切り替えにも対応しやすくなる
10. テスト実行
ここまで作成したら、基本的な動作の確認です。
zsh
1npm run dev
ログイン画面が起動したら、
src/app/api/login/route.ts
で記載したテスト用のログインデータを入力します。txt
1 accountId: "demoAccount0123",
2 email: "demo@example.com",
3 password: "passWord0123456",
ログイン成功時:

ログイン失敗時:

上記のような2パターンの状態を確認できます。
また、ログインしていない状態(別ブラウザなどチェックするほうが簡単かも)で直接
http://localhost:3000/dashboard
でダッシュボードページを表示しようとしてもログイン画面へ遷移することを確認可能です。これで、ログイン機能のUIを利用した実践ができました。まだDB連携などこれからですが、まずは基本の流れが体験できました。
次回はこの
AuthenticatedArea
を使って、/users
ページでログイン済みユーザー一覧を管理者限定で表示するUIの構築に進みます。参考文献
- JWT.io — JSON Web Token 入門
- jose - Node.js用のJWT実装(GitHub)
- MDN Web Docs: localStorage
- Next.js App Router の API Routes(公式)
- 認証とは何か?セッション vs JWT を比較
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
この記事の更新履歴
2025/7/30
`src/components/AuthenticatedArea.tsx`を修正:ログイン状態でないときに、ログイン後の画面(/dashboard)へ直接アクセスした際、エラーメッセージではなくログイン画面へ遷移させるように修正
2025/7/29
初回公開