Dashboard for all teachers (own-data scoped) + fix report IDOR

#3
by kuanz - opened
chatkit/backend/app/admin.py CHANGED
@@ -1,14 +1,22 @@
1
- """Admin-only API: analytics dashboard, user/session management, schema info.
2
 
3
- All routes require an admin teacher (see auth.get_current_admin).
 
 
 
 
 
 
4
  """
5
 
6
  from __future__ import annotations
7
 
 
 
8
  from fastapi import APIRouter, Depends, HTTPException
9
  from pydantic import BaseModel
10
 
11
- from .auth import get_current_admin
12
  from .database import (
13
  admin_list_sessions,
14
  admin_list_students,
@@ -30,13 +38,30 @@ from .storage import create_signed_url
30
  router = APIRouter(prefix="/api/admin", tags=["admin"])
31
 
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  @router.get("/stats")
34
- async def admin_stats(_admin=Depends(get_current_admin)):
35
- stats = await get_stats()
36
- stats["schema_version"] = await get_schema_version()
 
 
37
  return stats
38
 
39
 
 
 
40
  @router.get("/users")
41
  async def admin_users(_admin=Depends(get_current_admin)):
42
  return {"users": await list_all_users()}
@@ -62,24 +87,24 @@ async def admin_delete_user(user_id: int, admin=Depends(get_current_admin)):
62
  return {"status": "deleted"}
63
 
64
 
 
 
65
  @router.get("/sessions")
66
- async def admin_sessions(_admin=Depends(get_current_admin)):
67
- return {"sessions": await admin_list_sessions()}
68
 
69
 
70
  @router.get("/sessions/{session_id}")
71
- async def admin_session_detail(session_id: int, _admin=Depends(get_current_admin)):
72
  """Full quiz detail: raw files (signed URLs), structured parsed data, results."""
73
  quiz = await get_session(session_id)
74
- if not quiz:
75
  raise HTTPException(status_code=404, detail="Session not found")
76
 
77
- # Group structured parsed data by type (questions / student_answers / etc.)
78
  parsed = {}
79
  for item in await get_parsed_data(session_id):
80
  parsed[item["data_type"]] = item["structured_data"]
81
 
82
- # Raw files with short-lived signed download URLs (None if Storage disabled/missing).
83
  raw_files = []
84
  for f in await get_raw_files(session_id):
85
  f["download_url"] = await create_signed_url(f.get("storage_path", ""))
@@ -94,21 +119,24 @@ async def admin_session_detail(session_id: int, _admin=Depends(get_current_admin
94
 
95
 
96
  @router.delete("/sessions/{session_id}")
97
- async def admin_delete_session(session_id: int, _admin=Depends(get_current_admin)):
98
- if not await get_session(session_id):
 
99
  raise HTTPException(status_code=404, detail="Session not found")
100
  await delete_session(session_id)
101
  return {"status": "deleted"}
102
 
103
 
 
 
104
  @router.get("/students")
105
- async def admin_students(_admin=Depends(get_current_admin)):
106
- return {"students": await admin_list_students()}
107
 
108
 
109
  @router.get("/students/{student_id}")
110
- async def admin_student_detail(student_id: int, _admin=Depends(get_current_admin)):
111
  student = await get_student(student_id)
112
- if not student:
113
  raise HTTPException(status_code=404, detail="Student not found")
114
  return {"student": student, "timeline": await get_student_timeline(student_id)}
 
1
+ """Dashboard API.
2
 
3
+ Two access levels share these routes:
4
+ - Admins (is_admin) see ALL teachers' data and can manage users.
5
+ - Regular teachers see ONLY their own data (their quizzes/students/results) and
6
+ cannot reach user-management or other teachers' records.
7
+
8
+ Scoping is enforced server-side: list endpoints filter by the caller's teacher
9
+ id, and detail endpoints verify ownership before returning anything.
10
  """
11
 
12
  from __future__ import annotations
13
 
14
+ from typing import Optional
15
+
16
  from fastapi import APIRouter, Depends, HTTPException
17
  from pydantic import BaseModel
18
 
19
+ from .auth import get_current_admin, get_current_user
20
  from .database import (
21
  admin_list_sessions,
22
  admin_list_students,
 
38
  router = APIRouter(prefix="/api/admin", tags=["admin"])
39
 
40
 
41
+ def _scope(user: dict) -> Optional[int]:
42
+ """None for admins (see everything), else the teacher id to scope to."""
43
+ return None if user.get("is_admin") else user["id"]
44
+
45
+
46
+ def _owns_quiz(user: dict, quiz: dict) -> bool:
47
+ return user.get("is_admin") or quiz.get("teacher_id") == user["id"]
48
+
49
+
50
+ def _owns_student(user: dict, student: dict) -> bool:
51
+ return user.get("is_admin") or student.get("teacher_id") == user["id"]
52
+
53
+
54
  @router.get("/stats")
55
+ async def dashboard_stats(user=Depends(get_current_user)):
56
+ stats = await get_stats(_scope(user))
57
+ stats["is_admin"] = bool(user.get("is_admin"))
58
+ if user.get("is_admin"):
59
+ stats["schema_version"] = await get_schema_version()
60
  return stats
61
 
62
 
63
+ # ---- User management (admin only) ----
64
+
65
  @router.get("/users")
66
  async def admin_users(_admin=Depends(get_current_admin)):
67
  return {"users": await list_all_users()}
 
87
  return {"status": "deleted"}
88
 
89
 
90
+ # ---- Sessions / quizzes (scoped) ----
91
+
92
  @router.get("/sessions")
93
+ async def dashboard_sessions(user=Depends(get_current_user)):
94
+ return {"sessions": await admin_list_sessions(_scope(user))}
95
 
96
 
97
  @router.get("/sessions/{session_id}")
98
+ async def dashboard_session_detail(session_id: int, user=Depends(get_current_user)):
99
  """Full quiz detail: raw files (signed URLs), structured parsed data, results."""
100
  quiz = await get_session(session_id)
101
+ if not quiz or not _owns_quiz(user, quiz):
102
  raise HTTPException(status_code=404, detail="Session not found")
103
 
 
104
  parsed = {}
105
  for item in await get_parsed_data(session_id):
106
  parsed[item["data_type"]] = item["structured_data"]
107
 
 
108
  raw_files = []
109
  for f in await get_raw_files(session_id):
110
  f["download_url"] = await create_signed_url(f.get("storage_path", ""))
 
119
 
120
 
121
  @router.delete("/sessions/{session_id}")
122
+ async def dashboard_delete_session(session_id: int, user=Depends(get_current_user)):
123
+ quiz = await get_session(session_id)
124
+ if not quiz or not _owns_quiz(user, quiz):
125
  raise HTTPException(status_code=404, detail="Session not found")
126
  await delete_session(session_id)
127
  return {"status": "deleted"}
128
 
129
 
130
+ # ---- Students (scoped) ----
131
+
132
  @router.get("/students")
133
+ async def dashboard_students(user=Depends(get_current_user)):
134
+ return {"students": await admin_list_students(_scope(user))}
135
 
136
 
137
  @router.get("/students/{student_id}")
138
+ async def dashboard_student_detail(student_id: int, user=Depends(get_current_user)):
139
  student = await get_student(student_id)
140
+ if not student or not _owns_student(user, student):
141
  raise HTTPException(status_code=404, detail="Student not found")
142
  return {"student": student, "timeline": await get_student_timeline(student_id)}
chatkit/backend/app/database.py CHANGED
@@ -403,7 +403,10 @@ async def get_student_timeline(student_id: int) -> list[dict]:
403
  res = await db.execute(
404
  select(StudentResult, Quiz.title, Quiz.subject, Quiz.created_at)
405
  .join(Quiz, StudentResult.quiz_id == Quiz.id)
 
406
  .where(StudentResult.student_id == student_id)
 
 
407
  .order_by(Quiz.created_at)
408
  )
409
  out = []
@@ -523,27 +526,57 @@ async def get_report(report_id: int) -> Optional[dict]:
523
  # Analytics (admin dashboard)
524
  # =============================================================================
525
 
526
- async def get_stats() -> dict:
527
- async with get_sessionmaker()() as db:
528
- async def count(model):
529
- return (await db.execute(select(func.count(model.id)))).scalar_one()
530
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  return {
532
- "teachers": await count(Teacher),
533
- "students": await count(Student),
534
- "quizzes": await count(Quiz),
535
- "reports": await count(Report),
536
- "student_results": await count(StudentResult),
537
- "prompts": await count(Prompt),
 
 
 
 
 
 
 
 
 
 
538
  }
539
 
540
 
541
- async def admin_list_students(limit: int = 500) -> list[dict]:
542
- """All students across teachers, with quiz count + average score (admin)."""
543
  async with get_sessionmaker()() as db:
544
- res = await db.execute(
545
- select(Student, Teacher.email).join(Teacher, Student.teacher_id == Teacher.id).order_by(Student.name).limit(limit)
 
 
 
546
  )
 
 
 
547
  out = []
548
  for s, email in res.all():
549
  d = _row(s)
@@ -567,7 +600,10 @@ async def get_quiz_results(quiz_id: int) -> list[dict]:
567
  res = await db.execute(
568
  select(StudentResult, Student.name, Student.id)
569
  .join(Student, StudentResult.student_id == Student.id)
 
570
  .where(StudentResult.quiz_id == quiz_id)
 
 
571
  .order_by(Student.name)
572
  )
573
  out = []
@@ -596,15 +632,18 @@ async def get_schema_version() -> Optional[str]:
596
  return None
597
 
598
 
599
- async def admin_list_sessions(limit: int = 200) -> list[dict]:
600
- """All quizzes across teachers, with author email + counts (admin)."""
601
  async with get_sessionmaker()() as db:
602
- res = await db.execute(
603
  select(Quiz, Teacher.email)
604
  .join(Teacher, Quiz.teacher_id == Teacher.id)
605
  .order_by(Quiz.created_at.desc())
606
  .limit(limit)
607
  )
 
 
 
608
  out = []
609
  for q, email in res.all():
610
  d = _row(q)
 
403
  res = await db.execute(
404
  select(StudentResult, Quiz.title, Quiz.subject, Quiz.created_at)
405
  .join(Quiz, StudentResult.quiz_id == Quiz.id)
406
+ .join(Student, StudentResult.student_id == Student.id)
407
  .where(StudentResult.student_id == student_id)
408
+ # defense-in-depth: only the student's own teacher's quizzes
409
+ .where(Quiz.teacher_id == Student.teacher_id)
410
  .order_by(Quiz.created_at)
411
  )
412
  out = []
 
526
  # Analytics (admin dashboard)
527
  # =============================================================================
528
 
529
+ async def get_stats(teacher_id: Optional[int] = None) -> dict:
530
+ """Global stats (admin) or stats scoped to one teacher's own data."""
531
+ async with get_sessionmaker()() as db:
532
+ async def count(stmt):
533
+ return (await db.execute(stmt)).scalar_one()
534
+
535
+ if teacher_id is None:
536
+ return {
537
+ "teachers": await count(select(func.count(Teacher.id))),
538
+ "students": await count(select(func.count(Student.id))),
539
+ "quizzes": await count(select(func.count(Quiz.id))),
540
+ "reports": await count(select(func.count(Report.id))),
541
+ "student_results": await count(select(func.count(StudentResult.id))),
542
+ "prompts": await count(select(func.count(Prompt.id))),
543
+ }
544
+
545
+ # Scoped to one teacher: own students/quizzes/prompts directly; reports and
546
+ # results are owned transitively through the teacher's own quizzes.
547
+ own_quiz = select(Quiz.id).where(Quiz.teacher_id == teacher_id).scalar_subquery()
548
  return {
549
+ "teachers": 1,
550
+ "students": await count(
551
+ select(func.count(Student.id)).where(Student.teacher_id == teacher_id)
552
+ ),
553
+ "quizzes": await count(
554
+ select(func.count(Quiz.id)).where(Quiz.teacher_id == teacher_id)
555
+ ),
556
+ "reports": await count(
557
+ select(func.count(Report.id)).where(Report.quiz_id.in_(own_quiz))
558
+ ),
559
+ "student_results": await count(
560
+ select(func.count(StudentResult.id)).where(StudentResult.quiz_id.in_(own_quiz))
561
+ ),
562
+ "prompts": await count(
563
+ select(func.count(Prompt.id)).where(Prompt.teacher_id == teacher_id)
564
+ ),
565
  }
566
 
567
 
568
+ async def admin_list_students(teacher_id: Optional[int] = None, limit: int = 500) -> list[dict]:
569
+ """Students with quiz count + avg score; all teachers (admin) or one teacher's own."""
570
  async with get_sessionmaker()() as db:
571
+ stmt = (
572
+ select(Student, Teacher.email)
573
+ .join(Teacher, Student.teacher_id == Teacher.id)
574
+ .order_by(Student.name)
575
+ .limit(limit)
576
  )
577
+ if teacher_id is not None:
578
+ stmt = stmt.where(Student.teacher_id == teacher_id)
579
+ res = await db.execute(stmt)
580
  out = []
581
  for s, email in res.all():
582
  d = _row(s)
 
600
  res = await db.execute(
601
  select(StudentResult, Student.name, Student.id)
602
  .join(Student, StudentResult.student_id == Student.id)
603
+ .join(Quiz, StudentResult.quiz_id == Quiz.id)
604
  .where(StudentResult.quiz_id == quiz_id)
605
+ # defense-in-depth: only students belonging to this quiz's teacher
606
+ .where(Student.teacher_id == Quiz.teacher_id)
607
  .order_by(Student.name)
608
  )
609
  out = []
 
632
  return None
633
 
634
 
635
+ async def admin_list_sessions(teacher_id: Optional[int] = None, limit: int = 200) -> list[dict]:
636
+ """Quizzes with author email + counts; all teachers (admin) or one teacher's own."""
637
  async with get_sessionmaker()() as db:
638
+ stmt = (
639
  select(Quiz, Teacher.email)
640
  .join(Teacher, Quiz.teacher_id == Teacher.id)
641
  .order_by(Quiz.created_at.desc())
642
  .limit(limit)
643
  )
644
+ if teacher_id is not None:
645
+ stmt = stmt.where(Quiz.teacher_id == teacher_id)
646
+ res = await db.execute(stmt)
647
  out = []
648
  for q, email in res.all():
649
  d = _row(q)
chatkit/backend/app/main.py CHANGED
@@ -503,6 +503,11 @@ async def api_get_report(report_id: int, user=Depends(get_current_user)):
503
  report = await get_report(report_id)
504
  if not report:
505
  raise HTTPException(status_code=404, detail="Report not found")
 
 
 
 
 
506
  return report
507
 
508
 
 
503
  report = await get_report(report_id)
504
  if not report:
505
  raise HTTPException(status_code=404, detail="Report not found")
506
+ # Reports have no teacher_id; enforce ownership via the parent quiz so one
507
+ # teacher cannot read another teacher's report by enumerating ids.
508
+ quiz = await get_session(report["quiz_id"])
509
+ if not quiz or (not user.get("is_admin") and quiz["user_id"] != user["id"]):
510
+ raise HTTPException(status_code=404, detail="Report not found")
511
  return report
512
 
513
 
chatkit/backend/tests/test_database.py CHANGED
@@ -90,6 +90,32 @@ async def test_stats_counts(fresh_db):
90
  assert stats["student_results"] == 1
91
 
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  @pytest.mark.unit
94
  def test_score_student_grades_only_concrete_keys():
95
  grid = grid_from_dict(
 
90
  assert stats["student_results"] == 1
91
 
92
 
93
+ @pytest.mark.unit
94
+ async def test_stats_and_lists_scope_to_teacher(fresh_db):
95
+ # Two teachers, each with their own quiz + student + result.
96
+ t1 = await database.create_user("t1@example.com", "h")
97
+ t2 = await database.create_user("t2@example.com", "h")
98
+ s1 = await database.match_or_create_student(t1, "Alice")
99
+ s2 = await database.match_or_create_student(t2, "Bob")
100
+ q1 = await database.create_session(t1, "T1 Quiz")
101
+ q2 = await database.create_session(t2, "T2 Quiz")
102
+ await database.save_student_result(q1, s1, {"answers": []}, 3, 3, 100.0)
103
+ await database.save_student_result(q2, s2, {"answers": []}, 3, 1, 33.3)
104
+
105
+ # Global (admin) sees everything.
106
+ g = await database.get_stats()
107
+ assert g["teachers"] == 2 and g["students"] == 2 and g["quizzes"] == 2 and g["student_results"] == 2
108
+
109
+ # Scoped to t1 sees only t1's data.
110
+ sc = await database.get_stats(t1)
111
+ assert sc["students"] == 1 and sc["quizzes"] == 1 and sc["student_results"] == 1
112
+
113
+ # Scoped list endpoints filter by teacher.
114
+ assert {x["title"] for x in await database.admin_list_sessions(t1)} == {"T1 Quiz"}
115
+ assert {x["name"] for x in await database.admin_list_students(t1)} == {"Alice"}
116
+ assert len(await database.admin_list_sessions()) == 2 # admin sees both
117
+
118
+
119
  @pytest.mark.unit
120
  def test_score_student_grades_only_concrete_keys():
121
  grid = grid_from_dict(
chatkit/frontend/src/App.tsx CHANGED
@@ -202,7 +202,7 @@ ${doneReports
202
  return <LoginForm onLogin={setUser} />;
203
  }
204
 
205
- if (showAdmin && user.is_admin) {
206
  return (
207
  <div className="min-h-screen">
208
  <Header
@@ -213,7 +213,7 @@ ${doneReports
213
  onLogout={handleLogout}
214
  />
215
  <div className="pt-20">
216
- <AdminDashboard />
217
  </div>
218
  </div>
219
  );
 
202
  return <LoginForm onLogin={setUser} />;
203
  }
204
 
205
+ if (showAdmin) {
206
  return (
207
  <div className="min-h-screen">
208
  <Header
 
213
  onLogout={handleLogout}
214
  />
215
  <div className="pt-20">
216
+ <AdminDashboard isAdmin={!!user.is_admin} />
217
  </div>
218
  </div>
219
  );
chatkit/frontend/src/components/Header.tsx CHANGED
@@ -32,7 +32,7 @@ export function Header({ userEmail, onLogout, isAdmin, showAdmin, onToggleAdmin
32
  </div>
33
 
34
  <div className="flex items-center gap-4">
35
- {isAdmin && onToggleAdmin && (
36
  <button
37
  onClick={onToggleAdmin}
38
  className={`text-sm font-medium px-4 py-2 rounded-full border transition-colors ${
@@ -41,7 +41,7 @@ export function Header({ userEmail, onLogout, isAdmin, showAdmin, onToggleAdmin
41
  : "text-[var(--color-text-muted)] border-[var(--color-border)] hover:text-[var(--color-text)]"
42
  }`}
43
  >
44
- {showAdmin ? "← 返回應用" : "管理後台"}
45
  </button>
46
  )}
47
  <div className="flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-success)]/10 border border-[var(--color-success)]/20">
 
32
  </div>
33
 
34
  <div className="flex items-center gap-4">
35
+ {onToggleAdmin && (
36
  <button
37
  onClick={onToggleAdmin}
38
  className={`text-sm font-medium px-4 py-2 rounded-full border transition-colors ${
 
41
  : "text-[var(--color-text-muted)] border-[var(--color-border)] hover:text-[var(--color-text)]"
42
  }`}
43
  >
44
+ {showAdmin ? "← 返回應用" : isAdmin ? "管理後台" : "我的資料"}
45
  </button>
46
  )}
47
  <div className="flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-success)]/10 border border-[var(--color-success)]/20">
chatkit/frontend/src/components/admin/AdminDashboard.tsx CHANGED
@@ -97,18 +97,24 @@ const TABS: { key: Tab; label: string }[] = [
97
  { key: "students", label: "學生" },
98
  ];
99
 
100
- export function AdminDashboard() {
101
  const [tab, setTab] = useState<Tab>("overview");
 
 
102
 
103
  return (
104
  <main className="max-w-6xl mx-auto px-4 pb-12">
105
  <div className="text-center mb-6">
106
- <h2 className="font-display text-2xl font-bold text-[var(--color-text)]">管理後台</h2>
107
- <p className="text-[var(--color-text-muted)] mt-1">資料庫管理與使用分析</p>
 
 
 
 
108
  </div>
109
 
110
  <div className="flex justify-center gap-2 mb-6">
111
- {TABS.map((t) => (
112
  <button
113
  key={t.key}
114
  onClick={() => setTab(t.key)}
@@ -123,9 +129,9 @@ export function AdminDashboard() {
123
  ))}
124
  </div>
125
 
126
- {tab === "overview" && <Overview />}
127
- {tab === "teachers" && <Teachers />}
128
- {tab === "sessions" && <Sessions />}
129
  {tab === "students" && <Students />}
130
  </main>
131
  );
@@ -163,23 +169,25 @@ function StatCard({ label, value }: { label: string; value: number | string }) {
163
  );
164
  }
165
 
166
- function Overview() {
167
  const { data, loading, error } = useFetch<Stats>("/api/admin/stats");
168
  if (loading) return <Spinner />;
169
  if (error || !data) return <ErrorBox msg={error} />;
170
  return (
171
  <div>
172
  <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
173
- <StatCard label="教師" value={data.teachers} />
174
- <StatCard label="學生" value={data.students} />
175
- <StatCard label="測驗" value={data.quizzes} />
176
  <StatCard label="已生成報告" value={data.reports} />
177
  <StatCard label="學生成績紀錄" value={data.student_results} />
178
  <StatCard label="提示詞" value={data.prompts} />
179
  </div>
180
- <p className="text-center text-xs text-[var(--color-text-muted)] mt-6">
181
- 資料庫結構版本:{data.schema_version ?? "未知"}
182
- </p>
 
 
183
  </div>
184
  );
185
  }
@@ -224,7 +232,7 @@ function Teachers() {
224
  );
225
  }
226
 
227
- function Sessions() {
228
  const { data, loading, error, reload } = useFetch<{ sessions: SessionRow[] }>("/api/admin/sessions");
229
  const [detailId, setDetailId] = useState<number | null>(null);
230
 
@@ -240,13 +248,17 @@ function Sessions() {
240
  reload();
241
  };
242
 
 
 
 
 
243
  return (
244
- <Table headers={["標題", "科目", "教師", "狀態", "報告", "建立時間", ""]}>
245
  {data.sessions.map((s) => (
246
  <tr key={s.id} className="border-t border-[var(--color-border)]">
247
  <Td>{s.title}</Td>
248
  <Td>{s.subject || "—"}</Td>
249
- <Td>{s.teacher_email}</Td>
250
  <Td>{s.status}</Td>
251
  <Td>{s.report_count}</Td>
252
  <Td>{new Date(s.created_at).toLocaleDateString()}</Td>
@@ -255,9 +267,11 @@ function Sessions() {
255
  <button onClick={() => setDetailId(s.id)} className="text-sm underline text-[var(--color-primary)]">
256
  查看詳情
257
  </button>
258
- <button onClick={() => remove(s)} className="text-sm text-red-400 hover:underline">
259
- 刪除
260
- </button>
 
 
261
  </div>
262
  </Td>
263
  </tr>
 
97
  { key: "students", label: "學生" },
98
  ];
99
 
100
+ export function AdminDashboard({ isAdmin }: { isAdmin: boolean }) {
101
  const [tab, setTab] = useState<Tab>("overview");
102
+ // The teachers tab + user management is admin-only; teachers see only their own data.
103
+ const tabs = isAdmin ? TABS : TABS.filter((t) => t.key !== "teachers");
104
 
105
  return (
106
  <main className="max-w-6xl mx-auto px-4 pb-12">
107
  <div className="text-center mb-6">
108
+ <h2 className="font-display text-2xl font-bold text-[var(--color-text)]">
109
+ {isAdmin ? "管理後台" : "我的資料中心"}
110
+ </h2>
111
+ <p className="text-[var(--color-text-muted)] mt-1">
112
+ {isAdmin ? "資料庫管理與使用分析" : "您自己儲存的測驗、學生與成績"}
113
+ </p>
114
  </div>
115
 
116
  <div className="flex justify-center gap-2 mb-6">
117
+ {tabs.map((t) => (
118
  <button
119
  key={t.key}
120
  onClick={() => setTab(t.key)}
 
129
  ))}
130
  </div>
131
 
132
+ {tab === "overview" && <Overview isAdmin={isAdmin} />}
133
+ {tab === "teachers" && isAdmin && <Teachers />}
134
+ {tab === "sessions" && <Sessions isAdmin={isAdmin} />}
135
  {tab === "students" && <Students />}
136
  </main>
137
  );
 
169
  );
170
  }
171
 
172
+ function Overview({ isAdmin }: { isAdmin: boolean }) {
173
  const { data, loading, error } = useFetch<Stats>("/api/admin/stats");
174
  if (loading) return <Spinner />;
175
  if (error || !data) return <ErrorBox msg={error} />;
176
  return (
177
  <div>
178
  <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
179
+ {isAdmin && <StatCard label="教師" value={data.teachers} />}
180
+ <StatCard label={isAdmin ? "學生" : "我的學生"} value={data.students} />
181
+ <StatCard label={isAdmin ? "測驗" : "我的測驗"} value={data.quizzes} />
182
  <StatCard label="已生成報告" value={data.reports} />
183
  <StatCard label="學生成績紀錄" value={data.student_results} />
184
  <StatCard label="提示詞" value={data.prompts} />
185
  </div>
186
+ {isAdmin && (
187
+ <p className="text-center text-xs text-[var(--color-text-muted)] mt-6">
188
+ 資料庫結構版本:{data.schema_version ?? "未知"}
189
+ </p>
190
+ )}
191
  </div>
192
  );
193
  }
 
232
  );
233
  }
234
 
235
+ function Sessions({ isAdmin }: { isAdmin: boolean }) {
236
  const { data, loading, error, reload } = useFetch<{ sessions: SessionRow[] }>("/api/admin/sessions");
237
  const [detailId, setDetailId] = useState<number | null>(null);
238
 
 
248
  reload();
249
  };
250
 
251
+ const headers = isAdmin
252
+ ? ["標題", "科目", "教師", "狀態", "報告", "建立時間", ""]
253
+ : ["標題", "科目", "狀態", "報告", "建立時間", ""];
254
+
255
  return (
256
+ <Table headers={headers}>
257
  {data.sessions.map((s) => (
258
  <tr key={s.id} className="border-t border-[var(--color-border)]">
259
  <Td>{s.title}</Td>
260
  <Td>{s.subject || "—"}</Td>
261
+ {isAdmin && <Td>{s.teacher_email}</Td>}
262
  <Td>{s.status}</Td>
263
  <Td>{s.report_count}</Td>
264
  <Td>{new Date(s.created_at).toLocaleDateString()}</Td>
 
267
  <button onClick={() => setDetailId(s.id)} className="text-sm underline text-[var(--color-primary)]">
268
  查看詳情
269
  </button>
270
+ {isAdmin && (
271
+ <button onClick={() => remove(s)} className="text-sm text-red-400 hover:underline">
272
+ 刪除
273
+ </button>
274
+ )}
275
  </div>
276
  </Td>
277
  </tr>