React+Tailwind管理画面のMySQL認証

React+Tailwind管理画面のMySQL認証
  • URLをコピーしました!

React Tailwind 管理画面を短期間で用意したいとき、無料テンプレートのTailAdminは有力な出発点です。
サイドバー、表、チャート、登録画面、ログイン画面まで一式入っており、見た目の土台はすぐ揃います。
ただし、画面にある「Sign Up」「Sign In」は見た目だけです。
押してもユーザーは保存されず、ログイン判定も行われません。

本記事では、最も一般的な構成であるReactフロント+Express API+MySQL+JWTで、用意済みの登録・ログインUIを実認証までつなぎます。
フォルダ構成とソースコードを具体的に示し、処理の流れが追えるようにします。

TailAdmin is a free and open-source admin dashboard template built on React and Tailwind CSS, providing developers with everything they need to create a comprehensive, data-driven back-end, dashboard, or admin panel solution for upcoming web projects.

引用:TailAdmin/free-react-tailwind-admin-dashboard – GitHub
目次

TailAdminで揃うものと、自分で作るもの

TailAdminの無料版は、React 19、TypeScript、Tailwind CSS v4、Viteで動く管理画面です。
認証まわりは /signin と /signup のルート、ページ、フォームコンポーネントまで用意されています。
公式デモでも、同じパスで登録・ログイン画面を確認できます。

一方で、メールアドレスの重複チェック、パスワードのハッシュ化、トークン発行、ログイン後の画面ガードは含まれません。
src/components/auth/SignInForm.tsx と SignUpForm.tsx を見ると、<form> に onSubmit がなく、入力欄にも value が付いていません。

UIを残したまま、送信処理と状態管理だけを足すのが正しい進め方です。

ポイント

TailAdminは管理画面の見た目を担当します。
ユーザー情報の保存とログイン判定は、必ず別プロセスのAPIとデータベースで行います。

React Tailwind管理画面で使う認証構成

ブラウザだけでMySQLに接続してはいけません。
接続情報やパスワード検証をフロントに置くと、改ざんで突破されます。
そのため、画面はTailAdmin、認証処理はAPI、永続化はMySQL、という3層に分けます。
学習記事や案件でいちばんよく見る組み合わせは次のとおりです。

層役割採用
フロント画面・入力・トークン保持TailAdmin(React / Tailwind)
API登録・ログイン・本人確認Express
DBユーザー保存MySQL
認証方式ログイン状態の証明JWT + bcrypt

パスワードは平文で保存しません。
bcryptjs でハッシュ化し、照合だけ行います。
ログイン成功後は jsonwebtoken でJWTを発行し、以降のAPIはトークンで本人を確認します。
セッションCookie方式もありますが、React管理画面の入門ではJWTの方が手順が追いやすいです。
本番ではHttpOnly Cookieへ移す余地を残しつつ、まずはこの構成で流れを掴みます。

フォルダ構成

TailAdminのリポジトリを壊さないよう、フロントとAPIを兄弟ディレクトリに置きます。
1つのGitリポジトリの中に frontend と backend を並べる形が、初心者にも分かりやすいです。

admin-app/
├── frontend/                 # TailAdminをクローンしたReactアプリ
│   ├── src/
│   │   ├── api/
│   │   │   └── auth.ts       # 登録・ログインのfetch
│   │   ├── components/auth/
│   │   │   ├── SignInForm.tsx
│   │   │   ├── SignUpForm.tsx
│   │   │   └── ProtectedRoute.tsx
│   │   ├── context/
│   │   │   └── AuthContext.tsx
│   │   └── App.tsx
│   └── vite.config.ts
└── backend/
    ├── src/
    │   ├── config/
    │   │   └── db.js
    │   ├── middleware/
    │   │   └── auth.js
    │   ├── routes/
    │   │   └── auth.js
    │   └── server.js
    ├── .env
    └── package.json

フロントは公式リポジトリをそのまま使います。
追加するのは src/api、AuthContext、ProtectedRoute と、既存フォームへの送信処理だけです。
API側はExpressの定番配置です。
最初からMVCを細かく分けすぎると迷子になるため、認証はルート1ファイルにまとめています。

TailAdminの認証画面の場所

無料版の認証UIは、次のパスにあります。

  • ページ: src/pages/AuthPages/SignIn.tsx / SignUp.tsx
  • フォーム: src/components/auth/SignInForm.tsx / SignUpForm.tsx
  • ルート: /signin と /signup(src/App.tsx)

