diff --git a/frontend/module/admin/documents/AdminDocumentsTable.tsx b/frontend/module/admin/documents/AdminDocumentsTable.tsx new file mode 100644 index 0000000..543d02e --- /dev/null +++ b/frontend/module/admin/documents/AdminDocumentsTable.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; + +interface AdminDocument { + id: string; + title: string; + ownerEmail: string; + status: string; + riskScore: number | null; + riskLevel: "LOW" | "MEDIUM" | "HIGH" | null; + uploadedAt: string; +} + +const STATUS_COLORS: Record = { + PENDING: "bg-gray-100 text-gray-700", + ANALYZING: "bg-blue-100 text-blue-700", + VERIFIED: "bg-green-100 text-green-700", + FLAGGED: "bg-orange-100 text-orange-700", + REJECTED: "bg-red-100 text-red-700", +}; + +const RISK_COLORS: Record = { + LOW: "bg-green-100 text-green-700", + MEDIUM: "bg-amber-100 text-amber-700", + HIGH: "bg-red-100 text-red-700", +}; + +const PAGE_SIZE = 10; + +function StatusBadge({ label, colorClass }: { label: string; colorClass: string }) { + return ( + + {label} + + ); +} + +function SkeletonRow({ cols }: { cols: number }) { + return ( + + {Array.from({ length: cols }).map((_, i) => ( + +
+ + ))} + + ); +} + +export default function AdminDocumentsTable() { + const [docs, setDocs] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [statusFilter, setStatusFilter] = useState(""); + const [riskFilter, setRiskFilter] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isAdmin, setIsAdmin] = useState(null); + + const token = () => localStorage.getItem("access_token") ?? ""; + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ""; + + useEffect(() => { + fetch(`${apiBase}/api/module/users/me`, { + headers: { Authorization: `Bearer ${token()}` }, + }) + .then((r) => r.json()) + .then((u) => setIsAdmin(u.role === "admin")) + .catch(() => setIsAdmin(false)); + }, [apiBase]); + + const fetchDocs = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ + page: String(page), + limit: String(PAGE_SIZE), + ...(statusFilter ? { status: statusFilter } : {}), + ...(riskFilter ? { riskLevel: riskFilter } : {}), + }); + const res = await fetch( + `${apiBase}/api/module/admin/documents?${params}`, + { headers: { Authorization: `Bearer ${token()}` } } + ); + if (res.status === 403) throw new Error("403"); + if (!res.ok) throw new Error("Failed to load documents."); + const data = await res.json(); + setDocs(data.data ?? data); + setTotal(data.total ?? (data.data ?? data).length); + } catch (err) { + setError(err instanceof Error ? err.message : "Unexpected error."); + } finally { + setLoading(false); + } + }, [page, statusFilter, riskFilter, apiBase]); + + useEffect(() => { + if (isAdmin === null) return; + if (!isAdmin) { + setError("403"); + setLoading(false); + return; + } + fetchDocs(); + }, [isAdmin, fetchDocs]); + + if (isAdmin === false || error === "403") { + return ( +
+

403 — You are not authorized to view this page.

+
+ ); + } + + const totalPages = Math.ceil(total / PAGE_SIZE); + + return ( +
+
+ + +
+ + {error && error !== "403" && ( +

{error}

+ )} + +
+ + + + + + + + + + + + + {loading + ? Array.from({ length: 5 }).map((_, i) => ) + : docs.map((doc) => ( + + + + + + + + + ))} + +
TitleOwner EmailStatusRisk ScoreUpload DateView
{doc.title}{doc.ownerEmail} + + + {doc.riskLevel ? ( + + ) : ( + + )} + + {new Date(doc.uploadedAt).toLocaleDateString()} + + + View + +
+
+ + {totalPages > 1 && ( +
+ Page {page} of {totalPages} +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/module/admin/users/AdminUsersTable.tsx b/frontend/module/admin/users/AdminUsersTable.tsx new file mode 100644 index 0000000..4934c67 --- /dev/null +++ b/frontend/module/admin/users/AdminUsersTable.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import ConfirmationModal from "../../components/confirmation-modal/ConfirmationModal"; + +interface AdminUser { + id: string; + fullName: string; + email: string; + role: string; + verified: boolean; + createdAt: string; +} + +const ROLES = ["user", "admin", "reviewer"]; +const PAGE_SIZE = 10; + +function VerifiedBadge({ verified }: { verified: boolean }) { + return ( + + {verified ? "Verified" : "Unverified"} + + ); +} + +export default function AdminUsersTable() { + const [users, setUsers] = useState([]); + const [filtered, setFiltered] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isAdmin, setIsAdmin] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [roleLoading, setRoleLoading] = useState(null); + const [roleMsg, setRoleMsg] = useState<{ id: string; msg: string } | null>(null); + + const token = () => localStorage.getItem("access_token") ?? ""; + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ""; + + useEffect(() => { + fetch(`${apiBase}/api/module/users/me`, { + headers: { Authorization: `Bearer ${token()}` }, + }) + .then((r) => r.json()) + .then((u) => setIsAdmin(u.role === "admin")) + .catch(() => setIsAdmin(false)); + }, [apiBase]); + + const fetchUsers = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) }); + const res = await fetch(`${apiBase}/api/module/admin/users?${params}`, { + headers: { Authorization: `Bearer ${token()}` }, + }); + if (res.status === 403) throw new Error("403"); + if (!res.ok) throw new Error("Failed to load users."); + const data = await res.json(); + const list = data.data ?? data; + setUsers(list); + setFiltered(list); + setTotal(data.total ?? list.length); + } catch (err) { + setError(err instanceof Error ? err.message : "Unexpected error."); + } finally { + setLoading(false); + } + }, [page, apiBase]); + + useEffect(() => { + if (isAdmin === null) return; + if (!isAdmin) { setError("403"); setLoading(false); return; } + fetchUsers(); + }, [isAdmin, fetchUsers]); + + useEffect(() => { + const q = search.toLowerCase(); + setFiltered( + q ? users.filter((u) => u.fullName.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)) : users + ); + }, [search, users]); + + async function handleRoleChange(userId: string, newRole: string) { + setRoleLoading(userId); + setRoleMsg(null); + try { + const res = await fetch(`${apiBase}/api/module/admin/users/${userId}/role`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token()}` }, + body: JSON.stringify({ role: newRole }), + }); + if (!res.ok) throw new Error("Failed to update role."); + setUsers((prev) => prev.map((u) => u.id === userId ? { ...u, role: newRole } : u)); + setRoleMsg({ id: userId, msg: "Role updated" }); + } catch { + setRoleMsg({ id: userId, msg: "Failed to update" }); + } finally { + setRoleLoading(null); + setTimeout(() => setRoleMsg(null), 3000); + } + } + + async function handleDelete() { + if (!deleteTarget) return; + setDeleting(true); + try { + await fetch(`${apiBase}/api/module/admin/users/${deleteTarget.id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token()}` }, + }); + setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id)); + setDeleteTarget(null); + } catch {} finally { + setDeleting(false); + } + } + + if (isAdmin === false || error === "403") { + return ( +
+

403 — You are not authorized to view this page.

+
+ ); + } + + const totalPages = Math.ceil(total / PAGE_SIZE); + + return ( +
+ setSearch(e.target.value)} + placeholder="Search by name or email…" + className="w-full max-w-sm rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + {error && error !== "403" &&

{error}

} + +
+ + + + + + + + + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 6 }).map((__, j) => ( + + ))} + + )) + ) : filtered.map((user) => ( + + + + + + + + + ))} + +
Full NameEmailRoleVerifiedCreatedActions
{user.fullName}{user.email} +
+ + {roleMsg?.id === user.id && ( + {roleMsg.msg} + )} +
+
+ {new Date(user.createdAt).toLocaleDateString()} + + +
+
+ + {totalPages > 1 && ( +
+ Page {page} of {totalPages} +
+ + +
+
+ )} + + setDeleteTarget(null)} + /> +
+ ); +} diff --git a/frontend/module/notifications/NotificationsPanel.tsx b/frontend/module/notifications/NotificationsPanel.tsx new file mode 100644 index 0000000..408adb8 --- /dev/null +++ b/frontend/module/notifications/NotificationsPanel.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; + +interface Notification { + id: string; + message: string; + read: boolean; + createdAt: string; + resourceUrl?: string; +} + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"} ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs} hour${hrs === 1 ? "" : "s"} ago`; + return `${Math.floor(hrs / 24)} day${Math.floor(hrs / 24) === 1 ? "" : "s"} ago`; +} + +export default function NotificationsPanel() { + const router = useRouter(); + const [unreadCount, setUnreadCount] = useState(0); + const [notifications, setNotifications] = useState([]); + const [open, setOpen] = useState(false); + const panelRef = useRef(null); + + const token = () => localStorage.getItem("access_token") ?? ""; + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ""; + + const fetchUnreadCount = useCallback(async () => { + try { + const res = await fetch(`${apiBase}/api/module/notifications/unread-count`, { + headers: { Authorization: `Bearer ${token()}` }, + }); + if (res.ok) { + const data = await res.json(); + setUnreadCount(data.count ?? 0); + } + } catch {} + }, [apiBase]); + + const fetchNotifications = useCallback(async () => { + try { + const res = await fetch(`${apiBase}/api/module/notifications?limit=20`, { + headers: { Authorization: `Bearer ${token()}` }, + }); + if (res.ok) { + const data = await res.json(); + setNotifications(data.data ?? data); + } + } catch {} + }, [apiBase]); + + useEffect(() => { + fetchUnreadCount(); + }, [fetchUnreadCount]); + + useEffect(() => { + if (!open) return; + fetchNotifications(); + }, [open, fetchNotifications]); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + async function handleNotificationClick(n: Notification) { + setOpen(false); + try { + await fetch(`${apiBase}/api/module/notifications/${n.id}/read`, { + method: "PATCH", + headers: { Authorization: `Bearer ${token()}` }, + }); + setNotifications((prev) => + prev.map((item) => (item.id === n.id ? { ...item, read: true } : item)) + ); + setUnreadCount((c) => Math.max(0, c - (n.read ? 0 : 1))); + } catch {} + if (n.resourceUrl) router.push(n.resourceUrl); + } + + async function handleMarkAllRead() { + try { + await fetch(`${apiBase}/api/module/notifications/read-all`, { + method: "PATCH", + headers: { Authorization: `Bearer ${token()}` }, + }); + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + setUnreadCount(0); + } catch {} + } + + return ( +
+ + + {open && ( +
+
+

Notifications

+ +
+ +
    + {notifications.length === 0 ? ( +
  • + No notifications +
  • + ) : ( + notifications.map((n) => ( +
  • + +
  • + )) + )} +
+
+ )} +
+ ); +} diff --git a/frontend/module/profile/edit/EditProfileForm.tsx b/frontend/module/profile/edit/EditProfileForm.tsx new file mode 100644 index 0000000..f84d7bc --- /dev/null +++ b/frontend/module/profile/edit/EditProfileForm.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +const SUPPORTED_LANGUAGES = [ + { code: "en", label: "English" }, + { code: "fr", label: "French" }, + { code: "es", label: "Spanish" }, + { code: "de", label: "German" }, + { code: "pt", label: "Portuguese" }, + { code: "ar", label: "Arabic" }, + { code: "zh", label: "Chinese" }, +]; + +interface ProfileData { + fullName: string; + preferredLanguage: string; +} + +export default function EditProfileForm() { + const router = useRouter(); + const [initial, setInitial] = useState(null); + const [fullName, setFullName] = useState(""); + const [preferredLanguage, setPreferredLanguage] = useState("en"); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [toast, setToast] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + + const token = () => localStorage.getItem("access_token") ?? ""; + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ""; + + useEffect(() => { + fetch(`${apiBase}/api/module/users/me`, { + headers: { Authorization: `Bearer ${token()}` }, + }) + .then((r) => r.json()) + .then((u) => { + const data: ProfileData = { + fullName: u.fullName ?? "", + preferredLanguage: u.preferredLanguage ?? "en", + }; + setInitial(data); + setFullName(data.fullName); + setPreferredLanguage(data.preferredLanguage); + }) + .finally(() => setLoading(false)); + }, [apiBase]); + + const hasChanges = + initial !== null && + (fullName !== initial.fullName || preferredLanguage !== initial.preferredLanguage); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!hasChanges) return; + setSaving(true); + setFieldErrors({}); + + const body: Partial = {}; + if (fullName !== initial!.fullName) body.fullName = fullName; + if (preferredLanguage !== initial!.preferredLanguage) + body.preferredLanguage = preferredLanguage; + + try { + const res = await fetch(`${apiBase}/api/module/users/me`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token()}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + if (data.errors) { + setFieldErrors(data.errors); + } else { + setToast(data.message ?? "Update failed."); + setTimeout(() => setToast(null), 4000); + } + return; + } + + setInitial({ fullName, preferredLanguage }); + setToast("Profile updated successfully."); + setTimeout(() => setToast(null), 4000); + } finally { + setSaving(false); + } + } + + if (loading) { + return ( +
+ {[1, 2].map((i) => ( +
+
+
+
+ ))} +
+ ); + } + + return ( +
+

Edit Profile

+ + {toast && ( +

+ {toast} +

+ )} + +
+
+ + setFullName(e.target.value)} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + {fieldErrors.fullName && ( +

{fieldErrors.fullName}

+ )} +
+ +
+ + + {fieldErrors.preferredLanguage && ( +

+ {fieldErrors.preferredLanguage} +

+ )} +
+ +
+ + +
+
+
+ ); +}