feat(admin): implement Phase 3 Admin SPA Router and Dual-Token Auth integration
This commit is contained in:
parent
71e19bfe43
commit
eaa2621a17
@ -1,55 +1,27 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { Suspense } from 'react';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import Layout from './components/Layout';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Login from './pages/Login';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import Users from './pages/Users';
|
||||
import Products from './pages/Products';
|
||||
import Orders from './pages/Orders';
|
||||
import Coupons from './pages/Coupons';
|
||||
import Settings from './pages/Settings';
|
||||
import Reports from './pages/Reports';
|
||||
import Categories from './pages/Categories';
|
||||
import Blogs from './pages/Blogs';
|
||||
import Wiki from './pages/Wiki';
|
||||
import Pets from './pages/Pets';
|
||||
import UITexts from './pages/UITexts';
|
||||
import Media from './pages/Media';
|
||||
import CMS from './pages/CMS';
|
||||
import WholesaleApplications from './pages/WholesaleApplications';
|
||||
import Videos from './pages/Videos';
|
||||
import ContactSubmissions from './pages/ContactSubmissions';
|
||||
import { router } from './routes/adminRoutes';
|
||||
|
||||
function SuspenseFallback() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 font-vazir" dir="rtl">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-10 h-10 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-sm font-bold text-gray-500">در حال بارگذاری ماژول ادمین...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<>
|
||||
<Toaster position="top-center" toastOptions={{ className: 'font-vazir' }} />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="products" element={<Products />} />
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="coupons" element={<Coupons />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="reports" element={<Reports />} />
|
||||
<Route path="categories" element={<Categories />} />
|
||||
<Route path="blogs" element={<Blogs />} />
|
||||
<Route path="wiki" element={<Wiki />} />
|
||||
<Route path="videos" element={<Videos />} />
|
||||
<Route path="pets" element={<Pets />} />
|
||||
<Route path="ui-texts" element={<UITexts />} />
|
||||
<Route path="media" element={<Media />} />
|
||||
<Route path="cms" element={<CMS />} />
|
||||
<Route path="wholesale" element={<WholesaleApplications />} />
|
||||
<Route path="contact" element={<ContactSubmissions />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,43 +1,75 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import api from '../services/api';
|
||||
import { useAdminAuthStore } from '../store/adminAuthStore';
|
||||
import type { ApiResponse, AuthResponseData, AdminUser } from '../types/admin';
|
||||
|
||||
export default function ProtectedRoute() {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const { isAuthenticated, accessToken, setAuth, clearAuth } = useAdminAuthStore();
|
||||
const [isValidating, setIsValidating] = useState(true);
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setIsValid(false);
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
|
||||
api.get('/users/profile')
|
||||
.then(() => {
|
||||
setIsValid(true);
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('adminToken');
|
||||
setIsValid(false);
|
||||
})
|
||||
.finally(() => {
|
||||
const validateSession = async () => {
|
||||
if (isAuthenticated && accessToken) {
|
||||
setIsValidating(false);
|
||||
});
|
||||
}, [token]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token || !isValid) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
try {
|
||||
const response = await api.post<ApiResponse<AuthResponseData>>('/auth/refresh');
|
||||
if (response.data?.success && response.data.data?.accessToken) {
|
||||
if (isMounted) {
|
||||
setAuth(response.data.data.accessToken, response.data.data.user);
|
||||
setIsValidating(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fallback profile check if token already in memory
|
||||
}
|
||||
|
||||
if (accessToken) {
|
||||
try {
|
||||
const profileRes = await api.get<ApiResponse<AdminUser>>('/users/profile');
|
||||
if (profileRes.data?.data && isMounted) {
|
||||
setAuth(accessToken, profileRes.data.data);
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Token invalid
|
||||
}
|
||||
}
|
||||
|
||||
if (isMounted) {
|
||||
clearAuth();
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
validateSession();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [isAuthenticated, accessToken, setAuth, clearAuth]);
|
||||
|
||||
if (isValidating) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 font-vazir" dir="rtl">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-10 h-10 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-sm font-bold text-gray-500">در حال اعتبارسنجی نشست کاری ادمین...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!useAdminAuthStore.getState().isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video, PhoneCall } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useAdminAuthStore } from '../store/adminAuthStore';
|
||||
|
||||
const menuGroups = [
|
||||
{
|
||||
@ -57,8 +58,10 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [newOrdersCount, setNewOrdersCount] = useState(0);
|
||||
const clearAuth = useAdminAuthStore((state) => state.clearAuth);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
useState(() => {
|
||||
const fetchOrdersCount = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/dashboard/stats');
|
||||
@ -70,14 +73,22 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
|
||||
}
|
||||
};
|
||||
fetchOrdersCount();
|
||||
// Poll stats every 60 seconds
|
||||
const interval = setInterval(fetchOrdersCount, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
intervalRef.current = setInterval(fetchOrdersCount, 60000);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('adminToken');
|
||||
navigate('/login');
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
} finally {
|
||||
clearAuth();
|
||||
localStorage.removeItem('adminToken');
|
||||
navigate('/login');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -2,6 +2,8 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Bell, Search, UserCircle, Menu } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import { useAdminAuthStore } from '../store/adminAuthStore';
|
||||
|
||||
interface TopbarProps {
|
||||
toggleMenu: () => void;
|
||||
@ -28,8 +30,9 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
const clearAuth = useAdminAuthStore((state) => state.clearAuth);
|
||||
const adminUser = useAdminAuthStore((state) => state.adminUser);
|
||||
|
||||
// Close search results dropdown on clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) {
|
||||
@ -68,12 +71,23 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
||||
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('adminToken');
|
||||
toast.success('خروج با موفقیت انجام شد');
|
||||
navigate('/login');
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} catch {
|
||||
// Ignore
|
||||
} finally {
|
||||
clearAuth();
|
||||
localStorage.removeItem('adminToken');
|
||||
toast.success('خروج با موفقیت انجام شد');
|
||||
navigate('/login');
|
||||
}
|
||||
};
|
||||
|
||||
const displayName = adminUser
|
||||
? `${adminUser.firstName || ''} ${adminUser.lastName || ''}`.trim() || adminUser.email || adminUser.mobile
|
||||
: 'مدیر سیستم';
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-4 sm:px-6 sticky top-0 z-40 shadow-sm font-vazir">
|
||||
<div className="flex-1 flex items-center gap-3 max-w-xl" ref={searchContainerRef}>
|
||||
@ -99,7 +113,6 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
||||
className="w-full bg-gray-50 border border-gray-200 text-gray-900 text-sm rounded-xl focus:ring-purple-500 focus:border-purple-500 block pr-10 p-2.5 outline-none font-bold transition-all"
|
||||
/>
|
||||
|
||||
{/* Live Search Results Dropdown */}
|
||||
{showResults && searchQuery.trim() !== '' && (
|
||||
<div className="absolute top-full right-0 left-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-2xl z-50 overflow-hidden max-h-60 overflow-y-auto">
|
||||
{filteredResults.length > 0 ? (
|
||||
@ -124,7 +137,6 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Realtime Notifications (TASK-2.20) */}
|
||||
<div className="relative" ref={notifRef}>
|
||||
<button
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
@ -159,15 +171,14 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Profile Dropdown (TASK-2.19) */}
|
||||
<div className="relative" ref={userMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-3 pl-2 border-l border-gray-200 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="text-left hidden md:block">
|
||||
<p className="text-sm font-bold text-gray-900">مدیر سیستم</p>
|
||||
<p className="text-xs font-medium text-purple-600">ادمین ارشد</p>
|
||||
<p className="text-sm font-bold text-gray-900">{displayName}</p>
|
||||
<p className="text-xs font-medium text-purple-600">{adminUser?.role || 'ادمین ارشد'}</p>
|
||||
</div>
|
||||
<UserCircle className="w-10 h-10 text-purple-600" />
|
||||
</button>
|
||||
|
||||
@ -1,25 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Lock, Mail, ChevronLeft } from 'lucide-react';
|
||||
import { Lock, Mail, Phone, ChevronLeft, KeyRound } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import { useAdminAuthStore } from '../store/adminAuthStore';
|
||||
import type { ApiResponse, AuthResponseData } from '../types/admin';
|
||||
|
||||
export default function Login() {
|
||||
const [loginMode, setLoginMode] = useState<'password' | 'otp'>('password');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const [phoneNumber, setPhoneNumber] = useState('');
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [otpSent, setOtpSent] = useState(false);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const setAuth = useAdminAuthStore((state) => state.setAuth);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.post('/auth/admin-login', { email, password });
|
||||
if (response.data?.success) {
|
||||
localStorage.setItem('adminToken', response.data.data.accessToken);
|
||||
const response = await api.post<ApiResponse<AuthResponseData>>('/auth/admin-login', { email, password });
|
||||
if (response.data?.success && response.data.data) {
|
||||
setAuth(response.data.data.accessToken, response.data.data.user);
|
||||
toast.success('ورود با موفقیت انجام شد');
|
||||
navigate('/');
|
||||
} else {
|
||||
toast.error('اطلاعات ورود نامعتبر است');
|
||||
}
|
||||
} catch {
|
||||
toast.error('ایمیل یا رمز عبور اشتباه است');
|
||||
@ -28,6 +39,51 @@ export default function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!phoneNumber || phoneNumber.length < 10) {
|
||||
toast.error('شماره موبایل معتبر وارد کنید');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.post<ApiResponse>('/auth/send-otp', { phoneNumber });
|
||||
if (response.data?.success) {
|
||||
toast.success(response.data.message || 'کد تایید پیامک شد');
|
||||
setOtpSent(true);
|
||||
}
|
||||
} catch {
|
||||
toast.error('خطا در ارسال کد تایید');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!otpCode || otpCode.length < 4) {
|
||||
toast.error('کد تایید معتبر وارد کنید');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.post<ApiResponse<AuthResponseData>>('/auth/verify-otp', { phoneNumber, code: otpCode });
|
||||
if (response.data?.success && response.data.data) {
|
||||
setAuth(response.data.data.accessToken, response.data.data.user);
|
||||
toast.success('ورود موفقیتآمیز بود');
|
||||
navigate('/');
|
||||
} else {
|
||||
toast.error('کد تایید نامعتبر است');
|
||||
}
|
||||
} catch {
|
||||
toast.error('کد تایید اشتباه یا منقضی شده است');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8 font-vazir" dir="rtl">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
@ -44,54 +100,146 @@ export default function Login() {
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow-xl shadow-gray-100 sm:rounded-3xl sm:px-10 border border-gray-100">
|
||||
<form className="space-y-6" onSubmit={handleLogin}>
|
||||
<div>
|
||||
<label className="block text-sm font-black text-gray-700">آدرس ایمیل</label>
|
||||
<div className="mt-2 relative">
|
||||
<div className="absolute inset-y-0 right-0 pl-3 flex items-center pr-3 pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="block w-full pl-3 pr-10 py-3 border border-gray-200 rounded-xl focus:ring-purple-500 focus:border-purple-500 sm:text-sm bg-gray-50 font-bold transition-all"
|
||||
placeholder="admin@canino-iran.com"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Toggle Login Mode */}
|
||||
<div className="flex bg-gray-100 p-1 rounded-xl mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setLoginMode('password'); setOtpSent(false); }}
|
||||
className={`flex-1 py-2 text-xs font-black rounded-lg transition-all ${loginMode === 'password' ? 'bg-white text-purple-600 shadow-sm' : 'text-gray-500 hover:text-gray-900'}`}
|
||||
>
|
||||
ورود با رمز عبور
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setLoginMode('otp'); setOtpSent(false); }}
|
||||
className={`flex-1 py-2 text-xs font-black rounded-lg transition-all ${loginMode === 'otp' ? 'bg-white text-purple-600 shadow-sm' : 'text-gray-500 hover:text-gray-900'}`}
|
||||
>
|
||||
ورود با پیامک (OTP)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-black text-gray-700">رمز عبور</label>
|
||||
<div className="mt-2 relative">
|
||||
<div className="absolute inset-y-0 right-0 pl-3 flex items-center pr-3 pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
{loginMode === 'password' ? (
|
||||
<form className="space-y-6" onSubmit={handlePasswordLogin}>
|
||||
<div>
|
||||
<label className="block text-sm font-black text-gray-700">آدرس ایمیل ادمین</label>
|
||||
<div className="mt-2 relative">
|
||||
<div className="absolute inset-y-0 right-0 pl-3 flex items-center pr-3 pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="block w-full pl-3 pr-10 py-3 border border-gray-200 rounded-xl focus:ring-purple-500 focus:border-purple-500 sm:text-sm bg-gray-50 font-bold transition-all"
|
||||
placeholder="admin@canino-iran.com"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full pl-3 pr-10 py-3 border border-gray-200 rounded-xl focus:ring-purple-500 focus:border-purple-500 sm:text-sm bg-gray-50 font-bold transition-all text-left"
|
||||
placeholder="••••••••"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-black text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{isLoading ? 'در حال ورود...' : 'ورود به سیستم'}
|
||||
{!isLoading && <ChevronLeft className="w-5 h-5" />}
|
||||
</button>
|
||||
<div>
|
||||
<label className="block text-sm font-black text-gray-700">رمز عبور</label>
|
||||
<div className="mt-2 relative">
|
||||
<div className="absolute inset-y-0 right-0 pl-3 flex items-center pr-3 pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full pl-3 pr-10 py-3 border border-gray-200 rounded-xl focus:ring-purple-500 focus:border-purple-500 sm:text-sm bg-gray-50 font-bold transition-all text-left"
|
||||
placeholder="••••••••"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-black text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{isLoading ? 'در حال ورود...' : 'ورود به سیستم'}
|
||||
{!isLoading && <ChevronLeft className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{!otpSent ? (
|
||||
<form className="space-y-6" onSubmit={handleSendOtp}>
|
||||
<div>
|
||||
<label className="block text-sm font-black text-gray-700">شماره تلفن همراه</label>
|
||||
<div className="mt-2 relative">
|
||||
<div className="absolute inset-y-0 right-0 pl-3 flex items-center pr-3 pointer-events-none">
|
||||
<Phone className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
className="block w-full pl-3 pr-10 py-3 border border-gray-200 rounded-xl focus:ring-purple-500 focus:border-purple-500 sm:text-sm bg-gray-50 font-bold transition-all text-left"
|
||||
placeholder="09123456789"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-black text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{isLoading ? 'در حال ارسال کد...' : 'ارسال کد تایید پیامکی'}
|
||||
{!isLoading && <ChevronLeft className="w-5 h-5" />}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleVerifyOtp}>
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<label className="block text-sm font-black text-gray-700">کد تایید ۵ رقمی</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOtpSent(false)}
|
||||
className="text-xs text-purple-600 font-bold hover:underline"
|
||||
>
|
||||
تغییر شماره
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 relative">
|
||||
<div className="absolute inset-y-0 right-0 pl-3 flex items-center pr-3 pointer-events-none">
|
||||
<KeyRound className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value)}
|
||||
className="block w-full pl-3 pr-10 py-3 border border-gray-200 rounded-xl focus:ring-purple-500 focus:border-purple-500 sm:text-sm bg-gray-50 font-bold transition-all text-center tracking-widest text-lg"
|
||||
placeholder="12345"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-black text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{isLoading ? 'در حال تایید...' : 'تایید و ورود به پنل'}
|
||||
{!isLoading && <ChevronLeft className="w-5 h-5" />}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -44,6 +44,8 @@ export interface Product {
|
||||
videoUrl?: string;
|
||||
pdfUrl?: string;
|
||||
symptoms?: Array<{ symptom: string } | string>;
|
||||
isPreorder?: boolean;
|
||||
preorderDeposit?: string | number;
|
||||
}
|
||||
|
||||
export default function Products() {
|
||||
@ -214,8 +216,8 @@ export default function Products() {
|
||||
videoUrl: product.videoUrl || '',
|
||||
pdfUrl: product.pdfUrl || '',
|
||||
symptoms: product.symptoms ? product.symptoms.map((s: { symptom: string } | string) => typeof s === 'string' ? s : s.symptom) : [],
|
||||
isPreorder: Boolean((product as any).isPreorder),
|
||||
preorderDeposit: (product as any).preorderDeposit || ''
|
||||
isPreorder: Boolean(product.isPreorder),
|
||||
preorderDeposit: product.preorderDeposit || ''
|
||||
});
|
||||
} else {
|
||||
setEditingProduct(null);
|
||||
|
||||
@ -6,6 +6,8 @@ import Skeleton from '../components/ui/Skeleton';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
|
||||
import type { UserAddress, PetSummary } from '../types/admin';
|
||||
|
||||
export interface UserRecord {
|
||||
id: string;
|
||||
firstName?: string;
|
||||
@ -16,8 +18,8 @@ export interface UserRecord {
|
||||
status?: string;
|
||||
walletBalance?: number;
|
||||
createdAt?: string;
|
||||
addresses?: any[];
|
||||
pets?: any[];
|
||||
addresses?: UserAddress[];
|
||||
pets?: PetSummary[];
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
@ -290,7 +292,7 @@ export default function Users() {
|
||||
<h4 className="font-bold text-gray-900 mb-2">آدرسهای ثبتشده ({viewUser.addresses?.length || 0})</h4>
|
||||
{viewUser.addresses && viewUser.addresses.length > 0 ? (
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{viewUser.addresses.map((a: any, idx: number) => (
|
||||
{viewUser.addresses.map((a: UserAddress, idx: number) => (
|
||||
<div key={idx} className="p-2.5 bg-gray-50 rounded-lg border border-gray-200/60">
|
||||
<p className="font-bold text-gray-800">{a.title} - {a.receptorName}</p>
|
||||
<p className="text-gray-500 mt-0.5">{a.province}، {a.city}، {a.detail}</p>
|
||||
|
||||
69
frontend/admin-panel/src/routes/adminRoutes.tsx
Normal file
69
frontend/admin-panel/src/routes/adminRoutes.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { lazy, ComponentType } from 'react';
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import ProtectedRoute from '../components/ProtectedRoute';
|
||||
import Login from '../pages/Login';
|
||||
|
||||
// Lazy loading admin pages
|
||||
const Dashboard = lazy(() => import('../pages/Dashboard'));
|
||||
const Users = lazy(() => import('../pages/Users'));
|
||||
const Products = lazy(() => import('../pages/Products'));
|
||||
const Orders = lazy(() => import('../pages/Orders'));
|
||||
const Coupons = lazy(() => import('../pages/Coupons'));
|
||||
const Settings = lazy(() => import('../pages/Settings'));
|
||||
const Reports = lazy(() => import('../pages/Reports'));
|
||||
const Categories = lazy(() => import('../pages/Categories'));
|
||||
const Blogs = lazy(() => import('../pages/Blogs'));
|
||||
const Wiki = lazy(() => import('../pages/Wiki'));
|
||||
const Pets = lazy(() => import('../pages/Pets'));
|
||||
const UITexts = lazy(() => import('../pages/UITexts'));
|
||||
const Media = lazy(() => import('../pages/Media'));
|
||||
const CMS = lazy(() => import('../pages/CMS'));
|
||||
const WholesaleApplications = lazy(() => import('../pages/WholesaleApplications'));
|
||||
const Videos = lazy(() => import('../pages/Videos'));
|
||||
const ContactSubmissions = lazy(() => import('../pages/ContactSubmissions'));
|
||||
|
||||
export interface AdminRouteConfig {
|
||||
path: string;
|
||||
component: ComponentType;
|
||||
index?: boolean;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ path: 'users/*', element: <Users /> },
|
||||
{ path: 'products/*', element: <Products /> },
|
||||
{ path: 'orders/*', element: <Orders /> },
|
||||
{ path: 'coupons/*', element: <Coupons /> },
|
||||
{ path: 'settings/*', element: <Settings /> },
|
||||
{ path: 'reports/*', element: <Reports /> },
|
||||
{ path: 'categories/*', element: <Categories /> },
|
||||
{ path: 'blogs/*', element: <Blogs /> },
|
||||
{ path: 'wiki/*', element: <Wiki /> },
|
||||
{ path: 'videos/*', element: <Videos /> },
|
||||
{ path: 'pets/*', element: <Pets /> },
|
||||
{ path: 'ui-texts/*', element: <UITexts /> },
|
||||
{ path: 'media/*', element: <Media /> },
|
||||
{ path: 'cms/*', element: <CMS /> },
|
||||
{ path: 'wholesale/*', element: <WholesaleApplications /> },
|
||||
{ path: 'contact/*', element: <ContactSubmissions /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to="/" replace />,
|
||||
},
|
||||
]);
|
||||
@ -1,5 +1,8 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAdminAuthStore } from '../store/adminAuthStore';
|
||||
import type { ApiResponse, AuthResponseData } from '../types/admin';
|
||||
|
||||
export const BASE_DOMAIN = import.meta.env.VITE_API_URL
|
||||
? import.meta.env.VITE_API_URL.replace(/\/api$/, '')
|
||||
@ -19,13 +22,31 @@ export interface ApiErrorPayload {
|
||||
|
||||
const api = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
const processQueue = (error: unknown, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else if (token) {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = useAdminAuthStore.getState().accessToken;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
@ -34,20 +55,70 @@ api.interceptors.request.use((config) => {
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const responseData = error.response?.data as ApiErrorPayload | undefined;
|
||||
async (error: AxiosError<ApiErrorPayload>) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
const responseData = error.response?.data;
|
||||
const status = error.response?.status;
|
||||
const farsiMessage = responseData?.message || 'خطایی در ارتباط با سرور رخ داده است.';
|
||||
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('adminToken');
|
||||
if (window.location.pathname !== '/login') {
|
||||
toast.error('نشست کاری شما منقضی شده است. لطفاً مجدداً وارد شوید.');
|
||||
window.location.href = '/login';
|
||||
if (status === 401 && originalRequest && !originalRequest._retry) {
|
||||
if (originalRequest.url?.includes('/auth/refresh') || originalRequest.url?.includes('/auth/admin-login') || originalRequest.url?.includes('/auth/verify-otp')) {
|
||||
useAdminAuthStore.getState().clearAuth();
|
||||
if (window.location.pathname !== '/login') {
|
||||
toast.error('نشست کاری شما منقضی شده است. لطفاً مجدداً وارد شوید.');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
} else if (status === 403) {
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return api(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshResponse = await axios.post<ApiResponse<AuthResponseData>>(
|
||||
`${baseURL}/auth/refresh`,
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
);
|
||||
|
||||
if (refreshResponse.data?.success && refreshResponse.data.data?.accessToken) {
|
||||
const newToken = refreshResponse.data.data.accessToken;
|
||||
const user = refreshResponse.data.data.user;
|
||||
useAdminAuthStore.getState().setAuth(newToken, user);
|
||||
processQueue(null, newToken);
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return api(originalRequest);
|
||||
} else {
|
||||
throw new Error('Refresh failed');
|
||||
}
|
||||
} catch (refreshErr) {
|
||||
processQueue(refreshErr, null);
|
||||
useAdminAuthStore.getState().clearAuth();
|
||||
if (window.location.pathname !== '/login') {
|
||||
toast.error('نشست کاری شما منقضی شده است. لطفاً مجدداً وارد شوید.');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(refreshErr);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
toast.error('دسترسی شما به این بخش از پنل ادمین مجاز نمیباشد.');
|
||||
} else {
|
||||
} else if (status !== 401) {
|
||||
if (responseData?.details && Array.isArray(responseData.details) && responseData.details.length > 0) {
|
||||
toast.error(responseData.details[0]?.message || farsiMessage);
|
||||
} else {
|
||||
|
||||
22
frontend/admin-panel/src/store/adminAuthStore.ts
Normal file
22
frontend/admin-panel/src/store/adminAuthStore.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { create } from 'zustand';
|
||||
import type { AuthState, AdminUser } from '../types/admin';
|
||||
|
||||
export const useAdminAuthStore = create<AuthState>((set) => ({
|
||||
accessToken: null,
|
||||
adminUser: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
setAuth: (accessToken: string, adminUser: AdminUser) =>
|
||||
set({
|
||||
accessToken,
|
||||
adminUser,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
|
||||
clearAuth: () =>
|
||||
set({
|
||||
accessToken: null,
|
||||
adminUser: null,
|
||||
isAuthenticated: false,
|
||||
}),
|
||||
}));
|
||||
68
frontend/admin-panel/src/types/admin.ts
Normal file
68
frontend/admin-panel/src/types/admin.ts
Normal file
@ -0,0 +1,68 @@
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
email?: string | null;
|
||||
mobile: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
role: string;
|
||||
walletBalance?: string | number;
|
||||
charityDonationTotal?: string | number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
accessToken: string | null;
|
||||
adminUser: AdminUser | null;
|
||||
isAuthenticated: boolean;
|
||||
setAuth: (accessToken: string, adminUser: AdminUser) => void;
|
||||
clearAuth: () => void;
|
||||
}
|
||||
|
||||
export interface SendOtpPayload {
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
export interface VerifyOtpPayload {
|
||||
phoneNumber: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface AdminLoginPayload {
|
||||
email: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponseData {
|
||||
user: AdminUser;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
code?: string;
|
||||
details?: Array<{ field: string; message: string }> | Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UserAddress {
|
||||
id: string;
|
||||
title: string;
|
||||
receptorName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
detail: string;
|
||||
zipCode: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface PetSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
breed?: string;
|
||||
age?: number;
|
||||
weight?: number;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user