登録フォームの項目は First Name、Last Name、Email、Password です。
ログインフォームの項目は Email と Password です。
APIのJSONも、この項目名に合わせると接続が楽です。

環境を用意する

TailAdminは Node.js 20以上を前提にしています。
MySQL 8系をローカル、または開発用サーバーに用意してください。
フロントは次で起動できます。

git clone https://github.com/TailAdmin/free-react-tailwind-admin-dashboard.git frontend
cd frontend
npm install
npm run dev

別ターミナルでAPI用ディレクトリを作ります。

mkdir -p backend/src/{config,middleware,routes}
cd backend
npm init -y
npm install express mysql2 bcryptjs jsonwebtoken cors dotenv
npm install -D nodemon

package.json の scripts に "dev": "nodemon src/server.js" を足します。
APIは http://localhost:3001、Viteは http://localhost:5173 で動かします。

MySQLにユーザーテーブルを作る

認証に必要な列は、氏名、メール、パスワードハッシュ、作成日時です。
メールはログインIDになるため、UNIQUE制約を付けます。

CREATE DATABASE tailadmin_app
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

USE tailadmin_app;

CREATE TABLE users (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT,
  first_name VARCHAR(50) NOT NULL,
  last_name VARCHAR(50) NOT NULL,
  email VARCHAR(255) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

password という列名は使いません。
平文を入れないことを、列名の時点で明示します。
bcryptのハッシュは60文字前後ですが、余裕を見て255にしています。

Express APIを実装する

ここからが認証の本体です。
処理は「登録で行を増やす」「ログインでハッシュを照合する」「JWTを検証する」の3つだけです。

環境変数とDB接続

backend/.env に接続情報を置きます。
このファイルはGitに含めません。

PORT=3001
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=app_user
DB_PASSWORD=change-me
DB_NAME=tailadmin_app
JWT_SECRET=replace-with-long-random-string
JWT_EXPIRES_IN=7d
FRONTEND_ORIGIN=http://localhost:5173

JWT_SECRET は推測できない長い文字列にします。
短かったりサンプルのまま本番に出すと、トークンを偽造されます。

// backend/src/config/db.js
require("dotenv").config();
const mysql = require("mysql2/promise");

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT || 3306),
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
});

module.exports = pool;

mysql2 のPromise版を使うと、async/await でSQLを書けます。
リクエストごとに接続を作り直さず、プールを共有します。

登録とログインのルート

APIの入口は次の3本です。

  • POST /api/auth/register … ユーザー登録
  • POST /api/auth/login … ログイン
  • GET /api/auth/me … トークンから現在のユーザーを返す
// backend/src/routes/auth.js
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const pool = require("../config/db");
const authMiddleware = require("../middleware/auth");

const router = express.Router();
const SALT_ROUNDS = 10;

function createToken(user) {
  return jwt.sign(
    { userId: user.id, email: user.email },
    process.env.JWT_SECRET,
    { expiresIn: process.env.JWT_EXPIRES_IN || "7d" }
  );
}

function toPublicUser(row) {
  return {
    id: row.id,
    firstName: row.first_name,
    lastName: row.last_name,
    email: row.email,
  };
}

router.post("/register", async (req, res) => {
  try {
    const firstName = String(req.body.firstName || "").trim();
    const lastName = String(req.body.lastName || "").trim();
    const email = String(req.body.email || "").trim().toLowerCase();
    const password = String(req.body.password || "");

    if (!firstName || !lastName || !email || password.length < 8) {
      return res.status(400).json({
        message: "氏名・メール・8文字以上のパスワードを入力してください。",
      });
    }

    const [existing] = await pool.query(
      "SELECT id FROM users WHERE email = ? LIMIT 1",
      [email]
    );
    if (existing.length > 0) {
      return res.status(409).json({ message: "このメールアドレスは登録済みです。" });
    }

    const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
    const [result] = await pool.query(
      INSERT INTO users (first_name, last_name, email, password_hash)
       VALUES (?, ?, ?, ?),
      [firstName, lastName, email, passwordHash]
    );

    const user = {
      id: result.insertId,
      first_name: firstName,
      last_name: lastName,
      email,
    };
    return res.status(201).json({
      token: createToken(user),
      user: toPublicUser(user),
    });
  } catch (error) {
    console.error(error);
    return res.status(500).json({ message: "登録に失敗しました。" });
  }
});

