account and protected router
This commit is contained in:
12
src/App.tsx
12
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 (
|
||||
<div className="w-full h-full bg-liquid-background flex justify-center">
|
||||
<div className="relative w-full max-w-[1600px] h-full ">
|
||||
@@ -38,6 +29,3 @@ function App() {
|
||||
}
|
||||
|
||||
export default App;
|
||||
function useAppdispatch() {
|
||||
throw new Error('Function not implemented.');
|
||||
}
|
||||
|
||||
@@ -7,14 +7,32 @@ interface ButtonProps {
|
||||
className?: string;
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement, 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<ButtonProps> = ({
|
||||
disabled = false,
|
||||
text = '',
|
||||
className,
|
||||
onClick,
|
||||
children,
|
||||
color = 'secondary',
|
||||
}) => {
|
||||
return (
|
||||
<label
|
||||
@@ -32,6 +50,7 @@ export const ReverseButton: React.FC<ButtonProps> = ({
|
||||
'group-hover:bg-liquid-darkmain ',
|
||||
'px-[16px] py-[8px]',
|
||||
'bg-liquid-lighter ring-[1px] ring-liquid-darkmain ring-inset',
|
||||
ColorBgVariants[color],
|
||||
disabled && 'bg-liquid-lighter',
|
||||
)}
|
||||
>
|
||||
@@ -61,8 +80,9 @@ export const ReverseButton: React.FC<ButtonProps> = ({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-all duration-300 text-liquid-brightmain text-[18px] font-bold p-0 m-0 leading-[23px]',
|
||||
'transition-all duration-300 text-[18px] font-bold p-0 m-0 leading-[23px]',
|
||||
'group-hover:text-liquid-white ',
|
||||
ColorTextVariants[color],
|
||||
disabled && 'text-liquid-light',
|
||||
)}
|
||||
>
|
||||
|
||||
12
src/components/router/ProtectedRoute.tsx
Normal file
12
src/components/router/ProtectedRoute.tsx
Normal file
@@ -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 <Navigate to="/home/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -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 = () => {
|
||||
<div className="min-h-screen">
|
||||
<Menu />
|
||||
</div>
|
||||
<div className="">
|
||||
<div className="h-screen">
|
||||
<Routes>
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="account/*" element={<Account />} />
|
||||
</Route>
|
||||
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route path="account" element={<Login />} />
|
||||
<Route path="register" element={<Register />} />
|
||||
<Route path="missions/*" element={<Missions />} />
|
||||
<Route path="articles/*" element={<Articles />} />
|
||||
|
||||
0
src/redux/slices/account.ts
Normal file
0
src/redux/slices/account.ts
Normal file
@@ -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}`;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
42
src/views/home/account/Account.tsx
Normal file
42
src/views/home/account/Account.tsx
Normal file
@@ -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 (
|
||||
<div className="h-full w-[calc(100%+250px)] box-border grid grid-cols-[1fr,520px] relative">
|
||||
<div className=" h-full min-h-0 flex flex-col">
|
||||
<div className=" h-full grid grid-rows-[80px,1fr] ">
|
||||
<div className="">
|
||||
<AccountMenu />
|
||||
</div>
|
||||
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px] ">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/home/account/missions"
|
||||
element={<MissionsBlock />}
|
||||
/>
|
||||
<Route
|
||||
path="/home/account/articles"
|
||||
element={<ArticlesBlock />}
|
||||
/>
|
||||
<Route
|
||||
path="/home/account/contests"
|
||||
element={<ContestsBlock />}
|
||||
/>
|
||||
<Route path="*" element={<MissionsBlock />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" h-full min-h-0">
|
||||
<RightPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Account;
|
||||
5
src/views/home/account/AccoutMenu.tsx
Normal file
5
src/views/home/account/AccoutMenu.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const AccountMenu = () => {
|
||||
return <div className="h-full w-full relative "></div>;
|
||||
};
|
||||
|
||||
export default AccountMenu;
|
||||
5
src/views/home/account/ArticlesBlock.tsx
Normal file
5
src/views/home/account/ArticlesBlock.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const ArticlesBlock = () => {
|
||||
return <div className="h-full w-full relative "></div>;
|
||||
};
|
||||
|
||||
export default ArticlesBlock;
|
||||
5
src/views/home/account/ContestsBlock.tsx
Normal file
5
src/views/home/account/ContestsBlock.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const ContestsBlock = () => {
|
||||
return <div className="h-full w-full relative bg-fuchsia-600"></div>;
|
||||
};
|
||||
|
||||
export default ContestsBlock;
|
||||
5
src/views/home/account/MissionsBlock.tsx
Normal file
5
src/views/home/account/MissionsBlock.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const MissionsBlock = () => {
|
||||
return <div className="h-full w-full relative "></div>;
|
||||
};
|
||||
|
||||
export default MissionsBlock;
|
||||
25
src/views/home/account/RightPanel.tsx
Normal file
25
src/views/home/account/RightPanel.tsx
Normal file
@@ -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 (
|
||||
<div className="h-full w-full relative p-[20px]">
|
||||
<div>{name}</div>
|
||||
<div>{email}</div>
|
||||
<ReverseButton
|
||||
className="absolute bottom-[20px] right-[20px]"
|
||||
onClick={() => {
|
||||
dispatch(logout());
|
||||
}}
|
||||
text="Выход"
|
||||
color="error"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RightPanel;
|
||||
@@ -30,7 +30,7 @@ const Login = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (jwt) {
|
||||
navigate('/home/offices'); // или другая страница после входа
|
||||
navigate('/home/account'); // или другая страница после входа
|
||||
}
|
||||
}, [jwt]);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const Register = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (jwt) {
|
||||
navigate('/home');
|
||||
navigate('/home/account');
|
||||
}
|
||||
console.log(submitClicked);
|
||||
}, [jwt]);
|
||||
|
||||
Reference in New Issue
Block a user