Spaces:
Sleeping
Sleeping
Dashboard for all teachers (own-data scoped) + fix report IDOR
#3
by kuanz - opened
- chatkit/backend/app/admin.py +46 -18
- chatkit/backend/app/database.py +57 -18
- chatkit/backend/app/main.py +5 -0
- chatkit/backend/tests/test_database.py +26 -0
- chatkit/frontend/src/App.tsx +2 -2
- chatkit/frontend/src/components/Header.tsx +2 -2
- chatkit/frontend/src/components/admin/AdminDashboard.tsx +34 -20
chatkit/backend/app/admin.py
CHANGED
|
@@ -1,14 +1,22 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 35 |
-
stats = await get_stats()
|
| 36 |
-
stats["
|
|
|
|
|
|
|
| 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
|
| 67 |
-
return {"sessions": await admin_list_sessions()}
|
| 68 |
|
| 69 |
|
| 70 |
@router.get("/sessions/{session_id}")
|
| 71 |
-
async def
|
| 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
|
| 98 |
-
|
|
|
|
| 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
|
| 106 |
-
return {"students": await admin_list_students()}
|
| 107 |
|
| 108 |
|
| 109 |
@router.get("/students/{student_id}")
|
| 110 |
-
async def
|
| 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 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
return {
|
| 532 |
-
"teachers":
|
| 533 |
-
"students": await count(
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
"
|
| 537 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
}
|
| 539 |
|
| 540 |
|
| 541 |
-
async def admin_list_students(limit: int = 500) -> list[dict]:
|
| 542 |
-
"""
|
| 543 |
async with get_sessionmaker()() as db:
|
| 544 |
-
|
| 545 |
-
select(Student, Teacher.email)
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 601 |
async with get_sessionmaker()() as db:
|
| 602 |
-
|
| 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
|
| 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 |
-
{
|
| 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)]">
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
</div>
|
| 109 |
|
| 110 |
<div className="flex justify-center gap-2 mb-6">
|
| 111 |
-
{
|
| 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 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
| 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>
|