router.post("/login", async (req, res) => {
  try {
    const email = String(req.body.email || "").trim().toLowerCase();
    const password = String(req.body.password || "");

    const [rows] = await pool.query(
      SELECT id, first_name, last_name, email, password_hash
       FROM users WHERE email = ? LIMIT 1,
      [email]
    );
    const user = rows[0];
    const ok = user ? await bcrypt.compare(password, user.password_hash) : false;

    if (!ok) {
      return res.status(401).json({
        message: "メールアドレスまたはパスワードが正しくありません。",
      });
    }

    return res.json({
      token: createToken(user),
      user: toPublicUser(user),
    });
  } catch (error) {
    console.error(error);
    return res.status(500).json({ message: "ログインに失敗しました。" });
  }
});

router.get("/me", authMiddleware, async (req, res) => {
  const [rows] = await pool.query(
    SELECT id, first_name, last_name, email FROM users WHERE id = ? LIMIT 1,
    [req.user.userId]
  );
  if (rows.length === 0) {
    return res.status(404).json({ message: "ユーザーが見つかりません。" });
  }
  return res.json({ user: toPublicUser(rows[0]) });
});

module.exports = router;

ログイン失敗時は、メールが存在しない場合とパスワード違いを分けません。
存在確認だけ先に返すと、他人のメールが登録済みかどうか探られます。
プレースホルダ ? を使い、文字列結合でSQLを組み立てないでください。

JWTを検証するミドルウェア

ダッシュボード用APIを増やすとき、同じ関数を先頭に置きます。

// backend/src/middleware/auth.js
const jwt = require("jsonwebtoken");

module.exports = function authMiddleware(req, res, next) {
  const header = req.headers.authorization || "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : "";

  if (!token) {
    return res.status(401).json({ message: "ログインが必要です。" });
  }

  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch (error) {
    return res.status(401).json({ message: "トークンが無効です。" });
  }
};

サーバー起動とCORS

開発中はViteとAPIのオリジンが違うため、CORSを許可します。

// backend/src/server.js
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const authRouter = require("./routes/auth");

const app = express();
app.use(
  cors({
    origin: process.env.FRONTEND_ORIGIN,
    credentials: true,
  })
);
app.use(express.json());
app.use("/api/auth", authRouter);

const port = Number(process.env.PORT || 3001);
app.listen(port, () => {
  console.log(API listening on http://localhost:${port});
});

本番で同一ドメインのサブディレクトリにAPIを置く場合、CORSよりリバースプロキシの方が単純です。
同じホストでNode.js環境を分ける手順は、同じドメインでサブディレクトリごとにNode.js環境を分けて運用する方法で解説しています。

TailAdminのログイン画面をAPIにつなぐ

フロントは既存コンポーネントを活かし、通信と状態だけを足します。
開発時はViteのプロキシで /api をAPIへ転送すると、CORSを気にせず fetch("/api/...") と書けます。

// frontend/vite.config.ts の defineConfig に追加
server: {
  proxy: {
    "/api": "http://localhost:3001",
  },
},

APIクライアント

fetchを各画面に散らさず、1ファイルにまとめます。

// frontend/src/api/auth.ts
const API_BASE = import.meta.env.VITE_API_BASE ?? "";

export type AuthUser = {
  id: number;
  firstName: string;
  lastName: string;
  email: string;
};

type AuthResponse = {
  token: string;
  user: AuthUser;
};

async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
  const token = localStorage.getItem("token") ?? sessionStorage.getItem("token");
  const headers = new Headers(options.headers);
  headers.set("Content-Type", "application/json");
  if (token) {
    headers.set("Authorization", Bearer ${token});
  }

  const res = await fetch(${API_BASE}${path}, { ...options, headers });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    throw new Error(data.message || "通信に失敗しました。");
  }
  return data as T;
}

export function registerUser(body: {
  firstName: string;
  lastName: string;
  email: string;
  password: string;
}) {
  return request<AuthResponse>("/api/auth/register", {
    method: "POST",
    body: JSON.stringify(body),
  });
}

export function loginUser(body: { email: string; password: string }) {
  return request<AuthResponse>("/api/auth/login", {
    method: "POST",
    body: JSON.stringify(body),
  });
}

export function fetchMe() {
  return request<{ user: AuthUser }>("/api/auth/me");
}

ログイン状態をContextで持つ

