diff --git a/backend/alembic/versions/0002_add_role_status.py b/backend/alembic/versions/0002_add_role_status.py new file mode 100644 index 0000000..b53c933 --- /dev/null +++ b/backend/alembic/versions/0002_add_role_status.py @@ -0,0 +1,49 @@ +"""add role and status to users + +Revision ID: 0002_add_role_status +Revises: 0001_initial_schema +Create Date: 2026-04-01 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "0002_add_role_status" +down_revision = "0001" +branch_labels = None +depends_on = None + +user_role = postgresql.ENUM("admin", "teacher", "student", name="user_role") +user_status = postgresql.ENUM("pending", "approved", "rejected", name="user_status") + + +def upgrade() -> None: + user_role.create(op.get_bind(), checkfirst=True) + user_status.create(op.get_bind(), checkfirst=True) + + op.add_column( + "users", + sa.Column( + "role", + sa.Enum("admin", "teacher", "student", name="user_role"), + nullable=False, + server_default="student", + ), + ) + op.add_column( + "users", + sa.Column( + "status", + sa.Enum("pending", "approved", "rejected", name="user_status"), + nullable=False, + server_default="pending", + ), + ) + + +def downgrade() -> None: + op.drop_column("users", "status") + op.drop_column("users", "role") + user_role.drop(op.get_bind(), checkfirst=True) + user_status.drop(op.get_bind(), checkfirst=True) diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 431de48..e17a225 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -3,7 +3,7 @@ from jose import JWTError from sqlalchemy.orm import Session from .database import get_db -from .models import User +from .models import User, UserRole, UserStatus from .security import decode_access_token @@ -33,4 +33,35 @@ def get_current_user( if user is None: raise credentials_exception + # Guard: approved users only (pending/rejected cannot use the API) + if user.status != UserStatus.approved: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Your account is pending admin approval.", + ) + return user + + +def require_role(*roles: UserRole): + """ + Returns a FastAPI dependency that asserts the current user has one of the + given roles. Usage: Depends(require_role(UserRole.admin, UserRole.teacher)) + """ + def _check(current_user: User = Depends(get_current_user)) -> User: + if current_user.role not in roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to perform this action.", + ) + return current_user + return _check + + +def require_admin(current_user: User = Depends(get_current_user)) -> User: + if current_user.role != UserRole.admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required.", + ) + return current_user diff --git a/backend/app/main.py b/backend/app/main.py index c9fd14a..3090492 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,7 +3,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from .routers import annotations, auth, pdfs, ws +from .routers import admin, annotations, auth, pdfs, ws @asynccontextmanager @@ -24,6 +24,7 @@ app.add_middleware( ) app.include_router(auth.router, prefix="/api") +app.include_router(admin.router, prefix="/api") app.include_router(pdfs.router, prefix="/api") app.include_router(annotations.router, prefix="/api") app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path) diff --git a/backend/app/models.py b/backend/app/models.py index ba274ce..5a174ad 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,6 +1,9 @@ from datetime import datetime, timezone +import enum + from sqlalchemy import ( Column, + Enum, Integer, String, Text, @@ -13,6 +16,18 @@ from sqlalchemy.orm import relationship from .database import Base +class UserRole(str, enum.Enum): + admin = "admin" + teacher = "teacher" + student = "student" + + +class UserStatus(str, enum.Enum): + pending = "pending" + approved = "approved" + rejected = "rejected" + + class User(Base): __tablename__ = "users" @@ -20,6 +35,8 @@ class User(Base): username = Column(String(64), unique=True, nullable=False, index=True) email = Column(String(255), unique=True, nullable=False, index=True) password_hash = Column(String(255), nullable=False) + role = Column(Enum(UserRole, name="user_role"), nullable=False, default=UserRole.student) + status = Column(Enum(UserStatus, name="user_status"), nullable=False, default=UserStatus.pending) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) # Relationships diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..d12748b --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,83 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..dependencies import require_admin +from ..models import User, UserStatus +from ..schemas import UserApprove, UserRoleUpdate, UserOut + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +# ── GET /api/admin/users ────────────────────────────────────────────────────── + +@router.get("/users", response_model=list[UserOut]) +def list_users( + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Return all users (any role / status). Admin only.""" + return db.query(User).order_by(User.created_at.desc()).all() + + +# ── PATCH /api/admin/users/{user_id}/status ─────────────────────────────────── + +@router.patch("/users/{user_id}/status", response_model=UserOut) +def update_user_status( + user_id: int, + body: UserApprove, + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Approve or reject a user account. Admin only.""" + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found.") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="Cannot change your own status.") + + user.status = body.status + db.commit() + db.refresh(user) + return user + + +# ── PATCH /api/admin/users/{user_id}/role ───────────────────────────────────── + +@router.patch("/users/{user_id}/role", response_model=UserOut) +def update_user_role( + user_id: int, + body: UserRoleUpdate, + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Change a user's role. Admin only.""" + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found.") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="Cannot change your own role.") + + user.role = body.role + db.commit() + db.refresh(user) + return user + + +# ── DELETE /api/admin/users/{user_id} ───────────────────────────────────────── + +@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user( + user_id: int, + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Permanently delete a user and all their data. Admin only.""" + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found.") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="Cannot delete your own account.") + + db.delete(user) + db.commit() diff --git a/backend/app/routers/annotations.py b/backend/app/routers/annotations.py index fb17062..05d3336 100644 --- a/backend/app/routers/annotations.py +++ b/backend/app/routers/annotations.py @@ -6,10 +6,50 @@ from sqlalchemy.orm import Session from ..database import get_db from ..dependencies import get_current_user -from ..models import Annotation, PDF, User +from ..models import Annotation, PDF, User, UserRole from ..redis_client import ANN_TTL, ann_temp_key, ann_temp_page_pattern, get_redis from ..schemas import AnnotationIn, AnnotationOut + +def _do_delete_annotation(ann: Annotation, current_user: User, db: Session) -> None: + """Shared RBAC + delete logic, used by both delete endpoints.""" + from ..models import UserRole # avoid circular at module level + + ann_owner = db.get(User, ann.user_id) + + if current_user.role == UserRole.admin: + pass # full access + elif current_user.role == UserRole.teacher: + if ann.user_id == current_user.id: + pass # own annotation + elif ann_owner and ann_owner.role == UserRole.admin: + raise HTTPException( + status_code=http_status.HTTP_403_FORBIDDEN, + detail="Teachers cannot delete annotations belonging to an admin.", + ) + else: + pass # can delete student annotations (or annotations of deleted users) + else: # student + if ann.user_id != current_user.id: + raise HTTPException( + status_code=http_status.HTTP_403_FORBIDDEN, + detail="You can only delete your own annotations.", + ) + + pdf_id = ann.pdf_id + page_number = ann.page_number + user_id = ann.user_id + db.delete(ann) + db.commit() + + # Remove Redis temp entry so /all doesn't serve stale data + r = get_redis() + if r is not None: + try: + r.delete(ann_temp_key(pdf_id, page_number, user_id)) + except Exception: + pass + router = APIRouter(prefix="/annotations", tags=["annotations"]) @@ -211,3 +251,56 @@ def upsert_annotation( pass return ann + + +# ── DELETE /api/annotations/{annotation_id} ─────────────────────────────────── + +@router.delete("/{annotation_id}", status_code=http_status.HTTP_204_NO_CONTENT) +def delete_annotation( + annotation_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Delete annotation by primary key (RBAC enforced).""" + ann = db.get(Annotation, annotation_id) + if not ann: + raise HTTPException(status_code=404, detail="Annotation not found.") + _do_delete_annotation(ann, current_user, db) + + +# ── DELETE /api/annotations/{pdf_id}/{page_number}/user/{target_user_id} ────── + +@router.delete("/{pdf_id}/{page_number}/user/{target_user_id}", status_code=http_status.HTTP_204_NO_CONTENT) +def delete_user_page_annotation( + pdf_id: int, + page_number: int, + target_user_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Delete a specific user's annotation on a given page (RBAC enforced). + Useful for teachers clearing a student's page without knowing the annotation id. + Returns 204 even when no annotation exists (idempotent). + """ + _any_pdf_or_404(db, pdf_id) + ann = ( + db.query(Annotation) + .filter( + Annotation.pdf_id == pdf_id, + Annotation.page_number == page_number, + Annotation.user_id == target_user_id, + ) + .first() + ) + if ann is None: + # Nothing in DB — still clean up any Redis temp entry + r = get_redis() + if r is not None: + try: + r.delete(ann_temp_key(pdf_id, page_number, target_user_id)) + except Exception: + pass + return + _do_delete_annotation(ann, current_user, db) + diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 3f63346..5f1d3be 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -4,8 +4,8 @@ import os from ..database import get_db from ..dependencies import get_current_user -from ..models import User -from ..schemas import UserLogin, UserOut, UserRegister +from ..models import User, UserRole, UserStatus +from ..schemas import UserLogin, UserOut, UserRegister, UserApprove from ..security import ( ACCESS_TOKEN_EXPIRE_MINUTES, create_access_token, @@ -42,16 +42,25 @@ def register(body: UserRegister, response: Response, db: Session = Depends(get_d if db.query(User).filter(User.email == body.email).first(): raise HTTPException(status_code=400, detail="Email already registered.") + # Admin registers as approved immediately; others start as pending + initial_status = ( + UserStatus.approved if body.role == UserRole.admin else UserStatus.pending + ) + user = User( username=body.username, email=body.email, password_hash=hash_password(body.password), + role=body.role, + status=initial_status, ) db.add(user) db.commit() db.refresh(user) - _set_auth_cookie(response, user.id) + # Only set auth cookie if immediately approved (admin self-registration) + if user.status == UserStatus.approved: + _set_auth_cookie(response, user.id) return user @@ -68,6 +77,17 @@ def login(body: UserLogin, response: Response, db: Session = Depends(get_db)): if not user or not password_ok: raise HTTPException(status_code=401, detail="Invalid username or password.") + if user.status == UserStatus.pending: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Your account is pending admin approval.", + ) + if user.status == UserStatus.rejected: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Your account has been rejected.", + ) + _set_auth_cookie(response, user.id) return user diff --git a/backend/app/schemas.py b/backend/app/schemas.py index effa71a..347b4f5 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -2,6 +2,8 @@ from datetime import datetime from pydantic import BaseModel, EmailStr, field_validator import re +from .models import UserRole, UserStatus + # ── Auth ───────────────────────────────────────────────────────────────────── @@ -9,6 +11,7 @@ class UserRegister(BaseModel): username: str email: EmailStr password: str + role: UserRole = UserRole.student @field_validator("username") @classmethod @@ -37,11 +40,21 @@ class UserOut(BaseModel): id: int username: str email: str + role: UserRole + status: UserStatus created_at: datetime model_config = {"from_attributes": True} +class UserApprove(BaseModel): + status: UserStatus + + +class UserRoleUpdate(BaseModel): + role: UserRole + + class TokenPayload(BaseModel): sub: int # user id exp: int diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 0000000..d3f54af --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,338 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { api } from "@/lib/api"; +import type { User, UserRole, UserStatus } from "@/types"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const STATUS_LABELS: Record = { + pending: "Chờ duyệt", + approved: "Đã duyệt", + rejected: "Từ chối", +}; + +const ROLE_LABELS: Record = { + admin: "Admin", + teacher: "Giáo viên", + student: "Học viên", +}; + +const STATUS_COLORS: Record = { + pending: "bg-yellow-100 text-yellow-800", + approved: "bg-green-100 text-green-800", + rejected: "bg-red-100 text-red-800", +}; + +const ROLE_COLORS: Record = { + admin: "bg-purple-100 text-purple-800", + teacher: "bg-blue-100 text-blue-800", + student: "bg-gray-100 text-gray-700", +}; + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function AdminPage() { + const router = useRouter(); + const [me, setMe] = useState(null); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(null); // userId being mutated + const [filterStatus, setFilterStatus] = useState("all"); + const [filterRole, setFilterRole] = useState("all"); + const [search, setSearch] = useState(""); + + // ── Auth guard: admin only ───────────────────────────────────────────────── + useEffect(() => { + async function init() { + try { + const me = await api.me(); + if (me.role !== "admin") { router.replace("/dashboard"); return; } + setMe(me); + const list = await api.adminListUsers(); + setUsers(list); + } catch { + router.replace("/login"); + } finally { + setLoading(false); + } + } + init(); + }, [router]); + + // ── Mutate helpers ───────────────────────────────────────────────────────── + const updateUser = useCallback((updated: User) => { + setUsers(prev => prev.map(u => u.id === updated.id ? updated : u)); + }, []); + + const handleStatusChange = useCallback(async (userId: number, status: UserStatus) => { + setBusy(userId); + try { + const updated = await api.adminUpdateStatus(userId, status); + updateUser(updated); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to update status."); + } finally { + setBusy(null); + } + }, [updateUser]); + + const handleRoleChange = useCallback(async (userId: number, role: UserRole) => { + setBusy(userId); + try { + const updated = await api.adminUpdateRole(userId, role); + updateUser(updated); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to update role."); + } finally { + setBusy(null); + } + }, [updateUser]); + + const handleDelete = useCallback(async (userId: number, username: string) => { + if (!confirm(`Xoá tài khoản "${username}" và toàn bộ dữ liệu? Không thể hoàn tác.`)) return; + setBusy(userId); + try { + await api.adminDeleteUser(userId); + setUsers(prev => prev.filter(u => u.id !== userId)); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to delete user."); + } finally { + setBusy(null); + } + }, []); + + // ── Filtered list ────────────────────────────────────────────────────────── + const filtered = users.filter(u => { + if (filterStatus !== "all" && u.status !== filterStatus) return false; + if (filterRole !== "all" && u.role !== filterRole) return false; + if (search) { + const q = search.toLowerCase(); + if (!u.username.toLowerCase().includes(q) && !u.email.toLowerCase().includes(q)) return false; + } + return true; + }); + + const pendingCount = users.filter(u => u.status === "pending").length; + + // ── Loading ──────────────────────────────────────────────────────────────── + if (loading) { + return ( +
+ + + + +
+ ); + } + + return ( +
+ {/* ── Navbar ──────────────────────────────────────────────────────────── */} +
+
+
+ + PDF LMS + + Admin + +
+
+ {me?.username} + +
+
+
+ +
+ {/* ── Page title + stats ─────────────────────────────────────────────── */} +
+
+

Quản lý người dùng

+

{users.length} tài khoản tổng cộng

+
+ {pendingCount > 0 && ( +
setFilterStatus("pending")} + > + + + + {pendingCount} tài khoản chờ duyệt +
+ )} +
+ + {/* ── Error banner ──────────────────────────────────────────────────── */} + {error && ( +
+ {error} + +
+ )} + + {/* ── Filters ───────────────────────────────────────────────────────── */} +
+ setSearch(e.target.value)} + className="flex-1 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500" + /> + + + {(filterStatus !== "all" || filterRole !== "all" || search) && ( + + )} +
+ + {/* ── Table ────────────────────────────────────────────────────────── */} +
+ {filtered.length === 0 ? ( +
Không tìm thấy tài khoản nào.
+ ) : ( +
+ + + + + + + + + + + + + {filtered.map(u => { + const isSelf = u.id === me?.id; + const isLoading = busy === u.id; + return ( + + {/* ID */} + + + {/* User info */} + + + {/* Role selector */} + + + {/* Status selector */} + + + {/* Created at */} + + + {/* Actions */} + + + ); + })} + +
IDNgười dùngVai tròTrạng tháiNgày đăng kýHành động
{u.id} +
{u.username}
+
{u.email}
+
+ {isSelf ? ( + + {ROLE_LABELS[u.role]} + + ) : ( + + )} + + {isSelf ? ( + + {STATUS_LABELS[u.status]} + + ) : ( + + )} + + {new Date(u.created_at).toLocaleDateString("vi-VN")} + + {isLoading ? ( + + + + + ) : isSelf ? ( + + ) : ( + + )} +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index a52382e..988668d 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -71,6 +71,14 @@ export default function DashboardPage() { PDF LMS
{user?.username} + {user?.role === "admin" && ( + + )} + )} +
+ ); + })} {collabUsers.length > 5 && ( +{collabUsers.length - 5} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 31a99b7..8427164 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -52,4 +52,24 @@ export const api = { method: "PUT", body: JSON.stringify(body), }), + deleteAnnotation: (annotationId: number) => + request(`/api/annotations/${annotationId}`, { method: "DELETE" }), + clearUserAnnotation: (pdfId: number, page: number, userId: number) => + request(`/api/annotations/${pdfId}/${page}/user/${userId}`, { method: "DELETE" }), + + // Admin + adminListUsers: () => + request("/api/admin/users"), + adminUpdateStatus: (userId: number, status: import("@/types").UserStatus) => + request(`/api/admin/users/${userId}/status`, { + method: "PATCH", + body: JSON.stringify({ status }), + }), + adminUpdateRole: (userId: number, role: import("@/types").UserRole) => + request(`/api/admin/users/${userId}/role`, { + method: "PATCH", + body: JSON.stringify({ role }), + }), + adminDeleteUser: (userId: number) => + request(`/api/admin/users/${userId}`, { method: "DELETE" }), }; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b0b7497..6ec0905 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,7 +1,12 @@ +export type UserRole = "admin" | "teacher" | "student"; +export type UserStatus = "pending" | "approved" | "rejected"; + export interface User { id: number; username: string; email: string; + role: UserRole; + status: UserStatus; created_at: string; } diff --git a/readme.md b/readme.md index f3f2846..60d7c79 100644 --- a/readme.md +++ b/readme.md @@ -133,4 +133,11 @@ https://github.com/?tab=packages ```bash python3 -c "import secrets; print(secrets.token_hex(32))" -``` \ No newline at end of file +``` + + +Field Value +username admin +password Admin@12345 +role admin +status approved