diff --git a/src/App.tsx b/src/App.tsx
index 761c200..5ebbe36 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,19 +1,13 @@
import { useEffect } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
-import { useAppDispatch, useAppSelector } from '@/utils/hooks';
-import { hydrateAuth } from '@/features/auth/authSlice';
+import { useAppSelector } from '@/utils/hooks';
import AdminLayout from '@/components/layout/AdminLayout';
function App() {
- const dispatch = useAppDispatch();
const location = useLocation();
const navigate = useNavigate();
const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated);
- useEffect(() => {
- dispatch(hydrateAuth());
- }, [dispatch]);
-
useEffect(() => {
if (!isAuthenticated && location.pathname !== '/login' && location.pathname !== '/verify-otp') {
navigate('/login', { replace: true });
diff --git a/src/components/layout/AdminLayout.tsx b/src/components/layout/AdminLayout.tsx
index 0f6148b..f8b4bd2 100644
--- a/src/components/layout/AdminLayout.tsx
+++ b/src/components/layout/AdminLayout.tsx
@@ -6,6 +6,7 @@ import winsooLogo from "@/assets/images/winsoo.svg";
import { getSidebarMenuItems } from "@/constants/navigation";
import { colors } from "@/design/tokens/colors";
import { spacing } from "@/design/tokens/spacing";
+import LogoutButton from "@/features/auth/LogoutButton";
const { Header, Content, Sider } = Layout;
const { Title } = Typography;
@@ -137,6 +138,8 @@ const AdminLayout = ({ children }: AdminLayoutProps) => {
پنل ادمین وینسو
+
+
diff --git a/src/features/auth/LogoutButton.tsx b/src/features/auth/LogoutButton.tsx
new file mode 100644
index 0000000..933baad
--- /dev/null
+++ b/src/features/auth/LogoutButton.tsx
@@ -0,0 +1,90 @@
+import { Button, Modal, Typography } from 'antd';
+import { LogOut } from 'lucide';
+import { useState } from 'react';
+import LucideIcon from '@/components/ui/LucideIcon';
+import { colors } from '@/design/tokens/colors';
+import { spacing } from '@/design/tokens/spacing';
+import { useLogout } from '@/features/auth/useLogout';
+
+const { Title, Paragraph } = Typography;
+
+const LogoutButton = () => {
+ const { logout, isLoggingOut } = useLogout();
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ const handleConfirm = () => {
+ void logout();
+ };
+
+ return (
+ <>
+ }
+ onClick={() => setIsModalOpen(true)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: spacing.xs,
+ height: 40,
+ paddingInline: spacing.md,
+ borderRadius: 8,
+ background: 'rgba(255,255,255,0.15)',
+ color: colors.background,
+ }}
+ >
+ خروج
+
+
+ setIsModalOpen(false)}
+ centered
+ footer={null}
+ width={380}
+ mask={{ closable: !isLoggingOut }}
+ closable={!isLoggingOut}
+ >
+
+
+
+
+
+
+ خروج از حساب کاربری
+
+
+ آیا مطمئن هستید که میخواهید از پنل ادمین خارج شوید؟
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default LogoutButton;
diff --git a/src/features/auth/authService.ts b/src/features/auth/authService.ts
index 1be9093..65839d1 100644
--- a/src/features/auth/authService.ts
+++ b/src/features/auth/authService.ts
@@ -19,4 +19,8 @@ export const authService = {
},
};
},
+
+ logout: async (): Promise => {
+ await authApi.logout();
+ },
};
diff --git a/src/features/auth/authSlice.ts b/src/features/auth/authSlice.ts
index 1376559..e957a16 100644
--- a/src/features/auth/authSlice.ts
+++ b/src/features/auth/authSlice.ts
@@ -1,17 +1,42 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthState, LoginSuccessPayload } from '@/features/auth/authTypes';
import { STORAGE_KEYS } from '@/utils/storage';
-import { getAccessToken, storeAuthTokens } from '@/utils/authToken';
+import { clearAuthTokens, getAccessToken, isAccessTokenExpired, storeAuthTokens } from '@/utils/authToken';
-const initialState: AuthState = {
- user: null,
- accessToken: null,
- isAuthenticated: false,
+const clearPersistedAuthState = () => {
+ localStorage.removeItem(STORAGE_KEYS.accessToken);
+ localStorage.removeItem(STORAGE_KEYS.user);
+ localStorage.removeItem(STORAGE_KEYS.authState);
+ clearAuthTokens();
+};
+
+const loadPersistedAuthState = (): AuthState => {
+ const loggedOutState: AuthState = { user: null, accessToken: null, isAuthenticated: false };
+ const storedAuth = localStorage.getItem(STORAGE_KEYS.authState);
+ const storedAccessToken = getAccessToken();
+
+ if (!storedAuth) {
+ return loggedOutState;
+ }
+
+ try {
+ const parsed = JSON.parse(storedAuth) as AuthState;
+ const accessToken = parsed.accessToken || storedAccessToken;
+
+ if (accessToken && parsed.user && !isAccessTokenExpired()) {
+ return { user: parsed.user, accessToken, isAuthenticated: true };
+ }
+ } catch {
+ // fall through to the logged-out default below
+ }
+
+ clearPersistedAuthState();
+ return loggedOutState;
};
const authSlice = createSlice({
name: 'auth',
- initialState,
+ initialState: loadPersistedAuthState(),
reducers: {
loginSuccess: (state, action: PayloadAction) => {
state.user = action.payload.user;
@@ -38,32 +63,9 @@ const authSlice = createSlice({
state.accessToken = null;
state.isAuthenticated = false;
- localStorage.removeItem(STORAGE_KEYS.accessToken);
- localStorage.removeItem(STORAGE_KEYS.user);
- localStorage.removeItem(STORAGE_KEYS.authState);
- },
- hydrateAuth: (state) => {
- const storedAuth = localStorage.getItem(STORAGE_KEYS.authState);
- const storedAccessToken = getAccessToken();
-
- if (!storedAuth) {
- return;
- }
-
- try {
- const parsed = JSON.parse(storedAuth) as AuthState;
-
- if ((parsed.accessToken || storedAccessToken) && parsed.user) {
- state.user = parsed.user;
- state.accessToken = parsed.accessToken || storedAccessToken;
- state.isAuthenticated = true;
- }
- } catch {
- state.user = null;
- state.accessToken = null;
- state.isAuthenticated = false;
- }
+ clearPersistedAuthState();
},
+ hydrateAuth: () => loadPersistedAuthState(),
},
});
diff --git a/src/features/auth/useLogout.ts b/src/features/auth/useLogout.ts
new file mode 100644
index 0000000..6693b1b
--- /dev/null
+++ b/src/features/auth/useLogout.ts
@@ -0,0 +1,25 @@
+import { useCallback, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAppDispatch } from '@/utils/hooks';
+import { authService } from '@/features/auth/authService';
+import { logout as logoutAction } from '@/features/auth/authSlice';
+
+export const useLogout = () => {
+ const dispatch = useAppDispatch();
+ const navigate = useNavigate();
+ const [isLoggingOut, setIsLoggingOut] = useState(false);
+
+ const logout = useCallback(async () => {
+ setIsLoggingOut(true);
+
+ try {
+ await authService.logout();
+ } finally {
+ dispatch(logoutAction());
+ setIsLoggingOut(false);
+ navigate('/login', { replace: true });
+ }
+ }, [dispatch, navigate]);
+
+ return { logout, isLoggingOut };
+};
diff --git a/src/main.tsx b/src/main.tsx
index 8a0eec9..099bade 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,7 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { RouterProvider } from 'react-router-dom';
-import { ConfigProvider } from 'antd';
+import { App as AntdApp, ConfigProvider } from 'antd';
import 'antd/dist/reset.css';
import faIR from 'antd/locale/fa_IR';
import { store } from '@/app/store';
@@ -14,7 +14,9 @@ createRoot(document.getElementById('root')!).render(
-
+
+
+
,
diff --git a/src/pages/Login/Login.tsx b/src/pages/Login/Login.tsx
index 3bf9ff3..a8fb553 100644
--- a/src/pages/Login/Login.tsx
+++ b/src/pages/Login/Login.tsx
@@ -1,4 +1,4 @@
-import { Button, Card, Form, Input, Typography, message } from 'antd';
+import { App, Button, Card, Form, Input, Typography } from 'antd';
import { useNavigate } from 'react-router-dom';
import { authService } from '@/features/auth/authService';
import { colors } from '@/design/tokens/colors';
@@ -13,6 +13,7 @@ interface LoginFormValues {
const Login = () => {
const navigate = useNavigate();
const [form] = Form.useForm();
+ const { message } = App.useApp();
const onFinish = async (values: LoginFormValues) => {
try {
diff --git a/src/pages/VerifyOtp/VerifyOtp.tsx b/src/pages/VerifyOtp/VerifyOtp.tsx
index 0f45c42..e63d942 100644
--- a/src/pages/VerifyOtp/VerifyOtp.tsx
+++ b/src/pages/VerifyOtp/VerifyOtp.tsx
@@ -1,4 +1,4 @@
-import { Button, Card, Form, Input, Typography, message, type InputRef } from 'antd';
+import { App, Button, Card, Form, Input, Typography, type InputRef } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch } from '@/utils/hooks';
@@ -19,6 +19,7 @@ const VerifyOtp = () => {
const [phoneNumber] = useState(() => localStorage.getItem('pending_phone_number') ?? '');
const [form] = Form.useForm();
const otpInputRef = useRef(null);
+ const { message } = App.useApp();
useEffect(() => {
const timeoutId = window.setTimeout(() => {
diff --git a/src/services/authApi.ts b/src/services/authApi.ts
index ff31db4..015bd39 100644
--- a/src/services/authApi.ts
+++ b/src/services/authApi.ts
@@ -1,7 +1,7 @@
import axios from 'axios';
import { AUTH_SCOPE } from '@/constants/auth';
import { createBasicAuthHeader } from '@/config/env';
-import { clearAuthTokens, storeAuthTokens } from '@/utils/authToken';
+import { clearAuthTokens, getAccessToken, storeAuthTokens } from '@/utils/authToken';
import { normalizePhoneNumber, toE164PhoneNumber } from '@/utils/phone';
import { buildAuthUrl } from '@/utils/authUrl';
import type { RequestOtpPayload, RequestOtpResponse, TokenResponse } from '@/types/auth';
@@ -36,6 +36,7 @@ export const authApi = {
storeAuthTokens({
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
+ expires_in: response.data.expires_in,
});
return response.data;
@@ -57,12 +58,36 @@ export const authApi = {
storeAuthTokens({
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
+ expires_in: response.data.expires_in,
});
return response.data;
},
- logout: () => {
+ revokeToken: async (token: string): Promise => {
+ const params = new URLSearchParams();
+ params.append('token', token);
+ params.append('token_type_hint', 'access_token');
+
+ await axios.post(buildAuthUrl('/oauth2/revoke_token/'), params, {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: createBasicAuthHeader(),
+ },
+ });
+ },
+
+ logout: async (): Promise => {
+ const accessToken = getAccessToken();
+
+ if (accessToken) {
+ try {
+ await authApi.revokeToken(accessToken);
+ } catch {
+ // Token may already be expired/invalid server-side; still clear the local session.
+ }
+ }
+
clearAuthTokens();
},
};
diff --git a/src/utils/authToken.ts b/src/utils/authToken.ts
index 203abb8..55841c8 100644
--- a/src/utils/authToken.ts
+++ b/src/utils/authToken.ts
@@ -1,5 +1,6 @@
const ACCESS_TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
+const EXPIRES_AT_KEY = 'access_token_expires_at';
export const getAccessToken = (): string | null => {
return localStorage.getItem(ACCESS_TOKEN_KEY);
@@ -9,12 +10,22 @@ export const getRefreshToken = (): string | null => {
return localStorage.getItem(REFRESH_TOKEN_KEY);
};
-export const storeAuthTokens = (tokens: { access_token: string; refresh_token: string }) => {
+export const storeAuthTokens = (tokens: { access_token: string; refresh_token: string; expires_in?: number }) => {
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token);
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token);
+
+ if (tokens.expires_in) {
+ localStorage.setItem(EXPIRES_AT_KEY, String(Date.now() + tokens.expires_in * 1000));
+ }
+};
+
+export const isAccessTokenExpired = (): boolean => {
+ const expiresAt = localStorage.getItem(EXPIRES_AT_KEY);
+ return expiresAt !== null && Date.now() >= Number(expiresAt);
};
export const clearAuthTokens = () => {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
+ localStorage.removeItem(EXPIRES_AT_KEY);
};