feat: implement authentication modules and add daily statistics dashboard component
Build and Release / release (push) Successful in 28s

This commit is contained in:
2026-05-14 11:40:37 +07:00
parent 6363377b9a
commit a7c3aecc6e
9 changed files with 70 additions and 26 deletions
+60 -9
View File
@@ -1,13 +1,17 @@
"use client"; "use client";
import { useSidebar } from "@/context/SidebarContext"; import { useSidebar } from "@/context/SidebarContext";
import { UserData } from "@/interface/user";
import AppHeader from "@/layout/AppHeader"; import AppHeader from "@/layout/AppHeader";
import AppSidebar from "@/layout/AppSidebar"; import AppSidebar from "@/layout/AppSidebar";
import Backdrop from "@/layout/Backdrop"; import Backdrop from "@/layout/Backdrop";
import { apiGetCurrentUser } from "@/service/auth"; import { apiGetCurrentUser } from "@/service/auth";
import { setUserData } from "@/store/features/userSlice"; import { setUserData } from "@/store/features/userSlice";
import React, { useEffect } from "react"; import { RootState } from "@/store/store";
import { useDispatch } from "react-redux"; import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { toast } from "sonner";
export default function AdminLayout({ export default function AdminLayout({
children, children,
@@ -15,20 +19,44 @@ export default function AdminLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { isExpanded, isHovered, isMobileOpen } = useSidebar(); const { isExpanded, isHovered, isMobileOpen } = useSidebar();
const dispatch = useDispatch() const dispatch = useDispatch();
const router = useRouter();
const userData = useSelector((state: RootState) => state.user.data);
const [isLoading, setIsLoading] = useState(!userData);
useEffect(() => { useEffect(() => {
if (userData) {
setIsLoading(false);
return;
}
const fetchUser = async () => { const fetchUser = async () => {
try { try {
const userData = await apiGetCurrentUser(); setIsLoading(true);
dispatch(setUserData(userData.data)); const res = await apiGetCurrentUser();
} catch (err) { const userData: UserData = res;
console.error("Lỗi:", err);
const allowedRoles = ["ADMIN", "MOD", "HISTORIAN"];
const isBanned = userData.roles.some((role) => role.name === "BANNED");
const hasPermission = userData.roles.some((role) =>
allowedRoles.includes(role.name)
);
if (isBanned || !hasPermission) {
toast.error("Bạn không có quyền truy cập");
router.replace("/auth/signin");
}
dispatch(setUserData(res.data));
} catch {
router.replace("/auth/signin");
} finally {
setIsLoading(false);
} }
}; };
fetchUser(); fetchUser();
}, []) }, [dispatch, userData, router]);
// Dynamic class for main content margin based on sidebar state // Dynamic class for main content margin based on sidebar state
const mainContentMargin = isMobileOpen const mainContentMargin = isMobileOpen
@@ -37,6 +65,29 @@ export default function AdminLayout({
? "lg:ml-[290px]" ? "lg:ml-[290px]"
: "lg:ml-[90px]"; : "lg:ml-[90px]";
if (isLoading || !userData) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white dark:bg-gray-900">
<div className="flex flex-col items-center gap-5">
{/* Spinner */}
<div className="relative h-14 w-14">
<div className="absolute inset-0 rounded-full border-4 border-gray-200 dark:border-gray-700" />
<div className="absolute inset-0 rounded-full border-4 border-transparent border-t-blue-500 animate-spin" />
</div>
{/* Text */}
<div className="flex flex-col items-center gap-1.5">
<p className="text-base font-semibold text-gray-800 dark:text-gray-100">
Đang tải dữ liệu
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 animate-pulse">
Vui lòng chờ trong giây lát...
</p>
</div>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen xl:flex"> <div className="min-h-screen xl:flex">
{/* Sidebar and Backdrop */} {/* Sidebar and Backdrop */}
@@ -1,9 +1,5 @@
import GridShape from "@/components/common/GridShape";
import ThemeTogglerTwo from "@/components/common/ThemeTogglerTwo"; import ThemeTogglerTwo from "@/components/common/ThemeTogglerTwo";
import { ThemeProvider } from "@/context/ThemeContext"; import { ThemeProvider } from "@/context/ThemeContext";
import Image from "next/image";
import Link from "next/link";
import React from "react"; import React from "react";
export default function AuthLayout({ export default function AuthLayout({
@@ -100,7 +100,7 @@ export default function ResetPasswordForm() {
<div className="flex flex-col flex-1 w-full lg:w-1/2 overflow-y-auto no-scrollbar"> <div className="flex flex-col flex-1 w-full lg:w-1/2 overflow-y-auto no-scrollbar">
<div className="w-full max-w-md mx-auto mb-5 sm:pt-10"> <div className="w-full max-w-md mx-auto mb-5 sm:pt-10">
<Link <Link
href="/signin" href="/auth/signin"
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
> >
<ChevronLeftIcon /> <ChevronLeftIcon />
+3 -4
View File
@@ -71,7 +71,6 @@ export default function SignInForm() {
toast.success("Đăng nhập thành công!"); toast.success("Đăng nhập thành công!");
const data = await apiGetCurrentUser(); const data = await apiGetCurrentUser();
// console.log("Current User Data:", data);
if (data?.data) { if (data?.data) {
dispatch(setUserData(data.data)); dispatch(setUserData(data.data));
router.push("/"); router.push("/");
@@ -79,7 +78,7 @@ export default function SignInForm() {
} else { } else {
toast.error("Email hoặc mật khẩu không đúng."); toast.error("Email hoặc mật khẩu không đúng.");
} }
} catch (error) { } catch {
setErrorMsg("Lỗi khi đăng nhập. Vui lòng thử lại."); setErrorMsg("Lỗi khi đăng nhập. Vui lòng thử lại.");
toast.error("Đăng nhập thất bại. Vui lòng kiểm tra lại thông tin."); toast.error("Đăng nhập thất bại. Vui lòng kiểm tra lại thông tin.");
} finally { } finally {
@@ -206,7 +205,7 @@ export default function SignInForm() {
</span> </span>
</div> </div>
<Link <Link
href="/reset-password" href="/auth/reset-password"
className="text-sm text-brand-500 hover:text-brand-600 dark:text-brand-400" className="text-sm text-brand-500 hover:text-brand-600 dark:text-brand-400"
> >
Forgot password? Forgot password?
@@ -256,7 +255,7 @@ export default function SignInForm() {
<p className="text-sm font-normal text-center text-gray-700 dark:text-gray-400 sm:text-start"> <p className="text-sm font-normal text-center text-gray-700 dark:text-gray-400 sm:text-start">
Don&apos;t have an account? {""} Don&apos;t have an account? {""}
<Link <Link
href="/signup" href="/auth/signup"
className="text-brand-500 hover:text-brand-600 dark:text-brand-400" className="text-brand-500 hover:text-brand-600 dark:text-brand-400"
> >
Sign Up Sign Up
+4 -4
View File
@@ -72,7 +72,7 @@ export default function SignUpForm() {
setLoading(true); setLoading(true);
await apiCreateOTP(formData.email); await apiCreateOTP(formData.email);
setStep(2); setStep(2);
} catch (error) { } catch {
setErrorMsg("Lỗi khi tạo OTP. Vui lòng thử lại."); setErrorMsg("Lỗi khi tạo OTP. Vui lòng thử lại.");
toast.error("Tạo OTP thất bại. Vui lòng kiểm tra lại thông tin."); toast.error("Tạo OTP thất bại. Vui lòng kiểm tra lại thông tin.");
} finally { } finally {
@@ -106,7 +106,7 @@ export default function SignUpForm() {
token_id: tokenId, token_id: tokenId,
}; };
const signupRes = await apiSignUp(signupPayload); await apiSignUp(signupPayload);
await Swal.fire({ await Swal.fire({
title: "Tạo tài khoản thành công!. Quay về trang đăng nhập để tiếp tục", title: "Tạo tài khoản thành công!. Quay về trang đăng nhập để tiếp tục",
@@ -131,7 +131,7 @@ export default function SignUpForm() {
<div className="flex flex-col flex-1 lg:w-1/2 w-full overflow-y-auto no-scrollbar"> <div className="flex flex-col flex-1 lg:w-1/2 w-full overflow-y-auto no-scrollbar">
<div className="w-full max-w-md sm:pt-10 mx-auto mb-5"> <div className="w-full max-w-md sm:pt-10 mx-auto mb-5">
<Link <Link
href="/signin" href="/auth/signin"
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
> >
<ChevronLeftIcon /> <ChevronLeftIcon />
@@ -332,7 +332,7 @@ export default function SignUpForm() {
<p className="text-sm font-normal text-center text-gray-700 dark:text-gray-400 sm:text-start"> <p className="text-sm font-normal text-center text-gray-700 dark:text-gray-400 sm:text-start">
Already have an account?{" "} Already have an account?{" "}
<Link <Link
href="/signin" href="/auth/signin"
className="text-brand-500 hover:text-brand-600 dark:text-brand-400" className="text-brand-500 hover:text-brand-600 dark:text-brand-400"
> >
Sign In Sign In
+1 -3
View File
@@ -31,7 +31,6 @@ export default function MonthlyNewChart() {
try { try {
const response = await getStatistics({ start_date: start, end_date: end }); const response = await getStatistics({ start_date: start, end_date: end });
if (response?.data) { if (response?.data) {
// Tổng hợp theo tháng
const monthsMap: { [key: string]: number } = {}; const monthsMap: { [key: string]: number } = {};
response.data.forEach(item => { response.data.forEach(item => {
@@ -56,8 +55,7 @@ export default function MonthlyNewChart() {
setChartData(aggregated); setChartData(aggregated);
} }
} catch (error) { } catch {
console.error("Failed to fetch chart data", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
+1 -1
View File
@@ -67,7 +67,7 @@ api.interceptors.response.use(
isRefreshing = true; isRefreshing = true;
try { try {
await axios.post(`${baseURL}/auth/refresh`, {}, { withCredentials: true }); await axios.post(`${baseURL}/auth/refresh`, undefined, { withCredentials: true });
processQueue(null); processQueue(null);