TailAdminにはもともと src/context があります。
ここに認証用Contextを足すと、ヘッダーやガードから同じ状態を読めます。

// frontend/src/context/AuthContext.tsx
import { fetchMe, type AuthUser } from "@/api/auth";
import { createContext, useContext, useEffect, useMemo, useState } from "react";

type AuthContextValue = {
  user: AuthUser | null;
  token: string | null;
  ready: boolean;
  setSession: (token: string, user: AuthUser, persist: boolean) => void;
  logout: () => void;
};

const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<AuthUser | null>(null);
  const [token, setToken] = useState<string | null>(null);
  const [ready, setReady] = useState(false);

  useEffect(() => {
    const saved =
      localStorage.getItem("token") ?? sessionStorage.getItem("token");
    if (!saved) {
      setReady(true);
      return;
    }
    setToken(saved);
    fetchMe()
      .then((data) => setUser(data.user))
      .catch(() => {
        localStorage.removeItem("token");
        sessionStorage.removeItem("token");
        setToken(null);
      })
      .finally(() => setReady(true));
  }, []);

  const value = useMemo(
    () => ({
      user,
      token,
      ready,
      setSession: (nextToken, nextUser, persist) => {
        const storage = persist ? localStorage : sessionStorage;
        localStorage.removeItem("token");
        sessionStorage.removeItem("token");
        storage.setItem("token", nextToken);
        setToken(nextToken);
        setUser(nextUser);
      },
      logout: () => {
        localStorage.removeItem("token");
        sessionStorage.removeItem("token");
        setToken(null);
        setUser(null);
      },
    }),
    [user, token, ready]
  );

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) {
    throw new Error("useAuth must be used within AuthProvider");
  }
  return ctx;
}

「Keep me logged in」にチェックがあれば localStorage、なければ sessionStorage にトークンを置きます。
タブを閉じたら消したい場合は、チェックを外したログインにします。
localStorage はXSSで読まれる可能性があるため、公開サイトではCookie方式を検討してください。

SignInFormに送信処理を足す

見た目のJSXは残し、状態と onSubmit だけを足します。
既存の Input は value と onChange を受け取れます。

// SignInForm.tsx の追加分
import { loginUser } from "@/api/auth";
import { useAuth } from "@/context/AuthContext";
import { useNavigate } from "react-router";

const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { setSession } = useAuth();
const navigate = useNavigate();

async function handleSubmit(e: React.FormEvent) {
  e.preventDefault();
  setError("");
  setLoading(true);
  try {
    const data = await loginUser({ email, password });
    setSession(data.token, data.user, isChecked);
    navigate("/");
  } catch (err) {
    setError(err instanceof Error ? err.message : "ログインに失敗しました。");
  } finally {
    setLoading(false);
  }
}

フォーム側は次のように結びます。

<form onSubmit={handleSubmit}>
  <Input
    type="email"
    value={email}
    onChange={(e) => setEmail(e.target.value)}
    placeholder="info@gmail.com"
  />
  <Input
    type={showPassword ? "text" : "password"}
    value={password}
    onChange={(e) => setPassword(e.target.value)}
    placeholder="Enter your password"
  />
  {error ? <p className="text-error-500 text-sm">{error}</p> : null}
  <Button className="w-full" size="sm" disabled={loading}>
    {loading ? "Signing in..." : "Sign in"}
  </Button>
</form>

TailAdminの Button は type を渡しません。
HTMLの初期値は submit なので、form の onSubmit で受け取れます。

SignUpFormに送信処理を足す

登録画面は fname / lname / email / password をstateにします。
APIへはキャメルケースで送り、サーバー側でスネークケースの列へ変換します。

// SignUpForm.tsx の追加分
import { registerUser } from "@/api/auth";
import { useAuth } from "@/context/AuthContext";
import { useNavigate } from "react-router";

const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");

async function handleSubmit(e: React.FormEvent) {
  e.preventDefault();
  if (!isChecked) {
    setError("利用規約への同意が必要です。");
    return;
  }
  const data = await registerUser({ firstName, lastName, email, password });
  setSession(data.token, data.user, true);
  navigate("/");
}

GoogleとXのボタンは、無料版では見た目だけです。
OAuthまで必要なら、別途Google CloudのクライアントIDとコールバックAPIが要ります。
最初の認証ではメール+パスワードに集中した方が迷いません。

未ログインなら管理画面を開けなくする

