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 ( + <> + + + 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); };