diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..4037863
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,67 @@
+import { NextRequest, NextResponse } from "next/server"
+import { PUBLIC_ROUTES, canAccessRoute, UserRole } from "./src/config/routes.config"
+
+/**
+ * Middleware để kiểm tra authentication và authorization
+ * Chạy TRƯỚC khi render page
+ */
+export async function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl
+
+ // 1. Kiểm tra nếu là public route
+ if (PUBLIC_ROUTES.includes(pathname)) {
+ // Nếu user đã login, không cho vào signin/signup
+ const userDataCookie = request.cookies.get("userDataRedux")
+ if (userDataCookie && (pathname === "/signin" || pathname === "/signup")) {
+ return NextResponse.redirect(new URL("/", request.url))
+ }
+ return NextResponse.next()
+ }
+
+ // 2. Kiểm tra token (cookies)
+ const token = request.cookies.get("token") || request.cookies.get("access_token")
+ const userDataCookie = request.cookies.get("userDataRedux")
+
+ // 3. Nếu không có token, redirect về signin
+ if (!token) {
+ const signinUrl = new URL("/signin", request.url)
+ signinUrl.searchParams.set("from", pathname)
+ return NextResponse.redirect(signinUrl)
+ }
+
+ // 4. Kiểm tra role-based access
+ if (userDataCookie) {
+ try {
+ const userData = JSON.parse(userDataCookie.value)
+ const userRoles: UserRole[] = userData.roles?.map((r: any) => r.name) || []
+
+ // Kiểm tra user có quyền truy cập route này không
+ if (!canAccessRoute(userRoles, pathname)) {
+ // Redirect về dashboard hoặc 403 page
+ return NextResponse.redirect(new URL("/error-403", request.url))
+ }
+ } catch (error) {
+ console.error("Error parsing user data in middleware:", error)
+ // Nếu lỗi parse, vẫn cho qua (để tránh infinite redirect)
+ return NextResponse.next()
+ }
+ }
+
+ return NextResponse.next()
+}
+
+/**
+ * Cấu hình matcher - middleware chỉ chạy cho những routes này
+ */
+export const config = {
+ matcher: [
+ /*
+ * Chạy middleware cho tất cả paths ngoại trừ:
+ * - _next/static (static files)
+ * - _next/image (image optimization files)
+ * - favicon.ico (favicon file)
+ * - public folder
+ */
+ "/((?!_next/static|_next/image|favicon.ico|public).*)",
+ ],
+}
diff --git a/src/app/(admin)/(others-pages)/(tables)/applications-tables/page.tsx b/src/app/(admin)/(others-pages)/(tables)/applications-tables/page.tsx
index 38a2d8e..bf12559 100644
--- a/src/app/(admin)/(others-pages)/(tables)/applications-tables/page.tsx
+++ b/src/app/(admin)/(others-pages)/(tables)/applications-tables/page.tsx
@@ -5,6 +5,7 @@ import ComponentCard from "@/components/common/ComponentCard";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import Pagination from "@/components/tables/Pagination";
import Swal from "sweetalert2";
+import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
import ApplicationTable, {
AppSortColumn,
@@ -190,8 +191,9 @@ export default function HistorianApplicationPage() {
console.log(tableData)
// console.log("Pagination info:", pagination);
return (
-
-
+
+
+
}
>
- {/* Cập nhật Grid để chứa đủ các ô filter */}
@@ -354,5 +355,6 @@ export default function HistorianApplicationPage() {
onRefresh={fetchApplications}
/>
+
);
}
diff --git a/src/app/(admin)/(others-pages)/(tables)/user-table/page.tsx b/src/app/(admin)/(others-pages)/(tables)/user-table/page.tsx
index 28659ee..5b557a1 100644
--- a/src/app/(admin)/(others-pages)/(tables)/user-table/page.tsx
+++ b/src/app/(admin)/(others-pages)/(tables)/user-table/page.tsx
@@ -15,6 +15,7 @@ import {
import { useEffect, useState, useCallback } from "react";
import Pagination from "@/components/tables/Pagination";
import { LIMIT_ITEM_TABLE } from "../../../../../../constant";
+import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
export type SortColumn = "created_at" | "updated_at" | "display_name" | "email";
@@ -32,7 +33,9 @@ const formatDateTimeToISO = (
export default function UserTable() {
const [page, setPage] = useState
(1);
- const [limitInput, setLimitInput] = useState(LIMIT_ITEM_TABLE.toString());
+ const [limitInput, setLimitInput] = useState(
+ LIMIT_ITEM_TABLE.toString(),
+ );
const [selectedRole, setSelectedRole] = useState("");
const [roles, setRoles] = useState<{ id: string; name: string }[]>([]);
@@ -143,7 +146,6 @@ export default function UserTable() {
role_ids: selectedRole ? [selectedRole] : undefined,
};
- // Thêm format ngày giờ vào payload
const createdFrom = formatDateTimeToISO(
debouncedParams.fromDate,
debouncedParams.fromTime,
@@ -185,7 +187,7 @@ export default function UserTable() {
};
const pagination = tableData?.pagination;
-
+
// console.log(pagination);
const handleOpenDetail = (user: fullDataUser) => {
@@ -251,192 +253,197 @@ export default function UserTable() {
};
return (
-
-
-
-
-
+ );
+}
diff --git a/src/components/auth/ProtectedRoute.tsx b/src/components/auth/ProtectedRoute.tsx
new file mode 100644
index 0000000..1ac545a
--- /dev/null
+++ b/src/components/auth/ProtectedRoute.tsx
@@ -0,0 +1,62 @@
+"use client"
+
+import { ReactNode, useEffect } from "react"
+import { useRouter } from "next/navigation"
+import { useAuth } from "@/hooks/useAuth"
+import { UserRole } from "@/config/routes.config"
+
+interface ProtectedRouteProps {
+ children: ReactNode
+ requiredRoles?: UserRole[]
+ fallback?: ReactNode
+}
+
+/**
+ * Component để protect routes dựa trên role
+ * Sử dụng ở client-side trong layouts hoặc pages
+ */
+export const ProtectedRoute: React.FC
= ({
+ children,
+ requiredRoles = [],
+ fallback,
+}) => {
+ const router = useRouter()
+ const { isAuthenticated, userRoles, hasAnyRole } = useAuth()
+
+ useEffect(() => {
+ if (!isAuthenticated) {
+ router.replace("/signin")
+ }
+ }, [isAuthenticated, router])
+
+ if (!isAuthenticated) {
+ return null
+ }
+
+ if (requiredRoles.length > 0 && !hasAnyRole(requiredRoles)) {
+ if (fallback) {
+ return <>{fallback}>
+ }
+ router.replace("/error-403")
+ return null
+ }
+
+ return <>{children}>
+}
+
+/**
+ * Wrapper để render UI dựa trên role
+ */
+export const RoleGate: React.FC<{
+ requiredRoles: UserRole[]
+ children: ReactNode
+ fallback?: ReactNode
+}> = ({ requiredRoles, children, fallback }) => {
+ const { hasAnyRole } = useAuth()
+
+ if (!hasAnyRole(requiredRoles)) {
+ return <>{fallback || null}>
+ }
+
+ return <>{children}>
+}
diff --git a/src/components/auth/SignInForm.tsx b/src/components/auth/SignInForm.tsx
index bf11c12..714ac94 100644
--- a/src/components/auth/SignInForm.tsx
+++ b/src/components/auth/SignInForm.tsx
@@ -12,6 +12,7 @@ import { API, HOME_URL } from "../../../api";
import { setUserData } from "@/store/features/userSlice";
import { useDispatch } from "react-redux";
import { useRouter } from "next/navigation";
+import { saveUserToCookie } from "@/lib/cookieStorage";
export default function SignInForm() {
const router = useRouter();
@@ -73,7 +74,9 @@ export default function SignInForm() {
// console.log("Current User Data:", data);
if (data?.data) {
+ // Lưu user data vào Redux và cookies
dispatch(setUserData(data.data));
+ saveUserToCookie(data.data);
router.push("/");
}
} else {
diff --git a/src/components/header/UserDropdown.tsx b/src/components/header/UserDropdown.tsx
index 59832e7..97abb7f 100644
--- a/src/components/header/UserDropdown.tsx
+++ b/src/components/header/UserDropdown.tsx
@@ -4,14 +4,11 @@ import Link from "next/link";
import React, { useEffect, useState } from "react";
import { Dropdown } from "../ui/dropdown/Dropdown";
import { DropdownItem } from "../ui/dropdown/DropdownItem";
-import { fullDataUser } from "@/interface/admin";
import { UserMetaCardProps } from "@/interface/user";
import { apiGetCurrentUser, apiLogout } from "@/service/auth";
-import { useRouter } from "next/navigation";
+import { removeUserFromCookie } from "@/lib/cookieStorage";
export default function UserDropdown() {
- const router = useRouter();
-
const [isOpen, setIsOpen] = useState(false);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
@@ -47,6 +44,9 @@ export default function UserDropdown() {
} catch (error) {
console.error("Logout failed", error);
} finally {
+ // Xóa user data từ cookies
+ removeUserFromCookie();
+
localStorage.clear();
sessionStorage.clear();
diff --git a/src/config/routes.config.ts b/src/config/routes.config.ts
new file mode 100644
index 0000000..5c36d08
--- /dev/null
+++ b/src/config/routes.config.ts
@@ -0,0 +1,144 @@
+/**
+ * Role-Based Access Control Configuration
+ * Định nghĩa routes theo role và quyền truy cập
+ */
+
+export type UserRole = "ADMIN" | "MOD" | "HISTORIAN" | "USER"
+
+export interface RouteConfig {
+ path: string
+ allowedRoles: UserRole[]
+ label?: string
+}
+
+/**
+ * Public routes - không cần authentication
+ */
+export const PUBLIC_ROUTES = [
+ "/signin",
+ "/signup",
+ "/reset-password",
+ "/error-403",
+ "/error-404",
+ "/error-500",
+]
+
+
+export const PROTECTED_ROUTES: RouteConfig[] = [
+ // Dashboard
+ {
+ path: "/",
+ allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
+ label: "Dashboard",
+ },
+
+ // Admin/MOD + Historian routes
+ {
+ path: "/user-table",
+ allowedRoles: ["ADMIN", "MOD"],
+ label: "User Management",
+ },
+ {
+ path: "/applications-tables",
+ allowedRoles: ["ADMIN", "MOD"],
+ label: "Applications",
+ },
+
+ // All authenticated users
+ {
+ path: "/profile",
+ allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
+ label: "User Profile",
+ },
+ {
+ path: "/calendar",
+ allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
+ label: "Calendar",
+ },
+ {
+ path: "/line-chart",
+ allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
+ label: "Line Chart",
+ },
+ {
+ path: "/bar-chart",
+ allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
+ label: "Bar Chart",
+ },
+]
+
+/**
+ * Kiểm tra user role có được phép truy cập route không
+ */
+export const canAccessRoute = (
+ userRoles: UserRole[] | undefined,
+ routePath: string
+): boolean => {
+ // Nếu không có role, không được truy cập
+ if (!userRoles || userRoles.length === 0) {
+ return false
+ }
+
+ // Public routes không cần check
+ if (PUBLIC_ROUTES.includes(routePath)) {
+ return true
+ }
+
+ // Tìm route config theo độ dài path giảm dần để tránh route '/' khớp với tất cả
+ const routeConfig = PROTECTED_ROUTES
+ .slice()
+ .sort((a, b) => b.path.length - a.path.length)
+ .find(
+ (route) =>
+ routePath === route.path ||
+ (route.path !== "/" && routePath.startsWith(route.path + "/"))
+ )
+
+ // Nếu route không được định nghĩa, cho phép (mặc định)
+ if (!routeConfig) {
+ return true
+ }
+
+ // Check xem user có role được phép không
+ return userRoles.some((role) => routeConfig.allowedRoles.includes(role))
+}
+
+/**
+ * Lấy danh sách routes cho user role
+ */
+export const getAvailableRoutes = (userRoles: UserRole[] | undefined): RouteConfig[] => {
+ if (!userRoles || userRoles.length === 0) {
+ return []
+ }
+
+ return PROTECTED_ROUTES.filter((route) =>
+ userRoles.some((role) => route.allowedRoles.includes(role))
+ )
+}
+
+/**
+ * Kiểm tra user có role nào không
+ */
+export const hasRole = (userRoles: UserRole[] | undefined, role: UserRole): boolean => {
+ return userRoles?.includes(role) ?? false
+}
+
+/**
+ * Kiểm tra user có ít nhất một trong các role không
+ */
+export const hasAnyRole = (
+ userRoles: UserRole[] | undefined,
+ roles: UserRole[]
+): boolean => {
+ return userRoles?.some((role) => roles.includes(role)) ?? false
+}
+
+/**
+ * Kiểm tra user có tất cả các role không
+ */
+export const hasAllRoles = (
+ userRoles: UserRole[] | undefined,
+ roles: UserRole[]
+): boolean => {
+ return roles.every((role) => userRoles?.includes(role))
+}
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts
new file mode 100644
index 0000000..57ebee5
--- /dev/null
+++ b/src/hooks/useAuth.ts
@@ -0,0 +1,66 @@
+import { useAppSelector } from '@/store/store';
+
+import {
+ canAccessRoute,
+ hasRole,
+ hasAnyRole,
+ hasAllRoles,
+ getAvailableRoutes,
+ UserRole,
+} from "@/config/routes.config"
+
+/**
+ * Hook để kiểm tra authentication và authorization
+ */
+export const useAuth = () => {
+ const user = useAppSelector((state) => state.user.data)
+ const isAuthenticated = useAppSelector((state) => state.user.isAuthenticated)
+
+ const userRoles = user?.roles?.map((r) => r.name) as UserRole[] | undefined
+
+ return {
+ user,
+ isAuthenticated,
+ userRoles,
+
+ /**
+ * Kiểm tra user có role cụ thể không
+ */
+ hasRole: (role: UserRole) => hasRole(userRoles, role),
+
+ /**
+ * Kiểm tra user có ít nhất một trong các role không
+ */
+ hasAnyRole: (roles: UserRole[]) => hasAnyRole(userRoles, roles),
+
+ /**
+ * Kiểm tra user có tất cả các role không
+ */
+ hasAllRoles: (roles: UserRole[]) => hasAllRoles(userRoles, roles),
+
+ /**
+ * Kiểm tra user có quyền truy cập route không
+ */
+ canAccessRoute: (path: string) => canAccessRoute(userRoles, path),
+
+ /**
+ * Kiểm tra user là admin
+ */
+ isAdmin: () => hasRole(userRoles, "ADMIN"),
+
+ /**
+ * Kiểm tra user là moderator
+ */
+ isModerator: () => hasRole(userRoles, "MOD"),
+
+ /**
+ * Kiểm tra user là historian
+ */
+ isHistorian: () => hasRole(userRoles, "HISTORIAN"),
+
+ /**
+ * Lấy danh sách routes user có thể truy cập
+ */
+ getAvailableRoutes: () => getAvailableRoutes(userRoles),
+ }
+}
diff --git a/src/hooks/useRestoreUserData.ts b/src/hooks/useRestoreUserData.ts
new file mode 100644
index 0000000..61b98fa
--- /dev/null
+++ b/src/hooks/useRestoreUserData.ts
@@ -0,0 +1,19 @@
+"use client"
+
+import { useEffect } from "react"
+import { useDispatch } from "react-redux"
+import { setUserData, clearUserData } from "@/store/features/userSlice"
+import { getUserFromCookie } from "@/lib/cookieStorage"
+
+export const useRestoreUserData = () => {
+ const dispatch = useDispatch()
+
+ useEffect(() => {
+ const userData = getUserFromCookie()
+ if (userData) {
+ dispatch(setUserData(userData))
+ } else {
+ dispatch(clearUserData())
+ }
+ }, [dispatch])
+}
diff --git a/src/layout/AppSidebar.tsx b/src/layout/AppSidebar.tsx
index d12a378..51f5fad 100644
--- a/src/layout/AppSidebar.tsx
+++ b/src/layout/AppSidebar.tsx
@@ -18,6 +18,8 @@ import {
UserCircleIcon,
} from "../icons/index";
import SidebarWidget from "./SidebarWidget";
+import { useAuth } from "@/hooks/useAuth";
+import { canAccessRoute, PUBLIC_ROUTES } from "@/config/routes.config";
type NavItem = {
name: string;
@@ -101,6 +103,39 @@ const othersItems: NavItem[] = [
const AppSidebar: React.FC = () => {
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
const pathname = usePathname();
+ const { userRoles } = useAuth();
+
+ const hasAccess = (path?: string) => {
+ if (!path) return true;
+ if (!userRoles || userRoles.length === 0) {
+ return PUBLIC_ROUTES.includes(path) || ["/", "/profile", "/calendar"].includes(path);
+ }
+ return canAccessRoute(userRoles, path);
+ };
+
+ const buildNavItems = (items: NavItem[]) =>
+ items
+ .map((nav) => {
+ if (nav.path && !hasAccess(nav.path)) {
+ return null;
+ }
+
+ if (nav.subItems) {
+ const filteredItems = nav.subItems.filter((subItem) =>
+ hasAccess(subItem.path),
+ );
+ if (filteredItems.length === 0) {
+ return null;
+ }
+ return { ...nav, subItems: filteredItems };
+ }
+
+ return nav;
+ })
+ .filter((item): item is NavItem => item !== null);
+
+ const filteredMainNavItems = buildNavItems(navItems);
+ const filteredOthersNavItems = buildNavItems(othersItems);
const renderMenuItems = (
navItems: NavItem[],
@@ -357,7 +392,7 @@ const AppSidebar: React.FC = () => {
)}
- {renderMenuItems(navItems, "main")}
+ {renderMenuItems(filteredMainNavItems, "main")}
@@ -374,7 +409,7 @@ const AppSidebar: React.FC = () => {
)}
- {renderMenuItems(othersItems, "others")}
+ {renderMenuItems(filteredOthersNavItems, "others")}
diff --git a/src/lib/cookieStorage.ts b/src/lib/cookieStorage.ts
new file mode 100644
index 0000000..2c9ce3b
--- /dev/null
+++ b/src/lib/cookieStorage.ts
@@ -0,0 +1,37 @@
+import { UserData } from "@/interface/user"
+
+export const saveUserToCookie = (userData: UserData) => {
+ if (typeof document === "undefined") return
+
+ const userDataJson = JSON.stringify(userData)
+ document.cookie = `userDataRedux=${encodeURIComponent(userDataJson)}; path=/; max-age=86400`
+}
+
+
+export const removeUserFromCookie = () => {
+ if (typeof document === "undefined") return
+
+ document.cookie = "userDataRedux=; path=/; max-age=0"
+}
+
+export const getUserFromCookie = (): UserData | null => {
+ if (typeof document === "undefined") return null
+
+ const name = "userDataRedux="
+ const decodedCookie = decodeURIComponent(document.cookie)
+ const cookieArray = decodedCookie.split(";")
+
+ for (let cookie of cookieArray) {
+ cookie = cookie.trim()
+ if (cookie.indexOf(name) === 0) {
+ try {
+ const userData = JSON.parse(cookie.substring(name.length))
+ return userData
+ } catch (error) {
+ console.error("Error parsing user cookie:", error)
+ return null
+ }
+ }
+ }
+ return null
+}
diff --git a/src/store/StoreProvider.tsx b/src/store/StoreProvider.tsx
index f4adf37..38495e7 100644
--- a/src/store/StoreProvider.tsx
+++ b/src/store/StoreProvider.tsx
@@ -3,6 +3,13 @@
import { useRef } from 'react';
import { Provider } from 'react-redux';
import { store } from './store';
+import { useRestoreUserData } from '@/hooks/useRestoreUserData';
+
+
+function RestoreUserDataComponent() {
+ useRestoreUserData();
+ return null;
+}
export default function StoreProvider({
children,
@@ -10,5 +17,10 @@ export default function StoreProvider({
children: React.ReactNode;
}) {
const storeRef = useRef(store);
- return {children};
+ return (
+
+
+ {children}
+
+ );
}
\ No newline at end of file
diff --git a/src/store/features/userSlice.ts b/src/store/features/userSlice.ts
index aed21c5..41711e8 100644
--- a/src/store/features/userSlice.ts
+++ b/src/store/features/userSlice.ts
@@ -1,5 +1,6 @@
import { UserData } from '@/interface/user';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { getUserFromCookie } from '@/lib/cookieStorage';
const getStoredApplication = () => {
if (typeof window !== "undefined") {
@@ -9,15 +10,24 @@ const getStoredApplication = () => {
return null;
};
+const getStoredUserData = (): UserData | null => {
+ if (typeof window === "undefined") {
+ return null;
+ }
+ return getUserFromCookie();
+};
+
interface UserState {
data: UserData | null;
isAuthenticated: boolean;
selectedApplication: any | null;
}
+const storedUserData = getStoredUserData();
+
const initialState: UserState = {
- data: null,
- isAuthenticated: false,
+ data: storedUserData,
+ isAuthenticated: Boolean(storedUserData),
selectedApplication: getStoredApplication(),
};
@@ -29,6 +39,10 @@ const userSlice = createSlice({
state.data = action.payload;
state.isAuthenticated = true;
},
+ clearUserData: (state) => {
+ state.data = null;
+ state.isAuthenticated = false;
+ },
setSelectedApplication: (state, action: PayloadAction) => {
state.selectedApplication = action.payload;
if (typeof window !== "undefined") {
@@ -44,5 +58,5 @@ const userSlice = createSlice({
},
});
-export const { setUserData, setSelectedApplication, clearSelectedApplication } = userSlice.actions;
+export const { setUserData, clearUserData, setSelectedApplication, clearSelectedApplication } = userSlice.actions;
export default userSlice.reducer;
\ No newline at end of file
diff --git a/src/store/store.ts b/src/store/store.ts
index c3c8be0..29c2bd1 100644
--- a/src/store/store.ts
+++ b/src/store/store.ts
@@ -1,4 +1,5 @@
import { configureStore } from '@reduxjs/toolkit';
+import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux'; // Thêm dòng này
import userReducer from './features/userSlice';
export const store = configureStore({
@@ -8,4 +9,7 @@ export const store = configureStore({
});
export type RootState = ReturnType;
-export type AppDispatch = typeof store.dispatch;
\ No newline at end of file
+export type AppDispatch = typeof store.dispatch;
+
+export const useAppDispatch = () => useDispatch();
+export const useAppSelector: TypedUseSelectorHook = useSelector;
\ No newline at end of file