mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 09:03:11 +07:00
Merge pull request 'dev01' (#2) from dev01 into main
Reviewed-on: basketballcantho/ten-project#2
This commit is contained in:
@@ -17,3 +17,8 @@ ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
# ── Ports ─────────────────────────────────────────────────────────────────────
|
||||
# Port exposed on the host for the Next.js frontend
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# ── Docker Hub images (optional — leave blank to build locally) ───────────────
|
||||
# Set these on the target machine so docker compose pull works without building
|
||||
# BACKEND_IMAGE=your_dockerhub_username/lms-backend:latest
|
||||
# FRONTEND_IMAGE=your_dockerhub_username/lms-frontend:latest
|
||||
|
||||
@@ -10,6 +10,10 @@ FROM python:3.12-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# postgresql-client provides pg_dump / psql used by the backup/restore API
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed packages from deps stage
|
||||
COPY --from=deps /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
COPY --from=deps /usr/local/bin /usr/local/bin
|
||||
|
||||
@@ -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 .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
|
||||
|
||||
+5
-2
@@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .routers import annotations, auth, pdfs
|
||||
from .routers import admin, annotations, auth, backup, pdfs, ws
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -17,12 +17,15 @@ app = FastAPI(title="LMS API", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000"], # Next.js dev server
|
||||
allow_origins=["http://localhost:3000", "http://localhost:3001"],
|
||||
allow_credentials=True, # required for cookies
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
app.include_router(backup.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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Redis helper — returns a connected client or None when Redis is unavailable.
|
||||
|
||||
Key namespace design (prevents cross-file / cross-user data leakage):
|
||||
|
||||
ann:{pdf_id}:{page_number}:{user_id}
|
||||
|
||||
Every segment is mandatory, so:
|
||||
- User A opening file 1 never touches User A's data on file 2
|
||||
- User A opening file 1 never touches User B's data on file 1
|
||||
- Data for page 3 never affects page 7
|
||||
|
||||
TTL: 24 h — temp entry expires automatically if the user never returns.
|
||||
On permanent save the entry is deleted immediately.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import redis as redis_lib
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: Optional[redis_lib.Redis] = None
|
||||
_warned = False # log the "unavailable" warning only once
|
||||
|
||||
|
||||
def get_redis() -> Optional[redis_lib.Redis]:
|
||||
"""Return a live Redis client, or None if Redis is unreachable."""
|
||||
global _client, _warned
|
||||
|
||||
if _client is not None:
|
||||
try:
|
||||
_client.ping()
|
||||
return _client
|
||||
except Exception:
|
||||
_client = None # connection dropped — try to reconnect below
|
||||
|
||||
url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
try:
|
||||
r: redis_lib.Redis = redis_lib.from_url(
|
||||
url,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=2,
|
||||
socket_timeout=2,
|
||||
)
|
||||
r.ping()
|
||||
_client = r
|
||||
_warned = False
|
||||
logger.info("Redis connected: %s", url)
|
||||
return _client
|
||||
except Exception as exc:
|
||||
if not _warned:
|
||||
logger.warning(
|
||||
"Redis unavailable (%s) — temp annotation cache disabled. "
|
||||
"Set REDIS_URL or start a local Redis instance to enable it.",
|
||||
exc,
|
||||
)
|
||||
_warned = True
|
||||
return None
|
||||
|
||||
|
||||
# Key helpers — centralised so there's one place to change the format.
|
||||
ANN_TTL = 86_400 # 24 hours
|
||||
|
||||
|
||||
def ann_temp_key(pdf_id: int, page_number: int, user_id: int) -> str:
|
||||
"""Fully-namespaced Redis key for one user's unsaved canvas on one PDF page."""
|
||||
return f"ann:{pdf_id}:{page_number}:{user_id}"
|
||||
|
||||
|
||||
def ann_temp_page_pattern(pdf_id: int, page_number: int) -> str:
|
||||
"""Glob pattern to list all users' temp entries for a given page."""
|
||||
return f"ann:{pdf_id}:{page_number}:*"
|
||||
@@ -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()
|
||||
@@ -1,13 +1,55 @@
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, status as http_status
|
||||
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"])
|
||||
|
||||
|
||||
@@ -18,6 +60,14 @@ def _verify_pdf_ownership(db: Session, pdf_id: int, user_id: int) -> PDF:
|
||||
return pdf
|
||||
|
||||
|
||||
def _any_pdf_or_404(db: Session, pdf_id: int) -> PDF:
|
||||
"""Return PDF regardless of owner — any authenticated user may read annotations."""
|
||||
pdf = db.get(PDF, pdf_id)
|
||||
if not pdf:
|
||||
raise HTTPException(status_code=404, detail="PDF not found.")
|
||||
return pdf
|
||||
|
||||
|
||||
# ── GET /api/annotations/{pdf_id}/{page_number} ───────────────────────────────
|
||||
|
||||
@router.get("/{pdf_id}/{page_number}", response_model=AnnotationOut)
|
||||
@@ -27,7 +77,7 @@ def get_annotation(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_verify_pdf_ownership(db, pdf_id, current_user.id)
|
||||
_any_pdf_or_404(db, pdf_id)
|
||||
|
||||
ann = (
|
||||
db.query(Annotation)
|
||||
@@ -52,6 +102,109 @@ def get_annotation(
|
||||
return ann
|
||||
|
||||
|
||||
# ── GET /api/annotations/{pdf_id}/{page_number}/all ───────────────────────────
|
||||
# Returns a merged canvas_data whose "objects" array contains annotations from
|
||||
# ALL users for the given page. Any authenticated user may call this.
|
||||
|
||||
@router.get("/{pdf_id}/{page_number}/all", response_model=AnnotationOut)
|
||||
def get_all_annotations(
|
||||
pdf_id: int,
|
||||
page_number: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_any_pdf_or_404(db, pdf_id)
|
||||
|
||||
rows = (
|
||||
db.query(Annotation)
|
||||
.filter(
|
||||
Annotation.pdf_id == pdf_id,
|
||||
Annotation.page_number == page_number,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Build user_id → canvas_data from DB (permanent saves)
|
||||
# Key = pdf_id + page_number + user_id → completely isolated per file/page/user
|
||||
user_data: dict[int, dict] = {}
|
||||
for row in rows:
|
||||
user_data[row.user_id] = dict(row.canvas_data) if row.canvas_data else {}
|
||||
|
||||
# Overlay with Redis temp data — unsaved strokes written by path:created / clear.
|
||||
# If a user has BOTH a DB record and a temp entry, Redis wins (more recent).
|
||||
r = get_redis()
|
||||
if r is not None:
|
||||
try:
|
||||
pattern = ann_temp_page_pattern(pdf_id, page_number)
|
||||
temp_keys: list[str] = r.keys(pattern)
|
||||
for key in temp_keys:
|
||||
# key format: ann:{pdf_id}:{page_number}:{user_id}
|
||||
try:
|
||||
uid = int(key.split(":")[-1])
|
||||
except ValueError:
|
||||
continue
|
||||
raw = r.get(key)
|
||||
if raw:
|
||||
try:
|
||||
user_data[uid] = json.loads(raw)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass # Redis hiccup — fall through with DB data only
|
||||
|
||||
if not user_data:
|
||||
return AnnotationOut(
|
||||
id=0,
|
||||
pdf_id=pdf_id,
|
||||
page_number=page_number,
|
||||
canvas_data={},
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Canvas-level metadata (version, background…) comes from the latest DB record
|
||||
latest = max(rows, key=lambda row: row.updated_at) if rows else None
|
||||
base: dict = dict(latest.canvas_data) if latest and latest.canvas_data else {}
|
||||
|
||||
merged_objects: list = []
|
||||
for uid, canvas in user_data.items():
|
||||
for obj in (canvas or {}).get("objects", []):
|
||||
obj_copy = dict(obj)
|
||||
obj_copy["_owner_id"] = uid
|
||||
merged_objects.append(obj_copy)
|
||||
base["objects"] = merged_objects
|
||||
|
||||
return AnnotationOut(
|
||||
id=latest.id if latest else 0,
|
||||
pdf_id=pdf_id,
|
||||
page_number=page_number,
|
||||
canvas_data=base,
|
||||
updated_at=latest.updated_at if latest else datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
# ── PUT /api/annotations/{pdf_id}/{page_number}/temp ──────────────────────────
|
||||
# Writes unsaved canvas state to Redis so every subsequent /all call includes it.
|
||||
# Called automatically (debounced) by the frontend after each stroke.
|
||||
|
||||
@router.put("/{pdf_id}/{page_number}/temp", status_code=http_status.HTTP_204_NO_CONTENT)
|
||||
def upsert_temp_annotation(
|
||||
pdf_id: int,
|
||||
page_number: int,
|
||||
body: AnnotationIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_any_pdf_or_404(db, pdf_id)
|
||||
r = get_redis()
|
||||
if r is None:
|
||||
return # Redis unavailable — silently skip
|
||||
key = ann_temp_key(pdf_id, page_number, current_user.id)
|
||||
try:
|
||||
r.setex(key, ANN_TTL, json.dumps(body.canvas_data))
|
||||
except Exception:
|
||||
pass # non-critical
|
||||
|
||||
|
||||
# ── PUT /api/annotations/{pdf_id}/{page_number} ───────────────────────────────
|
||||
|
||||
@router.put("/{pdf_id}/{page_number}", response_model=AnnotationOut)
|
||||
@@ -62,7 +215,7 @@ def upsert_annotation(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_verify_pdf_ownership(db, pdf_id, current_user.id)
|
||||
_any_pdf_or_404(db, pdf_id)
|
||||
|
||||
ann = (
|
||||
db.query(Annotation)
|
||||
@@ -88,4 +241,66 @@ def upsert_annotation(
|
||||
|
||||
db.commit()
|
||||
db.refresh(ann)
|
||||
|
||||
# Data is now in DB — remove the Redis temp entry so /all doesn't double-count
|
||||
r = get_redis()
|
||||
if r is not None:
|
||||
try:
|
||||
r.delete(ann_temp_key(pdf_id, page_number, current_user.id))
|
||||
except Exception:
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
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, ChangePassword
|
||||
from ..security import (
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
create_access_token,
|
||||
@@ -16,6 +17,7 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
_COOKIE_NAME = "access_token"
|
||||
_COOKIE_MAX_AGE = ACCESS_TOKEN_EXPIRE_MINUTES * 60 # seconds
|
||||
_COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
|
||||
|
||||
|
||||
def _set_auth_cookie(response: Response, user_id: int) -> None:
|
||||
@@ -24,7 +26,7 @@ def _set_auth_cookie(response: Response, user_id: int) -> None:
|
||||
key=_COOKIE_NAME,
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=True, # send only over HTTPS (Nginx handles TLS in prod)
|
||||
secure=_COOKIE_SECURE,
|
||||
samesite="lax",
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
path="/",
|
||||
@@ -40,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
|
||||
|
||||
|
||||
@@ -66,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
|
||||
|
||||
@@ -82,3 +104,30 @@ def logout(response: Response):
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
|
||||
# ── GET /auth/token (return raw JWT for WebSocket auth) ─────────────────────
|
||||
|
||||
@router.get("/token")
|
||||
def get_token(access_token: str | None = Cookie(default=None)):
|
||||
"""
|
||||
Returns the current JWT so the frontend can pass it as a WebSocket
|
||||
query param (browsers can't send cookies on WS upgrade in all cases).
|
||||
"""
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated.")
|
||||
return {"access_token": access_token}
|
||||
|
||||
|
||||
# ── POST /auth/change-password ────────────────────────────────────────────────
|
||||
|
||||
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def change_password(
|
||||
body: ChangePassword,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if not verify_password(body.current_password, current_user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Mật khẩu hiện tại không đúng.")
|
||||
current_user.password_hash = hash_password(body.new_password)
|
||||
db.commit()
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Backup / Restore endpoints — Admin only.
|
||||
|
||||
Backup : GET /api/admin/backup/download
|
||||
Streams a ZIP containing:
|
||||
- dump.sql (pg_dump plain-text SQL of the entire database)
|
||||
- uploads/ (all uploaded PDF files from the volume)
|
||||
- meta.json (LMS version, timestamp, db name)
|
||||
|
||||
Restore : POST /api/admin/backup/restore
|
||||
Accepts the same ZIP, drops & recreates the schema via
|
||||
psql, then copies files back into the uploads directory.
|
||||
The running app is re-migrated automatically by entrypoint
|
||||
on next restart, but this endpoint also runs alembic upgrade head.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db, engine
|
||||
from ..dependencies import require_admin
|
||||
from ..models import User
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["backup"])
|
||||
|
||||
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/uploads"))
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "")
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_db_url(url: str) -> dict:
|
||||
"""Parse DATABASE_URL into components for pg_dump / psql CLI."""
|
||||
p = urlparse(url)
|
||||
return {
|
||||
"host": p.hostname or "db",
|
||||
"port": str(p.port or 5432),
|
||||
"user": p.username or "lms_user",
|
||||
"password": p.password or "",
|
||||
"dbname": p.path.lstrip("/") or "lms_db",
|
||||
}
|
||||
|
||||
|
||||
def _pg_env(db_params: dict) -> dict:
|
||||
"""Return env dict that passes PGPASSWORD so no password prompt."""
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = db_params["password"]
|
||||
return env
|
||||
|
||||
|
||||
# ── GET /api/admin/backup/download ────────────────────────────────────────────
|
||||
|
||||
@router.get("/backup/download")
|
||||
def download_backup(
|
||||
_admin: User = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Create an in-memory ZIP with pg_dump SQL + all uploaded files
|
||||
and stream it back to the browser.
|
||||
"""
|
||||
db_params = _parse_db_url(DATABASE_URL)
|
||||
|
||||
# 1. Run pg_dump → SQL text
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"pg_dump",
|
||||
"-h", db_params["host"],
|
||||
"-p", db_params["port"],
|
||||
"-U", db_params["user"],
|
||||
"-d", db_params["dbname"],
|
||||
"--no-password",
|
||||
"--format=plain",
|
||||
"--no-owner",
|
||||
"--no-acl",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=_pg_env(db_params),
|
||||
timeout=120,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="pg_dump not found in container. Add postgresql-client to backend Dockerfile.",
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"pg_dump failed: {result.stderr[:500]}",
|
||||
)
|
||||
|
||||
sql_bytes = result.stdout.encode("utf-8")
|
||||
|
||||
# 2. Build ZIP in memory
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
|
||||
# meta.json
|
||||
meta = {
|
||||
"lms_backup_version": 1,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"db_name": db_params["dbname"],
|
||||
}
|
||||
zf.writestr("meta.json", json.dumps(meta, indent=2))
|
||||
|
||||
# Database dump
|
||||
zf.writestr("dump.sql", sql_bytes)
|
||||
|
||||
# Upload files
|
||||
if UPLOAD_DIR.exists():
|
||||
for filepath in UPLOAD_DIR.rglob("*"):
|
||||
if filepath.is_file():
|
||||
arcname = "uploads/" + filepath.relative_to(UPLOAD_DIR).as_posix()
|
||||
zf.write(filepath, arcname)
|
||||
|
||||
buf.seek(0)
|
||||
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"lms_backup_{timestamp}.zip"
|
||||
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# ── POST /api/admin/backup/restore ────────────────────────────────────────────
|
||||
|
||||
@router.post("/backup/restore", status_code=status.HTTP_200_OK)
|
||||
async def restore_backup(
|
||||
file: UploadFile,
|
||||
_admin: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Restore from a backup ZIP created by /backup/download.
|
||||
⚠️ THIS OVERWRITES all current data.
|
||||
Steps:
|
||||
1. Validate ZIP contains meta.json + dump.sql
|
||||
2. Drop all tables (via SQLAlchemy) and recreate via psql
|
||||
3. Re-run alembic upgrade head
|
||||
4. Restore upload files
|
||||
"""
|
||||
if not file.filename or not file.filename.endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="File must be a .zip backup.")
|
||||
|
||||
contents = await file.read()
|
||||
if len(contents) < 22: # minimum valid ZIP size
|
||||
raise HTTPException(status_code=400, detail="Invalid or empty ZIP file.")
|
||||
|
||||
db_params = _parse_db_url(DATABASE_URL)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp = Path(tmpdir)
|
||||
|
||||
# ── Unzip ─────────────────────────────────────────────────────────
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(contents)) as zf:
|
||||
names = zf.namelist()
|
||||
if "dump.sql" not in names:
|
||||
raise HTTPException(status_code=400, detail="ZIP missing dump.sql — not a valid LMS backup.")
|
||||
if "meta.json" not in names:
|
||||
raise HTTPException(status_code=400, detail="ZIP missing meta.json — not a valid LMS backup.")
|
||||
zf.extractall(tmp)
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(status_code=400, detail="Corrupted ZIP file.")
|
||||
|
||||
# ── Validate meta ─────────────────────────────────────────────────
|
||||
meta = json.loads((tmp / "meta.json").read_text())
|
||||
if meta.get("lms_backup_version") != 1:
|
||||
raise HTTPException(status_code=400, detail="Unsupported backup version.")
|
||||
|
||||
# ── Close all active DB connections to allow DROP ─────────────────
|
||||
db.close()
|
||||
engine.dispose()
|
||||
|
||||
pg_env = _pg_env(db_params)
|
||||
|
||||
# ── Drop and recreate the public schema ───────────────────────────
|
||||
drop_sql = (
|
||||
"DROP SCHEMA public CASCADE; "
|
||||
"CREATE SCHEMA public; "
|
||||
"GRANT ALL ON SCHEMA public TO PUBLIC;"
|
||||
)
|
||||
drop_result = subprocess.run(
|
||||
[
|
||||
"psql",
|
||||
"-h", db_params["host"],
|
||||
"-p", db_params["port"],
|
||||
"-U", db_params["user"],
|
||||
"-d", db_params["dbname"],
|
||||
"--no-password",
|
||||
"-c", drop_sql,
|
||||
],
|
||||
capture_output=True, text=True,
|
||||
env=pg_env, timeout=30,
|
||||
)
|
||||
if drop_result.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to reset schema: {drop_result.stderr[:400]}",
|
||||
)
|
||||
|
||||
# ── Restore SQL dump ──────────────────────────────────────────────
|
||||
sql_file = str(tmp / "dump.sql")
|
||||
restore_result = subprocess.run(
|
||||
[
|
||||
"psql",
|
||||
"-h", db_params["host"],
|
||||
"-p", db_params["port"],
|
||||
"-U", db_params["user"],
|
||||
"-d", db_params["dbname"],
|
||||
"--no-password",
|
||||
"-f", sql_file,
|
||||
],
|
||||
capture_output=True, text=True,
|
||||
env=pg_env, timeout=120,
|
||||
)
|
||||
if restore_result.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"psql restore failed: {restore_result.stderr[:400]}",
|
||||
)
|
||||
|
||||
# ── Re-run Alembic migrations (idempotent) ────────────────────────
|
||||
subprocess.run(
|
||||
["alembic", "upgrade", "head"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
# ── Restore uploaded files ────────────────────────────────────────
|
||||
uploads_src = tmp / "uploads"
|
||||
if uploads_src.exists():
|
||||
if UPLOAD_DIR.exists():
|
||||
shutil.rmtree(UPLOAD_DIR)
|
||||
shutil.copytree(uploads_src, UPLOAD_DIR)
|
||||
else:
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "Restore complete. Please refresh the page.",
|
||||
"backup_created_at": meta.get("created_at"),
|
||||
}
|
||||
+30
-11
@@ -14,7 +14,7 @@ from ..schemas import PDFOut, PDFUploadResult
|
||||
router = APIRouter(prefix="/pdfs", tags=["pdfs"])
|
||||
|
||||
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/uploads"))
|
||||
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||
|
||||
|
||||
def _user_dir(user_id: int) -> Path:
|
||||
@@ -30,6 +30,29 @@ def _own_or_404(db: Session, pdf_id: int, user_id: int) -> PDF:
|
||||
return pdf
|
||||
|
||||
|
||||
def _any_or_404(db: Session, pdf_id: int) -> PDF:
|
||||
"""Return PDF if it exists — any authenticated user may read it."""
|
||||
pdf = db.get(PDF, pdf_id)
|
||||
if not pdf:
|
||||
raise HTTPException(status_code=404, detail="PDF not found.")
|
||||
return pdf
|
||||
|
||||
|
||||
def _pdf_out(pdf: PDF, db: Session):
|
||||
"""Build PDFOut including owner_username."""
|
||||
from ..schemas import PDFOut as _PDFOut
|
||||
owner = db.get(User, pdf.user_id)
|
||||
data = {
|
||||
"id": pdf.id,
|
||||
"user_id": pdf.user_id,
|
||||
"owner_username": owner.username if owner else "unknown",
|
||||
"title": pdf.title,
|
||||
"total_pages": pdf.total_pages,
|
||||
"created_at": pdf.created_at,
|
||||
}
|
||||
return _PDFOut.model_validate(data)
|
||||
|
||||
|
||||
# ── GET /api/pdfs ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("", response_model=list[PDFOut])
|
||||
@@ -37,12 +60,8 @@ def list_pdfs(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return (
|
||||
db.query(PDF)
|
||||
.filter(PDF.user_id == current_user.id)
|
||||
.order_by(PDF.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
pdfs = db.query(PDF).order_by(PDF.created_at.desc()).all()
|
||||
return [_pdf_out(p, db) for p in pdfs]
|
||||
|
||||
|
||||
# ── POST /api/pdfs/upload ─────────────────────────────────────────────────────
|
||||
@@ -71,7 +90,7 @@ async def upload_pdfs(
|
||||
# ── Size guard ────────────────────────────────────────────────────────
|
||||
body = header + await file.read()
|
||||
if len(body) > MAX_FILE_SIZE:
|
||||
results.append(PDFUploadResult(filename=file.filename or "", success=False, error="File exceeds 50 MB limit."))
|
||||
results.append(PDFUploadResult(filename=file.filename or "", success=False, error="File exceeds 500 MB limit."))
|
||||
continue
|
||||
|
||||
# ── Save to disk with UUID filename (prevents path traversal) ─────────
|
||||
@@ -90,7 +109,7 @@ async def upload_pdfs(
|
||||
db.commit()
|
||||
db.refresh(pdf)
|
||||
|
||||
results.append(PDFUploadResult(filename=file.filename or "", success=True, pdf=PDFOut.model_validate(pdf)))
|
||||
results.append(PDFUploadResult(filename=file.filename or "", success=True, pdf=_pdf_out(pdf, db)))
|
||||
|
||||
return results
|
||||
|
||||
@@ -121,8 +140,8 @@ def serve_pdf(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Serve the raw PDF bytes — only to the owning user."""
|
||||
pdf = _own_or_404(db, pdf_id, current_user.id)
|
||||
"""Serve the raw PDF bytes — any authenticated user may read."""
|
||||
pdf = _any_or_404(db, pdf_id)
|
||||
file_path = Path(pdf.file_path)
|
||||
|
||||
if not file_path.exists():
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
WebSocket collaboration endpoint.
|
||||
|
||||
URL: ws://.../ws/pdf/{pdf_id}?token=<JWT>
|
||||
|
||||
Each PDF has its own "room". When a client connects it broadcasts a `presence`
|
||||
event to the room. All annotation mutations are forwarded verbatim to every
|
||||
other client in the room.
|
||||
|
||||
Message schema (JSON):
|
||||
→ client sends:
|
||||
{ "type": "object_add", "payload": <fabric object JSON> }
|
||||
{ "type": "object_remove", "payload": { "obj_id": "<id>" } }
|
||||
{ "type": "clear" }
|
||||
{ "type": "cursor", "payload": { "x": 0, "y": 0, "page": 1 } }
|
||||
{ "type": "ping" }
|
||||
|
||||
← server sends to ALL others in room:
|
||||
same messages, with "user_id" / "username" / "color" injected
|
||||
|
||||
← server sends to ALL (including sender) on join/leave:
|
||||
{ "type": "presence", "users": [ { "user_id", "username", "color" } ] }
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
|
||||
from jose import JWTError
|
||||
|
||||
from ..security import decode_access_token
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# ── Deterministic colour per user (hue based on user_id) ─────────────────────
|
||||
|
||||
def _user_color(user_id: int) -> str:
|
||||
hue = (user_id * 61) % 360 # spread nicely around the wheel
|
||||
return f"hsl({hue},70%,50%)"
|
||||
|
||||
|
||||
# ── Room manager ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _Room:
|
||||
def __init__(self) -> None:
|
||||
# websocket → user info dict
|
||||
self._clients: dict[WebSocket, dict[str, Any]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def join(self, ws: WebSocket, user_info: dict[str, Any]) -> None:
|
||||
async with self._lock:
|
||||
self._clients[ws] = user_info
|
||||
await self._broadcast_presence()
|
||||
|
||||
async def leave(self, ws: WebSocket) -> None:
|
||||
async with self._lock:
|
||||
self._clients.pop(ws, None)
|
||||
await self._broadcast_presence()
|
||||
|
||||
@property
|
||||
def user_list(self) -> list[dict[str, Any]]:
|
||||
"""Deduplicated by user_id — same user may have multiple WS connections."""
|
||||
seen: set[int] = set()
|
||||
result: list[dict[str, Any]] = []
|
||||
for info in self._clients.values():
|
||||
uid = info["user_id"]
|
||||
if uid not in seen:
|
||||
seen.add(uid)
|
||||
result.append(info)
|
||||
return result
|
||||
|
||||
async def broadcast(self, message: dict[str, Any], exclude: WebSocket | None = None) -> None:
|
||||
"""Send message to every client except `exclude`."""
|
||||
async with self._lock:
|
||||
targets = [ws for ws in self._clients if ws is not exclude]
|
||||
for ws in targets:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def broadcast_all(self, message: dict[str, Any]) -> None:
|
||||
"""Send message to every client including sender."""
|
||||
await self.broadcast(message, exclude=None)
|
||||
|
||||
async def _broadcast_presence(self) -> None:
|
||||
await self.broadcast_all({"type": "presence", "users": self.user_list})
|
||||
|
||||
|
||||
class _RoomManager:
|
||||
def __init__(self) -> None:
|
||||
self._rooms: dict[int, _Room] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get_or_create(self, pdf_id: int) -> _Room:
|
||||
async with self._lock:
|
||||
if pdf_id not in self._rooms:
|
||||
self._rooms[pdf_id] = _Room()
|
||||
return self._rooms[pdf_id]
|
||||
|
||||
async def cleanup(self, pdf_id: int) -> None:
|
||||
async with self._lock:
|
||||
room = self._rooms.get(pdf_id)
|
||||
if room and not room._clients:
|
||||
del self._rooms[pdf_id]
|
||||
|
||||
|
||||
manager = _RoomManager()
|
||||
|
||||
# ── WebSocket endpoint ────────────────────────────────────────────────────────
|
||||
|
||||
@router.websocket("/ws/pdf/{pdf_id}")
|
||||
async def pdf_collaboration(websocket: WebSocket, pdf_id: int):
|
||||
# ── Auth: JWT passed as query param (cookies aren't reliably sent on WS) ──
|
||||
token = websocket.query_params.get("token")
|
||||
if not token:
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
|
||||
try:
|
||||
user_id = decode_access_token(token)
|
||||
except JWTError:
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
|
||||
# ── Fetch username from DB ────────────────────────────────────────────────
|
||||
db = next(get_db())
|
||||
try:
|
||||
user: User | None = db.get(User, user_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if user is None:
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
|
||||
color = _user_color(user_id)
|
||||
user_info = {"user_id": user_id, "username": user.username, "color": color}
|
||||
|
||||
await websocket.accept()
|
||||
room = await manager.get_or_create(pdf_id)
|
||||
await room.join(websocket, user_info)
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
msg_type = msg.get("type")
|
||||
|
||||
if msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
continue
|
||||
|
||||
# Inject sender identity and forward to all other clients
|
||||
if msg_type in {"object_add", "object_remove", "clear", "cursor", "color_sync"}:
|
||||
msg.update(user_info)
|
||||
await room.broadcast(msg, exclude=websocket)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
await room.leave(websocket)
|
||||
await manager.cleanup(pdf_id)
|
||||
@@ -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,33 @@ 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 ChangePassword(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def password_strength(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("Password must be at least 8 characters.")
|
||||
return v
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
sub: int # user id
|
||||
exp: int
|
||||
@@ -51,6 +76,8 @@ class TokenPayload(BaseModel):
|
||||
|
||||
class PDFOut(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
owner_username: str
|
||||
title: str
|
||||
total_pages: int | None
|
||||
created_at: datetime
|
||||
|
||||
@@ -7,3 +7,4 @@ python-jose[cryptography]==3.3.0
|
||||
pydantic[email]==2.9.2
|
||||
python-multipart==0.0.12
|
||||
alembic==1.14.1
|
||||
redis>=5.0
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/bin/bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# LMS — Docker Compose Build & Run Script
|
||||
# Usage:
|
||||
# ./build.sh # build + start (production)
|
||||
# ./build.sh --no-cache # force full rebuild (no Docker layer cache)
|
||||
# ./build.sh --down # stop and remove containers
|
||||
# ./build.sh --restart # stop, rebuild, and start
|
||||
# ./build.sh --logs # tail logs after starting
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colour helpers ────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
ok() { echo -e "${GREEN}✔${RESET} $*"; }
|
||||
info() { echo -e "${CYAN}▶${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}⚠${RESET} $*"; }
|
||||
fail() { echo -e "${RED}✘${RESET} $*" >&2; exit 1; }
|
||||
step() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}"; }
|
||||
|
||||
# ── Parse flags ───────────────────────────────────────────────────────────────
|
||||
NO_CACHE=false
|
||||
DO_DOWN=false
|
||||
DO_RESTART=false
|
||||
DO_LOGS=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--no-cache) NO_CACHE=true ;;
|
||||
--down) DO_DOWN=true ;;
|
||||
--restart) DO_RESTART=true ;;
|
||||
--logs) DO_LOGS=true ;;
|
||||
--help|-h)
|
||||
sed -n '/^# Usage:/,/^# ─/p' "$0" | grep '^#' | sed 's/^# \?//'
|
||||
exit 0 ;;
|
||||
*) fail "Unknown option: $arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Change to script directory ────────────────────────────────────────────────
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# ── Helper: check required commands ──────────────────────────────────────────
|
||||
require_cmd() {
|
||||
command -v "$1" &>/dev/null || fail "'$1' is not installed or not in PATH."
|
||||
}
|
||||
require_cmd docker
|
||||
require_cmd docker compose 2>/dev/null || {
|
||||
# Older Docker installs use "docker-compose" instead of "docker compose"
|
||||
require_cmd docker-compose
|
||||
# Shim so the rest of the script uses the right command
|
||||
docker() {
|
||||
if [ "$1" = "compose" ]; then shift; docker-compose "$@"; else command docker "$@"; fi
|
||||
}
|
||||
export -f docker
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# --down: stop and remove containers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if $DO_DOWN; then
|
||||
step "Stopping & removing containers"
|
||||
docker compose down --remove-orphans
|
||||
ok "All containers stopped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Validate .env
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
step "Checking .env"
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
warn ".env not found — copying from .env.example"
|
||||
if [ ! -f .env.example ]; then
|
||||
fail ".env.example not found either. Cannot continue."
|
||||
fi
|
||||
cp .env.example .env
|
||||
echo ""
|
||||
warn "Please edit .env and set POSTGRES_PASSWORD and JWT_SECRET_KEY, then re-run this script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source .env
|
||||
|
||||
ERRORS=0
|
||||
check_var() {
|
||||
local name=$1 val=${!1:-} placeholder=${2:-""}
|
||||
if [ -z "$val" ] || { [ -n "$placeholder" ] && [ "$val" = "$placeholder" ]; }; then
|
||||
warn "Missing or placeholder value for ${BOLD}${name}${RESET} in .env"
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
}
|
||||
|
||||
check_var POSTGRES_PASSWORD "change_me_strong_password"
|
||||
check_var JWT_SECRET_KEY "change_me_generate_with_secrets_token_hex_32"
|
||||
|
||||
if [ $ERRORS -gt 0 ]; then
|
||||
echo ""
|
||||
echo -e " ${YELLOW}Tip — generate a JWT secret:${RESET}"
|
||||
echo " python3 -c \"import secrets; print(secrets.token_hex(32))\""
|
||||
echo ""
|
||||
fail "Fix the above values in .env and re-run."
|
||||
fi
|
||||
|
||||
ok ".env looks good"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# --restart: tear down first
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if $DO_RESTART; then
|
||||
step "Stopping existing containers"
|
||||
docker compose down --remove-orphans
|
||||
ok "Stopped."
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Build images
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
step "Building Docker images"
|
||||
|
||||
BUILD_ARGS=""
|
||||
$NO_CACHE && BUILD_ARGS="--no-cache" && warn "Building without layer cache (--no-cache)"
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
docker compose build $BUILD_ARGS
|
||||
|
||||
ok "Images built successfully"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Start stack
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
step "Starting services"
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Wait for backend health
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
step "Waiting for backend to become healthy"
|
||||
|
||||
MAX_WAIT=60
|
||||
ELAPSED=0
|
||||
INTERVAL=3
|
||||
printf " "
|
||||
while true; do
|
||||
STATUS=$(docker compose ps --format json backend 2>/dev/null \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('Health','') or d.get('State',''))" 2>/dev/null || echo "")
|
||||
case "$STATUS" in
|
||||
healthy) echo ""; ok "Backend is healthy"; break ;;
|
||||
running) printf "."; sleep $INTERVAL; ELAPSED=$((ELAPSED+INTERVAL)) ;;
|
||||
*) printf "."; sleep $INTERVAL; ELAPSED=$((ELAPSED+INTERVAL)) ;;
|
||||
esac
|
||||
if [ $ELAPSED -ge $MAX_WAIT ]; then
|
||||
echo ""
|
||||
warn "Timed out waiting for backend health check — checking logs:"
|
||||
docker compose logs --tail=20 backend
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Summary
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
PORT="${FRONTEND_PORT:-3000}"
|
||||
echo ""
|
||||
echo -e "${BOLD}${GREEN}══════════════════════════════════════════${RESET}"
|
||||
echo -e "${BOLD}${GREEN} ✅ LMS is up!${RESET}"
|
||||
echo -e "${BOLD}${GREEN}══════════════════════════════════════════${RESET}"
|
||||
echo -e " ${BOLD}App URL :${RESET} http://localhost:${PORT}"
|
||||
echo -e " ${BOLD}Backend :${RESET} http://localhost:${PORT}/api/docs (via proxy)"
|
||||
echo ""
|
||||
docker compose ps
|
||||
echo ""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Tail logs if requested
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if $DO_LOGS; then
|
||||
step "Tailing logs (Ctrl+C to stop)"
|
||||
docker compose logs -f
|
||||
fi
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# Pull LMS images from GHCR and start the stack on a new machine.
|
||||
# Usage: ./deploy.sh
|
||||
# Requires: .env file with BACKEND_IMAGE, FRONTEND_IMAGE, POSTGRES_PASSWORD, JWT_SECRET_KEY
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "❌ .env not found. Copy .env.example → .env and fill in the values."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source .env
|
||||
|
||||
if [ -z "$POSTGRES_PASSWORD" ] || [ "$POSTGRES_PASSWORD" = "change_me_strong_password" ]; then
|
||||
echo "❌ Set POSTGRES_PASSWORD in .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$JWT_SECRET_KEY" ] || [ "$JWT_SECRET_KEY" = "change_me_generate_with_secrets_token_hex_32" ]; then
|
||||
echo "❌ Set JWT_SECRET_KEY in .env"
|
||||
echo " Generate: python3 -c \"import secrets; print(secrets.token_hex(32))\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Login GHCR if token is provided
|
||||
if [ -n "$GITHUB_TOKEN" ] && [ -n "$GITHUB_USER" ]; then
|
||||
echo "🔐 Logging in to ghcr.io..."
|
||||
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_USER" --password-stdin
|
||||
fi
|
||||
|
||||
echo "📦 Pulling images from GHCR..."
|
||||
docker compose pull
|
||||
|
||||
echo "🚀 Starting stack..."
|
||||
docker compose up -d
|
||||
|
||||
echo ""
|
||||
echo "✅ Running! Open http://localhost:${FRONTEND_PORT:-3000}"
|
||||
docker compose ps
|
||||
@@ -18,8 +18,22 @@ services:
|
||||
networks:
|
||||
- lms_net
|
||||
|
||||
# ── Redis (temp annotation cache) ────────────────────────────────────────────
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: redis-server --save "" --appendonly no # in-memory only, no disk writes
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
- lms_net
|
||||
|
||||
# ── FastAPI backend ───────────────────────────────────────────────────────────
|
||||
backend:
|
||||
image: ${BACKEND_IMAGE:-lms-backend:latest}
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
@@ -27,12 +41,15 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-lms_user}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lms_db}
|
||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
|
||||
JWT_ALGORITHM: HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||
UPLOAD_DIR: /uploads
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
volumes:
|
||||
- pdf_uploads:/uploads
|
||||
expose:
|
||||
@@ -42,6 +59,7 @@ services:
|
||||
|
||||
# ── Next.js frontend ──────────────────────────────────────────────────────────
|
||||
frontend:
|
||||
image: ${FRONTEND_IMAGE:-lms-frontend:latest}
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -27,6 +27,7 @@ ENV NODE_ENV=production
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/next.config.js ./
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ const nextConfig = {
|
||||
source: "/api/:path*",
|
||||
destination: `${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/ws/:path*",
|
||||
destination: `${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"}/ws/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
"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
|
||||
|
||||
// Backup / Restore state
|
||||
const [backupLoading, setBackupLoading] = useState(false);
|
||||
const [restoreLoading, setRestoreLoading] = useState(false);
|
||||
const [backupMsg, setBackupMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const restoreInputRef = useState<HTMLInputElement | null>(null);
|
||||
|
||||
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={() => router.push("/change-password")}
|
||||
className="text-sm text-gray-500 hover:text-blue-600 transition"
|
||||
title="Đổi mật khẩu"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{/* ── Backup & Restore ─────────────────────────────────────────────── */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 shadow-sm">
|
||||
<h2 className="text-sm font-bold text-gray-800 mb-1">Sao lưu & Khôi phục</h2>
|
||||
<p className="text-xs text-gray-400 mb-4">
|
||||
File ZIP chứa toàn bộ cơ sở dữ liệu (SQL dump) và các file PDF đã tải lên.
|
||||
Dùng để di chuyển sang server khác.
|
||||
</p>
|
||||
|
||||
{backupMsg && (
|
||||
<div className={`mb-4 text-xs px-3 py-2 rounded-lg flex items-center justify-between ${
|
||||
backupMsg.type === "ok"
|
||||
? "bg-green-50 border border-green-200 text-green-700"
|
||||
: "bg-red-50 border border-red-200 text-red-700"
|
||||
}`}>
|
||||
{backupMsg.text}
|
||||
<button onClick={() => setBackupMsg(null)} className="ml-3 opacity-60 hover:opacity-100">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* Download backup */}
|
||||
<button
|
||||
disabled={backupLoading}
|
||||
onClick={async () => {
|
||||
setBackupLoading(true);
|
||||
setBackupMsg(null);
|
||||
try {
|
||||
const blob = await api.adminDownloadBackup();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
a.href = url;
|
||||
a.download = `lms_backup_${ts}.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setBackupMsg({ type: "ok", text: "Tải backup thành công." });
|
||||
} catch (e: unknown) {
|
||||
setBackupMsg({ type: "err", text: e instanceof Error ? e.message : "Backup thất bại." });
|
||||
} finally {
|
||||
setBackupLoading(false);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center gap-2 text-sm font-medium bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white px-4 py-2 rounded-lg transition"
|
||||
>
|
||||
{backupLoading ? (
|
||||
<svg className="animate-spin h-4 w-4" 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>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
)}
|
||||
{backupLoading ? "Đang tạo backup…" : "Tải backup (.zip)"}
|
||||
</button>
|
||||
|
||||
{/* Restore */}
|
||||
<label
|
||||
className={`flex items-center justify-center gap-2 text-sm font-medium border-2 border-dashed px-4 py-2 rounded-lg transition cursor-pointer ${
|
||||
restoreLoading
|
||||
? "opacity-60 cursor-not-allowed border-gray-300 text-gray-400"
|
||||
: "border-orange-300 text-orange-600 hover:bg-orange-50"
|
||||
}`}
|
||||
>
|
||||
{restoreLoading ? (
|
||||
<svg className="animate-spin h-4 w-4" 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>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l4-4m0 0l4 4m-4-4v12" />
|
||||
</svg>
|
||||
)}
|
||||
{restoreLoading ? "Đang khôi phục…" : "Khôi phục từ backup (.zip)"}
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
disabled={restoreLoading}
|
||||
ref={el => { restoreInputRef[1](el); }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!confirm(
|
||||
`⚠️ Khôi phục từ "${file.name}" sẽ XOÁ TOÀN BỘ dữ liệu hiện tại và thay bằng backup.\n\nBạn có chắc chắn không?`
|
||||
)) {
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
setRestoreLoading(true);
|
||||
setBackupMsg(null);
|
||||
try {
|
||||
const result = await api.adminRestoreBackup(file);
|
||||
const ts = result.backup_created_at
|
||||
? new Date(result.backup_created_at).toLocaleString("vi-VN")
|
||||
: "";
|
||||
setBackupMsg({
|
||||
type: "ok",
|
||||
text: `Khôi phục thành công${ts ? " (backup ngày " + ts + ")" : ""}. Trang sẽ tải lại sau 3 giây.`,
|
||||
});
|
||||
setTimeout(() => window.location.reload(), 3000);
|
||||
} catch (err: unknown) {
|
||||
setBackupMsg({ type: "err", text: err instanceof Error ? err.message : "Khôi phục thất bại." });
|
||||
} finally {
|
||||
setRestoreLoading(false);
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-300 mt-3">
|
||||
⚠️ Khôi phục sẽ ghi đè toàn bộ dữ liệu hiện tại (người dùng, PDF, annotation). Hãy tải backup trước khi khôi phục.
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({
|
||||
current_password: "",
|
||||
new_password: "",
|
||||
confirm_password: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (form.new_password !== form.confirm_password) {
|
||||
setError("Mật khẩu mới và xác nhận không khớp.");
|
||||
return;
|
||||
}
|
||||
if (form.new_password.length < 8) {
|
||||
setError("Mật khẩu mới phải ít nhất 8 ký tự.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.changePassword({
|
||||
current_password: form.current_password,
|
||||
new_password: form.new_password,
|
||||
});
|
||||
setSuccess(true);
|
||||
setForm({ current_password: "", new_password: "", confirm_password: "" });
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Đổi mật khẩu thất bại.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center px-4">
|
||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-md border border-gray-200 p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-gray-400 hover:text-blue-600 transition"
|
||||
title="Quay lại"
|
||||
>
|
||||
<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>
|
||||
<h1 className="text-xl font-bold text-gray-900">Đổi mật khẩu</h1>
|
||||
</div>
|
||||
|
||||
{/* Success */}
|
||||
{success && (
|
||||
<div className="mb-5 bg-green-50 border border-green-200 text-green-700 text-sm px-4 py-3 rounded-lg flex items-center gap-2">
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Đổi mật khẩu thành công!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-5 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>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
{/* Current password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Mật khẩu hiện tại
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={form.current_password}
|
||||
onChange={e => setForm(f => ({ ...f, current_password: e.target.value }))}
|
||||
required
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 pr-10 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="Nhập mật khẩu hiện tại"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(v => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showCurrent
|
||||
? <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
|
||||
: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Mật khẩu mới
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNew ? "text" : "password"}
|
||||
value={form.new_password}
|
||||
onChange={e => setForm(f => ({ ...f, new_password: e.target.value }))}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 pr-10 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="Ít nhất 8 ký tự"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNew(v => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showNew
|
||||
? <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
|
||||
: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
{/* Strength indicator */}
|
||||
{form.new_password && (
|
||||
<div className="mt-1.5 flex gap-1">
|
||||
{[1,2,3,4].map(i => {
|
||||
const len = form.new_password.length;
|
||||
const hasUpper = /[A-Z]/.test(form.new_password);
|
||||
const hasSpecial = /[^A-Za-z0-9]/.test(form.new_password);
|
||||
const score = (len >= 8 ? 1 : 0) + (len >= 12 ? 1 : 0) + (hasUpper ? 1 : 0) + (hasSpecial ? 1 : 0);
|
||||
const active = i <= score;
|
||||
const color = score <= 1 ? "bg-red-400" : score === 2 ? "bg-yellow-400" : score === 3 ? "bg-blue-400" : "bg-green-500";
|
||||
return <div key={i} className={`h-1 flex-1 rounded-full ${active ? color : "bg-gray-200"}`} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Xác nhận mật khẩu mới
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={form.confirm_password}
|
||||
onChange={e => setForm(f => ({ ...f, confirm_password: e.target.value }))}
|
||||
required
|
||||
className={`w-full border rounded-lg px-3 py-2.5 pr-10 text-sm focus:outline-none focus:ring-1 ${
|
||||
form.confirm_password && form.confirm_password !== form.new_password
|
||||
? "border-red-400 focus:border-red-400 focus:ring-red-300"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
}`}
|
||||
placeholder="Nhập lại mật khẩu mới"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm(v => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showConfirm
|
||||
? <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
|
||||
: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
{form.confirm_password && form.confirm_password !== form.new_password && (
|
||||
<p className="mt-1 text-xs text-red-500">Mật khẩu không khớp.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !form.current_password || !form.new_password || !form.confirm_password}
|
||||
className="mt-1 w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white font-semibold text-sm py-2.5 rounded-lg transition"
|
||||
>
|
||||
{loading ? "Đang lưu…" : "Đổi mật khẩu"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,23 @@ export default function DashboardPage() {
|
||||
<span className="font-bold text-lg text-blue-600">PDF LMS</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<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
|
||||
onClick={() => router.push("/change-password")}
|
||||
className="text-sm text-gray-500 hover:text-blue-600 transition"
|
||||
title="Đổi mật khẩu"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-sm text-gray-500 hover:text-red-600 transition"
|
||||
@@ -83,7 +100,7 @@ export default function DashboardPage() {
|
||||
|
||||
{/* ── Main content ───────────────────────────────────────────────── */}
|
||||
<main className="max-w-6xl mx-auto px-4 py-8">
|
||||
<h2 className="text-xl font-bold mb-6 text-gray-800">My PDFs</h2>
|
||||
<h2 className="text-xl font-bold mb-6 text-gray-800">All PDFs</h2>
|
||||
|
||||
<UploadZone onUploaded={handleUploaded} />
|
||||
|
||||
@@ -94,7 +111,7 @@ export default function DashboardPage() {
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{pdfs.map((pdf) => (
|
||||
<PDFCard key={pdf.id} pdf={pdf} onDelete={handleDelete} />
|
||||
<PDFCard key={pdf.id} pdf={pdf} currentUserId={user!.id} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PDFItem } from "@/types";
|
||||
|
||||
interface Props {
|
||||
pdf: PDFItem;
|
||||
currentUserId: number;
|
||||
onDelete: (id: number) => void;
|
||||
}
|
||||
|
||||
@@ -14,8 +15,9 @@ function formatDate(iso: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export default function PDFCard({ pdf, onDelete }: Props) {
|
||||
export default function PDFCard({ pdf, currentUserId, onDelete }: Props) {
|
||||
const router = useRouter();
|
||||
const isOwner = pdf.user_id === currentUserId;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -39,22 +41,29 @@ export default function PDFCard({ pdf, onDelete }: Props) {
|
||||
{pdf.total_pages != null && (
|
||||
<p className="text-xs text-gray-400">{pdf.total_pages} pages</p>
|
||||
)}
|
||||
{!isOwner && (
|
||||
<p className="mt-1.5 text-[10px] text-blue-500 font-medium truncate">
|
||||
by {pdf.owner_username}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete button — visible on hover */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(pdf.id);
|
||||
}}
|
||||
title="Delete"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition bg-white/80 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-lg p-1.5 shadow"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Delete button — only for owner, visible on hover */}
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(pdf.id);
|
||||
}}
|
||||
title="Delete"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition bg-white/80 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-lg p-1.5 shadow"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export default function UploadZone({ onUploaded }: Props) {
|
||||
Drag & drop PDFs here, or{" "}
|
||||
<span className="text-blue-600">click to browse</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400">Multiple files supported · Max 50 MB each</p>
|
||||
<p className="mt-1 text-xs text-gray-400">Multiple files supported · Max 500 MB each</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* useCollaboration
|
||||
*
|
||||
* Manages a WebSocket connection to the backend collaboration room for a PDF.
|
||||
* The hook is intentionally "dumb" about canvas internals — callers supply
|
||||
* callbacks that do the actual Fabric.js work.
|
||||
*
|
||||
* Protocol (all messages are JSON):
|
||||
*
|
||||
* → we send:
|
||||
* { type: "object_add", payload: <fabric JSON object>, page: number }
|
||||
* { type: "object_remove", payload: { obj_id: string }, page: number }
|
||||
* { type: "clear", page: number }
|
||||
* { type: "cursor", payload: { x: number, y: number }, page: number }
|
||||
* { type: "ping" }
|
||||
*
|
||||
* ← we receive (same shapes, plus user_id / username / color injected by server):
|
||||
* { type: "presence", users: CollabUser[] }
|
||||
* { type: "object_add", ..., user_id, username, color }
|
||||
* { type: "object_remove", ..., user_id, username, color }
|
||||
* { type: "clear", ..., user_id, username, color }
|
||||
* { type: "cursor", ..., user_id, username, color }
|
||||
* { type: "pong" }
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface CollabUser {
|
||||
user_id: number;
|
||||
username: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface RemoteEvent {
|
||||
type: "object_add" | "object_remove" | "clear" | "cursor" | "color_sync";
|
||||
payload?: unknown;
|
||||
page?: number;
|
||||
user_id: number;
|
||||
username: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
pdfId: number;
|
||||
/** JWT access token — fetched from /api/auth/me or passed in */
|
||||
token: string | null;
|
||||
onEvent: (event: RemoteEvent) => void;
|
||||
/**
|
||||
* Called whenever a new peer joins the room (presence list grows).
|
||||
* The host should respond by re-broadcasting all their current canvas objects
|
||||
* so late-joining users see pre-existing annotations.
|
||||
*/
|
||||
onPeerJoined?: () => void;
|
||||
}
|
||||
|
||||
const WS_BASE =
|
||||
typeof window !== "undefined"
|
||||
? (window.location.protocol === "https:" ? "wss" : "ws") +
|
||||
"://" +
|
||||
// Replace the port (or add one) to reach the backend directly on 8000
|
||||
window.location.host.replace(/:\d+$/, "") + ":8000"
|
||||
: "ws://localhost:8000";
|
||||
|
||||
const PING_INTERVAL = 25_000; // 25 s keepalive
|
||||
const RECONNECT_DELAY = 3_000; // 3 s reconnect on unexpected close
|
||||
|
||||
export function useCollaboration({ pdfId, token, onEvent, onPeerJoined }: Options) {
|
||||
const [users, setUsers] = useState<CollabUser[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [peerColors, setPeerColors] = useState<Record<number, string>>({});
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const onEventRef = useRef(onEvent);
|
||||
onEventRef.current = onEvent;
|
||||
|
||||
const onPeerJoinedRef = useRef(onPeerJoined);
|
||||
onPeerJoinedRef.current = onPeerJoined;
|
||||
|
||||
// Track previous user count to detect new peers joining
|
||||
const prevUserCountRef = useRef(0);
|
||||
|
||||
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!token || !mountedRef.current) return;
|
||||
|
||||
const url = `${WS_BASE}/ws/pdf/${pdfId}?token=${encodeURIComponent(token)}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mountedRef.current) { ws.close(); return; }
|
||||
setConnected(true);
|
||||
pingTimerRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
|
||||
}, PING_INTERVAL);
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data as string);
|
||||
if (msg.type === "pong") return;
|
||||
if (msg.type === "color_sync") {
|
||||
const { user_id, color } = msg as { user_id: number; color: string };
|
||||
setPeerColors(prev => ({ ...prev, [user_id]: color }));
|
||||
return;
|
||||
}
|
||||
if (msg.type === "presence") {
|
||||
const incoming = (msg.users ?? []) as CollabUser[];
|
||||
setUsers(incoming);
|
||||
// Update peer color map from presence payload (includes our own entry).
|
||||
setPeerColors(
|
||||
incoming.reduce<Record<number, string>>((acc, u) => {
|
||||
acc[u.user_id] = u.color;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
// If someone new joined (count increased) and we're already in the room,
|
||||
// notify the caller so they can re-broadcast their current canvas objects.
|
||||
if (
|
||||
prevUserCountRef.current > 0 &&
|
||||
incoming.length > prevUserCountRef.current
|
||||
) {
|
||||
onPeerJoinedRef.current?.();
|
||||
}
|
||||
prevUserCountRef.current = incoming.length;
|
||||
return;
|
||||
}
|
||||
onEventRef.current(msg as RemoteEvent);
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
if (pingTimerRef.current) clearInterval(pingTimerRef.current);
|
||||
if (mountedRef.current) {
|
||||
reconnectTimerRef.current = setTimeout(connect, RECONNECT_DELAY);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
}, [pdfId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
connect();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (pingTimerRef.current) clearInterval(pingTimerRef.current);
|
||||
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
/** Send an annotation event to all other clients in the room. */
|
||||
const send = useCallback((msg: Record<string, unknown>) => {
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendObjectAdd = useCallback(
|
||||
(fabricObject: object, page: number) =>
|
||||
send({ type: "object_add", payload: fabricObject, page }),
|
||||
[send]
|
||||
);
|
||||
|
||||
const sendObjectRemove = useCallback(
|
||||
(objId: string, page: number) =>
|
||||
send({ type: "object_remove", payload: { obj_id: objId }, page }),
|
||||
[send]
|
||||
);
|
||||
|
||||
const sendClear = useCallback(
|
||||
(page: number) => send({ type: "clear", page }),
|
||||
[send]
|
||||
);
|
||||
|
||||
const sendCursor = useCallback(
|
||||
(x: number, y: number, page: number) =>
|
||||
send({ type: "cursor", payload: { x, y }, page }),
|
||||
[send]
|
||||
);
|
||||
|
||||
const sendColorSync = useCallback(
|
||||
(color: string) => send({ type: "color_sync", color }),
|
||||
[send]
|
||||
);
|
||||
|
||||
return { users, connected, peerColors, sendObjectAdd, sendObjectRemove, sendClear, sendCursor, sendColorSync };
|
||||
}
|
||||
@@ -30,6 +30,8 @@ export const api = {
|
||||
register: (body: { username: string; email: string; password: string }) =>
|
||||
request<import("@/types").User>("/api/auth/register", { method: "POST", body: JSON.stringify(body) }),
|
||||
logout: () => request<void>("/api/auth/logout", { method: "POST" }),
|
||||
changePassword: (body: { current_password: string; new_password: string }) =>
|
||||
request<void>("/api/auth/change-password", { method: "POST", body: JSON.stringify(body) }),
|
||||
|
||||
// PDFs
|
||||
listPdfs: () => request<import("@/types").PDFItem[]>("/api/pdfs"),
|
||||
@@ -40,9 +42,63 @@ export const api = {
|
||||
// Annotations
|
||||
getAnnotation: (pdfId: number, page: number) =>
|
||||
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}`),
|
||||
getAllAnnotations: (pdfId: number, page: number) =>
|
||||
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}/all`),
|
||||
upsertAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) =>
|
||||
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
upsertTempAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) =>
|
||||
request<void>(`/api/annotations/${pdfId}/${page}/temp`, {
|
||||
method: "PUT",
|
||||
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" }),
|
||||
|
||||
// Backup / Restore
|
||||
/** Triggers pg_dump + file pack; returns a Blob for the browser to download. */
|
||||
adminDownloadBackup: async (): Promise<Blob> => {
|
||||
const res = await fetch("/api/admin/backup/download", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(detail?.detail ?? "Backup failed");
|
||||
}
|
||||
return res.blob();
|
||||
},
|
||||
|
||||
/** Upload a backup ZIP to restore the system. */
|
||||
adminRestoreBackup: async (file: File): Promise<{ message: string; backup_created_at: string }> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch("/api/admin/backup/restore", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(detail?.detail ?? "Restore failed");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export interface PDFItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
owner_username: string;
|
||||
title: string;
|
||||
total_pages: number | null;
|
||||
created_at: string;
|
||||
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
declare module "fabric" {
|
||||
export const fabric: any;
|
||||
}
|
||||
|
||||
declare module "pdfjs-dist" {
|
||||
export const GlobalWorkerOptions: {
|
||||
workerSrc: string;
|
||||
};
|
||||
export const version: string;
|
||||
export function getDocument(params: any): {
|
||||
promise: Promise<any>;
|
||||
};
|
||||
}
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/bin/bash
|
||||
# Push LMS images to GitHub Container Registry (GHCR)
|
||||
# Auto-bumps the patch version in VERSION file on every successful push.
|
||||
#
|
||||
# Usage:
|
||||
# ./push-ghcr.sh # auto-bump patch: 1.2.3 → 1.2.4
|
||||
# ./push-ghcr.sh minor # bump minor: 1.2.3 → 1.3.0
|
||||
# ./push-ghcr.sh major # bump major: 1.2.3 → 2.0.0
|
||||
# ./push-ghcr.sh 2.5.1 # use exact version (no auto-bump)
|
||||
# ./push-ghcr.sh --no-cache # force full Docker rebuild
|
||||
#
|
||||
# Reads GITHUB_USER and GITHUB_TOKEN from .env
|
||||
# Version is stored in ./VERSION
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# ── Colour helpers ────────────────────────────────────────────────────────────
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
ok() { echo -e "${GREEN}✔${RESET} $*"; }
|
||||
info() { echo -e "${CYAN}▶${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}⚠${RESET} $*"; }
|
||||
fail() { echo -e "\033[0;31m✘${RESET} $*" >&2; exit 1; }
|
||||
step() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}"; }
|
||||
|
||||
# ── Parse arguments ───────────────────────────────────────────────────────────
|
||||
BUMP_TYPE="patch"
|
||||
EXPLICIT_TAG=""
|
||||
NO_CACHE=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
major|minor|patch) BUMP_TYPE=$arg ;;
|
||||
--no-cache) NO_CACHE="--no-cache" ;;
|
||||
[0-9]*.[0-9]*.[0-9]*) EXPLICIT_TAG=$arg ;; # exact semver passed
|
||||
*) fail "Unknown argument: $arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Load .env ─────────────────────────────────────────────────────────────────
|
||||
[ -f .env ] || fail ".env not found. Copy .env.example → .env first."
|
||||
# shellcheck disable=SC2046
|
||||
export $(grep -v '^#' .env | grep -E 'GITHUB_USER|GITHUB_TOKEN' | xargs)
|
||||
|
||||
GITHUB_USER="${GITHUB_USER:?GITHUB_USER not set in .env}"
|
||||
GITHUB_TOKEN="${GITHUB_TOKEN:?GITHUB_TOKEN not set in .env}"
|
||||
|
||||
# ── Read current version ──────────────────────────────────────────────────────
|
||||
VERSION_FILE="VERSION"
|
||||
[ -f "$VERSION_FILE" ] || echo "1.0.0" > "$VERSION_FILE"
|
||||
|
||||
CURRENT=$(cat "$VERSION_FILE" | tr -d '[:space:]')
|
||||
if ! [[ $CURRENT =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
fail "VERSION file contains invalid semver: '$CURRENT'. Expected format: X.Y.Z"
|
||||
fi
|
||||
|
||||
IFS='.' read -r V_MAJOR V_MINOR V_PATCH <<< "$CURRENT"
|
||||
|
||||
# ── Compute new version ───────────────────────────────────────────────────────
|
||||
if [ -n "$EXPLICIT_TAG" ]; then
|
||||
NEW_VERSION="$EXPLICIT_TAG"
|
||||
info "Using explicit version: ${BOLD}$NEW_VERSION${RESET}"
|
||||
else
|
||||
case $BUMP_TYPE in
|
||||
major) NEW_VERSION="$((V_MAJOR+1)).0.0" ;;
|
||||
minor) NEW_VERSION="${V_MAJOR}.$((V_MINOR+1)).0" ;;
|
||||
patch) NEW_VERSION="${V_MAJOR}.${V_MINOR}.$((V_PATCH+1))" ;;
|
||||
esac
|
||||
info "Bumping ${BUMP_TYPE}: ${BOLD}${CURRENT}${RESET} → ${BOLD}${NEW_VERSION}${RESET}"
|
||||
fi
|
||||
|
||||
# ── Image names ───────────────────────────────────────────────────────────────
|
||||
REGISTRY="ghcr.io/${GITHUB_USER}"
|
||||
BACKEND_VERSIONED="${REGISTRY}/lms-backend:${NEW_VERSION}"
|
||||
FRONTEND_VERSIONED="${REGISTRY}/lms-frontend:${NEW_VERSION}"
|
||||
BACKEND_LATEST="${REGISTRY}/lms-backend:latest"
|
||||
FRONTEND_LATEST="${REGISTRY}/lms-frontend:latest"
|
||||
|
||||
step "LMS → GHCR Push"
|
||||
echo -e " Version : ${BOLD}${NEW_VERSION}${RESET}"
|
||||
echo -e " Backend : ${BACKEND_VERSIONED}"
|
||||
echo -e " Frontend : ${FRONTEND_VERSIONED}"
|
||||
|
||||
# ── 1. Login to GHCR ─────────────────────────────────────────────────────────
|
||||
step "Login to ghcr.io"
|
||||
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_USER" --password-stdin
|
||||
ok "Logged in"
|
||||
|
||||
# ── 2. Build images ───────────────────────────────────────────────────────────
|
||||
step "Building images"
|
||||
[ -n "$NO_CACHE" ] && warn "Building without layer cache"
|
||||
# shellcheck disable=SC2086
|
||||
docker compose build $NO_CACHE
|
||||
ok "Build complete"
|
||||
|
||||
# ── 3. Tag versioned + latest ─────────────────────────────────────────────────
|
||||
step "Tagging images"
|
||||
docker tag lms-backend:latest "$BACKEND_VERSIONED"
|
||||
docker tag lms-frontend:latest "$FRONTEND_VERSIONED"
|
||||
docker tag lms-backend:latest "$BACKEND_LATEST"
|
||||
docker tag lms-frontend:latest "$FRONTEND_LATEST"
|
||||
ok "Tagged ${NEW_VERSION} + latest"
|
||||
|
||||
# ── 4. Push versioned + latest ────────────────────────────────────────────────
|
||||
step "Pushing to GHCR"
|
||||
docker push "$BACKEND_VERSIONED"
|
||||
docker push "$FRONTEND_VERSIONED"
|
||||
docker push "$BACKEND_LATEST"
|
||||
docker push "$FRONTEND_LATEST"
|
||||
ok "Push complete"
|
||||
|
||||
# ── 5. Save new version (only after successful push) ─────────────────────────
|
||||
if [ -z "$EXPLICIT_TAG" ]; then
|
||||
echo "$NEW_VERSION" > "$VERSION_FILE"
|
||||
|
||||
# Commit VERSION bump if inside a git repo
|
||||
if git rev-parse --git-dir &>/dev/null; then
|
||||
git add "$VERSION_FILE"
|
||||
git commit -m "chore: bump version ${CURRENT} → ${NEW_VERSION}" --no-verify 2>/dev/null \
|
||||
&& ok "Committed VERSION bump to git" \
|
||||
|| warn "VERSION updated locally but git commit skipped (nothing to commit or no git user configured)"
|
||||
else
|
||||
ok "VERSION file updated to ${NEW_VERSION}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${BOLD}${GREEN}══════════════════════════════════════════════════════${RESET}"
|
||||
echo -e "${BOLD}${GREEN} ✅ Images pushed successfully — v${NEW_VERSION}${RESET}"
|
||||
echo -e "${BOLD}${GREEN}══════════════════════════════════════════════════════${RESET}"
|
||||
echo -e " ${BOLD}Versioned tags:${RESET}"
|
||||
echo " $BACKEND_VERSIONED"
|
||||
echo " $FRONTEND_VERSIONED"
|
||||
echo -e " ${BOLD}Latest tags also updated:${RESET}"
|
||||
echo " $BACKEND_LATEST"
|
||||
echo " $FRONTEND_LATEST"
|
||||
echo ""
|
||||
echo -e " ${BOLD}📋 Add to .env on target machine:${RESET}"
|
||||
echo " BACKEND_IMAGE=${BACKEND_VERSIONED}"
|
||||
echo " FRONTEND_IMAGE=${FRONTEND_VERSIONED}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}🚀 Deploy on target machine:${RESET}"
|
||||
echo " docker compose pull && docker compose up -d"
|
||||
@@ -51,4 +51,102 @@ Please use the following technologies for this project:
|
||||
Let's build this step-by-step to avoid context limits.
|
||||
1. First, analyze this spec and confirm you understand.
|
||||
2. Provide the database schema models (SQLAlchemy or Prisma).
|
||||
3. Wait for my confirmation before writing the Backend API routes.
|
||||
3. Wait for my confirmation before writing the Backend API routes.
|
||||
|
||||
---
|
||||
|
||||
## 7. CI/CD — Push lên GitHub Container Registry (GHCR)
|
||||
|
||||
### 7.1. Yêu cầu
|
||||
|
||||
- **Docker** đã cài và đang chạy
|
||||
- **GitHub Personal Access Token (PAT)** với quyền `write:packages` và `read:packages`
|
||||
- Tạo tại: https://github.com/settings/tokens → *Generate new token (classic)*
|
||||
- File `.env` đã được cấu hình (xem `.env.example`)
|
||||
|
||||
### 7.2. Cấu hình `.env`
|
||||
|
||||
Thêm các dòng sau vào file `.env` (file này đã được gitignore, **không commit**):
|
||||
|
||||
```env
|
||||
GITHUB_USER=<github_username_của_bạn>
|
||||
GITHUB_TOKEN=<personal_access_token>
|
||||
BACKEND_IMAGE=ghcr.io/<github_username>/lms-backend:latest
|
||||
FRONTEND_IMAGE=ghcr.io/<github_username>/lms-frontend:latest
|
||||
```
|
||||
|
||||
### 7.3. Build & Push lên GHCR
|
||||
|
||||
```bash
|
||||
chmod +x push-ghcr.sh
|
||||
./push-ghcr.sh
|
||||
```
|
||||
|
||||
Script sẽ tự động:
|
||||
1. Đọc `GITHUB_USER` và `GITHUB_TOKEN` từ `.env`
|
||||
2. Đăng nhập vào `ghcr.io`
|
||||
3. Build cả hai image (`lms-backend`, `lms-frontend`) bằng `docker compose build`
|
||||
4. Tag và push lên GHCR
|
||||
|
||||
Để push với tag cụ thể (ví dụ: version):
|
||||
```bash
|
||||
./push-ghcr.sh v1.0.0
|
||||
```
|
||||
|
||||
### 7.4. Deploy trên máy khác
|
||||
|
||||
Trên máy đích (server, VPS, máy tính khác):
|
||||
|
||||
```bash
|
||||
# 1. Copy các file cần thiết
|
||||
scp docker-compose.yml .env.example deploy.sh user@server:/opt/lms/
|
||||
ssh user@server
|
||||
|
||||
# 2. Tạo .env từ example
|
||||
cd /opt/lms
|
||||
cp .env.example .env
|
||||
# Điền các giá trị: POSTGRES_PASSWORD, JWT_SECRET_KEY, GITHUB_USER, GITHUB_TOKEN,
|
||||
# BACKEND_IMAGE, FRONTEND_IMAGE
|
||||
|
||||
# 3. Chạy deploy
|
||||
chmod +x deploy.sh
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
Script `deploy.sh` sẽ:
|
||||
1. Kiểm tra `.env` hợp lệ
|
||||
2. Đăng nhập GHCR (nếu có token)
|
||||
3. Pull image từ GHCR
|
||||
4. Khởi động toàn bộ stack: `db`, `backend`, `frontend`
|
||||
|
||||
### 7.5. Kiểm tra packages trên GitHub
|
||||
|
||||
Sau khi push, image sẽ xuất hiện tại:
|
||||
```
|
||||
https://github.com/<github_username>?tab=packages
|
||||
```
|
||||
|
||||
> **Lưu ý:** Mặc định packages ở chế độ **Private**. Để máy khác pull mà không cần token, vào
|
||||
> GitHub → Packages → tên package → *Package settings* → đổi visibility sang **Public**.
|
||||
|
||||
### 7.6. Sinh JWT Secret Key
|
||||
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
|
||||
Field Value
|
||||
username admin
|
||||
password Admin@12345
|
||||
role admin
|
||||
status approved
|
||||
|
||||
|
||||
Lệnh Kết quả tag
|
||||
./push-ghcr.sh 1.0.0 → 1.0.1 (tự động tăng patch)
|
||||
./push-ghcr.sh minor 1.0.0 → 1.1.0
|
||||
./push-ghcr.sh major 1.0.0 → 2.0.0
|
||||
./push-ghcr.sh 1.5.0 đúng 1.5.0, không auto-bump
|
||||
./push-ghcr.sh --no-cache rebuild + auto-bump patch
|
||||
./push-ghcr.sh minor --no-cache bump minor + rebuild từ đầu
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/bin/bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# start-dev.sh — Start LMS in local development mode
|
||||
# Runs Backend (uvicorn) + Frontend (next dev) in parallel
|
||||
# Usage:
|
||||
# ./start-dev.sh # start both
|
||||
# ./start-dev.sh backend # start backend only
|
||||
# ./start-dev.sh frontend # start frontend only
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# ── Load .env ─────────────────────────────────────────────────────────────────
|
||||
if [ ! -f .env ]; then
|
||||
echo "❌ .env not found. Copy .env.example → .env and fill in values."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Export only the variables we need (skip GHCR secrets)
|
||||
export $(grep -v '^#' .env | grep -E 'POSTGRES_(DB|USER|PASSWORD)|JWT_SECRET_KEY|ACCESS_TOKEN_EXPIRE_MINUTES|FRONTEND_PORT' | xargs)
|
||||
|
||||
# Defaults
|
||||
POSTGRES_DB="${POSTGRES_DB:-lms_db}"
|
||||
POSTGRES_USER="${POSTGRES_USER:-lms_user}"
|
||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-lms_test_password_123}"
|
||||
JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY is not set in .env}"
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
|
||||
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
||||
|
||||
DATABASE_URL="postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}"
|
||||
UPLOAD_DIR="./backend/uploads"
|
||||
|
||||
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
|
||||
|
||||
TARGET="${1:-both}"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
start_backend() {
|
||||
echo -e "${CYAN}${BOLD}▶ Starting Backend${NC} (port ${BACKEND_PORT})"
|
||||
|
||||
# Check Python venv
|
||||
VENV=""
|
||||
if [ -d backend/.venv ]; then
|
||||
VENV="backend/.venv/bin/"
|
||||
elif [ -d backend/venv ]; then
|
||||
VENV="backend/venv/bin/"
|
||||
fi
|
||||
|
||||
# Run Alembic migrations first
|
||||
echo -e "${YELLOW} Running Alembic migrations...${NC}"
|
||||
(
|
||||
cd backend
|
||||
DATABASE_URL="$DATABASE_URL" \
|
||||
${VENV}alembic upgrade head 2>&1 | sed 's/^/ [alembic] /'
|
||||
)
|
||||
|
||||
mkdir -p "$UPLOAD_DIR"
|
||||
|
||||
echo -e "${GREEN} Backend ready → http://localhost:${BACKEND_PORT}${NC}"
|
||||
echo -e "${GREEN} API docs → http://localhost:${BACKEND_PORT}/docs${NC}"
|
||||
echo ""
|
||||
|
||||
DATABASE_URL="$DATABASE_URL" \
|
||||
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES="$ACCESS_TOKEN_EXPIRE_MINUTES" \
|
||||
UPLOAD_DIR="$UPLOAD_DIR" \
|
||||
REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}" \
|
||||
${VENV}uvicorn main:app \
|
||||
--host 0.0.0.0 \
|
||||
--port "$BACKEND_PORT" \
|
||||
--reload \
|
||||
--app-dir backend
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
start_frontend() {
|
||||
echo -e "${CYAN}${BOLD}▶ Starting Frontend${NC} (port ${FRONTEND_PORT})"
|
||||
|
||||
if [ ! -d frontend/node_modules ]; then
|
||||
echo -e "${YELLOW} node_modules not found — running npm install...${NC}"
|
||||
(cd frontend && npm install)
|
||||
fi
|
||||
|
||||
echo -e "${GREEN} Frontend ready → http://localhost:${FRONTEND_PORT}${NC}"
|
||||
echo ""
|
||||
|
||||
NEXT_PUBLIC_API_URL="http://localhost:${BACKEND_PORT}" \
|
||||
PORT="$FRONTEND_PORT" \
|
||||
npm --prefix frontend run dev
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Entrypoint
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${BOLD}╔══════════════════════════════════════╗${NC}"
|
||||
echo -e "${BOLD}║ LMS — Dev Server ║${NC}"
|
||||
echo -e "${BOLD}╚══════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
case "$TARGET" in
|
||||
backend)
|
||||
start_backend
|
||||
;;
|
||||
frontend)
|
||||
start_frontend
|
||||
;;
|
||||
both)
|
||||
# Run backend in background, frontend in foreground
|
||||
# Trap Ctrl+C to kill both
|
||||
trap 'echo -e "\n${RED}Stopping...${NC}"; kill 0' INT TERM
|
||||
|
||||
start_backend &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# Small delay so backend logs don't jumble with frontend boot
|
||||
sleep 2
|
||||
|
||||
start_frontend &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Running. Press Ctrl+C to stop both servers.${NC}"
|
||||
echo ""
|
||||
|
||||
wait $BACKEND_PID $FRONTEND_PID
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [both|backend|frontend]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user