thực hiện phân quyền admin giáo viên và học sinh xong

This commit is contained in:
2026-04-01 19:26:47 +07:00
parent 8546d41a26
commit 386871bd4a
14 changed files with 796 additions and 37 deletions
@@ -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)
+32 -1
View File
@@ -3,7 +3,7 @@ from jose import JWTError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .database import get_db from .database import get_db
from .models import User from .models import User, UserRole, UserStatus
from .security import decode_access_token from .security import decode_access_token
@@ -33,4 +33,35 @@ def get_current_user(
if user is None: if user is None:
raise credentials_exception 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 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
+2 -1
View File
@@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .routers import annotations, auth, pdfs, ws from .routers import admin, annotations, auth, pdfs, ws
@asynccontextmanager @asynccontextmanager
@@ -24,6 +24,7 @@ app.add_middleware(
) )
app.include_router(auth.router, prefix="/api") app.include_router(auth.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
app.include_router(pdfs.router, prefix="/api") app.include_router(pdfs.router, prefix="/api")
app.include_router(annotations.router, prefix="/api") app.include_router(annotations.router, prefix="/api")
app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path) app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path)
+17
View File
@@ -1,6 +1,9 @@
from datetime import datetime, timezone from datetime import datetime, timezone
import enum
from sqlalchemy import ( from sqlalchemy import (
Column, Column,
Enum,
Integer, Integer,
String, String,
Text, Text,
@@ -13,6 +16,18 @@ from sqlalchemy.orm import relationship
from .database import Base 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): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@@ -20,6 +35,8 @@ class User(Base):
username = Column(String(64), unique=True, nullable=False, index=True) username = Column(String(64), unique=True, nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False, index=True) email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False) 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) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
# Relationships # Relationships
+83
View File
@@ -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()
+94 -1
View File
@@ -6,10 +6,50 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..dependencies import get_current_user 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 ..redis_client import ANN_TTL, ann_temp_key, ann_temp_page_pattern, get_redis
from ..schemas import AnnotationIn, AnnotationOut 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"]) router = APIRouter(prefix="/annotations", tags=["annotations"])
@@ -211,3 +251,56 @@ def upsert_annotation(
pass pass
return ann 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)
+23 -3
View File
@@ -4,8 +4,8 @@ import os
from ..database import get_db from ..database import get_db
from ..dependencies import get_current_user from ..dependencies import get_current_user
from ..models import User from ..models import User, UserRole, UserStatus
from ..schemas import UserLogin, UserOut, UserRegister from ..schemas import UserLogin, UserOut, UserRegister, UserApprove
from ..security import ( from ..security import (
ACCESS_TOKEN_EXPIRE_MINUTES, ACCESS_TOKEN_EXPIRE_MINUTES,
create_access_token, 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(): if db.query(User).filter(User.email == body.email).first():
raise HTTPException(status_code=400, detail="Email already registered.") 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( user = User(
username=body.username, username=body.username,
email=body.email, email=body.email,
password_hash=hash_password(body.password), password_hash=hash_password(body.password),
role=body.role,
status=initial_status,
) )
db.add(user) db.add(user)
db.commit() db.commit()
db.refresh(user) 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 return user
@@ -68,6 +77,17 @@ def login(body: UserLogin, response: Response, db: Session = Depends(get_db)):
if not user or not password_ok: if not user or not password_ok:
raise HTTPException(status_code=401, detail="Invalid username or password.") 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) _set_auth_cookie(response, user.id)
return user return user
+13
View File
@@ -2,6 +2,8 @@ from datetime import datetime
from pydantic import BaseModel, EmailStr, field_validator from pydantic import BaseModel, EmailStr, field_validator
import re import re
from .models import UserRole, UserStatus
# ── Auth ───────────────────────────────────────────────────────────────────── # ── Auth ─────────────────────────────────────────────────────────────────────
@@ -9,6 +11,7 @@ class UserRegister(BaseModel):
username: str username: str
email: EmailStr email: EmailStr
password: str password: str
role: UserRole = UserRole.student
@field_validator("username") @field_validator("username")
@classmethod @classmethod
@@ -37,11 +40,21 @@ class UserOut(BaseModel):
id: int id: int
username: str username: str
email: str email: str
role: UserRole
status: UserStatus
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class UserApprove(BaseModel):
status: UserStatus
class UserRoleUpdate(BaseModel):
role: UserRole
class TokenPayload(BaseModel): class TokenPayload(BaseModel):
sub: int # user id sub: int # user id
exp: int exp: int
+338
View File
@@ -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<UserStatus, string> = {
pending: "Chờ duyệt",
approved: "Đã duyệt",
rejected: "Từ chối",
};
const ROLE_LABELS: Record<UserRole, string> = {
admin: "Admin",
teacher: "Giáo viên",
student: "Học viên",
};
const STATUS_COLORS: Record<UserStatus, string> = {
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<UserRole, string> = {
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<User | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [busy, setBusy] = useState<number | null>(null); // userId being mutated
const [filterStatus, setFilterStatus] = useState<UserStatus | "all">("all");
const [filterRole, setFilterRole] = useState<UserRole | "all">("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 (
<div className="min-h-screen flex items-center justify-center">
<svg className="animate-spin h-8 w-8 text-blue-500" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* ── Navbar ──────────────────────────────────────────────────────────── */}
<header className="bg-white border-b shadow-sm sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/dashboard")}
className="text-gray-400 hover:text-blue-600 transition"
title="Về Dashboard"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="font-bold text-lg text-blue-600">PDF LMS</span>
<span className="text-xs bg-purple-100 text-purple-700 font-semibold px-2 py-0.5 rounded-full">
Admin
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500 hidden sm:block">{me?.username}</span>
<button
onClick={async () => { await api.logout(); router.replace("/login"); }}
className="text-sm text-gray-500 hover:text-red-600 transition"
>
Đăng xuất
</button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8">
{/* ── Page title + stats ─────────────────────────────────────────────── */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Quản người dùng</h1>
<p className="text-sm text-gray-500 mt-0.5">{users.length} tài khoản tổng cộng</p>
</div>
{pendingCount > 0 && (
<div
className="flex items-center gap-2 bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-2 rounded-lg text-sm font-medium cursor-pointer hover:bg-yellow-100 transition"
onClick={() => setFilterStatus("pending")}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
{pendingCount} tài khoản chờ duyệt
</div>
)}
</div>
{/* ── Error banner ──────────────────────────────────────────────────── */}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg flex items-center justify-between">
{error}
<button onClick={() => setError("")} className="ml-3 text-red-400 hover:text-red-600"></button>
</div>
)}
{/* ── Filters ───────────────────────────────────────────────────────── */}
<div className="bg-white border border-gray-200 rounded-xl p-4 mb-4 flex flex-col sm:flex-row gap-3">
<input
type="text"
placeholder="Tìm theo username / email…"
value={search}
onChange={e => 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"
/>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value as UserStatus | "all")}
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
>
<option value="all">Tất cả trạng thái</option>
<option value="pending">Chờ duyệt</option>
<option value="approved">Đã duyệt</option>
<option value="rejected">Từ chối</option>
</select>
<select
value={filterRole}
onChange={e => setFilterRole(e.target.value as UserRole | "all")}
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
>
<option value="all">Tất cả vai trò</option>
<option value="admin">Admin</option>
<option value="teacher">Giáo viên</option>
<option value="student">Học viên</option>
</select>
{(filterStatus !== "all" || filterRole !== "all" || search) && (
<button
onClick={() => { setFilterStatus("all"); setFilterRole("all"); setSearch(""); }}
className="text-sm text-gray-500 hover:text-red-600 transition px-2"
>
Xoá lọc
</button>
)}
</div>
{/* ── Table ────────────────────────────────────────────────────────── */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
{filtered.length === 0 ? (
<div className="text-center text-gray-400 text-sm py-16">Không tìm thấy tài khoản nào.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3">ID</th>
<th className="px-4 py-3">Người dùng</th>
<th className="px-4 py-3">Vai trò</th>
<th className="px-4 py-3">Trạng thái</th>
<th className="px-4 py-3">Ngày đăng </th>
<th className="px-4 py-3 text-right">Hành đng</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filtered.map(u => {
const isSelf = u.id === me?.id;
const isLoading = busy === u.id;
return (
<tr key={u.id} className="hover:bg-gray-50 transition">
{/* ID */}
<td className="px-4 py-3 text-gray-400 tabular-nums">{u.id}</td>
{/* User info */}
<td className="px-4 py-3">
<div className="font-medium text-gray-900">{u.username}</div>
<div className="text-xs text-gray-400">{u.email}</div>
</td>
{/* Role selector */}
<td className="px-4 py-3">
{isSelf ? (
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${ROLE_COLORS[u.role]}`}>
{ROLE_LABELS[u.role]}
</span>
) : (
<select
value={u.role}
disabled={isLoading}
onChange={e => handleRoleChange(u.id, e.target.value as UserRole)}
className={`text-xs border rounded-lg px-2 py-1 focus:outline-none focus:border-blue-500 disabled:opacity-50 ${ROLE_COLORS[u.role]} border-transparent`}
>
<option value="student">Học viên</option>
<option value="teacher">Giáo viên</option>
<option value="admin">Admin</option>
</select>
)}
</td>
{/* Status selector */}
<td className="px-4 py-3">
{isSelf ? (
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${STATUS_COLORS[u.status]}`}>
{STATUS_LABELS[u.status]}
</span>
) : (
<select
value={u.status}
disabled={isLoading}
onChange={e => handleStatusChange(u.id, e.target.value as UserStatus)}
className={`text-xs border rounded-lg px-2 py-1 focus:outline-none focus:border-blue-500 disabled:opacity-50 ${STATUS_COLORS[u.status]} border-transparent`}
>
<option value="pending">Chờ duyệt</option>
<option value="approved">Duyệt</option>
<option value="rejected">Từ chối</option>
</select>
)}
</td>
{/* Created at */}
<td className="px-4 py-3 text-gray-400 tabular-nums whitespace-nowrap">
{new Date(u.created_at).toLocaleDateString("vi-VN")}
</td>
{/* Actions */}
<td className="px-4 py-3 text-right">
{isLoading ? (
<svg className="animate-spin h-4 w-4 text-blue-500 ml-auto" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : isSelf ? (
<span className="text-xs text-gray-300"></span>
) : (
<button
onClick={() => handleDelete(u.id, u.username)}
className="text-xs text-red-400 hover:text-red-600 hover:bg-red-50 px-2 py-1 rounded transition"
>
Xoá
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</main>
</div>
);
}
+8
View File
@@ -71,6 +71,14 @@ export default function DashboardPage() {
<span className="font-bold text-lg text-blue-600">PDF LMS</span> <span className="font-bold text-lg text-blue-600">PDF LMS</span>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm text-gray-500 hidden sm:block">{user?.username}</span> <span className="text-sm text-gray-500 hidden sm:block">{user?.username}</span>
{user?.role === "admin" && (
<button
onClick={() => router.push("/admin")}
className="text-xs bg-purple-100 text-purple-700 font-semibold px-2.5 py-1 rounded-full hover:bg-purple-200 transition"
>
Quản trị
</button>
)}
<button <button
onClick={handleLogout} onClick={handleLogout}
className="text-sm text-gray-500 hover:text-red-600 transition" className="text-sm text-gray-500 hover:text-red-600 transition"
+104 -30
View File
@@ -142,6 +142,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
const currentPageRef = useRef(1); // mutable copy for collab callbacks const currentPageRef = useRef(1); // mutable copy for collab callbacks
const skipRemoteRef = useRef(false); // prevent echo-back when applying remote events const skipRemoteRef = useRef(false); // prevent echo-back when applying remote events
const currentUserIdRef = useRef<number | null>(null); // populated from api.me(); used to identify own vs remote objects const currentUserIdRef = useRef<number | null>(null); // populated from api.me(); used to identify own vs remote objects
const [userRole, setUserRole] = useState<"admin" | "teacher" | "student">("student");
const userRoleRef = useRef<"admin" | "teacher" | "student">("student");
// Buffer for remote events that arrive while renderPageWithAnnotations is in progress // Buffer for remote events that arrive while renderPageWithAnnotations is in progress
const renderingRef = useRef(false); const renderingRef = useRef(false);
const pendingRemoteEvents = useRef<RemoteEvent[]>([]); const pendingRemoteEvents = useRef<RemoteEvent[]>([]);
@@ -225,6 +227,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
useEffect(() => { useEffect(() => {
api.me().then((u) => { api.me().then((u) => {
currentUserIdRef.current = u.id; currentUserIdRef.current = u.id;
setUserRole(u.role);
userRoleRef.current = u.role;
// Assign deterministic color from preset palette based on user id // Assign deterministic color from preset palette based on user id
const assigned = userIdToPresetColor(u.id); const assigned = userIdToPresetColor(u.id);
setPenColor(assigned); setPenColor(assigned);
@@ -299,11 +303,12 @@ export default function WorkbookViewer({ pdfId }: Props) {
skipRemoteRef.current = true; skipRemoteRef.current = true;
skipObjectTracking.current = true; skipObjectTracking.current = true;
fabric.util.enlivenObjects([objJson], (objects: any[]) => { fabric.util.enlivenObjects([objJson], (objects: any[]) => {
const canInteract = userRoleRef.current !== "student";
objects.forEach((obj: any) => { objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id; obj.collab_id = objJson.collab_id;
obj._isRemote = true; // don't save other users' live strokes under our account obj._isRemote = true; // don't save other users' live strokes under our account
obj.selectable = false; obj.selectable = canInteract;
obj.evented = false; obj.evented = canInteract;
fc.add(obj); fc.add(obj);
}); });
fc.renderAll(); fc.renderAll();
@@ -481,12 +486,13 @@ export default function WorkbookViewer({ pdfId }: Props) {
// Mark objects from other users as remote — prevents saving them under current user // Mark objects from other users as remote — prevents saving them under current user
const uid = currentUserIdRef.current; const uid = currentUserIdRef.current;
if (uid !== null) { if (uid !== null) {
const canInteract = userRoleRef.current !== "student";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
fc.getObjects().forEach((obj: any) => { fc.getObjects().forEach((obj: any) => {
if (obj._owner_id != null && obj._owner_id !== uid) { if (obj._owner_id != null && obj._owner_id !== uid) {
obj._isRemote = true; obj._isRemote = true;
obj.selectable = false; obj.selectable = canInteract;
obj.evented = false; obj.evented = canInteract;
} }
}); });
} }
@@ -512,11 +518,12 @@ export default function WorkbookViewer({ pdfId }: Props) {
skipRemoteRef.current = true; skipRemoteRef.current = true;
skipObjectTracking.current = true; skipObjectTracking.current = true;
fabric.util.enlivenObjects([objJson], (objects: any[]) => { fabric.util.enlivenObjects([objJson], (objects: any[]) => {
const canInteract = userRoleRef.current !== "student";
objects.forEach((obj: any) => { objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id; obj.collab_id = objJson.collab_id;
obj._isRemote = true; obj._isRemote = true;
obj.selectable = false; obj.selectable = canInteract;
obj.evented = false; obj.evented = canInteract;
fc.add(obj); fc.add(obj);
}); });
fc.renderAll(); fc.renderAll();
@@ -618,12 +625,13 @@ export default function WorkbookViewer({ pdfId }: Props) {
// Mark objects from other users as remote // Mark objects from other users as remote
const uid = currentUserIdRef.current; const uid = currentUserIdRef.current;
if (uid !== null) { if (uid !== null) {
const canInteract = userRoleRef.current !== "student";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
fc.getObjects().forEach((obj: any) => { fc.getObjects().forEach((obj: any) => {
if (obj._owner_id != null && obj._owner_id !== uid) { if (obj._owner_id != null && obj._owner_id !== uid) {
obj._isRemote = true; obj._isRemote = true;
obj.selectable = false; obj.selectable = canInteract;
obj.evented = false; obj.evented = canInteract;
} }
}); });
} }
@@ -899,9 +907,15 @@ export default function WorkbookViewer({ pdfId }: Props) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
fc.on("mouse:down", (options: any) => { fc.on("mouse:down", (options: any) => {
if (!options.target) return; if (!options.target) return;
const collab_id = options.target.collab_id as string | undefined; const obj = options.target as any;
fc.remove(options.target); const ownerId: number | undefined = obj._owner_id;
addedObjects.current = addedObjects.current.filter(o => o !== options.target); const uid = currentUserIdRef.current;
const role = userRoleRef.current;
// Students can only erase their own annotations
if (role === "student" && (obj._isRemote || (ownerId != null && ownerId !== uid))) return;
const collab_id = obj.collab_id as string | undefined;
fc.remove(obj);
addedObjects.current = addedObjects.current.filter(o => o !== obj);
fc.renderAll(); fc.renderAll();
if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current); if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
}); });
@@ -947,8 +961,14 @@ export default function WorkbookViewer({ pdfId }: Props) {
fc.isDrawingMode = false; fc.selection = false; fc.isDrawingMode = false; fc.selection = false;
fc.on("mouse:down", (options: any) => { fc.on("mouse:down", (options: any) => {
if (!options.target) return; if (!options.target) return;
const collab_id = options.target.collab_id as string | undefined; const obj = options.target as any;
fc.remove(options.target); fc.renderAll(); const ownerId: number | undefined = obj._owner_id;
const uid = currentUserIdRef.current;
const role = userRoleRef.current;
// Students can only erase their own annotations
if (role === "student" && (obj._isRemote || (ownerId != null && ownerId !== uid))) return;
const collab_id = obj.collab_id as string | undefined;
fc.remove(obj); fc.renderAll();
if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current); if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
}); break; }); break;
} }
@@ -1031,13 +1051,33 @@ export default function WorkbookViewer({ pdfId }: Props) {
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
const fc = fabricRef.current; const fc = fabricRef.current;
if (!fc) return; if (!fc) return;
if (!confirm("Clear all annotations on this page?")) return;
fc.clear(); const role = userRoleRef.current;
addedObjects.current = []; const uid = currentUserIdRef.current;
fc.renderAll();
collabSendRef.current?.clear(currentPageRef.current); if (role === "student") {
// Push empty canvas to Redis so other users see the clear immediately on next load // Students: only remove their own objects, leave others untouched
syncTempCanvasRef.current(currentPageRef.current); if (!confirm("Xoá annotation của bạn trên trang này?")) return;
const toRemove = fc.getObjects().filter((o: any) => !o._isRemote && (o._owner_id == null || o._owner_id === uid));
toRemove.forEach((o: any) => {
fc.remove(o);
// Broadcast removal so teacher canvas updates in real time
const collab_id = o.collab_id as string | undefined;
if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
});
addedObjects.current = addedObjects.current.filter(o => toRemove.indexOf(o) === -1);
fc.renderAll();
// Sync own cleared state to Redis
syncTempCanvasRef.current(currentPageRef.current);
} else {
// Teacher / Admin: clear everything
if (!confirm("Xoá tất cả annotation trên trang này?")) return;
fc.clear();
addedObjects.current = [];
fc.renderAll();
collabSendRef.current?.clear(currentPageRef.current);
syncTempCanvasRef.current(currentPageRef.current);
}
}, []); }, []);
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -1317,16 +1357,50 @@ export default function WorkbookViewer({ pdfId }: Props) {
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${collabConnected ? "bg-green-500" : "bg-gray-300"}`} /> <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${collabConnected ? "bg-green-500" : "bg-gray-300"}`} />
{collabUsers.length > 0 && ( {collabUsers.length > 0 && (
<div className="flex -space-x-1"> <div className="flex -space-x-1">
{collabUsers.slice(0, 5).map(u => ( {collabUsers.slice(0, 5).map(u => {
<span const isSelf = u.user_id === currentUserIdRef.current;
key={u.user_id} const canClear = !isSelf && (userRole === "admin" || userRole === "teacher");
title={u.username} return (
style={{ background: u.color }} <div key={u.user_id} className="relative group">
className="w-5 h-5 rounded-full border-2 border-white flex items-center justify-center text-[9px] text-white font-bold uppercase" <span
> title={u.username}
{u.username[0]} style={{ background: u.color }}
</span> className="w-5 h-5 rounded-full border-2 border-white flex items-center justify-center text-[9px] text-white font-bold uppercase select-none"
))} >
{u.username[0]}
</span>
{canClear && (
<button
onClick={async () => {
if (!confirm(`Xoá annotation của "${u.username}" trên trang ${currentPageRef.current}?`)) return;
try {
await api.clearUserAnnotation(pdfId, currentPageRef.current, u.user_id);
// Remove their objects from canvas
const removeFromCanvas = (fc: any) => {
const toRemove = fc.getObjects().filter((o: any) => o._owner_id === u.user_id);
toRemove.forEach((o: any) => fc.remove(o));
if (toRemove.length) fc.renderAll();
};
if (scrollModeRef.current === "continuous") {
pageFabricRefs.current.forEach(fc => { if (fc) removeFromCanvas(fc); });
} else if (fabricRef.current) {
removeFromCanvas(fabricRef.current);
}
localAnnotations.current = {};
} catch (e: unknown) {
alert(e instanceof Error ? e.message : "Xoá thất bại.");
}
}}
title={`Xoá annotation của ${u.username} trang này`}
className="absolute -top-1.5 -right-1.5 w-3.5 h-3.5 rounded-full bg-red-500 text-white hidden group-hover:flex items-center justify-center shadow z-10"
style={{ fontSize: 8 }}
>
</button>
)}
</div>
);
})}
{collabUsers.length > 5 && ( {collabUsers.length > 5 && (
<span className="w-5 h-5 rounded-full border-2 border-white bg-gray-400 flex items-center justify-center text-[9px] text-white font-bold"> <span className="w-5 h-5 rounded-full border-2 border-white bg-gray-400 flex items-center justify-center text-[9px] text-white font-bold">
+{collabUsers.length - 5} +{collabUsers.length - 5}
+20
View File
@@ -52,4 +52,24 @@ export const api = {
method: "PUT", method: "PUT",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
deleteAnnotation: (annotationId: number) =>
request<void>(`/api/annotations/${annotationId}`, { method: "DELETE" }),
clearUserAnnotation: (pdfId: number, page: number, userId: number) =>
request<void>(`/api/annotations/${pdfId}/${page}/user/${userId}`, { method: "DELETE" }),
// Admin
adminListUsers: () =>
request<import("@/types").User[]>("/api/admin/users"),
adminUpdateStatus: (userId: number, status: import("@/types").UserStatus) =>
request<import("@/types").User>(`/api/admin/users/${userId}/status`, {
method: "PATCH",
body: JSON.stringify({ status }),
}),
adminUpdateRole: (userId: number, role: import("@/types").UserRole) =>
request<import("@/types").User>(`/api/admin/users/${userId}/role`, {
method: "PATCH",
body: JSON.stringify({ role }),
}),
adminDeleteUser: (userId: number) =>
request<void>(`/api/admin/users/${userId}`, { method: "DELETE" }),
}; };
+5
View File
@@ -1,7 +1,12 @@
export type UserRole = "admin" | "teacher" | "student";
export type UserStatus = "pending" | "approved" | "rejected";
export interface User { export interface User {
id: number; id: number;
username: string; username: string;
email: string; email: string;
role: UserRole;
status: UserStatus;
created_at: string; created_at: string;
} }
+8 -1
View File
@@ -133,4 +133,11 @@ https://github.com/<github_username>?tab=packages
```bash ```bash
python3 -c "import secrets; print(secrets.token_hex(32))" python3 -c "import secrets; print(secrets.token_hex(32))"
``` ```
Field Value
username admin
password Admin@12345
role admin
status approved