React + TypeScript ログイン練習(AuthContext・React Router v6)
工場システムのログイン画面練習プロジェクトです。
モックJSONとの照合・AuthContext・未ログインの自動リダイレクトがすべて入っています。
📁 ファイル構成
factory-login/
├── src/
│ ├── context/
│ │ └── AuthContext.tsx ← ログイン状態の管理(メイン)
│ ├── components/
│ │ ├── LoginForm.tsx ← ログインフォーム
│ │ └── RequireAuth.tsx ← 未ログインのリダイレクト
│ ├── pages/
│ │ ├── Login.tsx ← /login ページ
│ │ └── Dashboard.tsx ← /dashboard ページ(要ログイン)
│ ├── data/
│ │ └── users.json ← モックユーザーデータ(8項目)
│ ├── types/
│ │ └── user.ts ← TypeScript型定義
│ ├── App.tsx ← ルーティング設定
│ └── main.tsx ← エントリーポイント
├── package.json
├── tsconfig.json
└── vite.config.ts
🚀 起動手順
npm install
npm run dev
# → http://localhost:5173
🔑 テストアカウント(パスワード共通: password)
| ユーザー名 | role | 状態 |
|---|---|---|
| yamada | admin | 有効 |
| suzuki | supervisor | 有効 |
| tanaka | operator | 有効 |
| sato | operator | 無効(エラーテスト用) |
ソースコード
package.json
{
"name": "factory-login",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.2.0",
"vite": "^5.1.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
index.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>工場管理システム</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
src/types/user.ts
// User型の定義
// "admin" | "supervisor" | "operator" はユニオン型
// → この3つの文字列のどれか、という意味
export type UserRole = "admin" | "supervisor" | "operator";
export type UserStatus = "active" | "inactive";
export type User = {
id: number;
username: string;
email: string;
name: string;
role: UserRole;
department: string;
line: string;
status: UserStatus;
};
src/data/users.json
[
{
"id": 1,
"username": "yamada",
"email": "yamada@factory.com",
"name": "山田 太郎",
"role": "admin",
"department": "管理部",
"line": "全ライン",
"status": "active"
},
{
"id": 2,
"username": "suzuki",
"email": "suzuki@factory.com",
"name": "鈴木 花子",
"role": "supervisor",
"department": "製造部",
"line": "Aライン",
"status": "active"
},
{
"id": 3,
"username": "tanaka",
"email": "tanaka@factory.com",
"name": "田中 一郎",
"role": "operator",
"department": "製造部",
"line": "Bライン",
"status": "active"
},
{
"id": 4,
"username": "sato",
"email": "sato@factory.com",
"name": "佐藤 次郎",
"role": "operator",
"department": "品質管理部",
"line": "検査ライン",
"status": "inactive"
}
]
src/context/AuthContext.tsx
ここが詰まりやすいポイント。3つのパーツで構成されています。
| パーツ | 役割 |
|---|---|
createContext |
「共有する箱」を作る |
AuthProvider |
箱に値を入れて子孫コンポーネントに配る |
useAuth() |
どのコンポーネントからでも箱の中身を取り出す |
import {
createContext,
useContext,
useState,
useCallback,
ReactNode,
} from "react";
import type { User } from "../types/user";
import usersData from "../data/users.json";
// JSONをUser型の配列にキャスト(tsconfig の resolveJsonModule: true が必要)
const USERS: User[] = usersData as User[];
// 練習用固定パスワード
const FIXED_PASSWORD = "password";
// ログイン結果の型(3パターン)
type LoginResult = "success" | "not_found" | "inactive";
// Contextに入れる値の型定義
type AuthContextType = {
user: User | null;
login: (loginId: string, password: string) => LoginResult;
logout: () => void;
isLoggedIn: boolean;
};
// createContext で「箱」を作る
// 初期値を null にしておき、useAuth() 内でチェックする
// → AuthProvider の外で useAuth() を呼んだらエラーにできる
const AuthContext = createContext<AuthContextType | null>(null);
// AuthProvider:箱に値を入れて子孫に配るコンポーネント
// App.tsx の一番外側でこれを使う
type AuthProviderProps = {
children: ReactNode;
};
export function AuthProvider({ children }: AuthProviderProps) {
// null = 未ログイン、User型 = ログイン中
const [user, setUser] = useState<User | null>(null);
// ログイン処理:users.json と照合する
const login = useCallback(
(loginId: string, password: string): LoginResult => {
// Array.find() で条件に合う最初のユーザーを探す
const found = USERS.find((u: User) => {
const idMatch = u.username === loginId || u.email === loginId;
const passwordMatch = password === FIXED_PASSWORD;
return idMatch && passwordMatch;
});
if (!found) return "not_found"; // 一致なし
if (found.status === "inactive") return "inactive"; // 無効アカウント
setUser(found); // stateにセット → アプリ全体に伝わる
return "success";
},
[]
);
// ログアウト:user を null に戻すだけ
const logout = useCallback(() => {
setUser(null);
}, []);
const value: AuthContextType = {
user,
login,
logout,
isLoggedIn: user !== null,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
// useAuth():どのコンポーネントからでも呼べるカスタムフック
// const { user, login, logout, isLoggedIn } = useAuth();
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (context === null) {
throw new Error(
"useAuth() は <AuthProvider> の内側で使ってください。\n" +
"App.tsx で <AuthProvider> が最外側にあるか確認してください。"
);
}
return context;
}
src/components/RequireAuth.tsx
未ログインのユーザーを /login に自動リダイレクトするコンポーネント。
App.tsx の Route設定で保護したいページを囲んで使う。
import { Navigate, Outlet } from "react-router-dom";
// Navigate → 別ページに移動するコンポーネント
// Outlet → 子Routeを描画する場所
import { useAuth } from "../context/AuthContext";
export default function RequireAuth() {
const { isLoggedIn } = useAuth();
if (!isLoggedIn) {
// 未ログイン → /login にリダイレクト
// replace={true} で「戻る」ボタンで戻れなくする
return <Navigate to="/login" replace />;
}
// ログイン済み → 子Routeを描画
return <Outlet />;
}
src/components/LoginForm.tsx
import { useState, KeyboardEvent } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import usersData from "../data/users.json";
import type { User } from "../types/user";
const USERS: User[] = usersData as User[];
export default function LoginForm() {
const [loginId, setLoginId] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [error, setError] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const { login } = useAuth(); // AuthContextからlogin関数を取得
const navigate = useNavigate(); // ページ遷移フック
const handleSubmit = async () => {
if (!loginId.trim() || !password.trim()) {
setError("ユーザー名とパスワードを入力してください");
return;
}
setIsLoading(true);
setError("");
// 本来はAPI呼び出し。モックなので少し待つ
await new Promise((resolve) => setTimeout(resolve, 500));
// AuthContext の login() → users.json と照合
const result = login(loginId, password);
setIsLoading(false);
if (result === "success") {
navigate("/dashboard"); // ← 成功したらここで遷移!
} else if (result === "inactive") {
setError("このアカウントは無効化されています。管理者にお問い合わせください。");
} else {
setError("ユーザー名またはパスワードが違います");
}
};
// Enterキーでも送信できるように
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") handleSubmit();
};
return (
<div style={styles.wrapper}>
<div style={styles.card}>
<div style={styles.header}>
<div style={styles.icon}>🏭</div>
<h1 style={styles.title}>工場管理システム</h1>
<p style={styles.subtitle}>FACTORY MANAGEMENT SYSTEM</p>
</div>
<div style={styles.form}>
<div style={styles.field}>
<label style={styles.label}>ユーザー名またはメールアドレス</label>
<input
type="text"
value={loginId}
onChange={(e) => setLoginId(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="例: yamada"
style={styles.input}
disabled={isLoading}
/>
</div>
<div style={styles.field}>
<label style={styles.label}>パスワード</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="パスワードを入力"
style={styles.input}
disabled={isLoading}
/>
</div>
{error && <p style={styles.error}>⚠️ {error}</p>}
<button
onClick={handleSubmit}
disabled={isLoading}
style={{ ...styles.button, opacity: isLoading ? 0.6 : 1 }}
>
{isLoading ? "認証中..." : "ログイン"}
</button>
</div>
{/* テスト用ユーザー一覧(クリックで自動入力) */}
<div style={styles.testBox}>
<p style={styles.testTitle}>🧪 テストアカウント(パスワード共通: password)</p>
<table style={styles.table}>
<thead>
<tr>
{["ユーザー名", "role", "部署", "ライン", "状態"].map((h) => (
<th key={h} style={styles.th}>{h}</th>
))}
</tr>
</thead>
<tbody>
{USERS.map((u) => (
<tr
key={u.id}
style={{ cursor: u.status === "active" ? "pointer" : "default" }}
onClick={() => { if (u.status === "active") setLoginId(u.username); }}
>
<td style={styles.td}>{u.username}</td>
<td style={styles.td}>{u.role}</td>
<td style={styles.td}>{u.department}</td>
<td style={styles.td}>{u.line}</td>
<td style={{ ...styles.td, color: u.status === "active" ? "#4ade80" : "#f87171", fontWeight: 600 }}>
{u.status === "active" ? "✓ 有効" : "✕ 無効"}
</td>
</tr>
))}
</tbody>
</table>
<p style={styles.hint}>※ 有効なアカウントをクリックすると自動入力されます</p>
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
wrapper: { minHeight: "100vh", background: "#0f172a", display: "flex", alignItems: "center", justifyContent: "center", padding: "24px", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
card: { background: "#1e293b", border: "1px solid #334155", borderRadius: "10px", padding: "36px", width: "100%", maxWidth: "620px", boxShadow: "0 8px 32px rgba(0,0,0,0.5)" },
header: { textAlign: "center", marginBottom: "28px" },
icon: { fontSize: "52px" },
title: { color: "#f1f5f9", fontSize: "22px", fontWeight: 700, margin: "8px 0 4px" },
subtitle: { color: "#64748b", fontSize: "11px", letterSpacing: "0.12em", margin: 0 },
form: { display: "flex", flexDirection: "column", gap: "16px" },
field: { display: "flex", flexDirection: "column", gap: "6px" },
label: { color: "#94a3b8", fontSize: "13px", fontWeight: 600 },
input: { background: "#0f172a", border: "1px solid #334155", borderRadius: "6px", padding: "10px 14px", color: "#f1f5f9", fontSize: "14px", outline: "none" },
error: { background: "rgba(220,38,38,0.15)", border: "1px solid #dc2626", borderRadius: "6px", padding: "10px 14px", color: "#fca5a5", fontSize: "13px", margin: 0 },
button: { background: "#2563eb", color: "#fff", border: "none", borderRadius: "6px", padding: "12px", fontSize: "15px", fontWeight: 700, cursor: "pointer", letterSpacing: "0.04em", marginTop: "4px" },
testBox: { marginTop: "24px", background: "#0f172a", borderRadius: "6px", padding: "16px", border: "1px solid #334155" },
testTitle: { color: "#94a3b8", fontSize: "12px", fontWeight: 700, margin: "0 0 10px" },
table: { width: "100%", borderCollapse: "collapse", fontSize: "12px" },
th: { color: "#64748b", textAlign: "left" as const, padding: "6px 8px", borderBottom: "1px solid #334155", fontWeight: 600 },
td: { color: "#94a3b8", padding: "6px 8px", borderBottom: "1px solid #1e293b" },
hint: { color: "#475569", fontSize: "11px", margin: "8px 0 0" },
};
src/pages/Login.tsx
// pages/ → URLに対応する画面全体
// components/ → 再利用できる部品
// このファイルは LoginForm を表示するだけ
import LoginForm from "../components/LoginForm";
export default function Login() {
return <LoginForm />;
}
src/pages/Dashboard.tsx
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import type { UserRole } from "../types/user";
const ROLE_CONFIG: Record<UserRole, { label: string; color: string; bg: string; border: string }> = {
admin: { label: "👑 管理者", color: "#92400e", bg: "#fef3c7", border: "#f59e0b" },
supervisor: { label: "🔧 監督者", color: "#1e3a8a", bg: "#dbeafe", border: "#3b82f6" },
operator: { label: "⚙️ オペレーター", color: "#14532d", bg: "#dcfce7", border: "#22c55e" },
};
const ROLE_PERMISSIONS: Record<UserRole, string[]> = {
admin: ["全データ閲覧", "全データ編集", "ユーザー管理", "システム設定"],
supervisor: ["担当ラインのデータ閲覧", "担当ラインのデータ編集", "レポート出力"],
operator: ["担当ラインのデータ閲覧のみ"],
};
export default function Dashboard() {
// RequireAuth で守られているので user は必ず User型(nullにならない)
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout(); // AuthContext の user を null にリセット
navigate("/login"); // ログインページへ遷移
};
if (!user) return null; // TypeScript用(理論上ここには来ない)
const roleConfig = ROLE_CONFIG[user.role];
const permissions = ROLE_PERMISSIONS[user.role];
return (
<div style={styles.wrapper}>
<div style={styles.card}>
<div style={styles.header}>
<div>
<h1 style={styles.title}>ようこそ、{user.name} さん</h1>
<p style={styles.subtitle}>{roleConfig.label} · {user.department}</p>
</div>
<button onClick={handleLogout} style={styles.logoutBtn}>ログアウト</button>
</div>
{/* ユーザー情報(8項目) */}
<section style={styles.section}>
<h2 style={styles.sectionTitle}>ユーザー情報</h2>
<div style={styles.grid}>
<InfoCard label="社員番号" value={`#${user.id}`} />
<InfoCard label="ユーザー名" value={user.username} />
<InfoCard label="氏名" value={user.name} />
<InfoCard label="メールアドレス" value={user.email} />
<InfoCard label="役割 (role)" value={user.role} highlight />
<InfoCard label="部署" value={user.department} />
<InfoCard label="担当ライン" value={user.line} />
<InfoCard label="ステータス" value={user.status} />
</div>
</section>
{/* 役割別権限 */}
<section style={styles.section}>
<h2 style={styles.sectionTitle}>あなたの権限</h2>
<div style={{ ...styles.roleBox, background: roleConfig.bg, borderColor: roleConfig.border, color: roleConfig.color }}>
<p style={{ fontWeight: 700, marginBottom: "10px" }}>{roleConfig.label}</p>
<ul style={{ margin: 0, paddingLeft: "20px" }}>
{permissions.map((p) => <li key={p} style={{ marginBottom: "4px", fontSize: "14px" }}>{p}</li>)}
</ul>
</div>
</section>
</div>
</div>
);
}
function InfoCard({ label, value, highlight }: { label: string; value: string | number; highlight?: boolean }) {
return (
<div style={styles.infoCard}>
<span style={styles.infoLabel}>{label}</span>
<span style={{ ...styles.infoValue, ...(highlight ? { color: "#818cf8", fontWeight: 700 } : {}) }}>
{value}
</span>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
wrapper: { minHeight: "100vh", background: "#0f172a", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "40px 24px", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
card: { background: "#1e293b", border: "1px solid #334155", borderRadius: "10px", padding: "36px", width: "100%", maxWidth: "680px", boxShadow: "0 8px 32px rgba(0,0,0,0.5)" },
header: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "28px" },
title: { color: "#f1f5f9", fontSize: "22px", fontWeight: 700, margin: "0 0 4px" },
subtitle: { color: "#64748b", fontSize: "13px", margin: 0 },
logoutBtn: { background: "transparent", border: "1px solid #475569", color: "#94a3b8", padding: "8px 18px", borderRadius: "6px", cursor: "pointer", fontSize: "13px" },
section: { marginBottom: "24px" },
sectionTitle: { color: "#64748b", fontSize: "11px", fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase" as const, margin: "0 0 12px" },
grid: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px" },
infoCard: { background: "#0f172a", border: "1px solid #334155", borderRadius: "6px", padding: "10px 14px", display: "flex", flexDirection: "column" as const, gap: "3px" },
infoLabel: { color: "#64748b", fontSize: "11px", fontWeight: 600, letterSpacing: "0.05em", textTransform: "uppercase" as const },
infoValue: { color: "#e2e8f0", fontSize: "14px" },
roleBox: { borderRadius: "8px", padding: "16px 18px", border: "1px solid" },
};
src/App.tsx
ルーティングの設定ファイル。構造が最重要。
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider } from "./context/AuthContext";
import RequireAuth from "./components/RequireAuth";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
export default function App() {
return (
// ① AuthProvider:一番外側で囲む。これがないと useAuth() がエラーになる
<AuthProvider>
{/* ② BrowserRouter:URLの変化をReact Routerに伝える */}
<BrowserRouter>
{/* ③ Routes:URLとコンポーネントを対応させる */}
<Routes>
{/* / → /login に自動リダイレクト */}
<Route path="/" element={<Navigate to="/login" replace />} />
{/* /login:誰でもアクセスできる */}
<Route path="/login" element={<Login />} />
{/* ログイン必須ゾーン:RequireAuth が未ログインを弾く */}
<Route element={<RequireAuth />}>
<Route path="/dashboard" element={<Dashboard />} />
{/* ページが増えたらここに追加 */}
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
src/main.tsx
// アプリのエントリーポイント(最初に実行されるファイル)
// index.html の <div id="root"> に App を描画する
// ここは定型文なので触らなくていい
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
カンニングシート
AuthContext の構造まとめ
// 1. createContext で「箱」を作る
const AuthContext = createContext<AuthContextType | null>(null);
// 2. AuthProvider で「箱に値を入れて子孫に配る」
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const value = { user, login, logout, isLoggedIn: user !== null };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// 3. useAuth() で「箱から値を取り出す」
export function useAuth() {
const context = useContext(AuthContext);
if (context === null) throw new Error("AuthProvider の外で使われました");
return context;
}
users.json との照合
const found = USERS.find((u: User) => {
const idMatch = u.username === loginId || u.email === loginId;
const passwordMatch = password === FIXED_PASSWORD;
return idMatch && passwordMatch;
});
未ログインのリダイレクト
// RequireAuth.tsx
if (!isLoggedIn) return <Navigate to="/login" replace />;
return <Outlet />;
// App.tsx での使い方
<Route element={<RequireAuth />}> // RequireAuth で囲む
<Route path="/dashboard" element={<Dashboard />} />
</Route>
ログイン成功後の遷移
const navigate = useNavigate();
const result = login(loginId, password);
if (result === "success") navigate("/dashboard"); // ← ここで遷移
よくある詰まりポイント
| 症状 | 原因 | 解決 |
|---|---|---|
| ログインしても遷移しない |
login() の戻り値を使っていない |
result === "success" で navigate() する |
useAuth() は AuthProvider の中で使ってください エラー |
AuthProvider で囲んでいない |
App.tsx の一番外側を <AuthProvider> で囲む |
/dashboard に未ログインでアクセスできる |
RequireAuth で囲んでいない |
App.tsx のRoute設定を確認 |
| JSONのimportでエラー | tsconfig の設定 |
"resolveJsonModule": true を追加 |
// ============================================================
// src/App.tsx
//
// 【ルーティング構造】
//
// / → /login にリダイレクト
// /login → ログインページ(誰でも見れる)
//
// ▼ ログイン必須ゾーン(RequireAuth で囲む)
// /dashboard → ダッシュボード
// /production → 生産管理
// /inspection → 品質検査
// /reports → レポート
//
// 【ポイント】
// RequireAuth の <Route element={<RequireAuth />}> を1つ書いて
// その中に保護したいページをまとめて入れるだけでOK。
// ページが増えても RequireAuth を何度も書かなくていい。
// ============================================================
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider } from "./context/AuthContext";
import RequireAuth from "./components/RequireAuth";
// ログイン不要ページ
import Login from "./pages/Login";
// ログイン必須ページ
import Dashboard from "./pages/Dashboard";
import Production from "./pages/Production";
import Inspection from "./pages/Inspection";
import Reports from "./pages/Reports";
export default function App() {
return (
// ① AuthProvider:一番外側で囲む。これがないと useAuth() がエラーになる
<AuthProvider>
{/* ② BrowserRouter:URLの変化をReact Routerに伝える */}
<BrowserRouter>
<Routes>
{/* / にアクセスしたら /login に自動リダイレクト */}
<Route path="/" element={<Navigate to="/login" replace />} />
{/* ログイン不要ページ */}
<Route path="/login" element={<Login />} />
{/* ======================================================
ログイン必須ゾーン
RequireAuth を element に指定した Route で囲むだけ。
未ログインなら RequireAuth が /login に飛ばしてくれる。
ページが増えたらこの中に追加するだけでOK。
====================================================== */}
<Route element={<RequireAuth />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/production" element={<Production />} />
<Route path="/inspection" element={<Inspection />} />
<Route path="/reports" element={<Reports />} />
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}