diff --git a/frontend/module/admin/layout/AdminDashboardLayout.tsx b/frontend/module/admin/layout/AdminDashboardLayout.tsx new file mode 100644 index 0000000..94be9bd --- /dev/null +++ b/frontend/module/admin/layout/AdminDashboardLayout.tsx @@ -0,0 +1,155 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { ReactNode, useEffect, useState } from "react"; +import { useAuth } from "../../auth/context/AuthContext"; + +interface NavItem { + href: string; + label: string; + icon: ReactNode; +} + +const NAV_ITEMS: NavItem[] = [ + { + href: "/admin/users", + label: "Users", + icon: ( + + + + ), + }, + { + href: "/admin/documents", + label: "All Documents", + icon: ( + + + + ), + }, + { + href: "/admin/audit", + label: "Audit Log", + icon: ( + + + + ), + }, + { + href: "/admin/queue", + label: "Queue Stats", + icon: ( + + + + ), + }, + { + href: "/admin/health", + label: "System Health", + icon: ( + + + + ), + }, +]; + +export default function AdminDashboardLayout({ + children, +}: { + children: ReactNode; +}) { + const { user, isLoading, logout } = useAuth(); + const pathname = usePathname(); + const router = useRouter(); + const [collapsed, setCollapsed] = useState(false); + + useEffect(() => { + if (!isLoading && user?.role !== "admin") { + router.replace("/dashboard?unauthorized=1"); + } + }, [isLoading, user, router]); + + useEffect(() => { + const mq = window.matchMedia("(max-width: 1023px)"); + setCollapsed(mq.matches); + const handler = (e: MediaQueryListEvent) => setCollapsed(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (user?.role !== "admin") return null; + + return ( +
+ + +
+
+ + {user.fullName} + + +
+
{children}
+
+
+ ); +} diff --git a/frontend/module/auth/context/AuthContext.tsx b/frontend/module/auth/context/AuthContext.tsx new file mode 100644 index 0000000..7fd896b --- /dev/null +++ b/frontend/module/auth/context/AuthContext.tsx @@ -0,0 +1,138 @@ +"use client"; + +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { useRouter } from "next/navigation"; + +interface User { + id: string; + email: string; + fullName: string; + role: string; + preferredLanguage?: string; + twoFactorEnabled?: boolean; +} + +interface AuthContextValue { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; + login: ( + email: string, + password: string + ) => Promise<{ success: boolean; error?: string }>; + logout: () => void; + refreshUser: () => Promise; +} + +const AuthContext = createContext(null); + +function getToken(): string { + return typeof localStorage !== "undefined" + ? (localStorage.getItem("access_token") ?? "") + : ""; +} + +function setToken(token: string) { + localStorage.setItem("access_token", token); +} + +function clearToken() { + localStorage.removeItem("access_token"); +} + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ""; + + const fetchMe = useCallback(async (): Promise => { + const token = getToken(); + if (!token) { + setUser(null); + return; + } + const res = await fetch(`${apiBase}/api/module/users/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data: User = await res.json(); + setUser(data); + } else { + clearToken(); + setUser(null); + } + }, [apiBase]); + + useEffect(() => { + fetchMe().finally(() => setIsLoading(false)); + }, [fetchMe]); + + const login = useCallback( + async ( + email: string, + password: string + ): Promise<{ success: boolean; error?: string }> => { + try { + const res = await fetch(`${apiBase}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + return { success: false, error: data.message ?? "Login failed." }; + } + const data = await res.json(); + setToken(data.access_token ?? data.token); + await fetchMe(); + return { success: true }; + } catch { + return { success: false, error: "Network error." }; + } + }, + [apiBase, fetchMe] + ); + + const logout = useCallback(() => { + clearToken(); + setUser(null); + router.push("/auth/login"); + }, [router]); + + const refreshUser = useCallback(async () => { + await fetchMe(); + }, [fetchMe]); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used inside "); + } + return ctx; +} + +export default AuthContext; diff --git a/frontend/module/components/export-button/DocumentExportButton.tsx b/frontend/module/components/export-button/DocumentExportButton.tsx new file mode 100644 index 0000000..aafabf3 --- /dev/null +++ b/frontend/module/components/export-button/DocumentExportButton.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useRef, useState } from "react"; + +interface DocumentExportButtonProps { + documentId?: string; +} + +type ExportType = "pdf" | "excel"; + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +export default function DocumentExportButton({ + documentId, +}: DocumentExportButtonProps) { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(null); + const [toastError, setToastError] = useState(null); + const menuRef = useRef(null); + + const token = () => localStorage.getItem("access_token") ?? ""; + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ""; + + async function handleExport(type: ExportType) { + setOpen(false); + setLoading(type); + setToastError(null); + try { + const url = + type === "pdf" && documentId + ? `${apiBase}/api/module/documents/${documentId}/export/pdf` + : `${apiBase}/api/module/documents/export/excel`; + + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token()}` }, + }); + if (!res.ok) throw new Error("Export request failed."); + const blob = await res.blob(); + const ext = type === "pdf" ? "pdf" : "xlsx"; + const name = documentId + ? `document-${documentId}.${ext}` + : `documents.${ext}`; + downloadBlob(blob, name); + } catch (err) { + setToastError(err instanceof Error ? err.message : "Export failed."); + setTimeout(() => setToastError(null), 4000); + } finally { + setLoading(null); + } + } + + const showPdf = !!documentId; + + return ( +
+ + + {open && ( +
+ {showPdf && ( + + )} + +
+ )} + + {toastError && ( +
+ {toastError} +
+ )} +
+ ); +} diff --git a/frontend/module/components/verify-button/VerificationRequestButton.tsx b/frontend/module/components/verify-button/VerificationRequestButton.tsx new file mode 100644 index 0000000..e2b7348 --- /dev/null +++ b/frontend/module/components/verify-button/VerificationRequestButton.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useState } from "react"; + +type DocumentStatus = "PENDING" | "FLAGGED" | "ANALYZING" | "VERIFIED" | string; + +interface VerificationRequestButtonProps { + documentId: string; + currentStatus: DocumentStatus; + onQueued?: () => void; +} + +interface Toast { + message: string; + type: "success" | "error"; +} + +export default function VerificationRequestButton({ + documentId, + currentStatus, + onQueued, +}: VerificationRequestButtonProps) { + const [status, setStatus] = useState(currentStatus); + const [loading, setLoading] = useState(false); + const [toast, setToast] = useState(null); + + function showToast(message: string, type: Toast["type"]) { + setToast({ message, type }); + setTimeout(() => setToast(null), 4000); + } + + async function handleRequest() { + setLoading(true); + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/documents/${documentId}/verify`, + { + method: "POST", + headers: { + Authorization: `Bearer ${localStorage.getItem("access_token") ?? ""}`, + }, + } + ); + if (res.status === 202) { + setStatus("ANALYZING"); + showToast("Verification queued", "success"); + onQueued?.(); + } else { + const data = await res.json().catch(() => ({})); + showToast(data.message ?? "Verification request failed.", "error"); + } + } catch { + showToast("Network error. Please try again.", "error"); + } finally { + setLoading(false); + } + } + + const isRequestable = status === "PENDING" || status === "FLAGGED"; + const isQueued = status === "ANALYZING"; + const isVerified = status === "VERIFIED"; + + return ( +
+ {isVerified ? ( + + + + + Verified on Stellar + + ) : isQueued ? ( + + ) : isRequestable ? ( + + ) : null} + + {toast && ( +
+ {toast.message} +
+ )} +
+ ); +}