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

This commit is contained in:
2026-04-01 19:26:47 +07:00
parent 8546d41a26
commit 386871bd4a
14 changed files with 796 additions and 37 deletions
+338
View File
@@ -0,0 +1,338 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import type { User, UserRole, UserStatus } from "@/types";
// ── Helpers ───────────────────────────────────────────────────────────────────
const STATUS_LABELS: Record<UserStatus, string> = {
pending: "Chờ duyệt",
approved: "Đã duyệt",
rejected: "Từ chối",
};
const ROLE_LABELS: Record<UserRole, string> = {
admin: "Admin",
teacher: "Giáo viên",
student: "Học viên",
};
const STATUS_COLORS: Record<UserStatus, string> = {
pending: "bg-yellow-100 text-yellow-800",
approved: "bg-green-100 text-green-800",
rejected: "bg-red-100 text-red-800",
};
const ROLE_COLORS: Record<UserRole, string> = {
admin: "bg-purple-100 text-purple-800",
teacher: "bg-blue-100 text-blue-800",
student: "bg-gray-100 text-gray-700",
};
// ── Page ──────────────────────────────────────────────────────────────────────
export default function AdminPage() {
const router = useRouter();
const [me, setMe] = useState<User | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [busy, setBusy] = useState<number | null>(null); // userId being mutated
const [filterStatus, setFilterStatus] = useState<UserStatus | "all">("all");
const [filterRole, setFilterRole] = useState<UserRole | "all">("all");
const [search, setSearch] = useState("");
// ── Auth guard: admin only ─────────────────────────────────────────────────
useEffect(() => {
async function init() {
try {
const me = await api.me();
if (me.role !== "admin") { router.replace("/dashboard"); return; }
setMe(me);
const list = await api.adminListUsers();
setUsers(list);
} catch {
router.replace("/login");
} finally {
setLoading(false);
}
}
init();
}, [router]);
// ── Mutate helpers ─────────────────────────────────────────────────────────
const updateUser = useCallback((updated: User) => {
setUsers(prev => prev.map(u => u.id === updated.id ? updated : u));
}, []);
const handleStatusChange = useCallback(async (userId: number, status: UserStatus) => {
setBusy(userId);
try {
const updated = await api.adminUpdateStatus(userId, status);
updateUser(updated);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to update status.");
} finally {
setBusy(null);
}
}, [updateUser]);
const handleRoleChange = useCallback(async (userId: number, role: UserRole) => {
setBusy(userId);
try {
const updated = await api.adminUpdateRole(userId, role);
updateUser(updated);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to update role.");
} finally {
setBusy(null);
}
}, [updateUser]);
const handleDelete = useCallback(async (userId: number, username: string) => {
if (!confirm(`Xoá tài khoản "${username}" và toàn bộ dữ liệu? Không thể hoàn tác.`)) return;
setBusy(userId);
try {
await api.adminDeleteUser(userId);
setUsers(prev => prev.filter(u => u.id !== userId));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to delete user.");
} finally {
setBusy(null);
}
}, []);
// ── Filtered list ──────────────────────────────────────────────────────────
const filtered = users.filter(u => {
if (filterStatus !== "all" && u.status !== filterStatus) return false;
if (filterRole !== "all" && u.role !== filterRole) return false;
if (search) {
const q = search.toLowerCase();
if (!u.username.toLowerCase().includes(q) && !u.email.toLowerCase().includes(q)) return false;
}
return true;
});
const pendingCount = users.filter(u => u.status === "pending").length;
// ── Loading ────────────────────────────────────────────────────────────────
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<svg className="animate-spin h-8 w-8 text-blue-500" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* ── Navbar ──────────────────────────────────────────────────────────── */}
<header className="bg-white border-b shadow-sm sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/dashboard")}
className="text-gray-400 hover:text-blue-600 transition"
title="Về Dashboard"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="font-bold text-lg text-blue-600">PDF LMS</span>
<span className="text-xs bg-purple-100 text-purple-700 font-semibold px-2 py-0.5 rounded-full">
Admin
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500 hidden sm:block">{me?.username}</span>
<button
onClick={async () => { await api.logout(); router.replace("/login"); }}
className="text-sm text-gray-500 hover:text-red-600 transition"
>
Đăng xuất
</button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8">
{/* ── Page title + stats ─────────────────────────────────────────────── */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Quản người dùng</h1>
<p className="text-sm text-gray-500 mt-0.5">{users.length} tài khoản tổng cộng</p>
</div>
{pendingCount > 0 && (
<div
className="flex items-center gap-2 bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-2 rounded-lg text-sm font-medium cursor-pointer hover:bg-yellow-100 transition"
onClick={() => setFilterStatus("pending")}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
{pendingCount} tài khoản chờ duyệt
</div>
)}
</div>
{/* ── Error banner ──────────────────────────────────────────────────── */}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg flex items-center justify-between">
{error}
<button onClick={() => setError("")} className="ml-3 text-red-400 hover:text-red-600"></button>
</div>
)}
{/* ── Filters ───────────────────────────────────────────────────────── */}
<div className="bg-white border border-gray-200 rounded-xl p-4 mb-4 flex flex-col sm:flex-row gap-3">
<input
type="text"
placeholder="Tìm theo username / email…"
value={search}
onChange={e => setSearch(e.target.value)}
className="flex-1 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
/>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value as UserStatus | "all")}
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
>
<option value="all">Tất cả trạng thái</option>
<option value="pending">Chờ duyệt</option>
<option value="approved">Đã duyệt</option>
<option value="rejected">Từ chối</option>
</select>
<select
value={filterRole}
onChange={e => setFilterRole(e.target.value as UserRole | "all")}
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
>
<option value="all">Tất cả vai trò</option>
<option value="admin">Admin</option>
<option value="teacher">Giáo viên</option>
<option value="student">Học viên</option>
</select>
{(filterStatus !== "all" || filterRole !== "all" || search) && (
<button
onClick={() => { setFilterStatus("all"); setFilterRole("all"); setSearch(""); }}
className="text-sm text-gray-500 hover:text-red-600 transition px-2"
>
Xoá lọc
</button>
)}
</div>
{/* ── Table ────────────────────────────────────────────────────────── */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
{filtered.length === 0 ? (
<div className="text-center text-gray-400 text-sm py-16">Không tìm thấy tài khoản nào.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3">ID</th>
<th className="px-4 py-3">Người dùng</th>
<th className="px-4 py-3">Vai trò</th>
<th className="px-4 py-3">Trạng thái</th>
<th className="px-4 py-3">Ngày đăng </th>
<th className="px-4 py-3 text-right">Hành đng</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filtered.map(u => {
const isSelf = u.id === me?.id;
const isLoading = busy === u.id;
return (
<tr key={u.id} className="hover:bg-gray-50 transition">
{/* ID */}
<td className="px-4 py-3 text-gray-400 tabular-nums">{u.id}</td>
{/* User info */}
<td className="px-4 py-3">
<div className="font-medium text-gray-900">{u.username}</div>
<div className="text-xs text-gray-400">{u.email}</div>
</td>
{/* Role selector */}
<td className="px-4 py-3">
{isSelf ? (
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${ROLE_COLORS[u.role]}`}>
{ROLE_LABELS[u.role]}
</span>
) : (
<select
value={u.role}
disabled={isLoading}
onChange={e => handleRoleChange(u.id, e.target.value as UserRole)}
className={`text-xs border rounded-lg px-2 py-1 focus:outline-none focus:border-blue-500 disabled:opacity-50 ${ROLE_COLORS[u.role]} border-transparent`}
>
<option value="student">Học viên</option>
<option value="teacher">Giáo viên</option>
<option value="admin">Admin</option>
</select>
)}
</td>
{/* Status selector */}
<td className="px-4 py-3">
{isSelf ? (
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${STATUS_COLORS[u.status]}`}>
{STATUS_LABELS[u.status]}
</span>
) : (
<select
value={u.status}
disabled={isLoading}
onChange={e => handleStatusChange(u.id, e.target.value as UserStatus)}
className={`text-xs border rounded-lg px-2 py-1 focus:outline-none focus:border-blue-500 disabled:opacity-50 ${STATUS_COLORS[u.status]} border-transparent`}
>
<option value="pending">Chờ duyệt</option>
<option value="approved">Duyệt</option>
<option value="rejected">Từ chối</option>
</select>
)}
</td>
{/* Created at */}
<td className="px-4 py-3 text-gray-400 tabular-nums whitespace-nowrap">
{new Date(u.created_at).toLocaleDateString("vi-VN")}
</td>
{/* Actions */}
<td className="px-4 py-3 text-right">
{isLoading ? (
<svg className="animate-spin h-4 w-4 text-blue-500 ml-auto" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : isSelf ? (
<span className="text-xs text-gray-300"></span>
) : (
<button
onClick={() => handleDelete(u.id, u.username)}
className="text-xs text-red-400 hover:text-red-600 hover:bg-red-50 px-2 py-1 rounded transition"
>
Xoá
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</main>
</div>
);
}
+8
View File
@@ -71,6 +71,14 @@ export default function DashboardPage() {
<span className="font-bold text-lg text-blue-600">PDF LMS</span>
<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={handleLogout}
className="text-sm text-gray-500 hover:text-red-600 transition"
+104 -30
View File
@@ -142,6 +142,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
const currentPageRef = useRef(1); // mutable copy for collab callbacks
const skipRemoteRef = useRef(false); // prevent echo-back when applying remote events
const currentUserIdRef = useRef<number | null>(null); // populated from api.me(); used to identify own vs remote objects
const [userRole, setUserRole] = useState<"admin" | "teacher" | "student">("student");
const userRoleRef = useRef<"admin" | "teacher" | "student">("student");
// Buffer for remote events that arrive while renderPageWithAnnotations is in progress
const renderingRef = useRef(false);
const pendingRemoteEvents = useRef<RemoteEvent[]>([]);
@@ -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) {
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${collabConnected ? "bg-green-500" : "bg-gray-300"}`} />
{collabUsers.length > 0 && (
<div className="flex -space-x-1">
{collabUsers.slice(0, 5).map(u => (
<span
key={u.user_id}
title={u.username}
style={{ background: u.color }}
className="w-5 h-5 rounded-full border-2 border-white flex items-center justify-center text-[9px] text-white font-bold uppercase"
>
{u.username[0]}
</span>
))}
{collabUsers.slice(0, 5).map(u => {
const isSelf = u.user_id === currentUserIdRef.current;
const canClear = !isSelf && (userRole === "admin" || userRole === "teacher");
return (
<div key={u.user_id} className="relative group">
<span
title={u.username}
style={{ background: u.color }}
className="w-5 h-5 rounded-full border-2 border-white flex items-center justify-center text-[9px] text-white font-bold uppercase select-none"
>
{u.username[0]}
</span>
{canClear && (
<button
onClick={async () => {
if (!confirm(`Xoá annotation của "${u.username}" trên trang ${currentPageRef.current}?`)) return;
try {
await api.clearUserAnnotation(pdfId, currentPageRef.current, u.user_id);
// Remove their objects from canvas
const removeFromCanvas = (fc: any) => {
const toRemove = fc.getObjects().filter((o: any) => o._owner_id === u.user_id);
toRemove.forEach((o: any) => fc.remove(o));
if (toRemove.length) fc.renderAll();
};
if (scrollModeRef.current === "continuous") {
pageFabricRefs.current.forEach(fc => { if (fc) removeFromCanvas(fc); });
} else if (fabricRef.current) {
removeFromCanvas(fabricRef.current);
}
localAnnotations.current = {};
} catch (e: unknown) {
alert(e instanceof Error ? e.message : "Xoá thất bại.");
}
}}
title={`Xoá annotation của ${u.username} trang này`}
className="absolute -top-1.5 -right-1.5 w-3.5 h-3.5 rounded-full bg-red-500 text-white hidden group-hover:flex items-center justify-center shadow z-10"
style={{ fontSize: 8 }}
>
</button>
)}
</div>
);
})}
{collabUsers.length > 5 && (
<span className="w-5 h-5 rounded-full border-2 border-white bg-gray-400 flex items-center justify-center text-[9px] text-white font-bold">
+{collabUsers.length - 5}
+20
View File
@@ -52,4 +52,24 @@ export const api = {
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" }),
};
+5
View File
@@ -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;
}