前の記事
次の記事
全体の流れ
まずは次の構成を目指す。
task-management-system-v2
├─ backend
│ └─ ProductionProgress
│ └─ ...
│
└─ frontend
└─ production-progress-web
フェーズ5-1:Next.jsプロジェクト作成
フェーズ5-2:フォルダ構成・共通レイアウト
フェーズ5-3:API接続設定
フェーズ5-4:ログイン画面
フェーズ5-5:JWT認証
フェーズ5-6:工程一覧画面
フェーズ5-7:作業一覧画面
フェーズ5-8:作業登録・編集
フェーズ5-9:検索・ページング
フェーズ5-10:Docker ComposeへNext.js追加
フェーズ5-1:Next.jsプロジェクト作成
VS Codeで、
C:\task-management-system-v2\frontend
のフォルダを開く。
VS Codeのターミナルを開く。
npx.cmd create-next-app@latest production-progress-web
カスタマイズでインストールを選んで、
Would you like to use TypeScript? → Yes
Which linter would you like to use? → ESLint
Would you like to use React Compiler? → No
Would you like to use Tailwind CSS? → No
Would you like your code inside a `src/` directory? → Yes
Would you like to use App Router? → Yes
Would you like to customize the import alias? → No
の質問に答えていく。
cd production-progress-web
続いて、
npm.cmd run dev
を実行する。
その後、ブラウザで。
http://localhost:3000
を開いて、Next.jsの初期画面が表示されればOK。
フェーズ5-2:初期画面を整理して、アプリ用の土台を作る
src/app/page.tsxを、いったん次のシンプルな内容に置き換える。
export default function Home() {
return (
<main>
<h1>工程進捗管理システム</h1>
<p>フロントエンド起動確認</p>
</main>
);
}
次に、src/app/layout.tsxを開く。
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "工程進捗管理システム",
description: "工程の進捗状況を管理するWebシステム",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ja">
<body>{children}</body>
</html>
);
}
layout.tsx
↓
アプリ全体の共通枠
page.tsx
↓
その中に表示する各画面
となっており、page.tsxの内容がchildrenの位置に表示される。
ログイン画面を作成
loginフォルダを作成
VS Codeで、
src
└─ app
├─ login
│ ├─ page.tsx
│ └─ login.module.css
├─ globals.css
├─ layout.tsx
└─ page.tsx
となるように、loginフォルダを作成する。
login/page.tsxを作成
src/app/login/page.tsx
// ログイン画面
export default function LoginPage() {
return (
<main>
<h1>ログイン</h1>
<form>
{/* ユーザー名入力欄 */}
<div>
<label htmlFor="username">ユーザー名</label>
<input
id="username"
name="username"
type="text"
/>
</div>
{/* パスワード入力欄 */}
<div>
<label htmlFor="password">パスワード</label>
<input
id="password"
name="password"
type="password"
/>
</div>
{/* ログインボタン */}
<button type="submit">
ログイン
</button>
</form>
</main>
);
}
ブラウザで、
http://localhost:3000/login
を開く。
CSS Modulesを使って見た目を整える
src/app/login/login.module.css
/* ログイン画面全体 */
.container {
/* 画面いっぱいの高さを確保する */
min-height: 100vh;
/* 子要素を中央に配置する */
display: flex;
justify-content: center;
align-items: center;
/* 背景色 */
background-color: #f5f5f5;
}
/* ログインフォームを囲むカード */
.loginCard {
width: 100%;
max-width: 400px;
padding: 32px;
background-color: white;
border: 1px solid #ddd;
border-radius: 8px;
}
/* 画面タイトル */
.title {
margin-top: 0;
margin-bottom: 24px;
text-align: center;
}
/* ユーザー名・パスワードそれぞれの入力エリア */
.formGroup {
margin-bottom: 16px;
}
/* 入力項目のラベル */
.label {
display: block;
margin-bottom: 6px;
font-weight: bold;
}
/* テキスト入力欄 */
.input {
width: 100%;
padding: 10px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 4px;
/* width: 100%でもpaddingによって
親要素からはみ出さないようにする */
box-sizing: border-box;
}
/* ログインボタン */
.loginButton {
width: 100%;
padding: 12px;
font-size: 16px;
cursor: pointer;
}
page.tsxからCSSを読み込む
page.tsxを次のように変更する。
// このログイン画面専用のCSSを読み込む
import styles from "./login.module.css";
// ログイン画面
export default function LoginPage() {
return (
// 画面全体
<main className={styles.container}>
{/* ログインフォームを囲むカード */}
<div className={styles.loginCard}>
<h1 className={styles.title}>
ログイン
</h1>
<form>
{/* ユーザー名 */}
<div className={styles.formGroup}>
<label
className={styles.label}
htmlFor="username"
>
ユーザー名
</label>
<input
className={styles.input}
id="username"
name="username"
type="text"
/>
</div>
{/* パスワード */}
<div className={styles.formGroup}>
<label
className={styles.label}
htmlFor="password"
>
パスワード
</label>
<input
className={styles.input}
id="password"
name="password"
type="password"
/>
</div>
{/* ログインボタン */}
<button
className={styles.loginButton}
type="submit"
>
ログイン
</button>
</form>
</div>
</main>
);
}
フェーズ5-3:Server ActionでログインAPIと接続
今回の構成
ブラウザ
↓ form送信
Next.js Server Action
↓ POST http://localhost:8080/api/Auth/login
ASP.NET Core API
↓
JWTを返す
↓
Next.js Server Action
↓
HttpOnly CookieにJWTを保存
↓
トップ画面へ移動
.env.localを作る
Next.jsプロジェクト直下、
production-progress-web
├─ src
├─ package.json
├─ next.config.ts
└─ .env.local ← 作成
に.env.localを作成する。
API_BASE_URL=http://localhost:8080
一度Next.jsを停止して、
npm.cmd run dev
で再起動する。
actions.tsを作成
次のファイルを作る。
src
└─ app
└─ login
├─ actions.ts ← 新規
├─ login-form.tsx ← 後で作成
├─ login.module.css
└─ page.tsx
src/app/login/actions.ts
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
/**
* ASP.NET CoreのログインAPIから返されるデータ。
*
* C#側のLoginResponseに対応する。
*/
type LoginResponse = {
userId: number;
username: string;
role: string;
accessToken: string;
expiresAt: string;
};
/**
* ログイン画面へ返す状態。
*
* ログイン失敗時はerrorにメッセージを入れる。
*/
export type LoginState = {
error: string | null;
};
/**
* ログイン処理を行うServer Action。
*
* ブラウザから直接ASP.NET Coreを呼ぶのではなく、
* Next.jsサーバーからASP.NET Core APIを呼び出す。
*/
export async function loginAction(
_prevState: LoginState,
formData: FormData
): Promise<LoginState> {
// --------------------------------------------------
// 入力値を取得
// --------------------------------------------------
const username = String(
formData.get("username") ?? ""
).trim();
const password = String(
formData.get("password") ?? ""
);
// --------------------------------------------------
// 簡単な入力チェック
// --------------------------------------------------
if (!username || !password) {
return {
error: "ユーザー名とパスワードを入力してください。",
};
}
// .env.localからASP.NET Core APIのURLを取得する
const apiBaseUrl = process.env.API_BASE_URL;
if (!apiBaseUrl) {
return {
error: "APIの接続先が設定されていません。",
};
}
let response: Response;
try {
// --------------------------------------------------
// ASP.NET CoreのログインAPIを呼び出す
// --------------------------------------------------
response = await fetch(
`${apiBaseUrl}/api/Auth/login`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
// C#側のLoginRequest
// Username / Password に対応する
body: JSON.stringify({
username,
password,
}),
}
);
} catch (error) {
console.error("ログインAPIへの接続に失敗しました。", error);
return {
error: "APIサーバーに接続できませんでした。",
};
}
// --------------------------------------------------
// HTTPエラー処理
// --------------------------------------------------
if (!response.ok) {
// ユーザー名またはパスワードが違う場合
if (response.status === 401) {
return {
error: "ユーザー名またはパスワードが正しくありません。",
};
}
console.error(
`ログインAPIエラー: ${response.status}`
);
return {
error: "ログイン処理でエラーが発生しました。",
};
}
// --------------------------------------------------
// APIのレスポンスを取得
// --------------------------------------------------
const data =
(await response.json()) as LoginResponse;
// --------------------------------------------------
// JWTをCookieへ保存
// --------------------------------------------------
const cookieStore = await cookies();
cookieStore.set(
"access_token",
data.accessToken,
{
// JavaScriptからCookieを読み取れないようにする
httpOnly: true,
// 同一サイトを基本とする
sameSite: "lax",
// 本番環境ではHTTPS通信だけに限定する
secure: process.env.NODE_ENV === "production",
// アプリ全体で使用する
path: "/",
// ASP.NET Core側で設定された
// JWTの有効期限とCookieの有効期限を合わせる
expires: new Date(data.expiresAt),
}
);
// --------------------------------------------------
// ログイン成功
// --------------------------------------------------
// 現時点ではトップページへ移動する
redirect("/");
}
login-form.tsxを作る
src/app/login/login-form.tsx
"use client";
import { useActionState } from "react";
import {
loginAction,
type LoginState,
} from "./actions";
import styles from "./login.module.css";
/**
* Server Actionの初期状態。
*
* 最初はエラーがないためnull。
*/
const initialState: LoginState = {
error: null,
};
/**
* ログインフォーム。
*
* 入力操作やログイン中表示が必要なので
* Client Componentとして作成する。
*/
export default function LoginForm() {
// --------------------------------------------------
// Server Actionと画面状態を接続する
// --------------------------------------------------
//
// state
// Server Actionから返ってきた状態
//
// formAction
// formのactionに指定する処理
//
// pending
// Server Action実行中ならtrue
//
const [state, formAction, pending] =
useActionState(
loginAction,
initialState
);
return (
<form action={formAction}>
{/* ユーザー名 */}
<div className={styles.formGroup}>
<label
className={styles.label}
htmlFor="username"
>
ユーザー名
</label>
<input
className={styles.input}
id="username"
name="username"
type="text"
autoComplete="username"
/>
</div>
{/* パスワード */}
<div className={styles.formGroup}>
<label
className={styles.label}
htmlFor="password"
>
パスワード
</label>
<input
className={styles.input}
id="password"
name="password"
type="password"
autoComplete="current-password"
/>
</div>
{/* ログイン失敗時だけエラーを表示 */}
{state.error && (
<p className={styles.error} role="alert">
{state.error}
</p>
)}
{/* ログインボタン */}
<button
className={styles.loginButton}
type="submit"
// ログイン処理中は連打できないようにする
disabled={pending}
>
{pending
? "ログイン中..."
: "ログイン"}
</button>
</form>
);
}
page.tsxを変更
src/app/login/page.tsx
import LoginForm from "./login-form";
import styles from "./login.module.css";
/**
* ログインページ
*/
export default function LoginPage() {
return (
<main className={styles.container}>
<div className={styles.loginCard}>
<h1 className={styles.title}>
ログイン
</h1>
{/* 実際の入力フォーム */}
<LoginForm />
</div>
</main>
);
}
次のように役割が分かれる。
page.tsx
│
│ 画面全体
↓
LoginForm
│
│ ユーザー操作
↓
loginAction
│
│ Server Action
↓
ASP.NET Core
エラー表示用CSSを追加
login.module.cssの最後に追加する。
/* ログイン失敗時に表示するエラーメッセージ */
.error {
margin-bottom: 16px;
color: #d32f2f;
font-size: 14px;
}
/* ログイン処理中のボタン */
.loginButton:disabled {
cursor: not-allowed;
opacity: 0.6;
}
動作確認
ASP.NET Core側のDockerを起動して、
http://localhost:8080/swagger
が開ける状態にする。
Next.jsも、
npm.cmd run dev
で起動する。
そして、
http://localhost:3000/login
を開く。
わざと間違ったパスワードでログインして、
ユーザー名またはパスワードが正しくありません。
と表示されることを確認。
その後、正しいadminの認証情報でログインして、
http://localhost:3000/
へ遷移できることを確認。
Chromeの開発者ツール(F12)で、
アプリケーション
→ Cookies
→ http://localhost:3000
を開き、access_tokenが作成されていることを確認。
フェーズ5-4:認証ガード(未ログインなら/loginへ戻す)
今回の動き。
未ログイン
↓
http://localhost:3000/
↓
proxy.ts
↓
access_token がない
↓
/login へリダイレクト
逆に、
ログイン済み
↓
/login を直接開く
↓
access_token がある
↓
/ へリダイレクト
とする。
src/proxy.tsを作成
src/proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
/**
* ページへアクセスされる前に実行される処理。
*
* access_token Cookieの有無を確認して、
* 未ログインユーザーが保護ページへ
* アクセスできないようにする。
*/
export default function proxy(request: NextRequest) {
// CookieからJWTを取得する
const accessToken =
request.cookies.get("access_token")?.value;
// 現在アクセスしているパス
const pathname = request.nextUrl.pathname;
// ログイン画面かどうか
const isLoginPage = pathname === "/login";
// --------------------------------------------------
// 未ログイン
// --------------------------------------------------
// access_tokenがない状態でトップページへアクセスしたら、
// ログイン画面へ移動する。
if (!accessToken && !isLoginPage) {
return NextResponse.redirect(
new URL("/login", request.url)
);
}
// --------------------------------------------------
// ログイン済み
// --------------------------------------------------
// ログイン済みでログイン画面へアクセスしたら、
// トップページへ移動する。
if (accessToken && isLoginPage) {
return NextResponse.redirect(
new URL("/", request.url)
);
}
// それ以外は通常通り処理する
return NextResponse.next();
}
/**
* Proxyを実行するURL。
*
* 現時点では
* /
* /login
* のみ対象にする。
*/
export const config = {
matcher: ["/", "/login"],
};
まず「未ログイン」を確認
現在ログイン済みなので、Chromeの開発者ツールからCookieを削除する。
その状態で、
http://localhost:3000/
を開く。
正常なら自動的に、ログイン画面へ遷移する。
ログイン後も確認
今度はadminでログインする。
その後、
http://localhost:3000/login
を開く。
http://localhost:3000
に遷移することを確認。
フェーズ5-5:ログアウト機能
ログアウト用Server Actionを作る
src/app/actions/logout.ts
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
/**
* ログアウト処理。
*
* ログイン時に保存したaccess_token Cookieを削除して、
* ログイン画面へ戻す。
*/
export async function logoutAction() {
// Cookieを操作するために取得する
const cookieStore = await cookies();
// JWTを保存しているCookieを削除する
cookieStore.delete("access_token");
// ログイン画面へ移動する
redirect("/login");
}
トップページにログアウトボタンを追加
src/app/page.tsxを変更する。
import { logoutAction } from "./actions/logout";
/**
* トップページ
*/
export default function Home() {
return (
<main>
<h1>工程進捗管理システム</h1>
<p>ログイン済みです。</p>
{/* ログアウト処理を呼び出す */}
<form action={logoutAction}>
<button type="submit">
ログアウト
</button>
</form>
</main>
);
}
動作確認
まずログインする。
トップページに、
工程進捗管理システム
ログイン済みです。
[ ログアウト ]
と表示されることを確認。
ログアウトを押す。
ログイン画面に遷移することを確認。