From aeab03d35c1a1acb6da1cf992c693534cc29e0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D0=B9=20=D0=9B=D0=B0?= =?UTF-8?q?=D0=B2=D1=88=D0=BE=D0=BD=D0=BE=D0=BA?= <114582703+valavshonok@users.noreply.github.com> Date: Wed, 5 Nov 2025 00:08:51 +0300 Subject: [PATCH] account and protected router --- src/App.tsx | 12 --- src/components/button/ReverseButton.tsx | 22 ++++- src/components/router/ProtectedRoute.tsx | 12 +++ src/pages/Home.tsx | 9 +- src/redux/slices/account.ts | 0 src/redux/slices/auth.ts | 112 +++++++++-------------- src/views/home/account/Account.tsx | 42 +++++++++ src/views/home/account/AccoutMenu.tsx | 5 + src/views/home/account/ArticlesBlock.tsx | 5 + src/views/home/account/ContestsBlock.tsx | 5 + src/views/home/account/MissionsBlock.tsx | 5 + src/views/home/account/RightPanel.tsx | 25 +++++ src/views/home/auth/Login.tsx | 2 +- src/views/home/auth/Register.tsx | 2 +- 14 files changed, 173 insertions(+), 85 deletions(-) create mode 100644 src/components/router/ProtectedRoute.tsx create mode 100644 src/redux/slices/account.ts create mode 100644 src/views/home/account/Account.tsx create mode 100644 src/views/home/account/AccoutMenu.tsx create mode 100644 src/views/home/account/ArticlesBlock.tsx create mode 100644 src/views/home/account/ContestsBlock.tsx create mode 100644 src/views/home/account/MissionsBlock.tsx create mode 100644 src/views/home/account/RightPanel.tsx diff --git a/src/App.tsx b/src/App.tsx index 522fbb5..6d0ec91 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,17 +8,8 @@ import Home from './pages/Home'; import Mission from './pages/Mission'; import ArticleEditor from './pages/ArticleEditor'; import Article from './pages/Article'; -import { useEffect } from 'react'; -import { loadTokensFromLocalStorage } from './redux/slices/auth'; -import { useAppDispatch } from './redux/hooks'; function App() { - const dispatch = useAppDispatch(); - - useEffect(() => { - dispatch(loadTokensFromLocalStorage()); - }, []); - return (
@@ -38,6 +29,3 @@ function App() { } export default App; -function useAppdispatch() { - throw new Error('Function not implemented.'); -} diff --git a/src/components/button/ReverseButton.tsx b/src/components/button/ReverseButton.tsx index ee38a6e..67ddceb 100644 --- a/src/components/button/ReverseButton.tsx +++ b/src/components/button/ReverseButton.tsx @@ -7,14 +7,32 @@ interface ButtonProps { className?: string; onClick: (e: React.MouseEvent) => void; children?: React.ReactNode; + color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success'; } +const ColorBgVariants = { + primary: 'group-hover:bg-liquid-brightmain ring-liquid-brightmain', + secondary: 'group-hover:bg-liquid-darkmain ring-liquid-darkmain', + error: 'group-hover:bg-liquid-red ring-liquid-red', + warning: 'group-hover:bg-liquid-orange ring-liquid-orange', + success: 'group-hover:bg-liquid-green ring-liquid-green', +}; + +const ColorTextVariants = { + primary: 'text-liquid-brightmain ', + secondary: 'text-liquid-brightmain ', + error: 'text-liquid-red ', + warning: 'text-liquid-orange ', + success: 'text-liquid-green ', +}; + export const ReverseButton: React.FC = ({ disabled = false, text = '', className, onClick, children, + color = 'secondary', }) => { return (
diff --git a/src/components/router/ProtectedRoute.tsx b/src/components/router/ProtectedRoute.tsx new file mode 100644 index 0000000..775a461 --- /dev/null +++ b/src/components/router/ProtectedRoute.tsx @@ -0,0 +1,12 @@ +// src/routes/ProtectedRoute.tsx +import { Navigate, Outlet } from 'react-router-dom'; +import { useAppSelector } from '../../redux/hooks'; + +export default function ProtectedRoute() { + const isAuthenticated = useAppSelector((state) => !!state.auth.jwt); + if (!isAuthenticated) { + return ; + } + + return ; +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 49e9651..9cebabb 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -13,6 +13,8 @@ import Contests from '../views/home/contests/Contests'; import { PrimaryButton } from '../components/button/PrimaryButton'; import Group from '../views/home/groups/Group'; import Contest from '../views/home/contest/Contest'; +import Account from '../views/home/account/Account'; +import ProtectedRoute from '../components/router/ProtectedRoute'; const Home = () => { const name = useAppSelector((state) => state.auth.username); @@ -28,10 +30,13 @@ const Home = () => {
-
+
+ }> + } /> + + } /> - } /> } /> } /> } /> diff --git a/src/redux/slices/account.ts b/src/redux/slices/account.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/redux/slices/auth.ts b/src/redux/slices/auth.ts index 2fba03e..50f6523 100644 --- a/src/redux/slices/auth.ts +++ b/src/redux/slices/auth.ts @@ -1,36 +1,63 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; -// πŸ”Ή Ѐункция для дСкодирования JWT +// πŸ”Ή Π”Π΅ΠΊΠΎΠ΄ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ JWT function decodeJwt(token: string) { const [, payload] = token.split('.'); const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); return JSON.parse(decodeURIComponent(escape(json))); } -// πŸ”Ή Π’ΠΈΠΏΡ‹ Π΄Π°Π½Π½Ρ‹Ρ… +// πŸ”Ή Π’ΠΈΠΏΡ‹ interface AuthState { jwt: string | null; refreshToken: string | null; username: string | null; - email: string | null; // <-- Π΄ΠΎΠ±Π°Π²ΠΈΠ»ΠΈ email + email: string | null; id: string | null; status: 'idle' | 'loading' | 'successful' | 'failed'; error: string | null; } -// πŸ”Ή Π˜Π½ΠΈΡ†ΠΈΠ°Π»ΠΈΠ·Π°Ρ†ΠΈΡ состояния +// πŸ”Ή Π˜Π½ΠΈΡ†ΠΈΠ°Π»ΠΈΠ·Π°Ρ†ΠΈΡ состояния с синхронной Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΎΠΉ ΠΈΠ· localStorage +const jwtFromStorage = localStorage.getItem('jwt'); +const refreshTokenFromStorage = localStorage.getItem('refreshToken'); + const initialState: AuthState = { - jwt: null, - refreshToken: null, + jwt: jwtFromStorage || null, + refreshToken: refreshTokenFromStorage || null, username: null, - email: null, // <-- Π΄ΠΎΠ±Π°Π²ΠΈΠ»ΠΈ email + email: null, id: null, status: 'idle', error: null, }; -// πŸ”Ή AsyncThunk: РСгистрация +// Если Ρ‚ΠΎΠΊΠ΅Π½ Π΅ΡΡ‚ΡŒ, подставляСм Π² axios ΠΈ Π΄Π΅ΠΊΠΎΠ΄ΠΈΡ€ΡƒΠ΅ΠΌ +if (jwtFromStorage) { + axios.defaults.headers.common['Authorization'] = `Bearer ${jwtFromStorage}`; + try { + const decoded = decodeJwt(jwtFromStorage); + initialState.username = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name' + ] || null; + initialState.email = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' + ] || null; + initialState.id = + decoded[ + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier' + ] || null; + } catch { + localStorage.removeItem('jwt'); + localStorage.removeItem('refreshToken'); + delete axios.defaults.headers.common['Authorization']; + } +} + +// πŸ”Ή AsyncThunk-Ρ‹ (login/register/refresh/whoami) ΠΎΡΡ‚Π°ΡŽΡ‚ΡΡ ΠΊΠ°ΠΊ Π±Ρ‹Π»ΠΈ export const registerUser = createAsyncThunk( 'auth/register', async ( @@ -47,7 +74,7 @@ export const registerUser = createAsyncThunk( email, password, }); - return response.data; // { jwt, refreshToken } + return response.data; } catch (err: any) { return rejectWithValue( err.response?.data?.message || 'Registration failed', @@ -56,7 +83,6 @@ export const registerUser = createAsyncThunk( }, ); -// πŸ”Ή AsyncThunk: Π›ΠΎΠ³ΠΈΠ½ export const loginUser = createAsyncThunk( 'auth/login', async ( @@ -68,7 +94,7 @@ export const loginUser = createAsyncThunk( username, password, }); - return response.data; // { jwt, refreshToken } + return response.data; } catch (err: any) { return rejectWithValue( err.response?.data?.message || 'Login failed', @@ -77,7 +103,6 @@ export const loginUser = createAsyncThunk( }, ); -// πŸ”Ή AsyncThunk: ОбновлСниС Ρ‚ΠΎΠΊΠ΅Π½Π° export const refreshToken = createAsyncThunk( 'auth/refresh', async ({ refreshToken }: { refreshToken: string }, { rejectWithValue }) => { @@ -85,7 +110,7 @@ export const refreshToken = createAsyncThunk( const response = await axios.post('/authentication/refresh', { refreshToken, }); - return response.data; // { jwt, refreshToken } + return response.data; } catch (err: any) { return rejectWithValue( err.response?.data?.message || 'Refresh token failed', @@ -94,13 +119,12 @@ export const refreshToken = createAsyncThunk( }, ); -// πŸ”Ή AsyncThunk: ΠŸΠΎΠ»ΡƒΡ‡Π΅Π½ΠΈΠ΅ ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΠΈ ΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ export const fetchWhoAmI = createAsyncThunk( 'auth/whoami', async (_, { rejectWithValue }) => { try { const response = await axios.get('/authentication/whoami'); - return response.data; // { username } + return response.data; } catch (err: any) { return rejectWithValue( err.response?.data?.message || 'Failed to fetch user info', @@ -109,22 +133,6 @@ export const fetchWhoAmI = createAsyncThunk( }, ); -// πŸ”Ή AsyncThunk: Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ° Ρ‚ΠΎΠΊΠ΅Π½ΠΎΠ² ΠΈΠ· localStorage -export const loadTokensFromLocalStorage = createAsyncThunk( - 'auth/loadTokens', - async () => { - const jwt = localStorage.getItem('jwt'); - const refreshToken = localStorage.getItem('refreshToken'); - - if (jwt && refreshToken) { - axios.defaults.headers.common['Authorization'] = `Bearer ${jwt}`; - return { jwt, refreshToken }; - } else { - return { jwt: null, refreshToken: null }; - } - }, -); - // πŸ”Ή Slice const authSlice = createSlice({ name: 'auth', @@ -134,7 +142,7 @@ const authSlice = createSlice({ state.jwt = null; state.refreshToken = null; state.username = null; - state.email = null; // <-- очистка email + state.email = null; state.id = null; state.status = 'idle'; state.error = null; @@ -144,7 +152,7 @@ const authSlice = createSlice({ }, }, extraReducers: (builder) => { - // РСгистрация + // ----------------- Register ----------------- builder.addCase(registerUser.pending, (state) => { state.status = 'loading'; state.error = null; @@ -154,7 +162,6 @@ const authSlice = createSlice({ state.jwt = action.payload.jwt; state.refreshToken = action.payload.refreshToken; - // πŸ”Έ Π”Π΅ΠΊΠΎΠ΄ΠΈΡ€ΡƒΠ΅ΠΌ JWT const decoded = decodeJwt(action.payload.jwt); state.username = decoded[ @@ -180,7 +187,7 @@ const authSlice = createSlice({ state.error = action.payload as string; }); - // Π›ΠΎΠ³ΠΈΠ½ + // ----------------- Login ----------------- builder.addCase(loginUser.pending, (state) => { state.status = 'loading'; state.error = null; @@ -190,7 +197,6 @@ const authSlice = createSlice({ state.jwt = action.payload.jwt; state.refreshToken = action.payload.refreshToken; - // πŸ”Έ Π”Π΅ΠΊΠΎΠ΄ΠΈΡ€ΡƒΠ΅ΠΌ JWT const decoded = decodeJwt(action.payload.jwt); state.username = decoded[ @@ -216,7 +222,7 @@ const authSlice = createSlice({ state.error = action.payload as string; }); - // ОбновлСниС Ρ‚ΠΎΠΊΠ΅Π½Π° + // ----------------- Refresh ----------------- builder.addCase(refreshToken.pending, (state) => { state.status = 'loading'; state.error = null; @@ -226,7 +232,6 @@ const authSlice = createSlice({ state.jwt = action.payload.jwt; state.refreshToken = action.payload.refreshToken; - // πŸ”Έ Π”Π΅ΠΊΠΎΠ΄ΠΈΡ€ΡƒΠ΅ΠΌ JWT const decoded = decodeJwt(action.payload.jwt); state.username = decoded[ @@ -252,7 +257,7 @@ const authSlice = createSlice({ state.error = action.payload as string; }); - // ΠŸΠΎΠ»ΡƒΡ‡Π΅Π½ΠΈΠ΅ ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΠΈ ΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ + // ----------------- WhoAmI ----------------- builder.addCase(fetchWhoAmI.pending, (state) => { state.status = 'loading'; state.error = null; @@ -265,35 +270,6 @@ const authSlice = createSlice({ state.status = 'failed'; state.error = action.payload as string; }); - - // Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ° Ρ‚ΠΎΠΊΠ΅Π½ΠΎΠ² ΠΈΠ· localStorage - builder.addCase( - loadTokensFromLocalStorage.fulfilled, - (state, action) => { - state.jwt = action.payload.jwt; - state.refreshToken = action.payload.refreshToken; - - if (action.payload.jwt) { - const decoded = decodeJwt(action.payload.jwt); - state.username = - decoded[ - 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name' - ] || null; - state.email = - decoded[ - 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' - ] || null; - state.id = - decoded[ - 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier' - ] || null; - - axios.defaults.headers.common[ - 'Authorization' - ] = `Bearer ${action.payload.jwt}`; - } - }, - ); }, }); diff --git a/src/views/home/account/Account.tsx b/src/views/home/account/Account.tsx new file mode 100644 index 0000000..b508835 --- /dev/null +++ b/src/views/home/account/Account.tsx @@ -0,0 +1,42 @@ +import { Route, Routes } from 'react-router-dom'; +import AccountMenu from './AccoutMenu'; +import RightPanel from './RightPanel'; +import MissionsBlock from './MissionsBlock'; +import ContestsBlock from './ContestsBlock'; +import ArticlesBlock from './ArticlesBlock'; + +const Account = () => { + return ( +
+
+
+
+ +
+
+ + } + /> + } + /> + } + /> + } /> + +
+
+
+
+ +
+
+ ); +}; + +export default Account; diff --git a/src/views/home/account/AccoutMenu.tsx b/src/views/home/account/AccoutMenu.tsx new file mode 100644 index 0000000..204bf1b --- /dev/null +++ b/src/views/home/account/AccoutMenu.tsx @@ -0,0 +1,5 @@ +const AccountMenu = () => { + return
; +}; + +export default AccountMenu; diff --git a/src/views/home/account/ArticlesBlock.tsx b/src/views/home/account/ArticlesBlock.tsx new file mode 100644 index 0000000..a0ec79c --- /dev/null +++ b/src/views/home/account/ArticlesBlock.tsx @@ -0,0 +1,5 @@ +const ArticlesBlock = () => { + return
; +}; + +export default ArticlesBlock; diff --git a/src/views/home/account/ContestsBlock.tsx b/src/views/home/account/ContestsBlock.tsx new file mode 100644 index 0000000..ef6d1f9 --- /dev/null +++ b/src/views/home/account/ContestsBlock.tsx @@ -0,0 +1,5 @@ +const ContestsBlock = () => { + return
; +}; + +export default ContestsBlock; diff --git a/src/views/home/account/MissionsBlock.tsx b/src/views/home/account/MissionsBlock.tsx new file mode 100644 index 0000000..b439d81 --- /dev/null +++ b/src/views/home/account/MissionsBlock.tsx @@ -0,0 +1,5 @@ +const MissionsBlock = () => { + return
; +}; + +export default MissionsBlock; diff --git a/src/views/home/account/RightPanel.tsx b/src/views/home/account/RightPanel.tsx new file mode 100644 index 0000000..effd006 --- /dev/null +++ b/src/views/home/account/RightPanel.tsx @@ -0,0 +1,25 @@ +import { ReverseButton } from '../../../components/button/ReverseButton'; +import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; +import { logout } from '../../../redux/slices/auth'; + +const RightPanel = () => { + const dispatch = useAppDispatch(); + const name = useAppSelector((state) => state.auth.username); + const email = useAppSelector((state) => state.auth.email); + return ( +
+
{name}
+
{email}
+ { + dispatch(logout()); + }} + text="Π’Ρ‹Ρ…ΠΎΠ΄" + color="error" + /> +
+ ); +}; + +export default RightPanel; diff --git a/src/views/home/auth/Login.tsx b/src/views/home/auth/Login.tsx index d44668f..548be70 100644 --- a/src/views/home/auth/Login.tsx +++ b/src/views/home/auth/Login.tsx @@ -30,7 +30,7 @@ const Login = () => { useEffect(() => { if (jwt) { - navigate('/home/offices'); // ΠΈΠ»ΠΈ другая страница послС Π²Ρ…ΠΎΠ΄Π° + navigate('/home/account'); // ΠΈΠ»ΠΈ другая страница послС Π²Ρ…ΠΎΠ΄Π° } }, [jwt]); diff --git a/src/views/home/auth/Register.tsx b/src/views/home/auth/Register.tsx index afdbc30..d341b39 100644 --- a/src/views/home/auth/Register.tsx +++ b/src/views/home/auth/Register.tsx @@ -32,7 +32,7 @@ const Register = () => { useEffect(() => { if (jwt) { - navigate('/home'); + navigate('/home/account'); } console.log(submitClicked); }, [jwt]);