今の App.tsx は、認証なしで / のダッシュボードを表示します。
これではログインの意味がありません。
ダッシュボード配下をガードで包みます。

// frontend/src/components/auth/ProtectedRoute.tsx
import { useAuth } from "@/context/AuthContext";
import { Navigate, Outlet } from "react-router";

export default function ProtectedRoute() {
  const { token, ready } = useAuth();
  if (!ready) {
    return null;
  }
  if (!token) {
    return <Navigate to="/signin" replace />;
  }
  return <Outlet />;
}

App.tsx では、AppLayout の外側に ProtectedRoute を置きます。
現行のTailAdminは main.tsx で ThemeProvider と LanguageProvider に包まれているので、その内側に AuthProvider を足します。

// frontend/src/main.tsx の一部
import { AuthProvider } from "./context/AuthContext.tsx";

<ThemeProvider>
  <LanguageProvider>
    <AuthProvider>
      <AppWrapper>
        <App />
      </AppWrapper>
    </AuthProvider>
  </LanguageProvider>
</ThemeProvider>

ルートは次のように、認証が必要な画面だけをガードします。

<Route element={<ProtectedRoute />}>
  <Route element={<AppLayout />}>
    <Route index path="/" element={<Home />} />
    {/* 既存のダッシュボード配下ルート */}
  </Route>
</Route>
<Route path="/signin" element={<SignIn />} />
<Route path="/signup" element={<SignUp />} />

これで未ログインのアクセスは /signin へ戻ります。
トークンがあってもAPIが401を返せば、AuthContext の初期化でログアウト扱いになります。

動きを確認する順番

一度に全部つなぐと、失敗箇所が分かりません。
次の順で確かめると切り分けやすいです。

  1. MySQLで users テーブルがあることを確認する
  2. npm run dev でAPIを起動し、POST /api/auth/register をcurlやRESTクライアントで叩く
  3. 同じメールで再登録し、409になることを確認する
  4. POST /api/auth/login でトークンが返ることを確認する
  5. TailAdminの /signup から画面登録し、ダッシュボードへ遷移することを確認する
  6. ログアウト相当でトークンを消し、直接 / を開いて /signin へ戻ることを確認する

API単体の確認例です。

curl -X POST http://localhost:3001/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"firstName":"Taro","lastName":"Yamada","email":"taro@example.com","password":"password1"}'

DBの password_hash が $2a$ や $2b$ で始まっていれば、ハッシュ化は成功しています。
画面からログインできないときは、まずこのcurlが通るかを見てください。

実装後に足すとよいもの

ここまでで「登録してログインし、管理画面を開く」は完成します。
運用に乗せる前に、次を検討します。

  • パスワードリセット(メール送信が必要)
  • レート制限(ログイン連続失敗の制限)
  • JWTをHttpOnly Cookieへ移す
  • HTTPS終端と証明書更新

同一ドメイン配下へAPIを置く話は、前述のサブディレクトリでNode.js環境を分ける記事が近いです。
ユーザー登録のあと、注文や記事など別テーブルを結合して取る段階では、Node.jsとMongoDBで複数のデータを結びつけて取得する方法の考え方が参考になります。
データベースはMySQLでも、関連データをAPIでまとめて返す設計は共通です。

パスワードはMySQLにそのまま保存してよいですか?

保存してはいけません。
必ずbcryptなどでハッシュ化し、照合だけ行います。

TailAdminのGoogleログインボタンはそのまま使えますか?

無料版のボタンはUIだけです。
OAuthのクライアント設定とコールバックAPIを別途実装するまで、メールとパスワード認証を先に完成させてください。

JWTとセッションのどちらが一般的ですか?

Reactの管理画面チュートリアルではJWTが多いです。
サーバーでセッションを持つ方式も一般的で、セキュリティを優先するならHttpOnly Cookieのセッションも候補です。

まとめ

React Tailwind 管理画面としてTailAdminを使う場合、登録・ログインの見た目は最初からあります。
足りないのは、MySQLへの保存と、APIでの照合です。
フロントとExpressを分け、JWTとbcryptでつなぐ構成が、いちばん説明しやすく再利用しやすいです。

まずはcurlでAPIを通し、そのあと既存の SignInForm / SignUpForm に onSubmit を足してください。
画面を作り直す必要はありません。
公式テンプレートの入手と画面構成は、TailAdminのドキュメントも合わせて確認すると安心です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次