React + TypeScript ログイン練習(5ページ構成・AuthContext・React Router v6)
工場システムのログイン練習プロジェクトです。
ログイン必須ページが4つある構成で、未ログインのリダイレクトを RequireAuth 1つにまとめる方法を確認できます。
📁 ファイル構成
factory-login/
├── src/
│ ├── context/
│ │ └── AuthContext.tsx ← ログイン状態の管理
│ ├── components/
│ │ ├── LoginForm.tsx ← ログインフォーム
│ │ ├── NavBar.tsx ← ログイン後の共通ナビ
│ │ └── RequireAuth.tsx ← 未ログインのリダイレクト
│ ├── pages/
│ │ ├── Login.tsx ← /login(誰でも見れる)
│ │ ├── Dashboard.tsx ← /dashboard(要ログイン)
│ │ ├── Production.tsx ← /production(要ログイン)
│ │ ├── Inspection.tsx ← /inspection(要ログイン)
│ │ └── Reports.tsx ← /reports(要ログイン)
│ ├── data/
│ │ └── users.json
│ ├── types/
│ │ └── user.ts
│ ├── 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
// ユニオン型: "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";
const USERS: User[] = usersData as User[];
const FIXED_PASSWORD = "password";
// ログイン結果の型(3パターン)
export type LoginResult = "success" | "not_found" | "inactive";
type AuthContextType = {
user: User | null;
login: (loginId: string, password: string) => LoginResult;
logout: () => void;
isLoggedIn: boolean;
};
// 初期値を null にして、Provider の外で useAuth() を呼んだらエラーにする
const AuthContext = createContext<AuthContextType | null>(null);
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 => {
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); // セットするとアプリ全体に伝わる
return "success";
},
[]
);
// user を null に戻すだけ。RequireAuth が反応して /login に飛ばされる
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 で 1つ書いて保護したいページをまとめて囲むだけでOK。
import { Navigate, Outlet } from "react-router-dom";
// Navigate → 別ページに移動するコンポーネント
// Outlet → 子 Route を描画する場所
import { useAuth } from "../context/AuthContext";
export default function RequireAuth() {
const { isLoggedIn } = useAuth();
if (!isLoggedIn) {
// replace={true} で「戻る」ボタンで保護ページに戻れなくする
return <Navigate to="/login" replace />;
}
return <Outlet />;
}
src/components/NavBar.tsx
ログイン後の全ページ共通ナビゲーション。
ページが増えたら NAV_LINKS 配列に追加するだけ。
import { useNavigate, NavLink } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
// ページが増えたらここに追加する
const NAV_LINKS = [
{ to: "/dashboard", label: "ダッシュボード" },
{ to: "/production", label: "生産管理" },
{ to: "/inspection", label: "品質検査" },
{ to: "/reports", label: "レポート" },
];
export default function NavBar() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = (): void => {
logout();
navigate("/login");
};
return (
<nav style={styles.nav}>
<div style={styles.left}>
<span style={styles.logo}>🏭 工場管理</span>
<div style={styles.links}>
{NAV_LINKS.map(({ to, label }) => (
<NavLink
key={to}
to={to}
style={({ isActive }) => ({
...styles.link,
...(isActive ? styles.linkActive : {}),
})}
>
{label}
</NavLink>
))}
</div>
</div>
<div style={styles.right}>
{user && (
<span style={styles.userName}>
{user.name}({user.role})
</span>
)}
<button onClick={handleLogout} style={styles.logoutBtn}>
ログアウト
</button>
</div>
</nav>
);
}
const styles: Record<string, React.CSSProperties> = {
nav: { background: "#1e293b", borderBottom: "1px solid #334155", padding: "0 24px", display: "flex", justifyContent: "space-between", alignItems: "center", height: "56px", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
left: { display: "flex", alignItems: "center", gap: "32px" },
logo: { color: "#f1f5f9", fontWeight: 700, fontSize: "15px", letterSpacing: "0.04em" },
links: { display: "flex", gap: "4px" },
link: { color: "#94a3b8", textDecoration: "none", padding: "6px 12px", borderRadius: "5px", fontSize: "13px", fontWeight: 500 },
linkActive: { color: "#f1f5f9", background: "#334155" },
right: { display: "flex", alignItems: "center", gap: "16px" },
userName: { color: "#64748b", fontSize: "13px" },
logoutBtn: { background: "transparent", border: "1px solid #475569", color: "#94a3b8", padding: "6px 14px", borderRadius: "5px", cursor: "pointer", fontSize: "12px" },
};
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();
const navigate = useNavigate();
const handleSubmit = async (): Promise<void> => {
if (!loginId.trim() || !password.trim()) {
setError("ユーザー名とパスワードを入力してください");
return;
}
setIsLoading(true);
setError("");
await new Promise<void>((resolve) => setTimeout(resolve, 500));
const result = login(loginId, password);
setIsLoading(false);
if (result === "success") {
navigate("/dashboard"); // ← 成功したらここで遷移
} else if (result === "inactive") {
setError("このアカウントは無効化されています。管理者にお問い合わせください。");
} else {
setError("ユーザー名またはパスワードが違います");
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
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/ → 再利用できる部品
import LoginForm from "../components/LoginForm";
export default function Login() {
return <LoginForm />;
}
src/pages/Dashboard.tsx
import NavBar from "../components/NavBar";
import { useAuth } from "../context/AuthContext";
import type { UserRole } from "../types/user";
const ROLE_PERMISSIONS: Record<UserRole, string[]> = {
admin: ["全データ閲覧", "全データ編集", "ユーザー管理", "システム設定"],
supervisor: ["担当ラインのデータ閲覧", "担当ラインのデータ編集", "レポート出力"],
operator: ["担当ラインのデータ閲覧のみ"],
};
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" },
};
export default function Dashboard() {
const { user } = useAuth();
if (!user) return null;
const roleConfig = ROLE_CONFIG[user.role];
const permissions = ROLE_PERMISSIONS[user.role];
return (
<div style={styles.page}>
<NavBar />
<div style={styles.content}>
<div style={styles.card}>
<h1 style={styles.title}>ようこそ、{user.name} さん</h1>
<p style={styles.subtitle}>{roleConfig.label} · {user.department}</p>
<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>
</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> = {
page: { minHeight: "100vh", background: "#0f172a", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
content: { display: "flex", justifyContent: "center", padding: "40px 24px" },
card: { background: "#1e293b", border: "1px solid #334155", borderRadius: "10px", padding: "36px", width: "100%", maxWidth: "680px" },
title: { color: "#f1f5f9", fontSize: "22px", fontWeight: 700, margin: "0 0 4px" },
subtitle: { color: "#64748b", fontSize: "13px", margin: "0 0 28px" },
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/pages/Production.tsx
// ログイン必須ページのテンプレート。
// 実際の業務内容に合わせて書き換える。
import NavBar from "../components/NavBar";
import { useAuth } from "../context/AuthContext";
export default function Production() {
const { user } = useAuth();
if (!user) return null;
return (
<div style={styles.page}>
<NavBar />
<div style={styles.content}>
<div style={styles.card}>
<h1 style={styles.title}>生産管理</h1>
<p style={styles.subtitle}>担当: {user.name} · {user.line}</p>
<p style={styles.placeholder}>ここに生産管理の内容を実装します。</p>
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: { minHeight: "100vh", background: "#0f172a", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
content: { display: "flex", justifyContent: "center", padding: "40px 24px" },
card: { background: "#1e293b", border: "1px solid #334155", borderRadius: "10px", padding: "36px", width: "100%", maxWidth: "680px" },
title: { color: "#f1f5f9", fontSize: "22px", fontWeight: 700, margin: "0 0 4px" },
subtitle: { color: "#64748b", fontSize: "13px", margin: "0 0 24px" },
placeholder: { color: "#475569", fontSize: "14px" },
};
src/pages/Inspection.tsx
import NavBar from "../components/NavBar";
import { useAuth } from "../context/AuthContext";
export default function Inspection() {
const { user } = useAuth();
if (!user) return null;
return (
<div style={styles.page}>
<NavBar />
<div style={styles.content}>
<div style={styles.card}>
<h1 style={styles.title}>品質検査</h1>
<p style={styles.subtitle}>担当: {user.name} · {user.line}</p>
<p style={styles.placeholder}>ここに品質検査の内容を実装します。</p>
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: { minHeight: "100vh", background: "#0f172a", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
content: { display: "flex", justifyContent: "center", padding: "40px 24px" },
card: { background: "#1e293b", border: "1px solid #334155", borderRadius: "10px", padding: "36px", width: "100%", maxWidth: "680px" },
title: { color: "#f1f5f9", fontSize: "22px", fontWeight: 700, margin: "0 0 4px" },
subtitle: { color: "#64748b", fontSize: "13px", margin: "0 0 24px" },
placeholder: { color: "#475569", fontSize: "14px" },
};
src/pages/Reports.tsx
import NavBar from "../components/NavBar";
import { useAuth } from "../context/AuthContext";
export default function Reports() {
const { user } = useAuth();
if (!user) return null;
return (
<div style={styles.page}>
<NavBar />
<div style={styles.content}>
<div style={styles.card}>
<h1 style={styles.title}>レポート</h1>
<p style={styles.subtitle}>担当: {user.name} · {user.department}</p>
<p style={styles.placeholder}>ここにレポートの内容を実装します。</p>
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: { minHeight: "100vh", background: "#0f172a", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
content: { display: "flex", justifyContent: "center", padding: "40px 24px" },
card: { background: "#1e293b", border: "1px solid #334155", borderRadius: "10px", padding: "36px", width: "100%", maxWidth: "680px" },
title: { color: "#f1f5f9", fontSize: "22px", fontWeight: 700, margin: "0 0 4px" },
subtitle: { color: "#64748b", fontSize: "13px", margin: "0 0 24px" },
placeholder: { color: "#475569", fontSize: "14px" },
};
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";
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 で1つにまとめて囲む */}
{/* 未ログインなら RequireAuth が /login に飛ばしてくれる */}
{/* ページが増えたらこの中に追加するだけ */}
<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>
);
}
src/main.tsx
// 定型文。触らなくていい。
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;
});
ログイン後の遷移
const navigate = useNavigate();
const result = login(loginId, password);
if (result === "success") navigate("/dashboard");
RequireAuth で複数ページをまとめて保護
// ページごとに個別に書かなくていい
<Route element={<RequireAuth />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/production" element={<Production />} />
<Route path="/inspection" element={<Inspection />} />
<Route path="/reports" element={<Reports />} />
</Route>
ページを増やすときの手順
1. src/pages/NewPage.tsx を作る
2. App.tsx に import を追加
3. <Route element={<RequireAuth />}> の中に
<Route path="/newpage" element={<NewPage />} /> を追加
4. NavBar.tsx の NAV_LINKS 配列にリンクを追加
よくある詰まりポイント
| 症状 | 原因 | 解決 |
|---|---|---|
| ログインしても遷移しない |
login() の戻り値を使っていない |
result === "success" で navigate() する |
useAuth() は AuthProvider の中で… エラー |
AuthProvider で囲んでいない |
App.tsx の一番外側を <AuthProvider> で囲む |
| 未ログインでもページが見えてしまう |
RequireAuth で囲んでいない |
App.tsx の Route 設定を確認 |
| JSON の import でエラー | tsconfig の設定不足 |
"resolveJsonModule": true を追加 |
| ログアウト後に戻るボタンで戻れてしまう |
replace が抜けている |
<Navigate to="/login" replace /> の replace を確認 |
React + TypeScript ログイン練習(解説コメント付き・5ページ構成)
工場システムのログイン練習プロジェクトです。全ファイルに解説コメントを入れてあります。
📁 ファイル構成
factory-login/
├── src/
│ ├── context/
│ │ └── AuthContext.tsx ← ログイン状態の管理(メイン)
│ ├── components/
│ │ ├── LoginForm.tsx ← ログインフォーム
│ │ ├── NavBar.tsx ← ログイン後の共通ナビ
│ │ └── RequireAuth.tsx ← 未ログインのリダイレクト
│ ├── pages/
│ │ ├── Login.tsx ← /login(誰でも見れる)
│ │ ├── Dashboard.tsx ← /dashboard(要ログイン)
│ │ ├── Production.tsx ← /production(要ログイン)
│ │ ├── Inspection.tsx ← /inspection(要ログイン)
│ │ └── Reports.tsx ← /reports(要ログイン)
│ ├── data/
│ │ └── users.json
│ ├── types/
│ │ └── user.ts
│ ├── App.tsx
│ └── main.tsx
├── index.html
├── 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
// ============================================================
// src/types/user.ts
//
// 【このファイルの役割】
// アプリ全体で使う「型」をまとめて定義する場所。
// TypeScript では「このデータはこういう形をしています」と
// 事前に宣言しておくことで、タイプミスや間違った使い方を
// コードを実行する前(コンパイル時)に教えてくれる。
//
// 【型定義のメリット】
// 例: user.rol と書いてしまったら → 赤波線ですぐ気づける
// user.role と書けば → 補完が効いて快適に書ける
// ============================================================
// ============================================================
// ユニオン型(Union Type)
//
// "admin" | "supervisor" | "operator" は
// 「この3つの文字列のどれか」という意味。
//
// メリット:
// user.role = "admn" と書いたらエラーになる(タイポ防止)
// user.role = "manager" と書いてもエラーになる(想定外の値を防ぐ)
// ============================================================
export type UserRole =
| "admin" // 管理者:全機能を使える
| "supervisor" // 監督者:担当ラインの編集ができる
| "operator"; // オペレーター:担当ラインの閲覧のみ
export type UserStatus =
| "active" // 有効:ログインできる
| "inactive"; // 無効:ログインできない(退職者など)
// ============================================================
// User 型
//
// type キーワードで「オブジェクトの形」を定義する。
// users.json のデータがこの形に合っているか TypeScript がチェックする。
//
// 【各プロパティの意味】
// id : 社員番号(数値)
// username : ログイン時に使うID(文字列)
// email : メールアドレス(文字列)
// name : 画面に表示する氏名(文字列)
// role : 役割(上で定義したユニオン型)
// department : 所属部署(文字列)
// line : 担当ライン(文字列)
// status : アカウント有効/無効(上で定義したユニオン型)
// ============================================================
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
// ============================================================
// src/context/AuthContext.tsx
//
// 【このファイルの役割】
// アプリ全体で「ログイン状態」を共有するための仕組みを作る。
//
// 【なぜ Context が必要か?】
// React では普通、データは props で「親 → 子」にしか渡せない。
//
// App
// └─ Layout
// └─ Sidebar
// └─ UserName ← ここでユーザー名を表示したい
//
// 上の例だと App → Layout → Sidebar → UserName と
// 全部の中間コンポーネントに props を渡し続けなければならない。
// これを「props のバケツリレー」と呼び、コードが複雑になる。
//
// Context を使えば:
// どのコンポーネントからでも useAuth() を呼ぶだけで
// ログイン情報を直接取り出せる。
//
// 【AuthContext の3つのパーツ】
// 1. createContext → 「共有する箱」を作る
// 2. AuthProvider → 「箱に値を入れて子孫に配る」コンポーネント
// 3. useAuth() → 「箱から値を取り出す」カスタムフック
// ============================================================
import {
createContext, // Context オブジェクトを作る React の組み込み関数
useContext, // Context から値を取り出す React フック
useState, // コンポーネントの state(状態)を管理するフック
useCallback, // 関数を「メモ化」して、毎回新しく作られるのを防ぐフック
ReactNode, // JSX(<div> など)を受け取るための型
} from "react";
// 自分で定義した型をインポート
import type { User } from "../types/user";
// モック(ダミー)ユーザーデータをインポート
// tsconfig.json に "resolveJsonModule": true が必要
import usersData from "../data/users.json";
// ============================================================
// 定数の準備
// ============================================================
// JSON データを User 型の配列としてキャスト(型を教える)
// as User[] = 「この配列の中身は User 型です」と TypeScript に伝える
const USERS: User[] = usersData as User[];
// 練習用の固定パスワード
// 実務では絶対にやってはいけない。本番では API が検証する。
const FIXED_PASSWORD = "password";
// ============================================================
// ログイン結果の型
//
// login() 関数の戻り値の型。
// 文字列リテラルのユニオン型にすることで、
// 呼び出し元が result === "success" のように安全に分岐できる。
//
// boolean(true/false)より詳細な情報を返せるのがメリット。
// ============================================================
export type LoginResult =
| "success" // ログイン成功
| "not_found" // ユーザーが見つからない or パスワード違い
| "inactive"; // アカウントが無効化されている
// ============================================================
// AuthContext に入れる値の型定義
//
// この型が useAuth() で取り出せるものの一覧になる。
// ============================================================
type AuthContextType = {
user: User | null; // ログイン中のユーザー情報。未ログインは null
// ↑
// User | null は「User 型 または null」という意味(ユニオン型)
// null = まだ誰もログインしていない状態
login: (loginId: string, password: string) => LoginResult;
// ↑ login は「loginId と password を受け取って LoginResult を返す関数」という型
logout: () => void;
// ↑ logout は「何も受け取らず、何も返さない関数」という型
// void = 戻り値なし
isLoggedIn: boolean;
// ↑ ログインしているか(true/false)。よく使うので boolean で持つ
};
// ============================================================
// Context オブジェクトの作成
//
// createContext<型>(初期値) で「共有する箱」を作る。
//
// 型を AuthContextType | null にして初期値を null にする理由:
// → null のままなら「AuthProvider の外で使われた」と分かる
// → useAuth() の中で null チェックして、エラーを出せる
// → 初期値を AuthContextType にしようとすると
// login/logout などダミーの実装が必要で面倒
// ============================================================
const AuthContext = createContext<AuthContextType | null>(null);
// ↑型 ↑初期値
// ============================================================
// AuthProvider コンポーネント
//
// 【役割】
// ログイン状態を管理して、子孫コンポーネント全員に配る。
//
// 【使い方(App.tsx)】
// <AuthProvider> ← これで全体を囲む
// <BrowserRouter>
// <Routes>...</Routes>
// </BrowserRouter>
// </AuthProvider>
//
// 【children とは?】
// <AuthProvider> の「中に書いたもの」が children として渡される。
// <AuthProvider>
// ここが children
// </AuthProvider>
// ============================================================
type AuthProviderProps = {
children: ReactNode; // 子コンポーネント(アプリの中身すべて)
};
export function AuthProvider({ children }: AuthProviderProps) {
// ----------------------------------------------------------
// ログイン状態の管理
//
// useState<User | null>(null)
// → 型は「User か null」
// → 初期値は null(= 誰もログインしていない)
//
// user が null = 未ログイン
// user が User型 = ログイン中
//
// setUser(found) を呼ぶと user が更新され、
// AuthProvider の中にいる全コンポーネントが再描画される。
// これによって「ログインした瞬間に画面が切り替わる」が実現できる。
// ----------------------------------------------------------
const [user, setUser] = useState<User | null>(null);
// ----------------------------------------------------------
// login 関数
//
// 引数:
// loginId ... ユーザー名 or メールアドレス(どちらでも入力できる)
// password ... パスワード
//
// 戻り値: LoginResult("success" | "not_found" | "inactive")
//
// useCallback の第2引数 [] は「依存配列」。
// 空配列 [] = この関数はコンポーネントが最初に作られた時だけ生成する。
// 毎回レンダリングのたびに新しい関数オブジェクトが作られるのを防ぐ。
// ----------------------------------------------------------
const login = useCallback(
(loginId: string, password: string): LoginResult => {
// Array.find() でモック JSON を検索する
//
// find() は配列の中から「条件を満たす最初の要素」を返す。
// 見つからなければ undefined を返す。
//
// コールバック関数の中で2つの条件を同時にチェック:
// 1. username か email のどちらかが入力値と一致するか
// 2. パスワードが一致するか
const found = USERS.find((u: User) => {
// username または email が一致するか(|| は OR)
const idMatch = u.username === loginId || u.email === loginId;
// パスワードが固定値と一致するか
const passwordMatch = password === FIXED_PASSWORD;
// 両方 true のユーザーだけ返す(&& は AND)
return idMatch && passwordMatch;
});
// found の型は User | undefined
// → 一致するユーザーがいれば User 型
// → いなければ undefined
// ユーザーが見つからなかった場合
if (!found) {
return "not_found";
// !found は「found が falsy(undefined)の場合」という意味
}
// ユーザーは見つかったがアカウントが無効の場合
if (found.status === "inactive") {
return "inactive";
}
// ここまで来たら認証成功
// setUser で state を更新 → アプリ全体に「ログインした」が伝わる
setUser(found);
return "success";
},
[] // 依存配列が空 = マウント時に1回だけ作成
);
// ----------------------------------------------------------
// logout 関数
//
// やることは1つだけ:setUser(null) で user を null に戻す。
// これだけで:
// - isLoggedIn が false になる
// - RequireAuth が「未ログイン」と判断して /login に飛ばす
// - NavBar のユーザー名表示が消える
// など、ログイン状態に依存している全 UI が自動で更新される。
// ----------------------------------------------------------
const logout = useCallback(() => {
setUser(null);
}, []);
// useCallback の依存配列が [] なので
// この logout 関数は常に同じ関数オブジェクトになる
// ----------------------------------------------------------
// Provider に渡す value オブジェクト
//
// ここに書いたものが useAuth() で取り出せる。
// ----------------------------------------------------------
const value: AuthContextType = {
user, // 現在のログインユーザー(null or User型)
login, // ログイン関数
logout, // ログアウト関数
isLoggedIn: user !== null,
// user !== null は「user が null でなければ true」
// ↓ 具体例
// user が null → user !== null は false → 未ログイン
// user が User型 → user !== null は true → ログイン中
};
// AuthContext.Provider で children を囲む。
// value に渡したオブジェクトが、
// この Provider より内側にいる全コンポーネントから useAuth() で取れる。
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
// ============================================================
// カスタムフック useAuth()
//
// 【役割】
// どのコンポーネントからでも AuthContext の値を取り出せる。
//
// 【使い方】
// import { useAuth } from "../context/AuthContext";
//
// function SomeComponent() {
// const { user, login, logout, isLoggedIn } = useAuth();
// // あとは自由に使える
// }
//
// 【なぜカスタムフックにするか?】
// useContext(AuthContext) と null チェックを毎回書くのは面倒。
// useAuth() にまとめることで1行で取り出せる。
// また null チェックが1箇所にまとまるのでバグに気づきやすい。
// ============================================================
export function useAuth(): AuthContextType {
// useContext(AuthContext) で AuthProvider が渡した value を取り出す
const context = useContext(AuthContext);
// context が null = AuthProvider の外で useAuth() が呼ばれた
//
// よくあるミス:
// App.tsx で <AuthProvider> で囲み忘れた
// AuthProvider より外側のコンポーネントで useAuth() を呼んだ
//
// エラーメッセージを丁寧に書いておくと、
// 詰まったときにすぐ原因に気づける。
if (context === null) {
throw new Error(
"useAuth() は <AuthProvider> の内側で使ってください。\n" +
"App.tsx で <AuthProvider> がアプリ全体を囲んでいるか確認してください。"
);
}
return context;
}
src/components/RequireAuth.tsx
// ============================================================
// src/components/RequireAuth.tsx
//
// 【このファイルの役割】
// ログインしていないユーザーを /login に自動リダイレクトする「門番」。
//
// 【なぜ必要か?】
// ログイン必須ページ(/dashboard など)のURLを直接打ち込まれたとき、
// ログインしていないのにそのページが表示されてしまうのを防ぐ。
//
// 【App.tsx での使い方】
//
// <Route element={<RequireAuth />}> ← この1行で囲む
// <Route path="/dashboard" element={<Dashboard />} />
// <Route path="/production" element={<Production />} />
// <Route path="/inspection" element={<Inspection />} />
// <Route path="/reports" element={<Reports />} />
// </Route>
//
// ポイント: RequireAuth を1つ書くだけで、
// 中の全ページが「ログイン必須」になる。
// ページごとに個別に書く必要はない。
//
// 【動作フロー】
// ① ユーザーが /dashboard にアクセス
// ② RequireAuth が isLoggedIn を確認
// ③-A 未ログインなら → <Navigate to="/login" replace /> で /login へ飛ばす
// ③-B ログイン済みなら → <Outlet /> で Dashboard を表示する
// ============================================================
import { Navigate, Outlet } from "react-router-dom";
// Navigate:
// 「このコンポーネントが描画されたら即座に指定のURLへ移動する」
// <Navigate to="/login" replace /> と書くと /login に移動する。
// replace オプション:
// true → 履歴を「置き換える」(戻るボタンで保護ページに戻れなくなる)
// false → 履歴に「追加する」(戻るボタンで保護ページに戻れてしまう)
// セキュリティのため replace={true} を推奨。
//
// Outlet:
// 「子 Route のコンポーネントをここに表示する」というプレースホルダー。
// <Route element={<RequireAuth />}> の中に書いた子 Route が
// この <Outlet /> の位置に表示される。
import { useAuth } from "../context/AuthContext";
export default function RequireAuth() {
// AuthContext からログイン状態を取得
// isLoggedIn は user !== null と同じ意味(AuthContext.tsx で定義)
const { isLoggedIn } = useAuth();
if (!isLoggedIn) {
// 未ログイン → /login にリダイレクト
//
// replace={true} にすることで、
// ブラウザの「戻る」ボタンを押しても
// ログインしていない状態で保護ページに戻れないようにする。
return <Navigate to="/login" replace />;
}
// ログイン済み → 子 Route(Dashboard など)を描画する
// <Outlet /> は「ここに子 Route の中身を表示する」という意味
return <Outlet />;
}
src/components/NavBar.tsx
// ============================================================
// src/components/NavBar.tsx
//
// 【このファイルの役割】
// ログイン後の全ページに表示する共通ナビゲーションバー。
//
// 【ここで使っている主な機能】
// NavLink : 現在のURLと一致するリンクに自動でスタイルを当てる
// useAuth : ログイン中のユーザー情報とログアウト関数を取得
// useNavigate : ログアウト後に /login へ移動する
//
// 【ページを増やすときはここだけ変更すればいい】
// NAV_LINKS 配列に1行追加するだけ。
// ============================================================
import { useNavigate, NavLink } from "react-router-dom";
// NavLink:
// 普通の <a href="..."> の代わりに使う React Router のコンポーネント。
// 通常の Link との違いは「現在のURLと一致するかどうか」が分かること。
// style や className に関数を渡すと isActive(一致しているか)が使える。
// 例: style={({ isActive }) => isActive ? 選択中のスタイル : 通常スタイル}
//
// useNavigate:
// React Router のフック。
// navigate("/login") のように呼ぶと指定のURLへ移動できる。
// ボタンクリックなど「コードの中で」ページ移動したいときに使う。
import { useAuth } from "../context/AuthContext";
// ============================================================
// ナビゲーションリンクの定義
//
// オブジェクトの配列にしておくことで、
// ページが増えても1行追加するだけで済む。
//
// to : リンク先のURL(<Route path="..."> と合わせる)
// label : 画面に表示するテキスト
// ============================================================
const NAV_LINKS = [
{ to: "/dashboard", label: "ダッシュボード" },
{ to: "/production", label: "生産管理" },
{ to: "/inspection", label: "品質検査" },
{ to: "/reports", label: "レポート" },
// ページを増やしたらここに追加する
// 例: { to: "/maintenance", label: "設備管理" },
];
export default function NavBar() {
// ----------------------------------------------------------
// useAuth() でログイン中のユーザー情報とログアウト関数を取得
//
// user : ログイン中のユーザーオブジェクト(NavBar はログイン後にしか
// 表示されないので null にはならないが、型は User | null)
// logout : AuthContext で定義したログアウト関数(user を null にする)
// ----------------------------------------------------------
const { user, logout } = useAuth();
// useNavigate でページ移動の関数を取得
const navigate = useNavigate();
// ----------------------------------------------------------
// ログアウト処理
//
// 2ステップ:
// 1. logout() → AuthContext の user を null にリセット
// → isLoggedIn が false になる
// → RequireAuth が反応して保護ページへのアクセスを塞ぐ
// 2. navigate("/login") → ログインページへ移動
// ----------------------------------------------------------
const handleLogout = (): void => {
logout(); // step1: state をリセット
navigate("/login"); // step2: ログインページへ移動
};
return (
<nav style={styles.nav}>
{/* 左側:ロゴ + ナビリンク */}
<div style={styles.left}>
<span style={styles.logo}>🏭 工場管理</span>
<div style={styles.links}>
{/*
NAV_LINKS 配列を map() でループして NavLink を生成する。
key={to} は React がリストを管理するための必須属性。
一意な値であれば何でもいいが、URL(to)はユニークなので使いやすい。
*/}
{NAV_LINKS.map(({ to, label }) => (
<NavLink
key={to}
to={to}
style={({ isActive }) => ({
// まず通常スタイルを展開(スプレッド構文)
...styles.link,
// isActive が true(現在のページ)なら選択中スタイルを上書き
...(isActive ? styles.linkActive : {}),
// {} は「上書きなし」という意味。スプレッドしても何も変わらない。
})}
>
{label}
</NavLink>
))}
</div>
</div>
{/* 右側:ログイン中のユーザー名 + ログアウトボタン */}
<div style={styles.right}>
{/*
user && (...) は「user が null でなければ表示する」という条件付きレンダリング。
user が null のとき(= 未ログイン)は何も表示しない。
NavBar はログイン後にしか表示されないが、型上 user は null になりうるので
この書き方で TypeScript のエラーを回避する。
*/}
{user && (
<span style={styles.userName}>
{user.name}({user.role})
</span>
)}
<button onClick={handleLogout} style={styles.logoutBtn}>
ログアウト
</button>
</div>
</nav>
);
}
// ============================================================
// スタイル定義
//
// Record<string, React.CSSProperties> は
// 「文字列をキーに持つ、CSSプロパティの型のオブジェクト」という意味。
// styles.nav のように使うと型チェックが効く。
// ============================================================
const styles: Record<string, React.CSSProperties> = {
nav: {
background: "#1e293b",
borderBottom: "1px solid #334155",
padding: "0 24px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
height: "56px",
fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif",
},
left: {
display: "flex",
alignItems: "center",
gap: "32px",
},
logo: {
color: "#f1f5f9",
fontWeight: 700,
fontSize: "15px",
letterSpacing: "0.04em",
},
links: {
display: "flex",
gap: "4px",
},
link: {
color: "#94a3b8",
textDecoration: "none", // 下線を消す
padding: "6px 12px",
borderRadius: "5px",
fontSize: "13px",
fontWeight: 500,
},
linkActive: {
// isActive が true のとき link スタイルに上書きされる
color: "#f1f5f9",
background: "#334155",
},
right: {
display: "flex",
alignItems: "center",
gap: "16px",
},
userName: {
color: "#64748b",
fontSize: "13px",
},
logoutBtn: {
background: "transparent",
border: "1px solid #475569",
color: "#94a3b8",
padding: "6px 14px",
borderRadius: "5px",
cursor: "pointer",
fontSize: "12px",
},
};
src/components/LoginForm.tsx
// ============================================================
// src/components/LoginForm.tsx
//
// 【このファイルの役割】
// ログインフォームの UI と入力処理をまとめたコンポーネント。
//
// 【処理の流れ】
// 1. ユーザーがユーザー名とパスワードを入力
// 2. ログインボタンを押す(または Enter キーを押す)
// 3. handleSubmit() が呼ばれる
// 4. AuthContext の login() で users.json と照合
// 5. 成功 → navigate("/dashboard") でページ遷移
// 失敗 → setError() でエラーメッセージを表示
//
// 【このファイルで使う主な React の機能】
// useState : 入力値・エラー・ローディング状態の管理
// useNavigate : ログイン成功後のページ遷移
// useAuth : AuthContext から login 関数を取得
// ============================================================
import { useState, KeyboardEvent } from "react";
// useState:
// コンポーネントの「状態(state)」を管理するフック。
// const [値, 更新関数] = useState(初期値) という形で使う。
// 更新関数を呼ぶと画面が再描画される。
//
// KeyboardEvent:
// キーボードイベントの型。
// onKeyDown のハンドラ関数の引数に使う。
import { useNavigate } from "react-router-dom";
// useNavigate:
// ページ移動の関数を返すフック。
// const navigate = useNavigate() で取得し、
// navigate("/dashboard") のように呼ぶと指定URLへ移動する。
import { useAuth } from "../context/AuthContext";
// useAuth:
// AuthContext から値を取り出すカスタムフック。
// ここでは login 関数だけ使う。
import usersData from "../data/users.json";
import type { User } from "../types/user";
// テストユーザー一覧の表示用にキャスト
const USERS: User[] = usersData as User[];
export default function LoginForm() {
// ----------------------------------------------------------
// state の定義
//
// フォームの入力値と UI の状態をそれぞれ state で管理する。
// state が変わるたびにコンポーネントが再描画される。
//
// useState<string>("") の書き方:
// <string> は型(TypeScript)
// ("") は初期値
// ----------------------------------------------------------
const [loginId, setLoginId] = useState<string>("");
// loginId : ユーザー名 or メールアドレスの入力値
// setLoginId: loginId を更新する関数
const [password, setPassword] = useState<string>("");
// password : パスワードの入力値
// setPassword: password を更新する関数
const [error, setError] = useState<string>("");
// error : エラーメッセージ。空文字 "" のときはエラー表示しない
// setError: エラーメッセージを更新する関数
const [isLoading, setIsLoading] = useState<boolean>(false);
// isLoading : 認証処理中かどうか(true のときボタンを無効化する)
// setIsLoading: isLoading を更新する関数
// ----------------------------------------------------------
// useAuth と useNavigate の初期化
// ----------------------------------------------------------
// AuthContext から login 関数だけを取り出す(分割代入)
const { login } = useAuth();
// ページ移動に使う navigate 関数を取得
const navigate = useNavigate();
// ----------------------------------------------------------
// handleSubmit: ログイン処理のメイン関数
//
// async/await を使っているのは「少し待つ演出」のため。
// 本来は API を呼ぶ非同期処理がここに入る。
//
// 戻り値の型 Promise<void>:
// async 関数は必ず Promise を返す。
// void = 戻り値の中身は使わない。
// ----------------------------------------------------------
const handleSubmit = async (): Promise<void> => {
// ── バリデーション(入力チェック)──────────────────────
// trim() で前後の空白を除去してから空欄チェック
// 空欄のまま送信させないためのガード
if (!loginId.trim() || !password.trim()) {
setError("ユーザー名とパスワードを入力してください");
return; // return で処理をここで止める(以降は実行しない)
}
// ── ローディング開始 ────────────────────────────────────
setIsLoading(true); // ボタンを「認証中...」に変える
setError(""); // 前回のエラーメッセージをリセット
// ── API 呼び出しの代わりに少し待つ ─────────────────────
// 実務では fetch() でサーバーに問い合わせる。
// モック用に setTimeout で 500ms 待つだけ。
// new Promise + setTimeout の組み合わせで「待つ」を実現する。
await new Promise<void>((resolve) => setTimeout(resolve, 500));
// ── AuthContext の login() で照合 ───────────────────────
// login() の中で users.json と照合している(AuthContext.tsx 参照)
// 戻り値は "success" | "not_found" | "inactive" のどれか
const result = login(loginId, password);
// ── ローディング終了 ────────────────────────────────────
setIsLoading(false);
// ── 結果に応じて分岐 ────────────────────────────────────
if (result === "success") {
// 成功 → navigate() でダッシュボードへ移動
// これが「ログインしたら画面が切り替わる」の実装部分
navigate("/dashboard");
} else if (result === "inactive") {
// 無効アカウント → 専用のエラーメッセージを表示
setError("このアカウントは無効化されています。管理者にお問い合わせください。");
} else {
// not_found → ユーザーが見つからない or パスワード違い
// セキュリティのため「どちらが違うか」は教えない
setError("ユーザー名またはパスワードが違います");
}
};
// ----------------------------------------------------------
// handleKeyDown: Enter キーでもログインできるようにする
//
// KeyboardEvent<HTMLInputElement> は
// 「input 要素でのキーボードイベント」という型。
// e.key でどのキーが押されたかを文字列で取得できる。
// ----------------------------------------------------------
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") {
handleSubmit(); // Enter が押されたらログイン処理を実行
}
};
// ----------------------------------------------------------
// JSX(見た目の部分)
// ----------------------------------------------------------
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}>
{/* ユーザー名 or メールアドレスの入力欄 */}
<div style={styles.field}>
<label style={styles.label}>ユーザー名またはメールアドレス</label>
<input
type="text"
value={loginId}
// onChange: 文字を入力するたびに呼ばれる
// e.target.value が今の入力値
// setLoginId() で state を更新 → 入力欄の表示が更新される
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" // type="password" で入力値を伏せ字にする
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="パスワードを入力"
style={styles.input}
disabled={isLoading}
/>
</div>
{/*
エラーメッセージの条件付き表示
error && (...) は「error が空文字でなければ表示する」という意味。
error が "" (空文字) → falsy → 何も表示しない
error が "xxx" → truthy → <p>...</p> を表示する
*/}
{error && <p style={styles.error}>⚠️ {error}</p>}
{/* ログインボタン */}
<button
onClick={handleSubmit}
disabled={isLoading} // 認証中はボタンを押せないようにする
style={{
...styles.button,
opacity: isLoading ? 0.6 : 1,
// isLoading が true なら少し透明にして「処理中」を伝える
// 三項演算子: 条件 ? 真の値 : 偽の値
}}
>
{isLoading ? "認証中..." : "ログイン"}
{/* isLoading が true なら "認証中..."、false なら "ログイン" */}
</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}
// u.status が "active" のときだけクリックでユーザー名を自動入力
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,
// status に応じて文字色を変える
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
// ============================================================
// src/pages/Login.tsx
//
// 【このファイルの役割】
// /login に対応するページコンポーネント。
// LoginForm コンポーネントを表示するだけ。
//
// 【pages/ と components/ の使い分け】
//
// pages/ → URLに対応する「画面全体」
// App.tsx の <Route element={...}> に直接渡すもの
//
// components/ → 再利用できる「部品」
// 複数のページから使い回すもの
//
// この分け方にしておくと:
// - LoginForm を別のページ(例: 管理者用ログイン)でも使い回せる
// - pages/ を見るだけで「どんな画面があるか」が一目で分かる
// - コードの責任範囲が明確になる
// ============================================================
import LoginForm from "../components/LoginForm";
export default function Login() {
// LoginForm を描画するだけ。
// ロジックは全部 LoginForm.tsx と AuthContext.tsx にある。
return <LoginForm />;
}
src/pages/Dashboard.tsx
// ============================================================
// src/pages/Dashboard.tsx
//
// 【このファイルの役割】
// /dashboard に対応するページ。ログイン後の最初の画面。
//
// 【RequireAuth による保護】
// このページは App.tsx で <Route element={<RequireAuth />}> の
// 中に入っているので、未ログインでアクセスしようとすると
// 自動的に /login にリダイレクトされる。
//
// 【useAuth() の使い方(このページの例)】
// const { user } = useAuth() でログイン中のユーザー情報を取得し、
// user.name、user.role などを画面に表示する。
// ============================================================
import NavBar from "../components/NavBar";
import { useAuth } from "../context/AuthContext";
import type { UserRole } from "../types/user";
// ============================================================
// 役割ごとの表示設定
//
// Record<UserRole, ...> は
// 「UserRole の全パターン(admin/supervisor/operator)を
// キーとして持つオブジェクト」という型。
// キーを網羅していないと TypeScript がエラーを出してくれる。
// ============================================================
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() {
// ----------------------------------------------------------
// useAuth() でログイン中のユーザーを取得
//
// RequireAuth で保護されているので、
// このページに来る時点で user は必ず User 型(null にならない)。
// しかし TypeScript の型は User | null なので、
// if (!user) return null; で「null の場合は何も描画しない」と
// 書いておかないとエラーになる。
// ----------------------------------------------------------
const { user } = useAuth();
// TypeScript のための null ガード
// RequireAuth があるので実際には null でここに来ることはない
if (!user) return null;
// user.role に対応する設定と権限を取得
const roleConfig = ROLE_CONFIG[user.role];
const permissions = ROLE_PERMISSIONS[user.role];
return (
<div style={styles.page}>
{/* NavBar は全ログイン後ページ共通のナビゲーション */}
<NavBar />
<div style={styles.content}>
<div style={styles.card}>
{/* ウェルカムメッセージ */}
<h1 style={styles.title}>ようこそ、{user.name} さん</h1>
<p style={styles.subtitle}>{roleConfig.label} · {user.department}</p>
{/* ユーザー情報グリッド */}
<section style={styles.section}>
<h2 style={styles.sectionTitle}>ユーザー情報</h2>
{/*
gridTemplateColumns: "1fr 1fr" で2列グリッドを作る。
1fr は「利用可能なスペースを均等に分割する」という意味。
*/}
<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>
{/*
スプレッド構文でベーススタイルと動的スタイルを合成する。
roleConfig の内容(色など)は役割によって変わる。
*/}
<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>
</div>
);
}
// ============================================================
// InfoCard: ユーザー情報を1項目表示する小さな部品
//
// 同じレイアウトを8回繰り返すので部品化している。
// props の型をインラインで定義している(型が単純なので別ファイルにしない)。
//
// highlight?: boolean の ? は「省略可能(省略時は undefined)」という意味。
// ============================================================
function InfoCard({ label, value, highlight }: {
label: string;
value: string | number; // 文字列 または 数値を受け付ける
highlight?: boolean; // ? = オプション(省略可能)
}) {
return (
<div style={styles.infoCard}>
{/* ラベル(小さい文字) */}
<span style={styles.infoLabel}>{label}</span>
{/* 値(highlight が true なら紫色・太字にする) */}
<span style={{
...styles.infoValue,
...(highlight ? { color: "#818cf8", fontWeight: 700 } : {}),
// highlight が true → 紫色・太字で上書き
// highlight が false → {} で何も上書きしない
}}>
{value}
</span>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: { minHeight: "100vh", background: "#0f172a", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
content: { display: "flex", justifyContent: "center", padding: "40px 24px" },
card: { background: "#1e293b", border: "1px solid #334155", borderRadius: "10px", padding: "36px", width: "100%", maxWidth: "680px" },
title: { color: "#f1f5f9", fontSize: "22px", fontWeight: 700, margin: "0 0 4px" },
subtitle: { color: "#64748b", fontSize: "13px", margin: "0 0 28px" },
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/pages/Production.tsx
// ============================================================
// src/pages/Production.tsx
//
// 【このファイルの役割】
// /production に対応するページ(生産管理)。
//
// 【RequireAuth による保護】
// App.tsx の <Route element={<RequireAuth />}> の中に入っているので
// 未ログインでアクセスすると自動的に /login にリダイレクトされる。
//
// 【このファイルの使い方】
// このページは「テンプレート」として作ってある。
// 実際の業務内容に合わせて、このファイルの中身を書き換えて使う。
//
// 【useAuth() で使える情報】
// const { user } = useAuth() で以下の情報が取れる:
// user.id : 社員番号
// user.username : ユーザー名
// user.name : 氏名
// user.email : メールアドレス
// user.role : 役割(admin / supervisor / operator)
// user.department : 部署
// user.line : 担当ライン
// user.status : ステータス(active / inactive)
// ============================================================
import NavBar from "../components/NavBar";
import { useAuth } from "../context/AuthContext";
export default function Production() {
// ログイン中のユーザーを取得
// このページに来た時点で user は必ず User 型(RequireAuth が保証)
const { user } = useAuth();
// TypeScript のための null ガード(実際には null でここに来ない)
if (!user) return null;
return (
<div style={styles.page}>
{/* 全ページ共通のナビゲーションバー */}
<NavBar />
<div style={styles.content}>
<div style={styles.card}>
{/* ページタイトル */}
<h1 style={styles.title}>生産管理</h1>
{/* ログイン中のユーザー情報を表示(useAuth() で取得) */}
<p style={styles.subtitle}>
担当: {user.name}({user.role}) · {user.line}
</p>
{/* ここに実際の業務画面を実装する */}
<p style={styles.placeholder}>
ここに生産管理の内容を実装します。
</p>
{/*
役割によって表示内容を変えるサンプル:
{user.role === "admin" && (
<div>管理者専用の操作パネル</div>
)}
{user.role !== "operator" && (
<div>管理者・監督者だけ見えるセクション</div>
)}
{user.line === "Aライン" && (
<div>Aライン専用のデータ</div>
)}
*/}
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: { minHeight: "100vh", background: "#0f172a", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
content: { display: "flex", justifyContent: "center", padding: "40px 24px" },
card: { background: "#1e293b", border: "1px solid #334155", borderRadius: "10px", padding: "36px", width: "100%", maxWidth: "680px" },
title: { color: "#f1f5f9", fontSize: "22px", fontWeight: 700, margin: "0 0 4px" },
subtitle: { color: "#64748b", fontSize: "13px", margin: "0 0 24px" },
placeholder: { color: "#475569", fontSize: "14px" },
};
src/pages/Inspection.tsx
// ============================================================
// src/pages/Inspection.tsx
//
// 【このファイルの役割】
// /inspection に対応するページ(品質検査)。
// RequireAuth で保護済み。未ログインは /login にリダイレクト。
//
// 【テンプレートとしての使い方】
// Production.tsx と同じ構造。
// このファイルの中身を実際の品質検査画面に書き換えて使う。
// ============================================================
import NavBar from "../components/NavBar";
import { useAuth } from "../context/AuthContext";
export default function Inspection() {
const { user } = useAuth();
if (!user) return null;
return (
<div style={styles.page}>
<NavBar />
<div style={styles.content}>
<div style={styles.card}>
<h1 style={styles.title}>品質検査</h1>
<p style={styles.subtitle}>
担当: {user.name}({user.role}) · {user.line}
</p>
<p style={styles.placeholder}>
ここに品質検査の内容を実装します。
</p>
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: { minHeight: "100vh", background: "#0f172a", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
content: { display: "flex", justifyContent: "center", padding: "40px 24px" },
card: { background: "#1e293b", border: "1px solid #334155", borderRadius: "10px", padding: "36px", width: "100%", maxWidth: "680px" },
title: { color: "#f1f5f9", fontSize: "22px", fontWeight: 700, margin: "0 0 4px" },
subtitle: { color: "#64748b", fontSize: "13px", margin: "0 0 24px" },
placeholder: { color: "#475569", fontSize: "14px" },
};
src/pages/Reports.tsx
// ============================================================
// src/pages/Reports.tsx
//
// 【このファイルの役割】
// /reports に対応するページ(レポート)。
// RequireAuth で保護済み。未ログインは /login にリダイレクト。
//
// 【テンプレートとしての使い方】
// Production.tsx と同じ構造。
// このファイルの中身を実際のレポート画面に書き換えて使う。
// ============================================================
import NavBar from "../components/NavBar";
import { useAuth } from "../context/AuthContext";
export default function Reports() {
const { user } = useAuth();
if (!user) return null;
return (
<div style={styles.page}>
<NavBar />
<div style={styles.content}>
<div style={styles.card}>
<h1 style={styles.title}>レポート</h1>
<p style={styles.subtitle}>
担当: {user.name}({user.role}) · {user.department}
</p>
<p style={styles.placeholder}>
ここにレポートの内容を実装します。
</p>
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: { minHeight: "100vh", background: "#0f172a", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
content: { display: "flex", justifyContent: "center", padding: "40px 24px" },
card: { background: "#1e293b", border: "1px solid #334155", borderRadius: "10px", padding: "36px", width: "100%", maxWidth: "680px" },
title: { color: "#f1f5f9", fontSize: "22px", fontWeight: 700, margin: "0 0 4px" },
subtitle: { color: "#64748b", fontSize: "13px", margin: "0 0 24px" },
placeholder: { color: "#475569", fontSize: "14px" },
};
src/App.tsx
// ============================================================
// src/App.tsx
//
// 【このファイルの役割】
// アプリ全体のルーティング(URL とページの対応)を設定する。
//
// 【ルーティングとは?】
// URL が変わったときに「どのコンポーネントを表示するか」を決める仕組み。
// 例: /login にアクセスしたら <Login /> を表示する、など。
//
// 【このファイルのURL対応】
//
// / → /login に自動リダイレクト
// /login → Login.tsx(誰でもアクセスできる)
//
// /dashboard → Dashboard.tsx ┐
// /production → Production.tsx ├ ログイン必須
// /inspection → Inspection.tsx │(RequireAuth が守る)
// /reports → Reports.tsx ┘
//
// 【構造のポイント3つ】
//
// 1. <AuthProvider> を一番外側に置く
// → useAuth() を使う全コンポーネントが AuthProvider の中にいる必要がある
// → BrowserRouter より外に書けば確実
//
// 2. <BrowserRouter> で囲む
// → これがないと useNavigate や NavLink が使えない
//
// 3. <Route element={<RequireAuth />}> で保護ページをまとめて囲む
// → この中のページ全部が「ログイン必須」になる
// → ページごとに個別に書かなくていい
// ============================================================
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
// BrowserRouter : URL の変化を React Router に伝える。アプリ全体を囲む。
// Routes : <Route> をまとめるコンテナ。URLに一致した Route だけ描画する。
// Route : URL とコンポーネントを対応させる。
// path="..." が URL、element={...} が表示するコンポーネント。
// Navigate : 描画された瞬間に別のURLへ移動するコンポーネント。
// リダイレクトに使う。
import { AuthProvider } from "./context/AuthContext";
// AuthProvider: ログイン状態をアプリ全体に共有する Provider。
// 一番外側に置くことで、全コンポーネントで useAuth() が使えるようになる。
import RequireAuth from "./components/RequireAuth";
// RequireAuth: 未ログインのユーザーを /login に飛ばす「門番」。
// <Route element={<RequireAuth />}> として使う。
// ── ログイン不要ページ ──────────────────────────────────────
import Login from "./pages/Login";
// ── ログイン必須ページ ──────────────────────────────────────
// (RequireAuth の中に入れることで保護される)
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: 一番外側で全体を囲む
//
// なぜ一番外側か?
// BrowserRouter や Routes の中でも useAuth() を使うコンポーネントがあるため、
// それらより外側に AuthProvider がいる必要がある。
// BrowserRouter → Routes → Route → RequireAuth(useAuth() を使う)
// という階層なので、AuthProvider はすべての外側に来る。
<AuthProvider>
{/* ② BrowserRouter: React Router を有効にする
これがないと useNavigate、NavLink、Route などが全部エラーになる */}
<BrowserRouter>
{/* ③ Routes: URL に一致した Route だけを描画するコンテナ
複数の Route の中から「今のURLに合うもの1つ」だけが表示される */}
<Routes>
{/* / にアクセスしたら /login に自動リダイレクト
replace: ブラウザの履歴を「追加」ではなく「置き換える」
→ 戻るボタンで / に戻ってループしないようにする */}
<Route path="/" element={<Navigate to="/login" replace />} />
{/* ログイン不要ページ: 誰でもアクセスできる */}
<Route path="/login" element={<Login />} />
{/* ================================================================
ログイン必須ゾーン
element={<RequireAuth />} を指定した Route で子 Route を囲む。
この書き方が React Router v6 の「レイアウトルート」という機能。
動作:
1. /dashboard などにアクセスする
2. まず RequireAuth が描画される
3. RequireAuth が isLoggedIn をチェック
4. 未ログイン → <Navigate to="/login" /> で /login へ移動
ログイン済み → <Outlet /> に子 Route を描画
ポイント:
RequireAuth を1つ書くだけで中の全ページが保護される。
ページが増えたら <Route element={<RequireAuth />}> の
中に追加するだけ。
================================================================ */}
<Route element={<RequireAuth />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/production" element={<Production />} />
<Route path="/inspection" element={<Inspection />} />
<Route path="/reports" element={<Reports />} />
{/* ページを増やすときはここに追加する
1. src/pages/NewPage.tsx を作る
2. このファイルの上部に import を追加する
3. ↓この中に Route を追加する
例: <Route path="/maintenance" element={<Maintenance />} /> */}
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
src/main.tsx
// ============================================================
// src/main.tsx
//
// 【このファイルの役割】
// アプリの「エントリーポイント」(最初に実行されるファイル)。
// index.html の <div id="root"> に React アプリを描画する。
//
// 【エントリーポイントとは?】
// ブラウザが最初に読み込むJSファイル。
// ここから App.tsx → 各ページ・コンポーネントへと読み込まれていく。
//
// 【このファイルは基本的に触らなくていい】
// Vite(開発ツール)が生成した定型文で、
// ほとんどのプロジェクトで同じ内容になる。
// ============================================================
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(
// document.getElementById("root") で index.html の <div id="root"> を取得
// ! は TypeScript の「non-null アサーション」: null でないことを保証する
document.getElementById("root")!
).render(
// React.StrictMode:
// 開発中に意図的に2回レンダリングして副作用のバグを見つけやすくするモード。
// 本番ビルド(npm run build)では自動的に無効化されるので残しておいてOK。
<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 で複数ページをまとめて保護
// ページごとに個別に書かなくていい。1つで全部まとめて保護できる。
<Route element={<RequireAuth />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/production" element={<Production />} />
<Route path="/inspection" element={<Inspection />} />
<Route path="/reports" element={<Reports />} />
</Route>
ページを増やすときの手順
1. src/pages/NewPage.tsx を作る(Production.tsx をコピーして中身を変える)
2. App.tsx に import NewPage from "./pages/NewPage"; を追加
3. <Route element={<RequireAuth />}> の中に
<Route path="/newpage" element={<NewPage />} /> を追加
4. NavBar.tsx の NAV_LINKS 配列に
{ to: "/newpage", label: "新しいページ" } を追加
よくある詰まりポイント
| 症状 | 原因 | 解決 |
|---|---|---|
| ログインしても遷移しない |
login() の戻り値を使っていない |
result === "success" で navigate() する |
useAuth() は AuthProvider の中で… エラー |
AuthProvider で囲んでいない |
App.tsx の一番外側を <AuthProvider> で囲む |
| 未ログインでもページが見えてしまう |
RequireAuth で囲んでいない |
App.tsx の Route 設定を確認 |
| JSON の import でエラー | tsconfig の設定不足 |
"resolveJsonModule": true を追加 |
| ログアウト後に戻るボタンで戻れてしまう |
replace が抜けている |
<Navigate to="/login" replace /> の replace を確認 |
React + TypeScript ログイン練習(解説コメント付き・5ページ構成・users.ts版)
全ファイルに解説コメント入り。モックデータを users.json ではなく型安全な users.ts で管理しています。
📁 ファイル構成
factory-login/
├── src/
│ ├── context/
│ │ └── AuthContext.tsx ← ログイン状態の管理(メイン)
│ ├── components/
│ │ ├── LoginForm.tsx ← ログインフォーム
│ │ ├── NavBar.tsx ← ログイン後の共通ナビ
│ │ └── RequireAuth.tsx ← 未ログインのリダイレクト
│ ├── pages/
│ │ ├── Login.tsx ← /login(誰でも見れる)
│ │ ├── Dashboard.tsx ← /dashboard(要ログイン)
│ │ ├── Production.tsx ← /production(要ログイン)
│ │ ├── Inspection.tsx ← /inspection(要ログイン)
│ │ └── Reports.tsx ← /reports(要ログイン)
│ ├── data/
│ │ └── users.ts ← モックユーザーデータ(型付き)
│ ├── types/
│ │ └── user.ts ← 型定義
│ ├── App.tsx
│ └── main.tsx
├── index.html
├── 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
// ============================================================
// src/types/user.ts
//
// 【このファイルの役割】
// アプリ全体で使う「型」をまとめて定義する場所。
// TypeScript では「このデータはこういう形をしています」と
// 事前に宣言しておくことで、タイプミスや間違った使い方を
// コードを実行する前(コンパイル時)に教えてくれる。
//
// 【型定義のメリット】
// 例: user.rol と書いてしまったら → 赤波線ですぐ気づける
// user.role と書けば → 補完が効いて快適に書ける
// ============================================================
// ============================================================
// ユニオン型(Union Type)
//
// "admin" | "supervisor" | "operator" は
// 「この3つの文字列のどれか」という意味。
//
// メリット:
// user.role = "admn" と書いたらエラーになる(タイポ防止)
// user.role = "manager" と書いてもエラーになる(想定外の値を防ぐ)
// ============================================================
export type UserRole =
| "admin" // 管理者:全機能を使える
| "supervisor" // 監督者:担当ラインの編集ができる
| "operator"; // オペレーター:担当ラインの閲覧のみ
export type UserStatus =
| "active" // 有効:ログインできる
| "inactive"; // 無効:ログインできない(退職者など)
// ============================================================
// User 型
//
// type キーワードで「オブジェクトの形」を定義する。
// users.json のデータがこの形に合っているか TypeScript がチェックする。
//
// 【各プロパティの意味】
// id : 社員番号(数値)
// username : ログイン時に使うID(文字列)
// email : メールアドレス(文字列)
// name : 画面に表示する氏名(文字列)
// role : 役割(上で定義したユニオン型)
// department : 所属部署(文字列)
// line : 担当ライン(文字列)
// status : アカウント有効/無効(上で定義したユニオン型)
// ============================================================
export type User = {
id: number;
username: string;
email: string;
name: string;
role: UserRole;
department: string;
line: string;
status: UserStatus;
};
src/data/users.ts
// ============================================================
// src/data/users.ts
//
// 【このファイルの役割】
// ログイン照合に使うモックユーザーデータ。
// JSON ではなく TypeScript ファイルにすることで
// User 型を直接インポートして型安全にデータを定義できる。
//
// 【JSON との違い】
//
// users.json の場合:
// import usersData from "../data/users.json";
// const USERS: User[] = usersData as User[]; // as でキャストが必要
// → JSON は型を知らないので「これは User 型です」と手動で教える必要がある
// → タイポしても気づけない
//
// users.ts(このファイル)の場合:
// import { USERS } from "../data/users";
// → 最初から User 型として定義するので as キャストが不要
// → role に "admn" と書いたら即エラーになる(タイポ防止)
// → VSCode の補完も効く
// ============================================================
// User 型と関連する型を同じプロジェクトからインポート
import type { User } from "../types/user";
// import type: 型だけをインポートする(実行時には消える)
// 型だけ使う場合は import type を使うのが推奨(パフォーマンス向上)
// ============================================================
// モックユーザーデータ
//
// 型を User[] と明示することで:
// - 各オブジェクトの形が User 型に合っているか自動チェックされる
// - role に想定外の文字列を書いたらエラーになる
// - status に "actve" と書いたらエラーになる(タイポ防止)
//
// export const にすることで他のファイルから import できる
// ============================================================
export const USERS: User[] = [
{
id: 1,
username: "yamada",
email: "yamada@factory.com",
name: "山田 太郎",
role: "admin", // UserRole 型: "admin" | "supervisor" | "operator"
department: "管理部",
line: "全ライン",
status: "active", // UserStatus 型: "active" | "inactive"
},
{
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
// ============================================================
// src/context/AuthContext.tsx
//
// 【このファイルの役割】
// アプリ全体で「ログイン状態」を共有するための仕組みを作る。
//
// 【なぜ Context が必要か?】
// React では普通、データは props で「親 → 子」にしか渡せない。
//
// App
// └─ Layout
// └─ Sidebar
// └─ UserName ← ここでユーザー名を表示したい
//
// 上の例だと App → Layout → Sidebar → UserName と
// 全部の中間コンポーネントに props を渡し続けなければならない。
// これを「props のバケツリレー」と呼び、コードが複雑になる。
//
// Context を使えば:
// どのコンポーネントからでも useAuth() を呼ぶだけで
// ログイン情報を直接取り出せる。
//
// 【AuthContext の3つのパーツ】
// 1. createContext → 「共有する箱」を作る
// 2. AuthProvider → 「箱に値を入れて子孫に配る」コンポーネント
// 3. useAuth() → 「箱から値を取り出す」カスタムフック
// ============================================================
import {
createContext, // Context オブジェクトを作る React の組み込み関数
useContext, // Context から値を取り出す React フック
useState, // コンポーネントの state(状態)を管理するフック
useCallback, // 関数を「メモ化」して、毎回新しく作られるのを防ぐフック
ReactNode, // JSX(<div> など)を受け取るための型
} from "react";
import type { User } from "../types/user";
// ★ JSON から TypeScript ファイルに変更 ★
// users.json → users.ts にしたことで:
// - as User[] のキャストが不要になった
// - USERS は最初から User[] 型として定義されている
// - データにタイポがあればこのimport時点でエラーになる
import { USERS } from "../data/users";
// ============================================================
// 定数の準備
// ============================================================
// 練習用の固定パスワード
// 実務では絶対にやってはいけない。本番では API が検証する。
const FIXED_PASSWORD = "password";
// ============================================================
// ログイン結果の型
//
// login() 関数の戻り値の型。
// 文字列リテラルのユニオン型にすることで、
// 呼び出し元が result === "success" のように安全に分岐できる。
//
// boolean(true/false)より詳細な情報を返せるのがメリット。
// ============================================================
export type LoginResult =
| "success" // ログイン成功
| "not_found" // ユーザーが見つからない or パスワード違い
| "inactive"; // アカウントが無効化されている
// ============================================================
// AuthContext に入れる値の型定義
//
// この型が useAuth() で取り出せるものの一覧になる。
// ============================================================
type AuthContextType = {
user: User | null; // ログイン中のユーザー情報。未ログインは null
// ↑
// User | null は「User 型 または null」という意味(ユニオン型)
// null = まだ誰もログインしていない状態
login: (loginId: string, password: string) => LoginResult;
// ↑ login は「loginId と password を受け取って LoginResult を返す関数」という型
logout: () => void;
// ↑ logout は「何も受け取らず、何も返さない関数」という型
// void = 戻り値なし
isLoggedIn: boolean;
// ↑ ログインしているか(true/false)。よく使うので boolean で持つ
};
// ============================================================
// Context オブジェクトの作成
//
// createContext<型>(初期値) で「共有する箱」を作る。
//
// 型を AuthContextType | null にして初期値を null にする理由:
// → null のままなら「AuthProvider の外で使われた」と分かる
// → useAuth() の中で null チェックして、エラーを出せる
// → 初期値を AuthContextType にしようとすると
// login/logout などダミーの実装が必要で面倒
// ============================================================
const AuthContext = createContext<AuthContextType | null>(null);
// ↑型 ↑初期値
// ============================================================
// AuthProvider コンポーネント
//
// 【役割】
// ログイン状態を管理して、子孫コンポーネント全員に配る。
//
// 【使い方(App.tsx)】
// <AuthProvider> ← これで全体を囲む
// <BrowserRouter>
// <Routes>...</Routes>
// </BrowserRouter>
// </AuthProvider>
//
// 【children とは?】
// <AuthProvider> の「中に書いたもの」が children として渡される。
// <AuthProvider>
// ここが children
// </AuthProvider>
// ============================================================
type AuthProviderProps = {
children: ReactNode; // 子コンポーネント(アプリの中身すべて)
};
export function AuthProvider({ children }: AuthProviderProps) {
// ----------------------------------------------------------
// ログイン状態の管理
//
// useState<User | null>(null)
// → 型は「User か null」
// → 初期値は null(= 誰もログインしていない)
//
// user が null = 未ログイン
// user が User型 = ログイン中
//
// setUser(found) を呼ぶと user が更新され、
// AuthProvider の中にいる全コンポーネントが再描画される。
// これによって「ログインした瞬間に画面が切り替わる」が実現できる。
// ----------------------------------------------------------
const [user, setUser] = useState<User | null>(null);
// ----------------------------------------------------------
// login 関数
//
// 引数:
// loginId ... ユーザー名 or メールアドレス(どちらでも入力できる)
// password ... パスワード
//
// 戻り値: LoginResult("success" | "not_found" | "inactive")
//
// useCallback の第2引数 [] は「依存配列」。
// 空配列 [] = この関数はコンポーネントが最初に作られた時だけ生成する。
// 毎回レンダリングのたびに新しい関数オブジェクトが作られるのを防ぐ。
// ----------------------------------------------------------
const login = useCallback(
(loginId: string, password: string): LoginResult => {
// Array.find() でモックデータを検索する
//
// find() は配列の中から「条件を満たす最初の要素」を返す。
// 見つからなければ undefined を返す。
//
// コールバック関数の中で2つの条件を同時にチェック:
// 1. username か email のどちらかが入力値と一致するか
// 2. パスワードが一致するか
const found = USERS.find((u: User) => {
// username または email が一致するか(|| は OR)
const idMatch = u.username === loginId || u.email === loginId;
// パスワードが固定値と一致するか
const passwordMatch = password === FIXED_PASSWORD;
// 両方 true のユーザーだけ返す(&& は AND)
return idMatch && passwordMatch;
});
// found の型は User | undefined
// → 一致するユーザーがいれば User 型
// → いなければ undefined
// ユーザーが見つからなかった場合
if (!found) {
return "not_found";
// !found は「found が falsy(undefined)の場合」という意味
}
// ユーザーは見つかったがアカウントが無効の場合
if (found.status === "inactive") {
return "inactive";
}
// ここまで来たら認証成功
// setUser で state を更新 → アプリ全体に「ログインした」が伝わる
setUser(found);
return "success";
},
[] // 依存配列が空 = マウント時に1回だけ作成
);
// ----------------------------------------------------------
// logout 関数
//
// やることは1つだけ:setUser(null) で user を null に戻す。
// これだけで:
// - isLoggedIn が false になる
// - RequireAuth が「未ログイン」と判断して /login に飛ばす
// - NavBar のユーザー名表示が消える
// など、ログイン状態に依存している全 UI が自動で更新される。
// ----------------------------------------------------------
const logout = useCallback(() => {
setUser(null);
}, []);
// ----------------------------------------------------------
// Provider に渡す value オブジェクト
//
// ここに書いたものが useAuth() で取り出せる。
// ----------------------------------------------------------
const value: AuthContextType = {
user, // 現在のログインユーザー(null or User型)
login, // ログイン関数
logout, // ログアウト関数
isLoggedIn: user !== null,
// user !== null は「user が null でなければ true」
// ↓ 具体例
// user が null → user !== null は false → 未ログイン
// user が User型 → user !== null は true → ログイン中
};
// AuthContext.Provider で children を囲む。
// value に渡したオブジェクトが、
// この Provider より内側にいる全コンポーネントから useAuth() で取れる。
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
// ============================================================
// カスタムフック useAuth()
//
// 【役割】
// どのコンポーネントからでも AuthContext の値を取り出せる。
//
// 【使い方】
// import { useAuth } from "../context/AuthContext";
//
// function SomeComponent() {
// const { user, login, logout, isLoggedIn } = useAuth();
// // あとは自由に使える
// }
//
// 【なぜカスタムフックにするか?】
// useContext(AuthContext) と null チェックを毎回書くのは面倒。
// useAuth() にまとめることで1行で取り出せる。
// また null チェックが1箇所にまとまるのでバグに気づきやすい。
// ============================================================
export function useAuth(): AuthContextType {
// useContext(AuthContext) で AuthProvider が渡した value を取り出す
const context = useContext(AuthContext);
// context が null = AuthProvider の外で useAuth() が呼ばれた
//
// よくあるミス:
// App.tsx で <AuthProvider> で囲み忘れた
// AuthProvider より外側のコンポーネントで useAuth() を呼んだ
if (context === null) {
throw new Error(
"useAuth() は <AuthProvider> の内側で使ってください。\n" +
"App.tsx で <AuthProvider> がアプリ全体を囲んでいるか確認してください。"
);
}
return context;
}
src/components/RequireAuth.tsx
// ============================================================
// src/components/RequireAuth.tsx
//
// 【このファイルの役割】
// ログインしていないユーザーを /login に自動リダイレクトする「門番」。
//
// 【なぜ必要か?】
// ログイン必須ページ(/dashboard など)のURLを直接打ち込まれたとき、
// ログインしていないのにそのページが表示されてしまうのを防ぐ。
//
// 【App.tsx での使い方】
//
// <Route element={<RequireAuth />}> ← この1行で囲む
// <Route path="/dashboard" element={<Dashboard />} />
// <Route path="/production" element={<Production />} />
// <Route path="/inspection" element={<Inspection />} />
// <Route path="/reports" element={<Reports />} />
// </Route>
//
// ポイント: RequireAuth を1つ書くだけで、
// 中の全ページが「ログイン必須」になる。
// ページごとに個別に書く必要はない。
//
// 【動作フロー】
// ① ユーザーが /dashboard にアクセス
// ② RequireAuth が isLoggedIn を確認
// ③-A 未ログインなら → <Navigate to="/login" replace /> で /login へ飛ばす
// ③-B ログイン済みなら → <Outlet /> で Dashboard を表示する
// ============================================================
import { Navigate, Outlet } from "react-router-dom";
// Navigate:
// 「このコンポーネントが描画されたら即座に指定のURLへ移動する」
// <Navigate to="/login" replace /> と書くと /login に移動する。
// replace オプション:
// true → 履歴を「置き換える」(戻るボタンで保護ページに戻れなくなる)
// false → 履歴に「追加する」(戻るボタンで保護ページに戻れてしまう)
// セキュリティのため replace={true} を推奨。
//
// Outlet:
// 「子 Route のコンポーネントをここに表示する」というプレースホルダー。
// <Route element={<RequireAuth />}> の中に書いた子 Route が
// この <Outlet /> の位置に表示される。
import { useAuth } from "../context/AuthContext";
export default function RequireAuth() {
// AuthContext からログイン状態を取得
// isLoggedIn は user !== null と同じ意味(AuthContext.tsx で定義)
const { isLoggedIn } = useAuth();
if (!isLoggedIn) {
// 未ログイン → /login にリダイレクト
//
// replace={true} にすることで、
// ブラウザの「戻る」ボタンを押しても
// ログインしていない状態で保護ページに戻れないようにする。
return <Navigate to="/login" replace />;
}
// ログイン済み → 子 Route(Dashboard など)を描画する
// <Outlet /> は「ここに子 Route の中身を表示する」という意味
return <Outlet />;
}
src/components/NavBar.tsx
// ============================================================
// src/components/NavBar.tsx
//
// 【このファイルの役割】
// ログイン後の全ページに表示する共通ナビゲーションバー。
//
// 【ここで使っている主な機能】
// NavLink : 現在のURLと一致するリンクに自動でスタイルを当てる
// useAuth : ログイン中のユーザー情報とログアウト関数を取得
// useNavigate : ログアウト後に /login へ移動する
//
// 【ページを増やすときはここだけ変更すればいい】
// NAV_LINKS 配列に1行追加するだけ。
// ============================================================
import { useNavigate, NavLink } from "react-router-dom";
// NavLink:
// 普通の <a href="..."> の代わりに使う React Router のコンポーネント。
// 通常の Link との違いは「現在のURLと一致するかどうか」が分かること。
// style や className に関数を渡すと isActive(一致しているか)が使える。
// 例: style={({ isActive }) => isActive ? 選択中のスタイル : 通常スタイル}
//
// useNavigate:
// React Router のフック。
// navigate("/login") のように呼ぶと指定のURLへ移動できる。
// ボタンクリックなど「コードの中で」ページ移動したいときに使う。
import { useAuth } from "../context/AuthContext";
// ============================================================
// ナビゲーションリンクの定義
//
// オブジェクトの配列にしておくことで、
// ページが増えても1行追加するだけで済む。
//
// to : リンク先のURL(<Route path="..."> と合わせる)
// label : 画面に表示するテキスト
// ============================================================
const NAV_LINKS = [
{ to: "/dashboard", label: "ダッシュボード" },
{ to: "/production", label: "生産管理" },
{ to: "/inspection", label: "品質検査" },
{ to: "/reports", label: "レポート" },
// ページを増やしたらここに追加する
// 例: { to: "/maintenance", label: "設備管理" },
];
export default function NavBar() {
// ----------------------------------------------------------
// useAuth() でログイン中のユーザー情報とログアウト関数を取得
//
// user : ログイン中のユーザーオブジェクト(NavBar はログイン後にしか
// 表示されないので null にはならないが、型は User | null)
// logout : AuthContext で定義したログアウト関数(user を null にする)
// ----------------------------------------------------------
const { user, logout } = useAuth();
// useNavigate でページ移動の関数を取得
const navigate = useNavigate();
// ----------------------------------------------------------
// ログアウト処理
//
// 2ステップ:
// 1. logout() → AuthContext の user を null にリセット
// → isLoggedIn が false になる
// → RequireAuth が反応して保護ページへのアクセスを塞ぐ
// 2. navigate("/login") → ログインページへ移動
// ----------------------------------------------------------
const handleLogout = (): void => {
logout(); // step1: state をリセット
navigate("/login"); // step2: ログインページへ移動
};
return (
<nav style={styles.nav}>
{/* 左側:ロゴ + ナビリンク */}
<div style={styles.left}>
<span style={styles.logo}>🏭 工場管理</span>
<div style={styles.links}>
{/*
NAV_LINKS 配列を map() でループして NavLink を生成する。
key={to} は React がリストを管理するための必須属性。
一意な値であれば何でもいいが、URL(to)はユニークなので使いやすい。
*/}
{NAV_LINKS.map(({ to, label }) => (
<NavLink
key={to}
to={to}
style={({ isActive }) => ({
// まず通常スタイルを展開(スプレッド構文)
...styles.link,
// isActive が true(現在のページ)なら選択中スタイルを上書き
...(isActive ? styles.linkActive : {}),
// {} は「上書きなし」という意味。スプレッドしても何も変わらない。
})}
>
{label}
</NavLink>
))}
</div>
</div>
{/* 右側:ログイン中のユーザー名 + ログアウトボタン */}
<div style={styles.right}>
{/*
user && (...) は「user が null でなければ表示する」という条件付きレンダリング。
user が null のとき(= 未ログイン)は何も表示しない。
NavBar はログイン後にしか表示されないが、型上 user は null になりうるので
この書き方で TypeScript のエラーを回避する。
*/}
{user && (
<span style={styles.userName}>
{user.name}({user.role})
</span>
)}
<button onClick={handleLogout} style={styles.logoutBtn}>
ログアウト
</button>
</div>
</nav>
);
}
// ============================================================
// スタイル定義
//
// Record<string, React.CSSProperties> は
// 「文字列をキーに持つ、CSSプロパティの型のオブジェクト」という意味。
// styles.nav のように使うと型チェックが効く。
// ============================================================
const styles: Record<string, React.CSSProperties> = {
nav: {
background: "#1e293b",
borderBottom: "1px solid #334155",
padding: "0 24px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
height: "56px",
fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif",
},
left: {
display: "flex",
alignItems: "center",
gap: "32px",
},
logo: {
color: "#f1f5f9",
fontWeight: 700,
fontSize: "15px",
letterSpacing: "0.04em",
},
links: {
display: "flex",
gap: "4px",
},
link: {
color: "#94a3b8",
textDecoration: "none", // 下線を消す
padding: "6px 12px",
borderRadius: "5px",
fontSize: "13px",
fontWeight: 500,
},
linkActive: {
// isActive が true のとき link スタイルに上書きされる
color: "#f1f5f9",
background: "#334155",
},
right: {
display: "flex",
alignItems: "center",
gap: "16px",
},
userName: {
color: "#64748b",
fontSize: "13px",
},
logoutBtn: {
background: "transparent",
border: "1px solid #475569",
color: "#94a3b8",
padding: "6px 14px",
borderRadius: "5px",
cursor: "pointer",
fontSize: "12px",
},
};
src/components/LoginForm.tsx
// ============================================================
// src/components/LoginForm.tsx
//
// 【このファイルの役割】
// ログインフォームの UI と入力処理をまとめたコンポーネント。
//
// 【処理の流れ】
// 1. ユーザーがユーザー名とパスワードを入力
// 2. ログインボタンを押す(または Enter キーを押す)
// 3. handleSubmit() が呼ばれる
// 4. AuthContext の login() で users.ts と照合
// 5. 成功 → navigate("/dashboard") でページ遷移
// 失敗 → setError() でエラーメッセージを表示
//
// 【このファイルで使う主な React の機能】
// useState : 入力値・エラー・ローディング状態の管理
// useNavigate : ログイン成功後のページ遷移
// useAuth : AuthContext から login 関数を取得
// ============================================================
import { useState, KeyboardEvent } from "react";
// useState:
// コンポーネントの「状態(state)」を管理するフック。
// const [値, 更新関数] = useState(初期値) という形で使う。
// 更新関数を呼ぶと画面が再描画される。
//
// KeyboardEvent:
// キーボードイベントの型。
// onKeyDown のハンドラ関数の引数に使う。
import { useNavigate } from "react-router-dom";
// useNavigate:
// ページ移動の関数を返すフック。
// const navigate = useNavigate() で取得し、
// navigate("/dashboard") のように呼ぶと指定URLへ移動する。
import { useAuth } from "../context/AuthContext";
// useAuth:
// AuthContext から値を取り出すカスタムフック。
// ここでは login 関数だけ使う。
// ★ JSON から TypeScript ファイルに変更 ★
// users.json → users.ts にしたことで:
// - as User[] のキャストが不要
// - USERS は最初から User[] 型として定義されている
import { USERS } from "../data/users";
import type { User } from "../types/user";
export default function LoginForm() {
// ----------------------------------------------------------
// state の定義
//
// フォームの入力値と UI の状態をそれぞれ state で管理する。
// state が変わるたびにコンポーネントが再描画される。
//
// useState<string>("") の書き方:
// <string> は型(TypeScript)
// ("") は初期値
// ----------------------------------------------------------
const [loginId, setLoginId] = useState<string>("");
// loginId : ユーザー名 or メールアドレスの入力値
// setLoginId: loginId を更新する関数
const [password, setPassword] = useState<string>("");
// password : パスワードの入力値
// setPassword: password を更新する関数
const [error, setError] = useState<string>("");
// error : エラーメッセージ。空文字 "" のときはエラー表示しない
// setError: エラーメッセージを更新する関数
const [isLoading, setIsLoading] = useState<boolean>(false);
// isLoading : 認証処理中かどうか(true のときボタンを無効化する)
// setIsLoading: isLoading を更新する関数
// ----------------------------------------------------------
// useAuth と useNavigate の初期化
// ----------------------------------------------------------
// AuthContext から login 関数だけを取り出す(分割代入)
const { login } = useAuth();
// ページ移動に使う navigate 関数を取得
const navigate = useNavigate();
// ----------------------------------------------------------
// handleSubmit: ログイン処理のメイン関数
//
// async/await を使っているのは「少し待つ演出」のため。
// 本来は API を呼ぶ非同期処理がここに入る。
//
// 戻り値の型 Promise<void>:
// async 関数は必ず Promise を返す。
// void = 戻り値の中身は使わない。
// ----------------------------------------------------------
const handleSubmit = async (): Promise<void> => {
// ── バリデーション(入力チェック)──────────────────────
// trim() で前後の空白を除去してから空欄チェック
if (!loginId.trim() || !password.trim()) {
setError("ユーザー名とパスワードを入力してください");
return; // return で処理をここで止める
}
// ── ローディング開始 ────────────────────────────────────
setIsLoading(true); // ボタンを「認証中...」に変える
setError(""); // 前回のエラーメッセージをリセット
// ── API 呼び出しの代わりに少し待つ ─────────────────────
// 実務では fetch() でサーバーに問い合わせる。
// モック用に setTimeout で 500ms 待つだけ。
await new Promise<void>((resolve) => setTimeout(resolve, 500));
// ── AuthContext の login() で照合 ───────────────────────
// login() の中で users.ts と照合している(AuthContext.tsx 参照)
// 戻り値は "success" | "not_found" | "inactive" のどれか
const result = login(loginId, password);
// ── ローディング終了 ────────────────────────────────────
setIsLoading(false);
// ── 結果に応じて分岐 ────────────────────────────────────
if (result === "success") {
// 成功 → navigate() でダッシュボードへ移動
navigate("/dashboard");
} else if (result === "inactive") {
// 無効アカウント → 専用のエラーメッセージを表示
setError("このアカウントは無効化されています。管理者にお問い合わせください。");
} else {
// not_found → ユーザーが見つからない or パスワード違い
// セキュリティのため「どちらが違うか」は教えない
setError("ユーザー名またはパスワードが違います");
}
};
// ----------------------------------------------------------
// handleKeyDown: Enter キーでもログインできるようにする
// ----------------------------------------------------------
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
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 が空文字でなければ表示する(条件付きレンダリング) */}
{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: User) => (
<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
// ============================================================
// src/pages/Login.tsx
//
// 【このファイルの役割】
// /login に対応するページコンポーネント。
// LoginForm コンポーネントを表示するだけ。
//
// 【pages/ と components/ の使い分け】
//
// pages/ → URLに対応する「画面全体」
// App.tsx の <Route element={...}> に直接渡すもの
//
// components/ → 再利用できる「部品」
// 複数のページから使い回すもの
//
// この分け方にしておくと:
// - LoginForm を別のページ(例: 管理者用ログイン)でも使い回せる
// - pages/ を見るだけで「どんな画面があるか」が一目で分かる
// - コードの責任範囲が明確になる
// ============================================================
import LoginForm from "../components/LoginForm";
export default function Login() {
// LoginForm を描画するだけ。
// ロジックは全部 LoginForm.tsx と AuthContext.tsx にある。
return <LoginForm />;
}
src/pages/Dashboard.tsx
// ============================================================
// src/pages/Dashboard.tsx
//
// 【このファイルの役割】
// /dashboard に対応するページ。ログイン後の最初の画面。
//
// 【RequireAuth による保護】
// このページは App.tsx で <Route element={<RequireAuth />}> の
// 中に入っているので、未ログインでアクセスしようとすると
// 自動的に /login にリダイレクトされる。
//
// 【useAuth() の使い方(このページの例)】
// const { user } = useAuth() でログイン中のユーザー情報を取得し、
// user.name、user.role などを画面に表示する。
// ============================================================
import NavBar from "../components/NavBar";
import { useAuth } from "../context/AuthContext";
import type { UserRole } from "../types/user";
// ============================================================
// 役割ごとの表示設定
//
// Record<UserRole, ...> は
// 「UserRole の全パターン(admin/supervisor/operator)を
// キーとして持つオブジェクト」という型。
// キーを網羅していないと TypeScript がエラーを出してくれる。
// ============================================================
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() {
// ----------------------------------------------------------
// useAuth() でログイン中のユーザーを取得
//
// RequireAuth で保護されているので、
// このページに来る時点で user は必ず User 型(null にならない)。
// しかし TypeScript の型は User | null なので、
// if (!user) return null; で「null の場合は何も描画しない」と
// 書いておかないとエラーになる。
// ----------------------------------------------------------
const { user } = useAuth();
// TypeScript のための null ガード
// RequireAuth があるので実際には null でここに来ることはない
if (!user) return null;
// user.role に対応する設定と権限を取得
const roleConfig = ROLE_CONFIG[user.role];
const permissions = ROLE_PERMISSIONS[user.role];
return (
<div style={styles.page}>
{/* NavBar は全ログイン後ページ共通のナビゲーション */}
<NavBar />
<div style={styles.content}>
<div style={styles.card}>
{/* ウェルカムメッセージ */}
<h1 style={styles.title}>ようこそ、{user.name} さん</h1>
<p style={styles.subtitle}>{roleConfig.label} · {user.department}</p>
{/* ユーザー情報グリッド */}
<section style={styles.section}>
<h2 style={styles.sectionTitle}>ユーザー情報</h2>
{/*
gridTemplateColumns: "1fr 1fr" で2列グリッドを作る。
1fr は「利用可能なスペースを均等に分割する」という意味。
*/}
<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>
{/*
スプレッド構文でベーススタイルと動的スタイルを合成する。
roleConfig の内容(色など)は役割によって変わる。
*/}
<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>
</div>
);
}
// ============================================================
// InfoCard: ユーザー情報を1項目表示する小さな部品
//
// 同じレイアウトを8回繰り返すので部品化している。
// props の型をインラインで定義している(型が単純なので別ファイルにしない)。
//
// highlight?: boolean の ? は「省略可能(省略時は undefined)」という意味。
// ============================================================
function InfoCard({ label, value, highlight }: {
label: string;
value: string | number; // 文字列 または 数値を受け付ける
highlight?: boolean; // ? = オプション(省略可能)
}) {
return (
<div style={styles.infoCard}>
{/* ラベル(小さい文字) */}
<span style={styles.infoLabel}>{label}</span>
{/* 値(highlight が true なら紫色・太字にする) */}
<span style={{
...styles.infoValue,
...(highlight ? { color: "#818cf8", fontWeight: 700 } : {}),
// highlight が true → 紫色・太字で上書き
// highlight が false → {} で何も上書きしない
}}>
{value}
</span>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: { minHeight: "100vh", background: "#0f172a", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
content: { display: "flex", justifyContent: "center", padding: "40px 24px" },
card: { background: "#1e293b", border: "1px solid #334155", borderRadius: "10px", padding: "36px", width: "100%", maxWidth: "680px" },
title: { color: "#f1f5f9", fontSize: "22px", fontWeight: 700, margin: "0 0 4px" },
subtitle: { color: "#64748b", fontSize: "13px", margin: "0 0 28px" },
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/pages/Production.tsx
// ============================================================
// src/pages/Production.tsx
//
// 【このファイルの役割】
// /production に対応するページ(生産管理)。
//
// 【RequireAuth による保護】
// App.tsx の <Route element={<RequireAuth />}> の中に入っているので
// 未ログインでアクセスすると自動的に /login にリダイレクトされる。
//
// 【このファイルの使い方】
// このページは「テンプレート」として作ってある。
// 実際の業務内容に合わせて、このファイルの中身を書き換えて使う。
//
// 【useAuth() で使える情報】
// const { user } = useAuth() で以下の情報が取れる:
// user.id : 社員番号
// user.username : ユーザー名
// user.name : 氏名
// user.email : メールアドレス
// user.role : 役割(admin / supervisor / operator)
// user.department : 部署
// user.line : 担当ライン
// user.status : ステータス(active / inactive)
// ============================================================
import NavBar from "../components/NavBar";
import { useAuth } from "../context/AuthContext";
export default function Production() {
// ログイン中のユーザーを取得
// このページに来た時点で user は必ず User 型(RequireAuth が保証)
const { user } = useAuth();
// TypeScript のための null ガード(実際には null でここに来ない)
if (!user) return null;
return (
<div style={styles.page}>
{/* 全ページ共通のナビゲーションバー */}
<NavBar />
<div style={styles.content}>
<div style={styles.card}>
{/* ページタイトル */}
<h1 style={styles.title}>生産管理</h1>
{/* ログイン中のユーザー情報を表示(useAuth() で取得) */}
<p style={styles.subtitle}>
担当: {user.name}({user.role}) · {user.line}
</p>
{/* ここに実際の業務画面を実装する */}
<p style={styles.placeholder}>
ここに生産管理の内容を実装します。
</p>
{/*
役割によって表示内容を変えるサンプル:
{user.role === "admin" && (
<div>管理者専用の操作パネル</div>
)}
{user.role !== "operator" && (
<div>管理者・監督者だけ見えるセクション</div>
)}
{user.line === "Aライン" && (
<div>Aライン専用のデータ</div>
)}
*/}
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: { minHeight: "100vh", background: "#0f172a", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
content: { display: "flex", justifyContent: "center", padding: "40px 24px" },
card: { background: "#1e293b", border: "1px solid #334155", borderRadius: "10px", padding: "36px", width: "100%", maxWidth: "680px" },
title: { color: "#f1f5f9", fontSize: "22px", fontWeight: 700, margin: "0 0 4px" },
subtitle: { color: "#64748b", fontSize: "13px", margin: "0 0 24px" },
placeholder: { color: "#475569", fontSize: "14px" },
};
src/pages/Inspection.tsx
// ============================================================
// src/pages/Inspection.tsx
//
// 【このファイルの役割】
// /inspection に対応するページ(品質検査)。
// RequireAuth で保護済み。未ログインは /login にリダイレクト。
//
// 【テンプレートとしての使い方】
// Production.tsx と同じ構造。
// このファイルの中身を実際の品質検査画面に書き換えて使う。
// ============================================================
import NavBar from "../components/NavBar";
import { useAuth } from "../context/AuthContext";
export default function Inspection() {
const { user } = useAuth();
if (!user) return null;
return (
<div style={styles.page}>
<NavBar />
<div style={styles.content}>
<div style={styles.card}>
<h1 style={styles.title}>品質検査</h1>
<p style={styles.subtitle}>
担当: {user.name}({user.role}) · {user.line}
</p>
<p style={styles.placeholder}>
ここに品質検査の内容を実装します。
</p>
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: { minHeight: "100vh", background: "#0f172a", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
content: { display: "flex", justifyContent: "center", padding: "40px 24px" },
card: { background: "#1e293b", border: "1px solid #334155", borderRadius: "10px", padding: "36px", width: "100%", maxWidth: "680px" },
title: { color: "#f1f5f9", fontSize: "22px", fontWeight: 700, margin: "0 0 4px" },
subtitle: { color: "#64748b", fontSize: "13px", margin: "0 0 24px" },
placeholder: { color: "#475569", fontSize: "14px" },
};
src/pages/Reports.tsx
// ============================================================
// src/pages/Reports.tsx
//
// 【このファイルの役割】
// /reports に対応するページ(レポート)。
// RequireAuth で保護済み。未ログインは /login にリダイレクト。
//
// 【テンプレートとしての使い方】
// Production.tsx と同じ構造。
// このファイルの中身を実際のレポート画面に書き換えて使う。
// ============================================================
import NavBar from "../components/NavBar";
import { useAuth } from "../context/AuthContext";
export default function Reports() {
const { user } = useAuth();
if (!user) return null;
return (
<div style={styles.page}>
<NavBar />
<div style={styles.content}>
<div style={styles.card}>
<h1 style={styles.title}>レポート</h1>
<p style={styles.subtitle}>
担当: {user.name}({user.role}) · {user.department}
</p>
<p style={styles.placeholder}>
ここにレポートの内容を実装します。
</p>
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: { minHeight: "100vh", background: "#0f172a", fontFamily: "'Segoe UI', 'Hiragino Sans', sans-serif" },
content: { display: "flex", justifyContent: "center", padding: "40px 24px" },
card: { background: "#1e293b", border: "1px solid #334155", borderRadius: "10px", padding: "36px", width: "100%", maxWidth: "680px" },
title: { color: "#f1f5f9", fontSize: "22px", fontWeight: 700, margin: "0 0 4px" },
subtitle: { color: "#64748b", fontSize: "13px", margin: "0 0 24px" },
placeholder: { color: "#475569", fontSize: "14px" },
};
src/App.tsx
// ============================================================
// src/App.tsx
//
// 【このファイルの役割】
// アプリ全体のルーティング(URL とページの対応)を設定する。
//
// 【ルーティングとは?】
// URL が変わったときに「どのコンポーネントを表示するか」を決める仕組み。
// 例: /login にアクセスしたら <Login /> を表示する、など。
//
// 【このファイルのURL対応】
//
// / → /login に自動リダイレクト
// /login → Login.tsx(誰でもアクセスできる)
//
// /dashboard → Dashboard.tsx ┐
// /production → Production.tsx ├ ログイン必須
// /inspection → Inspection.tsx │(RequireAuth が守る)
// /reports → Reports.tsx ┘
//
// 【構造のポイント3つ】
//
// 1. <AuthProvider> を一番外側に置く
// → useAuth() を使う全コンポーネントが AuthProvider の中にいる必要がある
// → BrowserRouter より外に書けば確実
//
// 2. <BrowserRouter> で囲む
// → これがないと useNavigate や NavLink が使えない
//
// 3. <Route element={<RequireAuth />}> で保護ページをまとめて囲む
// → この中のページ全部が「ログイン必須」になる
// → ページごとに個別に書かなくていい
// ============================================================
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
// BrowserRouter : URL の変化を React Router に伝える。アプリ全体を囲む。
// Routes : <Route> をまとめるコンテナ。URLに一致した Route だけ描画する。
// Route : URL とコンポーネントを対応させる。
// path="..." が URL、element={...} が表示するコンポーネント。
// Navigate : 描画された瞬間に別のURLへ移動するコンポーネント。
// リダイレクトに使う。
import { AuthProvider } from "./context/AuthContext";
// AuthProvider: ログイン状態をアプリ全体に共有する Provider。
// 一番外側に置くことで、全コンポーネントで useAuth() が使えるようになる。
import RequireAuth from "./components/RequireAuth";
// RequireAuth: 未ログインのユーザーを /login に飛ばす「門番」。
// <Route element={<RequireAuth />}> として使う。
// ── ログイン不要ページ ──────────────────────────────────────
import Login from "./pages/Login";
// ── ログイン必須ページ ──────────────────────────────────────
// (RequireAuth の中に入れることで保護される)
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: 一番外側で全体を囲む
//
// なぜ一番外側か?
// BrowserRouter や Routes の中でも useAuth() を使うコンポーネントがあるため、
// それらより外側に AuthProvider がいる必要がある。
// BrowserRouter → Routes → Route → RequireAuth(useAuth() を使う)
// という階層なので、AuthProvider はすべての外側に来る。
<AuthProvider>
{/* ② BrowserRouter: React Router を有効にする
これがないと useNavigate、NavLink、Route などが全部エラーになる */}
<BrowserRouter>
{/* ③ Routes: URL に一致した Route だけを描画するコンテナ
複数の Route の中から「今のURLに合うもの1つ」だけが表示される */}
<Routes>
{/* / にアクセスしたら /login に自動リダイレクト
replace: ブラウザの履歴を「追加」ではなく「置き換える」
→ 戻るボタンで / に戻ってループしないようにする */}
<Route path="/" element={<Navigate to="/login" replace />} />
{/* ログイン不要ページ: 誰でもアクセスできる */}
<Route path="/login" element={<Login />} />
{/* ================================================================
ログイン必須ゾーン
element={<RequireAuth />} を指定した Route で子 Route を囲む。
この書き方が React Router v6 の「レイアウトルート」という機能。
動作:
1. /dashboard などにアクセスする
2. まず RequireAuth が描画される
3. RequireAuth が isLoggedIn をチェック
4. 未ログイン → <Navigate to="/login" /> で /login へ移動
ログイン済み → <Outlet /> に子 Route を描画
ポイント:
RequireAuth を1つ書くだけで中の全ページが保護される。
ページが増えたら <Route element={<RequireAuth />}> の
中に追加するだけ。
================================================================ */}
<Route element={<RequireAuth />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/production" element={<Production />} />
<Route path="/inspection" element={<Inspection />} />
<Route path="/reports" element={<Reports />} />
{/* ページを増やすときはここに追加する
1. src/pages/NewPage.tsx を作る
2. このファイルの上部に import を追加する
3. ↓この中に Route を追加する
例: <Route path="/maintenance" element={<Maintenance />} /> */}
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
src/main.tsx
// ============================================================
// src/main.tsx
//
// 【このファイルの役割】
// アプリの「エントリーポイント」(最初に実行されるファイル)。
// index.html の <div id="root"> に React アプリを描画する。
//
// 【エントリーポイントとは?】
// ブラウザが最初に読み込むJSファイル。
// ここから App.tsx → 各ページ・コンポーネントへと読み込まれていく。
//
// 【このファイルは基本的に触らなくていい】
// Vite(開発ツール)が生成した定型文で、
// ほとんどのプロジェクトで同じ内容になる。
// ============================================================
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(
// document.getElementById("root") で index.html の <div id="root"> を取得
// ! は TypeScript の「non-null アサーション」: null でないことを保証する
document.getElementById("root")!
).render(
// React.StrictMode:
// 開発中に意図的に2回レンダリングして副作用のバグを見つけやすくするモード。
// 本番ビルド(npm run build)では自動的に無効化されるので残しておいてOK。
<React.StrictMode>
<App />
</React.StrictMode>
);
カンニングシート
users.json と users.ts の違い
// ❌ users.json の場合 → as キャストが必要・タイポを検出できない
import usersData from "../data/users.json";
const USERS: User[] = usersData as User[];
// ✅ users.ts の場合 → 最初から User[] 型・タイポは即エラー
import { USERS } from "../data/users";
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.ts との照合
const found = USERS.find((u: User) => {
const idMatch = u.username === loginId || u.email === loginId;
const passwordMatch = password === FIXED_PASSWORD;
return idMatch && passwordMatch;
});
RequireAuth で複数ページをまとめて保護
// 1つ書くだけで中の全ページが保護される
<Route element={<RequireAuth />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/production" element={<Production />} />
<Route path="/inspection" element={<Inspection />} />
<Route path="/reports" element={<Reports />} />
</Route>
ページを増やすときの手順
1. src/pages/NewPage.tsx を作る(Production.tsx をコピーして中身を変える)
2. App.tsx に import NewPage from "./pages/NewPage"; を追加
3. <Route element={<RequireAuth />}> の中に
<Route path="/newpage" element={<NewPage />} /> を追加
4. NavBar.tsx の NAV_LINKS 配列に
{ to: "/newpage", label: "新しいページ" } を追加
よくある詰まりポイント
| 症状 | 原因 | 解決 |
|---|---|---|
| ログインしても遷移しない |
login() の戻り値を使っていない |
result === "success" で navigate() する |
useAuth() は AuthProvider の中で… エラー |
AuthProvider で囲んでいない |
App.tsx の一番外側を <AuthProvider> で囲む |
| 未ログインでもページが見えてしまう |
RequireAuth で囲んでいない |
App.tsx の Route 設定を確認 |
| ログアウト後に戻るボタンで戻れてしまう |
replace が抜けている |
<Navigate to="/login" replace /> の replace を確認 |