account and protected router

This commit is contained in:
Виталий Лавшонок
2025-11-05 00:08:51 +03:00
parent 994954c817
commit aeab03d35c
14 changed files with 173 additions and 85 deletions

View File

@@ -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.');
}

View File

@@ -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',
)}
>

View 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 />;
}

View File

@@ -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 />} />

View File

View 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}`;
}
},
);
},
});

View 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;

View File

@@ -0,0 +1,5 @@
const AccountMenu = () => {
return <div className="h-full w-full relative "></div>;
};
export default AccountMenu;

View File

@@ -0,0 +1,5 @@
const ArticlesBlock = () => {
return <div className="h-full w-full relative "></div>;
};
export default ArticlesBlock;

View File

@@ -0,0 +1,5 @@
const ContestsBlock = () => {
return <div className="h-full w-full relative bg-fuchsia-600"></div>;
};
export default ContestsBlock;

View File

@@ -0,0 +1,5 @@
const MissionsBlock = () => {
return <div className="h-full w-full relative "></div>;
};
export default MissionsBlock;

View 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;

View File

@@ -30,7 +30,7 @@ const Login = () => {
useEffect(() => {
if (jwt) {
navigate('/home/offices'); // или другая страница после входа
navigate('/home/account'); // или другая страница после входа
}
}, [jwt]);

View File

@@ -32,7 +32,7 @@ const Register = () => {
useEffect(() => {
if (jwt) {
navigate('/home');
navigate('/home/account');
}
console.log(submitClicked);
}, [jwt]);