-
Notifications
You must be signed in to change notification settings - Fork 0
React 6. Guard
IOF edited this page Mar 11, 2026
·
2 revisions
Добавляем защиту маршрутов (Auth Guard) и защиту от несохраненных изменений (CanDeactivate).
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { CircularProgress, Box, Alert } from '@mui/material';
type AuthGuardProps = {
children: React.ReactNode;
redirectTo?: string;
};
export const AuthGuard = ({ children, redirectTo = '/login' }: AuthGuardProps) => {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
<CircularProgress />
</Box>
);
}
if (!user) {
// Сохраняем текущий URL для редиректа после входа
return <Navigate to={redirectTo} state={{ from: location.pathname }} replace />;
}
return <>{children}</>;
};import { useEffect, useCallback, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
type UsePreventUnsavedChangesProps = {
isDirty: boolean;
message?: string;
onConfirm?: () => void;
onCancel?: () => void;
};
export const usePreventUnsavedChanges = ({
isDirty,
message = 'У вас есть несохраненные изменения. Вы действительно хотите покинуть страницу?',
onConfirm,
onCancel,
}: UsePreventUnsavedChangesProps) => {
const navigate = useNavigate();
const location = useLocation();
const unblockRef = useRef<() => void>();
// Блокируем навигацию внутри приложения
useEffect(() => {
if (!isDirty) return;
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = message;
return message;
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [isDirty, message]);
// Функция для проверки перед навигацией
const checkBeforeNavigate = useCallback(
(to: string) => {
if (!isDirty) return true;
const confirmLeave = window.confirm(message);
if (confirmLeave) {
onConfirm?.();
return true;
} else {
onCancel?.();
return false;
}
},
[isDirty, message, onConfirm, onCancel]
);
// Перехватываем клики по ссылкам
useEffect(() => {
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const link = target.closest('a');
if (link && isDirty && link.href && link.href.startsWith(window.location.origin)) {
e.preventDefault();
const path = link.href.replace(window.location.origin, '');
if (checkBeforeNavigate(path)) {
navigate(path);
}
}
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [isDirty, navigate, checkBeforeNavigate]);
return {
checkBeforeNavigate,
};
};import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Button,
} from '@mui/material';
import { AlertTriangle } from 'lucide-react';
type ConfirmDialogProps = {
open: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
severity?: 'error' | 'warning' | 'info';
};
export const ConfirmDialog = ({
open,
title = 'Подтверждение',
message,
confirmText = 'Подтвердить',
cancelText = 'Отмена',
onConfirm,
onCancel,
severity = 'warning',
}: ConfirmDialogProps) => {
const getColor = () => {
switch (severity) {
case 'error': return 'error';
case 'warning': return 'warning';
case 'info': return 'info';
default: return 'primary';
}
};
return (
<Dialog
open={open}
onClose={onCancel}
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-description"
>
<DialogTitle id="confirm-dialog-title" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AlertTriangle color={severity === 'error' ? '#f44336' : '#ff9800'} size={24} />
{title}
</DialogTitle>
<DialogContent>
<DialogContentText id="confirm-dialog-description">
{message}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} color="inherit">
{cancelText}
</Button>
<Button onClick={onConfirm} color={getColor()} variant="contained" autoFocus>
{confirmText}
</Button>
</DialogActions>
</Dialog>
);
};import React, { useState, useEffect } from 'react';
import { usePreventUnsavedChanges } from '../../hooks/usePreventUnsavedChanges';
import { ConfirmDialog } from './ConfirmDialog';
type PreventUnsavedChangesProps = {
isDirty: boolean;
message?: string;
children: React.ReactNode;
onConfirm?: () => void;
};
export const PreventUnsavedChanges = ({
isDirty,
message = 'У вас есть несохраненные изменения. Вы действительно хотите покинуть страницу?',
children,
onConfirm,
}: PreventUnsavedChangesProps) => {
const [showDialog, setShowDialog] = useState(false);
const [pendingNavigation, setPendingNavigation] = useState<string | null>(null);
const { checkBeforeNavigate } = usePreventUnsavedChanges({
isDirty,
message,
onConfirm: () => {
if (pendingNavigation) {
window.location.href = pendingNavigation;
}
onConfirm?.();
},
});
// Перехватываем навигацию через history API
useEffect(() => {
if (!isDirty) return;
const handlePopState = (e: PopStateEvent) => {
e.preventDefault();
setShowDialog(true);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [isDirty]);
const handleConfirm = () => {
setShowDialog(false);
if (pendingNavigation) {
window.location.href = pendingNavigation;
}
};
return (
<>
{children}
<ConfirmDialog
open={showDialog}
title="Несохраненные изменения"
message={message}
confirmText="Покинуть страницу"
cancelText="Остаться"
onConfirm={handleConfirm}
onCancel={() => setShowDialog(false)}
severity="warning"
/>
</>
);
};import React, { useState, useEffect } from 'react';
import { Container, Paper, Typography, Box, Alert } from '@mui/material';
import { useNavigate, useLocation } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { UserPlus } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { RegisterForm } from '../components/RegisterForm';
import { PreventUnsavedChanges } from '../components/guards/PreventUnsavedChanges';
type FormData = {
login: string;
password: string;
name: string;
};
export const RegisterPage = () => {
const navigate = useNavigate();
const location = useLocation();
const { register: registerUser } = useAuth();
const [loading, setLoading] = useState(false);
const [serverError, setServerError] = useState('');
const [showDialog, setShowDialog] = useState(false);
const { register, handleSubmit, watch, formState, setError, clearErrors } = useForm<FormData>({
mode: 'onChange',
defaultValues: {
login: '',
password: '',
name: '',
},
});
const { errors, touchedFields, isValid, isDirty } = formState;
const loginValue = watch('login');
// Очищаем ошибку для логина при изменении
useEffect(() => {
if (loginValue !== 'admin') {
clearErrors('login');
}
}, [loginValue, clearErrors]);
const validateLogin = (value: string) => {
if (!value) return 'Логин обязателен';
if (value.length < 3) return 'Минимум 3 символа';
if (value === 'admin') return 'Недопустимый логин пользователя';
return undefined;
};
const validatePassword = (value: string) => {
if (!value) return 'Пароль обязателен';
if (value.length < 3) return 'Минимум 3 символа';
if (value.length > 8) return 'Максимум 8 символов';
return undefined;
};
const onSubmit = async (data: FormData) => {
try {
setLoading(true);
setServerError('');
await registerUser(data);
navigate('/login', { state: { registered: true } });
} catch (err: any) {
const errors = err.response?.data?.errors;
if (errors) {
if (errors.Login) {
setError('login', { type: 'manual', message: errors.Login[0] });
}
setServerError('Проверьте правильность заполнения полей');
} else {
setServerError(err.response?.data?.message || 'Ошибка регистрации');
}
} finally {
setLoading(false);
}
};
return (
<PreventUnsavedChanges isDirty={isDirty && !loading}>
<Container maxWidth="sm" sx={{ py: 4 }}>
<Paper sx={{ p: 4 }}>
<Box display="flex" flexDirection="column" alignItems="center" mb={3}>
<UserPlus size={48} color="#3f51b5" />
<Typography variant="h4" sx={{ mt: 2 }}>Регистрация</Typography>
</Box>
{location.state?.success && (
<Alert severity="success" sx={{ mb: 2 }}>
Регистрация успешна! Теперь вы можете войти.
</Alert>
)}
{serverError && (
<Alert severity="error" sx={{ mb: 2 }}>
{serverError}
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Поля формы (как в RegisterForm) */}
<Button
type="submit"
variant="contained"
disabled={!isValid || loading}
>
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
</Button>
</Box>
</form>
{isDirty && (
<Alert severity="info" sx={{ mt: 2 }}>
✏️ У вас есть несохраненные изменения
</Alert>
)}
<Box textAlign="center" mt={2}>
<Typography variant="body2">
Уже есть аккаунт?{' '}
<Button color="primary" onClick={() => navigate('/login')}>
Войти
</Button>
</Typography>
</Box>
</Paper>
</Container>
</PreventUnsavedChanges>
);
};import React from 'react';
import { Container, Paper, Typography, Box, Button } from '@mui/material';
import { Home, AlertCircle } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
export const NotFoundPage = () => {
const navigate = useNavigate();
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<Paper sx={{ p: 6, textAlign: 'center' }}>
<AlertCircle size={80} color="#f44336" />
<Typography variant="h1" sx={{ fontSize: '6rem', fontWeight: 'bold', color: '#f44336' }}>
404
</Typography>
<Typography variant="h4" gutterBottom>
Страница не найдена
</Typography>
<Typography variant="body1" color="text.secondary" paragraph sx={{ mb: 4 }}>
Запрашиваемая страница не существует или была перемещена.
</Typography>
<Button
variant="contained"
size="large"
startIcon={<Home size={20} />}
onClick={() => navigate('/')}
>
На главную
</Button>
</Paper>
</Container>
);
};import React from 'react';
import { Container, Paper, Typography, Box, Button, Alert } from '@mui/material';
import { Home, RefreshCw, AlertTriangle } from 'lucide-react';
import { useNavigate, useLocation } from 'react-router-dom';
export const ServerErrorPage = () => {
const navigate = useNavigate();
const location = useLocation();
const error = location.state?.error;
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<Paper sx={{ p: 6, textAlign: 'center' }}>
<AlertTriangle size={80} color="#f44336" />
<Typography variant="h1" sx={{ fontSize: '6rem', fontWeight: 'bold', color: '#f44336' }}>
500
</Typography>
<Typography variant="h4" gutterBottom>
Ошибка сервера
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
Произошла внутренняя ошибка сервера. Пожалуйста, попробуйте позже.
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 4, textAlign: 'left' }}>
<Typography variant="body2" component="pre" sx={{ fontFamily: 'monospace' }}>
{JSON.stringify(error, null, 2)}
</Typography>
</Alert>
)}
<Box display="flex" gap={2} justifyContent="center">
<Button
variant="contained"
startIcon={<Home size={20} />}
onClick={() => navigate('/')}
>
На главную
</Button>
<Button
variant="outlined"
startIcon={<RefreshCw size={20} />}
onClick={() => window.location.reload()}
>
Обновить
</Button>
</Box>
</Paper>
</Container>
);
};import React, { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { AuthProvider } from './contexts/AuthContext';
import { LoadingProvider } from './contexts/LoadingContext';
import { GlobalLoader } from './components/GlobalLoader';
import { AuthGuard } from './components/guards/AuthGuard';
import { Header } from './components/Header';
import { HomePage } from './pages/HomePage';
import { UsersPage } from './pages/UsersPage';
import { ProfilePage } from './pages/ProfilePage';
import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { LoadingDemoPage } from './pages/LoadingDemoPage';
import { NotFoundPage } from './pages/NotFoundPage';
import { ServerErrorPage } from './pages/ServerErrorPage';
import { setLoadingCallback } from './api/client';
import { useLoading } from './contexts/LoadingContext';
const theme = createTheme({
palette: {
primary: { main: '#3f51b5' },
},
});
const LoadingBridge = () => {
const { setLoading } = useLoading();
useEffect(() => {
setLoadingCallback(setLoading);
}, [setLoading]);
return null;
};
function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<LoadingProvider>
<AuthProvider>
<BrowserRouter>
<LoadingBridge />
<GlobalLoader message="Загрузка..." />
<Header />
<Routes>
{/* Публичные маршруты */}
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/loading-demo" element={<LoadingDemoPage />} />
{/* Защищенные маршруты */}
<Route path="/users" element={
<AuthGuard>
<UsersPage />
</AuthGuard>
} />
<Route path="/profile/:id" element={
<AuthGuard>
<ProfilePage />
</AuthGuard>
} />
{/* Маршруты ошибок */}
<Route path="/404" element={<NotFoundPage />} />
<Route path="/500" element={<ServerErrorPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</LoadingProvider>
</ThemeProvider>
);
}
export default App;// В LoginPage.tsx добавить:
import { useLocation } from 'react-router-dom';
// Внутри компонента:
const location = useLocation();
const from = location.state?.from || '/';
const onSuccess = () => {
navigate(from, { replace: true });
};// src/pages/GuardsDemoPage.tsx
import React, { useState } from 'react';
import { Container, Paper, Typography, Box, Button, TextField, Alert } from '@mui/material';
import { Shield, AlertTriangle } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { usePreventUnsavedChanges } from '../hooks/usePreventUnsavedChanges';
export const GuardsDemoPage = () => {
const [text, setText] = useState('');
const navigate = useNavigate();
const isDirty = text.length > 0;
usePreventUnsavedChanges({
isDirty,
message: 'У вас есть несохраненный текст. Вы действительно хотите уйти?',
});
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<Paper sx={{ p: 4 }}>
<Box display="flex" alignItems="center" gap={2} mb={3}>
<Shield size={32} color="#3f51b5" />
<Typography variant="h4">Демо защитников</Typography>
</Box>
<Alert severity="info" sx={{ mb: 3 }}>
<Typography variant="body2">
Введите текст и попробуйте уйти со страницы - появится предупреждение.
</Typography>
</Alert>
<TextField
fullWidth
label="Введите что-нибудь"
value={text}
onChange={(e) => setText(e.target.value)}
multiline
rows={4}
sx={{ mb: 3 }}
/>
<Box display="flex" gap={2}>
<Button
variant="contained"
component={Link}
to="/"
>
На главную (с защитой)
</Button>
<Button
variant="outlined"
onClick={() => navigate('/users')}
>
К пользователям (с защитой)
</Button>
</Box>
{isDirty && (
<Alert severity="warning" sx={{ mt: 2 }}>
<AlertTriangle size={16} /> Есть несохраненные изменения!
</Alert>
)}
</Paper>
</Container>
);
};-
AuthGuard:
- Попробуйте зайти на
/usersбез авторизации → редирект на/login - После входа должно вернуть на
/users
- Попробуйте зайти на
-
PreventUnsavedChanges:
- Зайдите на
/register, заполните любое поле - Попробуйте перейти по ссылке "Уже есть аккаунт?" → появится предупреждение
- Попробуйте обновить страницу → предупреждение браузера
- Зайдите на
-
404 страница:
- Зайдите на несуществующий URL → покажет 404
-
500 страница:
- Можно вызвать через демо ошибок
✅ AuthGuard - защита маршрутов от неавторизованных ✅ returnUrl - редирект обратно после входа ✅ PreventUnsavedChanges - защита от потери данных ✅ Кастомный ConfirmDialog - красивое подтверждение ✅ Страницы ошибок - 404 и 500 ✅ Демо-страница - для тестирования защитников