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?

簡易工程進捗管理システムの改良⑦~フェーズ5 Next.js フロントエンド作成~

0
Last updated at Posted at 2026-10-04

前の記事

次の記事

全体の流れ

まずは次の構成を目指す。

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>
  );
}

動作確認

まずログインする。
トップページに、

工程進捗管理システム

ログイン済みです。

[ ログアウト ]

と表示されることを確認。

ログアウトを押す。
ログイン画面に遷移することを確認。

0
0
0

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?