([]);
@@ -225,6 +227,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
useEffect(() => {
api.me().then((u) => {
currentUserIdRef.current = u.id;
+ setUserRole(u.role);
+ userRoleRef.current = u.role;
// Assign deterministic color from preset palette based on user id
const assigned = userIdToPresetColor(u.id);
setPenColor(assigned);
@@ -299,11 +303,12 @@ export default function WorkbookViewer({ pdfId }: Props) {
skipRemoteRef.current = true;
skipObjectTracking.current = true;
fabric.util.enlivenObjects([objJson], (objects: any[]) => {
+ const canInteract = userRoleRef.current !== "student";
objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id;
obj._isRemote = true; // don't save other users' live strokes under our account
- obj.selectable = false;
- obj.evented = false;
+ obj.selectable = canInteract;
+ obj.evented = canInteract;
fc.add(obj);
});
fc.renderAll();
@@ -481,12 +486,13 @@ export default function WorkbookViewer({ pdfId }: Props) {
// Mark objects from other users as remote — prevents saving them under current user
const uid = currentUserIdRef.current;
if (uid !== null) {
+ const canInteract = userRoleRef.current !== "student";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fc.getObjects().forEach((obj: any) => {
if (obj._owner_id != null && obj._owner_id !== uid) {
obj._isRemote = true;
- obj.selectable = false;
- obj.evented = false;
+ obj.selectable = canInteract;
+ obj.evented = canInteract;
}
});
}
@@ -512,11 +518,12 @@ export default function WorkbookViewer({ pdfId }: Props) {
skipRemoteRef.current = true;
skipObjectTracking.current = true;
fabric.util.enlivenObjects([objJson], (objects: any[]) => {
+ const canInteract = userRoleRef.current !== "student";
objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id;
obj._isRemote = true;
- obj.selectable = false;
- obj.evented = false;
+ obj.selectable = canInteract;
+ obj.evented = canInteract;
fc.add(obj);
});
fc.renderAll();
@@ -618,12 +625,13 @@ export default function WorkbookViewer({ pdfId }: Props) {
// Mark objects from other users as remote
const uid = currentUserIdRef.current;
if (uid !== null) {
+ const canInteract = userRoleRef.current !== "student";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fc.getObjects().forEach((obj: any) => {
if (obj._owner_id != null && obj._owner_id !== uid) {
obj._isRemote = true;
- obj.selectable = false;
- obj.evented = false;
+ obj.selectable = canInteract;
+ obj.evented = canInteract;
}
});
}
@@ -899,9 +907,15 @@ export default function WorkbookViewer({ pdfId }: Props) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fc.on("mouse:down", (options: any) => {
if (!options.target) return;
- const collab_id = options.target.collab_id as string | undefined;
- fc.remove(options.target);
- addedObjects.current = addedObjects.current.filter(o => o !== options.target);
+ const obj = options.target as any;
+ const ownerId: number | undefined = obj._owner_id;
+ const uid = currentUserIdRef.current;
+ const role = userRoleRef.current;
+ // Students can only erase their own annotations
+ if (role === "student" && (obj._isRemote || (ownerId != null && ownerId !== uid))) return;
+ const collab_id = obj.collab_id as string | undefined;
+ fc.remove(obj);
+ addedObjects.current = addedObjects.current.filter(o => o !== obj);
fc.renderAll();
if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
});
@@ -947,8 +961,14 @@ export default function WorkbookViewer({ pdfId }: Props) {
fc.isDrawingMode = false; fc.selection = false;
fc.on("mouse:down", (options: any) => {
if (!options.target) return;
- const collab_id = options.target.collab_id as string | undefined;
- fc.remove(options.target); fc.renderAll();
+ const obj = options.target as any;
+ const ownerId: number | undefined = obj._owner_id;
+ const uid = currentUserIdRef.current;
+ const role = userRoleRef.current;
+ // Students can only erase their own annotations
+ if (role === "student" && (obj._isRemote || (ownerId != null && ownerId !== uid))) return;
+ const collab_id = obj.collab_id as string | undefined;
+ fc.remove(obj); fc.renderAll();
if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
}); break;
}
@@ -1031,13 +1051,33 @@ export default function WorkbookViewer({ pdfId }: Props) {
const handleClear = useCallback(() => {
const fc = fabricRef.current;
if (!fc) return;
- if (!confirm("Clear all annotations on this page?")) return;
- fc.clear();
- addedObjects.current = [];
- fc.renderAll();
- collabSendRef.current?.clear(currentPageRef.current);
- // Push empty canvas to Redis so other users see the clear immediately on next load
- syncTempCanvasRef.current(currentPageRef.current);
+
+ const role = userRoleRef.current;
+ const uid = currentUserIdRef.current;
+
+ if (role === "student") {
+ // Students: only remove their own objects, leave others untouched
+ if (!confirm("Xoá annotation của bạn trên trang này?")) return;
+ const toRemove = fc.getObjects().filter((o: any) => !o._isRemote && (o._owner_id == null || o._owner_id === uid));
+ toRemove.forEach((o: any) => {
+ fc.remove(o);
+ // Broadcast removal so teacher canvas updates in real time
+ const collab_id = o.collab_id as string | undefined;
+ if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
+ });
+ addedObjects.current = addedObjects.current.filter(o => toRemove.indexOf(o) === -1);
+ fc.renderAll();
+ // Sync own cleared state to Redis
+ syncTempCanvasRef.current(currentPageRef.current);
+ } else {
+ // Teacher / Admin: clear everything
+ if (!confirm("Xoá tất cả annotation trên trang này?")) return;
+ fc.clear();
+ addedObjects.current = [];
+ fc.renderAll();
+ collabSendRef.current?.clear(currentPageRef.current);
+ syncTempCanvasRef.current(currentPageRef.current);
+ }
}, []);
// ─────────────────────────────────────────────────────────────────────────
@@ -1317,16 +1357,50 @@ export default function WorkbookViewer({ pdfId }: Props) {
{collabUsers.length > 0 && (
- {collabUsers.slice(0, 5).map(u => (
-
- {u.username[0]}
-
- ))}
+ {collabUsers.slice(0, 5).map(u => {
+ const isSelf = u.user_id === currentUserIdRef.current;
+ const canClear = !isSelf && (userRole === "admin" || userRole === "teacher");
+ return (
+
+
+ {u.username[0]}
+
+ {canClear && (
+ {
+ if (!confirm(`Xoá annotation của "${u.username}" trên trang ${currentPageRef.current}?`)) return;
+ try {
+ await api.clearUserAnnotation(pdfId, currentPageRef.current, u.user_id);
+ // Remove their objects from canvas
+ const removeFromCanvas = (fc: any) => {
+ const toRemove = fc.getObjects().filter((o: any) => o._owner_id === u.user_id);
+ toRemove.forEach((o: any) => fc.remove(o));
+ if (toRemove.length) fc.renderAll();
+ };
+ if (scrollModeRef.current === "continuous") {
+ pageFabricRefs.current.forEach(fc => { if (fc) removeFromCanvas(fc); });
+ } else if (fabricRef.current) {
+ removeFromCanvas(fabricRef.current);
+ }
+ localAnnotations.current = {};
+ } catch (e: unknown) {
+ alert(e instanceof Error ? e.message : "Xoá thất bại.");
+ }
+ }}
+ title={`Xoá annotation của ${u.username} trang này`}
+ className="absolute -top-1.5 -right-1.5 w-3.5 h-3.5 rounded-full bg-red-500 text-white hidden group-hover:flex items-center justify-center shadow z-10"
+ style={{ fontSize: 8 }}
+ >
+ ✕
+
+ )}
+
+ );
+ })}
{collabUsers.length > 5 && (
+{collabUsers.length - 5}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 31a99b7..8427164 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -52,4 +52,24 @@ export const api = {
method: "PUT",
body: JSON.stringify(body),
}),
+ deleteAnnotation: (annotationId: number) =>
+ request(`/api/annotations/${annotationId}`, { method: "DELETE" }),
+ clearUserAnnotation: (pdfId: number, page: number, userId: number) =>
+ request(`/api/annotations/${pdfId}/${page}/user/${userId}`, { method: "DELETE" }),
+
+ // Admin
+ adminListUsers: () =>
+ request("/api/admin/users"),
+ adminUpdateStatus: (userId: number, status: import("@/types").UserStatus) =>
+ request(`/api/admin/users/${userId}/status`, {
+ method: "PATCH",
+ body: JSON.stringify({ status }),
+ }),
+ adminUpdateRole: (userId: number, role: import("@/types").UserRole) =>
+ request(`/api/admin/users/${userId}/role`, {
+ method: "PATCH",
+ body: JSON.stringify({ role }),
+ }),
+ adminDeleteUser: (userId: number) =>
+ request(`/api/admin/users/${userId}`, { method: "DELETE" }),
};
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index b0b7497..6ec0905 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -1,7 +1,12 @@
+export type UserRole = "admin" | "teacher" | "student";
+export type UserStatus = "pending" | "approved" | "rejected";
+
export interface User {
id: number;
username: string;
email: string;
+ role: UserRole;
+ status: UserStatus;
created_at: string;
}
diff --git a/readme.md b/readme.md
index f3f2846..60d7c79 100644
--- a/readme.md
+++ b/readme.md
@@ -133,4 +133,11 @@ https://github.com/?tab=packages
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
-```
\ No newline at end of file
+```
+
+
+Field Value
+username admin
+password Admin@12345
+role admin
+status approved
From 217e2f97122cc8aed9a02b2dd95e85ee59198e59 Mon Sep 17 00:00:00 2001
From: hienp
Date: Wed, 1 Apr 2026 20:13:48 +0700
Subject: [PATCH 07/11] =?UTF-8?q?ho=C3=A0n=20th=C3=A0nh=20ch=E1=BB=A9c=20n?=
=?UTF-8?q?=C4=83ng=20hi=E1=BB=87n=20username=20annotation=20c=E1=BB=A7a?=
=?UTF-8?q?=20t=E1=BB=ABng=20user?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/routers/auth.py | 16 +-
backend/app/schemas.py | 12 ++
frontend/src/app/admin/page.tsx | 9 +
frontend/src/app/change-password/page.tsx | 200 +++++++++++++++++++++
frontend/src/app/dashboard/page.tsx | 9 +
frontend/src/components/WorkbookViewer.tsx | 138 +++++++++++++-
frontend/src/lib/api.ts | 2 +
7 files changed, 376 insertions(+), 10 deletions(-)
create mode 100644 frontend/src/app/change-password/page.tsx
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index 5f1d3be..317812f 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -5,7 +5,7 @@ import os
from ..database import get_db
from ..dependencies import get_current_user
from ..models import User, UserRole, UserStatus
-from ..schemas import UserLogin, UserOut, UserRegister, UserApprove
+from ..schemas import UserLogin, UserOut, UserRegister, UserApprove, ChangePassword
from ..security import (
ACCESS_TOKEN_EXPIRE_MINUTES,
create_access_token,
@@ -117,3 +117,17 @@ def get_token(access_token: str | None = Cookie(default=None)):
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()
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 347b4f5..b5e77cf 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -55,6 +55,18 @@ 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
diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx
index d3f54af..2b66ba1 100644
--- a/frontend/src/app/admin/page.tsx
+++ b/frontend/src/app/admin/page.tsx
@@ -151,6 +151,15 @@ export default function AdminPage() {
{me?.username}
+
router.push("/change-password")}
+ className="text-sm text-gray-500 hover:text-blue-600 transition"
+ title="Đổi mật khẩu"
+ >
+
+
+
+
{ await api.logout(); router.replace("/login"); }}
className="text-sm text-gray-500 hover:text-red-600 transition"
diff --git a/frontend/src/app/change-password/page.tsx b/frontend/src/app/change-password/page.tsx
new file mode 100644
index 0000000..7031de2
--- /dev/null
+++ b/frontend/src/app/change-password/page.tsx
@@ -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 (
+
+
+ {/* Header */}
+
+
router.back()}
+ className="text-gray-400 hover:text-blue-600 transition"
+ title="Quay lại"
+ >
+
+
+
+
+
Đổi mật khẩu
+
+
+ {/* Success */}
+ {success && (
+
+
+
+
+ Đổi mật khẩu thành công!
+
+ )}
+
+ {/* Error */}
+ {error && (
+
+ {error}
+ setError("")} className="ml-3 text-red-400 hover:text-red-600">✕
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx
index 988668d..87bd68c 100644
--- a/frontend/src/app/dashboard/page.tsx
+++ b/frontend/src/app/dashboard/page.tsx
@@ -79,6 +79,15 @@ export default function DashboardPage() {
Quản trị
)}
+
router.push("/change-password")}
+ className="text-sm text-gray-500 hover:text-blue-600 transition"
+ title="Đổi mật khẩu"
+ >
+
+
+
+
(null); // populated from api.me(); used to identify own vs remote objects
const [userRole, setUserRole] = useState<"admin" | "teacher" | "student">("student");
const userRoleRef = useRef<"admin" | "teacher" | "student">("student");
+ const [currentUsername, setCurrentUsername] = useState("");
+ const currentUsernameRef = useRef("");
+ const userMapRef = useRef>(new Map());
+ const [annotationTooltip, setAnnotationTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
// Buffer for remote events that arrive while renderPageWithAnnotations is in progress
const renderingRef = useRef(false);
const pendingRemoteEvents = useRef([]);
@@ -177,8 +181,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const syncTempCanvasRef = useRef<(page: number) => void>(() => {});
- // Keep currentPageRef in sync
- useEffect(() => { currentPageRef.current = currentPage; }, [currentPage]);
+ // Keep currentPageRef in sync; clear any lingering hover tooltip on page navigation
+ useEffect(() => { currentPageRef.current = currentPage; setAnnotationTooltip(null); }, [currentPage]);
// Keep mutable refs in sync
useEffect(() => { currentToolRef.current = tool; }, [tool]);
@@ -186,6 +190,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
useEffect(() => { strokeWidthRef.current = strokeWidth; }, [strokeWidth]);
useEffect(() => { fitModeRef.current = fitMode; }, [fitMode]);
useEffect(() => { scrollModeRef.current = scrollMode; }, [scrollMode]);
+ useEffect(() => { currentUsernameRef.current = currentUsername; }, [currentUsername]);
// Keep syncTempCanvasRef current so closures inside Fabric events always see latest pdfId
useEffect(() => {
@@ -193,7 +198,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
const fc = fabricRef.current;
if (!fc) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const fullData: any = fc.toJSON(["_owner_id"]);
+ const fullData: any = fc.toJSON(["_owner_id", "_owner_username"]);
const uid = currentUserIdRef.current;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ownObjects = ((fullData.objects as any[]) ?? []).filter(
@@ -229,6 +234,9 @@ export default function WorkbookViewer({ pdfId }: Props) {
currentUserIdRef.current = u.id;
setUserRole(u.role);
userRoleRef.current = u.role;
+ setCurrentUsername(u.username);
+ currentUsernameRef.current = u.username;
+ userMapRef.current.set(u.id, u.username);
// Assign deterministic color from preset palette based on user id
const assigned = userIdToPresetColor(u.id);
setPenColor(assigned);
@@ -307,6 +315,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id;
obj._isRemote = true; // don't save other users' live strokes under our account
+ if ((objJson as any)._owner_username) obj._owner_username = (objJson as any)._owner_username;
obj.selectable = canInteract;
obj.evented = canInteract;
fc.add(obj);
@@ -317,7 +326,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
});
return;
}
- }, []);
+ }, []);;
// When a new peer joins the room, broadcast all our own (non-remote) canvas
// objects so they receive our pre-existing annotations immediately.
@@ -358,6 +367,11 @@ export default function WorkbookViewer({ pdfId }: Props) {
collabSendRef.current = { objectAdd: sendObjectAdd, objectRemove: sendObjectRemove, clear: sendClear, colorSync: sendColorSync };
}, [sendObjectAdd, sendObjectRemove, sendClear, sendColorSync]);
+ // Keep user id→name map updated as peers connect/disconnect
+ useEffect(() => {
+ collabUsers.forEach(u => userMapRef.current.set(u.user_id, u.username));
+ }, [collabUsers]);
+
// Broadcast pen color only when BOTH WS is connected AND user color has been fetched.
// This prevents both users broadcasting the same default "#e63946" before api.me() resolves.
// Also re-broadcasts whenever the user manually picks a new color.
@@ -481,8 +495,17 @@ export default function WorkbookViewer({ pdfId }: Props) {
addedObjects.current = [];
if (annotationData) {
+ const rawObjs: any[] = ((annotationData as any).objects ?? []);
await new Promise((resolve) => {
fc.loadFromJSON(annotationData, () => {
+ // Enforce custom properties — Fabric may not restore underscore-prefixed props
+ fc.getObjects().forEach((obj: any, i: number) => {
+ const raw = rawObjs[i];
+ if (raw) {
+ if (obj._owner_id === undefined) obj._owner_id = raw._owner_id ?? null;
+ if (obj._owner_username === undefined) obj._owner_username = raw._owner_username ?? null;
+ }
+ });
// Mark objects from other users as remote — prevents saving them under current user
const uid = currentUserIdRef.current;
if (uid !== null) {
@@ -493,6 +516,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
obj._isRemote = true;
obj.selectable = canInteract;
obj.evented = canInteract;
+ if (!obj._owner_username) obj._owner_username = userMapRef.current.get(obj._owner_id) ?? null;
}
});
}
@@ -522,6 +546,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id;
obj._isRemote = true;
+ if ((objJson as any)._owner_username) obj._owner_username = (objJson as any)._owner_username;
obj.selectable = canInteract;
obj.evented = canInteract;
fc.add(obj);
@@ -620,8 +645,17 @@ export default function WorkbookViewer({ pdfId }: Props) {
} catch { /* no annotation */ }
}
if (annotationData) {
+ const rawObjsCont: any[] = ((annotationData as any).objects ?? []);
await new Promise((resolve) => {
fc.loadFromJSON(annotationData, () => {
+ // Enforce custom properties — Fabric may not restore underscore-prefixed props
+ fc.getObjects().forEach((obj: any, i: number) => {
+ const raw = rawObjsCont[i];
+ if (raw) {
+ if (obj._owner_id === undefined) obj._owner_id = raw._owner_id ?? null;
+ if (obj._owner_username === undefined) obj._owner_username = raw._owner_username ?? null;
+ }
+ });
// Mark objects from other users as remote
const uid = currentUserIdRef.current;
if (uid !== null) {
@@ -632,6 +666,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
obj._isRemote = true;
obj.selectable = canInteract;
obj.evented = canInteract;
+ if (!obj._owner_username) obj._owner_username = userMapRef.current.get(obj._owner_id) ?? null;
}
});
}
@@ -708,7 +743,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
if (skipRemoteRef.current) return;
if (!obj.collab_id) obj.collab_id = crypto.randomUUID();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const json = (obj as any).toJSON(["collab_id"]);
+ const json = (obj as any).toJSON(["collab_id", "_owner_username", "_owner_id"]);
collabSendRef.current?.objectAdd(json, currentPageRef.current);
});
}
@@ -721,11 +756,14 @@ export default function WorkbookViewer({ pdfId }: Props) {
options.path.set({ opacity: 0.42 });
fc.renderAll();
}
+ // Tag with owner info for tooltip display
+ options.path._owner_id = currentUserIdRef.current;
+ options.path._owner_username = currentUsernameRef.current;
// Broadcast stroke to collaborators
if (!skipRemoteRef.current) {
options.path.collab_id = crypto.randomUUID();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const obj = (options.path as any).toJSON(["collab_id"]);
+ const obj = (options.path as any).toJSON(["collab_id", "_owner_username", "_owner_id"]);
collabSendRef.current?.objectAdd(obj, currentPageRef.current);
}
// Sync unsaved canvas to Redis so late-joining users see this stroke
@@ -784,6 +822,61 @@ export default function WorkbookViewer({ pdfId }: Props) {
};
}, [pdfId, renderPageWithAnnotations, generateThumbnails, renderAllPages]);
+ // ── Annotation hover tooltip (works in all modes including pan) ────────
+ // Uses scroll-container mousemove + Fabric findTarget() so pointer-events:none
+ // on the canvas wrapper doesn't block tooltip detection.
+ useEffect(() => {
+ if (!isReady) return;
+ const container = scrollContainerRef.current;
+ if (!container) return;
+
+ const resolveOwnerName = (obj: any): string | null => {
+ if (obj._owner_username) return obj._owner_username as string;
+ const ownerId: number | null = obj._owner_id ?? null;
+ if (ownerId === null) return null; // no owner info — don't attribute to current user
+ const uid = currentUserIdRef.current;
+ if (ownerId === uid) return currentUsernameRef.current || null;
+ return userMapRef.current.get(ownerId) ?? `User #${ownerId}`;
+ };
+
+ let lastTarget: any = null;
+ const handleMouseMove = (e: MouseEvent) => {
+ let found: any = null;
+ if (scrollModeRef.current === "single") {
+ const fc = fabricRef.current;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ if (fc) found = (fc as any).findTarget(e, false) ?? null;
+ } else {
+ for (const fc of pageFabricRefs.current) {
+ if (!fc) continue;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const hit = (fc as any).findTarget(e, false);
+ if (hit) { found = hit; break; }
+ }
+ }
+ if (found === lastTarget) return; // avoid redundant state updates
+ lastTarget = found;
+ if (found) {
+ const name = resolveOwnerName(found);
+ if (name) {
+ setAnnotationTooltip({ x: e.clientX, y: e.clientY, text: name });
+ } else {
+ setAnnotationTooltip(null);
+ }
+ } else {
+ setAnnotationTooltip(null);
+ }
+ };
+ const handleMouseLeave = () => { lastTarget = null; setAnnotationTooltip(null); };
+
+ container.addEventListener("mousemove", handleMouseMove);
+ container.addEventListener("mouseleave", handleMouseLeave);
+ return () => {
+ container.removeEventListener("mousemove", handleMouseMove);
+ container.removeEventListener("mouseleave", handleMouseLeave);
+ };
+ }, [isReady]);
+
// Re-render when switching scroll modes
useEffect(() => {
if (!isReady) return;
@@ -893,6 +986,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
fontFamily: "Arial, sans-serif",
padding: 4,
});
+ textObj._owner_id = currentUserIdRef.current;
+ textObj._owner_username = currentUsernameRef.current;
fc.add(textObj);
fc.setActiveObject(textObj);
textObj.enterEditing();
@@ -993,7 +1088,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
const fc = fabricRef.current;
if (!fc) return;
// Preserve _owner_id so remote-object detection works on cache hits
- localAnnotations.current[currentPage] = fc.toJSON(["_owner_id"]);
+ localAnnotations.current[currentPage] = fc.toJSON(["_owner_id", "_owner_username"]);
await renderPageWithAnnotations(newPage);
setCurrentPage(newPage);
},
@@ -1011,8 +1106,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
if (!fc || !isReady || saving) return;
setSaving(true);
- // Include _owner_id in serialization so we can filter remote objects
- const fullCanvasData = fc.toJSON(["_owner_id"]);
+ // Include _owner_id and _owner_username in serialization
+ const fullCanvasData = fc.toJSON(["_owner_id", "_owner_username"]);
// Only save objects that belong to the current user (no _owner_id = own; _owner_id === uid = own)
const uid = currentUserIdRef.current;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1124,6 +1219,17 @@ export default function WorkbookViewer({ pdfId }: Props) {
{pdfTitle}
+ {/* Current user badge */}
+ {currentUsername && (
+
+
+ {currentUsername}
+
+ )}
+
{/* Thumbnail toggle */}
@@ -1505,6 +1611,20 @@ export default function WorkbookViewer({ pdfId }: Props) {
{/* ── Color picker portal \u2014 rendered into document.body to escape overflow:hidden ── */}
+ {/* Annotation owner tooltip */}
+ {annotationTooltip && (
+
+
+
+
+ {annotationTooltip.text}
+
+ )}
+
{colorPickerOpen && colorPickerPos && typeof document !== "undefined" && createPortal(
request("/api/auth/register", { method: "POST", body: JSON.stringify(body) }),
logout: () => request("/api/auth/logout", { method: "POST" }),
+ changePassword: (body: { current_password: string; new_password: string }) =>
+ request("/api/auth/change-password", { method: "POST", body: JSON.stringify(body) }),
// PDFs
listPdfs: () => request("/api/pdfs"),
From 64bd2a76c55a77b032341eb8e8c30c9748e2a460 Mon Sep 17 00:00:00 2001
From: root
Date: Wed, 1 Apr 2026 20:23:17 +0700
Subject: [PATCH 08/11] =?UTF-8?q?chore:=20bump=20version=201.0.0=20?=
=?UTF-8?q?=E2=86=92=201.0.1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
VERSION | 1 +
1 file changed, 1 insertion(+)
create mode 100644 VERSION
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..7dea76e
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+1.0.1
From d24610bf1a3454a95b13d7d5dd000db67e0c9b87 Mon Sep 17 00:00:00 2001
From: root
Date: Wed, 1 Apr 2026 20:28:21 +0700
Subject: [PATCH 09/11] =?UTF-8?q?chore:=20bump=20version=201.0.1=20?=
=?UTF-8?q?=E2=86=92=201.0.2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
VERSION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/VERSION b/VERSION
index 7dea76e..6d7de6e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.0.1
+1.0.2
From 2aa7d68e52f65f20af1f3ece93ff0f0f220e756c Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 2 Apr 2026 08:59:24 +0700
Subject: [PATCH 10/11] =?UTF-8?q?chore:=20bump=20version=201.0.2=20?=
=?UTF-8?q?=E2=86=92=201.0.3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
VERSION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/VERSION b/VERSION
index 6d7de6e..21e8796 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.0.2
+1.0.3
From 8f2ccf473b571323377ab3972210ff9af1fd880f Mon Sep 17 00:00:00 2001
From: hienp
Date: Thu, 2 Apr 2026 09:21:03 +0700
Subject: [PATCH 11/11] =?UTF-8?q?ho=C3=A0n=20th=C3=A0nh=20ch=E1=BB=A9c=20n?=
=?UTF-8?q?=C4=83ng=20backup=20v2=20restore?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/Dockerfile | 4 +
backend/app/main.py | 3 +-
backend/app/routers/backup.py | 258 ++++++++++++++++++++++++++++++++
build.sh | 183 ++++++++++++++++++++++
frontend/src/app/admin/page.tsx | 127 ++++++++++++++++
frontend/src/lib/api.ts | 27 ++++
push-ghcr.sh | 161 +++++++++++++++-----
readme.md | 9 ++
8 files changed, 732 insertions(+), 40 deletions(-)
create mode 100644 backend/app/routers/backup.py
create mode 100755 build.sh
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 5d19b4a..b7125ef 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -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
diff --git a/backend/app/main.py b/backend/app/main.py
index 3090492..b3e65ca 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from .routers import admin, annotations, auth, pdfs, ws
+from .routers import admin, annotations, auth, backup, pdfs, ws
@asynccontextmanager
@@ -25,6 +25,7 @@ app.add_middleware(
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)
diff --git a/backend/app/routers/backup.py b/backend/app/routers/backup.py
new file mode 100644
index 0000000..0328fd7
--- /dev/null
+++ b/backend/app/routers/backup.py
@@ -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"),
+ }
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..2e9c69d
--- /dev/null
+++ b/build.sh
@@ -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
diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx
index 2b66ba1..146d693 100644
--- a/frontend/src/app/admin/page.tsx
+++ b/frontend/src/app/admin/page.tsx
@@ -40,6 +40,13 @@ export default function AdminPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [busy, setBusy] = useState(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(null);
+
const [filterStatus, setFilterStatus] = useState("all");
const [filterRole, setFilterRole] = useState("all");
const [search, setSearch] = useState("");
@@ -238,6 +245,126 @@ export default function AdminPage() {
)}
+ {/* ── Backup & Restore ─────────────────────────────────────────────── */}
+
+
Sao lưu & Khôi phục
+
+ 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.
+
+
+ {backupMsg && (
+
+ {backupMsg.text}
+ setBackupMsg(null)} className="ml-3 opacity-60 hover:opacity-100">✕
+
+ )}
+
+
+ {/* Download backup */}
+
{
+ 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 ? (
+
+
+
+
+ ) : (
+
+
+
+ )}
+ {backupLoading ? "Đang tạo backup…" : "Tải backup (.zip)"}
+
+
+ {/* Restore */}
+
+ {restoreLoading ? (
+
+
+
+
+ ) : (
+
+
+
+ )}
+ {restoreLoading ? "Đang khôi phục…" : "Khôi phục từ backup (.zip)"}
+ { 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 = "";
+ }
+ }}
+ />
+
+
+
+ ⚠️ 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.
+
+
+
{/* ── Table ────────────────────────────────────────────────────────── */}
{filtered.length === 0 ? (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 21cbd3c..aa6fce1 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -74,4 +74,31 @@ export const api = {
}),
adminDeleteUser: (userId: number) =>
request(`/api/admin/users/${userId}`, { method: "DELETE" }),
+
+ // Backup / Restore
+ /** Triggers pg_dump + file pack; returns a Blob for the browser to download. */
+ adminDownloadBackup: async (): Promise => {
+ 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();
+ },
};
diff --git a/push-ghcr.sh b/push-ghcr.sh
index 03dd5ee..dfebc77 100755
--- a/push-ghcr.sh
+++ b/push-ghcr.sh
@@ -1,62 +1,145 @@
#!/bin/bash
# Push LMS images to GitHub Container Registry (GHCR)
-# Usage: ./push-ghcr.sh [tag]
+# 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 -e
+set -euo pipefail
cd "$(dirname "$0")"
-# Load .env
-if [ -f .env ]; then
- export $(grep -v '^#' .env | grep -E 'GITHUB_USER|GITHUB_TOKEN' | xargs)
-fi
+# ── 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}"; }
-TAG="${1:-latest}"
+# ── 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}"
-BACKEND_IMAGE="ghcr.io/${GITHUB_USER}/lms-backend:${TAG}"
-FRONTEND_IMAGE="ghcr.io/${GITHUB_USER}/lms-frontend:${TAG}"
+# ── Read current version ──────────────────────────────────────────────────────
+VERSION_FILE="VERSION"
+[ -f "$VERSION_FILE" ] || echo "1.0.0" > "$VERSION_FILE"
-echo "========================================"
-echo " LMS → GHCR Push"
-echo " Backend : $BACKEND_IMAGE"
-echo " Frontend: $FRONTEND_IMAGE"
-echo "========================================"
-echo ""
+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
-# ── 1. Login to GHCR ──────────────────────────────────────────────────────────
-echo "🔐 Logging in to ghcr.io ..."
+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 ───────────────────────────────────────────────────────────
-echo ""
-echo "🔨 Building images..."
-cd "$(dirname "$0")"
-docker compose build
+step "Building images"
+[ -n "$NO_CACHE" ] && warn "Building without layer cache"
+# shellcheck disable=SC2086
+docker compose build $NO_CACHE
+ok "Build complete"
-# ── 3. Tag for GHCR ───────────────────────────────────────────────────────────
-echo ""
-echo "🏷 Tagging images..."
-docker tag lms-backend:latest "$BACKEND_IMAGE"
-docker tag lms-frontend:latest "$FRONTEND_IMAGE"
+# ── 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 ───────────────────────────────────────────────────────────────────
-echo ""
-echo "⬆ Pushing to GHCR..."
-docker push "$BACKEND_IMAGE"
-docker push "$FRONTEND_IMAGE"
+# ── 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 "✅ Done! Images pushed:"
-echo " $BACKEND_IMAGE"
-echo " $FRONTEND_IMAGE"
+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 "📋 Add these to .env on the target machine:"
-echo " BACKEND_IMAGE=$BACKEND_IMAGE"
-echo " FRONTEND_IMAGE=$FRONTEND_IMAGE"
+echo -e " ${BOLD}📋 Add to .env on target machine:${RESET}"
+echo " BACKEND_IMAGE=${BACKEND_VERSIONED}"
+echo " FRONTEND_IMAGE=${FRONTEND_VERSIONED}"
echo ""
-echo "🚀 On the target machine:"
-echo " docker compose pull && docker compose up -d"
+echo -e " ${BOLD}🚀 Deploy on target machine:${RESET}"
+echo " docker compose pull && docker compose up -d"
diff --git a/readme.md b/readme.md
index 60d7c79..61036b0 100644
--- a/readme.md
+++ b/readme.md
@@ -141,3 +141,12 @@ 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
\ No newline at end of file