mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 10:43:11 +07:00
thực hiện phân quyền admin giáo viên và học sinh xong
This commit is contained in:
@@ -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)
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 lý 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 ký</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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" }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,3 +134,10 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user