feature/WNPT-533 #1
11 changed files with 203 additions and 45 deletions
|
|
@ -1,19 +1,13 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAppDispatch, useAppSelector } from '@/utils/hooks';
|
import { useAppSelector } from '@/utils/hooks';
|
||||||
import { hydrateAuth } from '@/features/auth/authSlice';
|
|
||||||
import AdminLayout from '@/components/layout/AdminLayout';
|
import AdminLayout from '@/components/layout/AdminLayout';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated);
|
const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(hydrateAuth());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated && location.pathname !== '/login' && location.pathname !== '/verify-otp') {
|
if (!isAuthenticated && location.pathname !== '/login' && location.pathname !== '/verify-otp') {
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import winsooLogo from "@/assets/images/winsoo.svg";
|
||||||
import { getSidebarMenuItems } from "@/constants/navigation";
|
import { getSidebarMenuItems } from "@/constants/navigation";
|
||||||
import { colors } from "@/design/tokens/colors";
|
import { colors } from "@/design/tokens/colors";
|
||||||
import { spacing } from "@/design/tokens/spacing";
|
import { spacing } from "@/design/tokens/spacing";
|
||||||
|
import LogoutButton from "@/features/auth/LogoutButton";
|
||||||
|
|
||||||
const { Header, Content, Sider } = Layout;
|
const { Header, Content, Sider } = Layout;
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
@ -137,6 +138,8 @@ const AdminLayout = ({ children }: AdminLayoutProps) => {
|
||||||
<Title level={4} style={{ margin: 0, color: colors.background, flex: 1, textAlign: "center" }}>
|
<Title level={4} style={{ margin: 0, color: colors.background, flex: 1, textAlign: "center" }}>
|
||||||
پنل ادمین وینسو
|
پنل ادمین وینسو
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
|
<LogoutButton />
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<Content style={{ margin: 24 }}>
|
<Content style={{ margin: 24 }}>
|
||||||
|
|
|
||||||
90
src/features/auth/LogoutButton.tsx
Normal file
90
src/features/auth/LogoutButton.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<LucideIcon icon={LogOut} size={16} />}
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
خروج
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={isModalOpen}
|
||||||
|
onCancel={() => setIsModalOpen(false)}
|
||||||
|
centered
|
||||||
|
footer={null}
|
||||||
|
width={380}
|
||||||
|
mask={{ closable: !isLoggingOut }}
|
||||||
|
closable={!isLoggingOut}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', padding: `${spacing.sm}px 0` }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
margin: `0 auto ${spacing.md}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LucideIcon icon={LogOut} size={24} color={colors.accent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Title level={4} style={{ marginBottom: spacing.md, color: colors.text }}>
|
||||||
|
خروج از حساب کاربری
|
||||||
|
</Title>
|
||||||
|
<Paragraph style={{ color: colors.textSecondary, marginTop: spacing.xs, marginBottom: 0 }}>
|
||||||
|
آیا مطمئن هستید که میخواهید از پنل ادمین خارج شوید؟
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: spacing.sm, marginTop: spacing.lg }}>
|
||||||
|
<Button block disabled={isLoggingOut} onClick={() => setIsModalOpen(false)}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type="primary"
|
||||||
|
loading={isLoggingOut}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
style={{ background: colors.accent, borderColor: colors.accent, boxShadow: 'none' }}
|
||||||
|
>
|
||||||
|
خروج
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogoutButton;
|
||||||
|
|
@ -19,4 +19,8 @@ export const authService = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
logout: async (): Promise<void> => {
|
||||||
|
await authApi.logout();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,42 @@
|
||||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||||
import type { AuthState, LoginSuccessPayload } from '@/features/auth/authTypes';
|
import type { AuthState, LoginSuccessPayload } from '@/features/auth/authTypes';
|
||||||
import { STORAGE_KEYS } from '@/utils/storage';
|
import { STORAGE_KEYS } from '@/utils/storage';
|
||||||
import { getAccessToken, storeAuthTokens } from '@/utils/authToken';
|
import { clearAuthTokens, getAccessToken, isAccessTokenExpired, storeAuthTokens } from '@/utils/authToken';
|
||||||
|
|
||||||
const initialState: AuthState = {
|
const clearPersistedAuthState = () => {
|
||||||
user: null,
|
localStorage.removeItem(STORAGE_KEYS.accessToken);
|
||||||
accessToken: null,
|
localStorage.removeItem(STORAGE_KEYS.user);
|
||||||
isAuthenticated: false,
|
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({
|
const authSlice = createSlice({
|
||||||
name: 'auth',
|
name: 'auth',
|
||||||
initialState,
|
initialState: loadPersistedAuthState(),
|
||||||
reducers: {
|
reducers: {
|
||||||
loginSuccess: (state, action: PayloadAction<LoginSuccessPayload>) => {
|
loginSuccess: (state, action: PayloadAction<LoginSuccessPayload>) => {
|
||||||
state.user = action.payload.user;
|
state.user = action.payload.user;
|
||||||
|
|
@ -38,32 +63,9 @@ const authSlice = createSlice({
|
||||||
state.accessToken = null;
|
state.accessToken = null;
|
||||||
state.isAuthenticated = false;
|
state.isAuthenticated = false;
|
||||||
|
|
||||||
localStorage.removeItem(STORAGE_KEYS.accessToken);
|
clearPersistedAuthState();
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
hydrateAuth: () => loadPersistedAuthState(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
25
src/features/auth/useLogout.ts
Normal file
25
src/features/auth/useLogout.ts
Normal file
|
|
@ -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 };
|
||||||
|
};
|
||||||
|
|
@ -2,7 +2,7 @@ import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { ConfigProvider } from 'antd';
|
import { App as AntdApp, ConfigProvider } from 'antd';
|
||||||
import 'antd/dist/reset.css';
|
import 'antd/dist/reset.css';
|
||||||
import faIR from 'antd/locale/fa_IR';
|
import faIR from 'antd/locale/fa_IR';
|
||||||
import { store } from '@/app/store';
|
import { store } from '@/app/store';
|
||||||
|
|
@ -14,7 +14,9 @@ createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<ConfigProvider direction="rtl" locale={faIR} theme={antdTheme}>
|
<ConfigProvider direction="rtl" locale={faIR} theme={antdTheme}>
|
||||||
<RouterProvider router={router} />
|
<AntdApp>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</AntdApp>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|
|
||||||
|
|
@ -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 { useNavigate } from 'react-router-dom';
|
||||||
import { authService } from '@/features/auth/authService';
|
import { authService } from '@/features/auth/authService';
|
||||||
import { colors } from '@/design/tokens/colors';
|
import { colors } from '@/design/tokens/colors';
|
||||||
|
|
@ -13,6 +13,7 @@ interface LoginFormValues {
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm<LoginFormValues>();
|
const [form] = Form.useForm<LoginFormValues>();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
const onFinish = async (values: LoginFormValues) => {
|
const onFinish = async (values: LoginFormValues) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -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 { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAppDispatch } from '@/utils/hooks';
|
import { useAppDispatch } from '@/utils/hooks';
|
||||||
|
|
@ -19,6 +19,7 @@ const VerifyOtp = () => {
|
||||||
const [phoneNumber] = useState(() => localStorage.getItem('pending_phone_number') ?? '');
|
const [phoneNumber] = useState(() => localStorage.getItem('pending_phone_number') ?? '');
|
||||||
const [form] = Form.useForm<VerifyOtpFormValues>();
|
const [form] = Form.useForm<VerifyOtpFormValues>();
|
||||||
const otpInputRef = useRef<InputRef>(null);
|
const otpInputRef = useRef<InputRef>(null);
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { AUTH_SCOPE } from '@/constants/auth';
|
import { AUTH_SCOPE } from '@/constants/auth';
|
||||||
import { createBasicAuthHeader } from '@/config/env';
|
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 { normalizePhoneNumber, toE164PhoneNumber } from '@/utils/phone';
|
||||||
import { buildAuthUrl } from '@/utils/authUrl';
|
import { buildAuthUrl } from '@/utils/authUrl';
|
||||||
import type { RequestOtpPayload, RequestOtpResponse, TokenResponse } from '@/types/auth';
|
import type { RequestOtpPayload, RequestOtpResponse, TokenResponse } from '@/types/auth';
|
||||||
|
|
@ -36,6 +36,7 @@ export const authApi = {
|
||||||
storeAuthTokens({
|
storeAuthTokens({
|
||||||
access_token: response.data.access_token,
|
access_token: response.data.access_token,
|
||||||
refresh_token: response.data.refresh_token,
|
refresh_token: response.data.refresh_token,
|
||||||
|
expires_in: response.data.expires_in,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|
@ -57,12 +58,36 @@ export const authApi = {
|
||||||
storeAuthTokens({
|
storeAuthTokens({
|
||||||
access_token: response.data.access_token,
|
access_token: response.data.access_token,
|
||||||
refresh_token: response.data.refresh_token,
|
refresh_token: response.data.refresh_token,
|
||||||
|
expires_in: response.data.expires_in,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
revokeToken: async (token: string): Promise<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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();
|
clearAuthTokens();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const ACCESS_TOKEN_KEY = 'access_token';
|
const ACCESS_TOKEN_KEY = 'access_token';
|
||||||
const REFRESH_TOKEN_KEY = 'refresh_token';
|
const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||||
|
const EXPIRES_AT_KEY = 'access_token_expires_at';
|
||||||
|
|
||||||
export const getAccessToken = (): string | null => {
|
export const getAccessToken = (): string | null => {
|
||||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||||
|
|
@ -9,12 +10,22 @@ export const getRefreshToken = (): string | null => {
|
||||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
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(ACCESS_TOKEN_KEY, tokens.access_token);
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_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 = () => {
|
export const clearAuthTokens = () => {
|
||||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
localStorage.removeItem(EXPIRES_AT_KEY);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue