Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions frontend/module/admin/layout/AdminDashboardLayout.tsx
Original file line number Diff line number Diff line change
@@ -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: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-4a4 4 0 110-8 4 4 0 010 8z" />
</svg>
),
},
{
href: "/admin/documents",
label: "All Documents",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414A1 1 0 0121 9.414V19a2 2 0 01-2 2z" />
</svg>
),
},
{
href: "/admin/audit",
label: "Audit Log",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
),
},
{
href: "/admin/queue",
label: "Queue Stats",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
{
href: "/admin/health",
label: "System Health",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
),
},
];

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 (
<div className="flex h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
</div>
);
}

if (user?.role !== "admin") return null;

return (
<div className="flex h-screen overflow-hidden bg-gray-100">
<aside
className={`flex flex-col border-r border-gray-200 bg-white transition-all duration-200 ${collapsed ? "w-16" : "w-60"}`}
>
<div className="flex h-14 items-center justify-between px-4 border-b border-gray-200">
{!collapsed && (
<span className="font-bold text-gray-900">Admin Panel</span>
)}
<button
onClick={() => setCollapsed((c) => !c)}
aria-label="Toggle sidebar"
className="rounded p-1 text-gray-500 hover:bg-gray-100"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>

<nav className="flex flex-1 flex-col gap-1 p-2">
{NAV_ITEMS.map((item) => {
const active =
pathname === item.href || pathname.startsWith(item.href + "/");
return (
<Link
key={item.href}
href={item.href}
title={collapsed ? item.label : undefined}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
active
? "bg-blue-50 text-blue-700"
: "text-gray-700 hover:bg-gray-100"
}`}
>
{item.icon}
{!collapsed && <span>{item.label}</span>}
</Link>
);
})}
</nav>
</aside>

<div className="flex flex-1 flex-col overflow-hidden">
<header className="flex h-14 items-center justify-between border-b border-gray-200 bg-white px-6">
<span className="text-sm font-medium text-gray-700">
{user.fullName}
</span>
<button
onClick={logout}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Logout
</button>
</header>
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
</div>
);
}
138 changes: 138 additions & 0 deletions frontend/module/auth/context/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
}

const AuthContext = createContext<AuthContextValue | null>(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<User | null>(null);
const [isLoading, setIsLoading] = useState(true);

const apiBase = process.env.NEXT_PUBLIC_API_URL ?? "";

const fetchMe = useCallback(async (): Promise<void> => {
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 (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isLoading,
login,
logout,
refreshUser,
}}
>
{children}
</AuthContext.Provider>
);
}

export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used inside <AuthProvider>");
}
return ctx;
}

export default AuthContext;
Loading
Loading