Skip to content

React 6. Guard

IOF edited this page Mar 11, 2026 · 2 revisions

Sprint 6 на React (Защитники маршрутов)

Добавляем защиту маршрутов (Auth Guard) и защиту от несохраненных изменений (CanDeactivate).

1. Создаем компонент AuthGuard (src/components/guards/AuthGuard.tsx)

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

2. Создаем хук для защиты от несохраненных изменений (src/hooks/usePreventUnsavedChanges.ts)

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,
  };
};

3. Создаем компонент ConfirmDialog (src/components/guards/ConfirmDialog.tsx)

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>
  );
};

4. Создаем компонент для защиты формы регистрации (src/components/guards/PreventUnsavedChanges.tsx)

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"
      />
    </>
  );
};

5. Обновляем страницу регистрации с защитой (src/pages/RegisterPage.tsx)

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>
  );
};

6. Создаем страницу 404 (src/pages/NotFoundPage.tsx)

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>
  );
};

7. Создаем страницу 500 (src/pages/ServerErrorPage.tsx)

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>
  );
};

8. Обновляем App.tsx с защищенными маршрутами

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;

9. Обновляем LoginPage с обработкой returnUrl

// В LoginPage.tsx добавить:
import { useLocation } from 'react-router-dom';

// Внутри компонента:
const location = useLocation();
const from = location.state?.from || '/';

const onSuccess = () => {
  navigate(from, { replace: true });
};

10. Добавляем тестовую страницу для демонстрации защитников

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

Тестирование функциональности

  1. AuthGuard:

    • Попробуйте зайти на /users без авторизации → редирект на /login
    • После входа должно вернуть на /users
  2. PreventUnsavedChanges:

    • Зайдите на /register, заполните любое поле
    • Попробуйте перейти по ссылке "Уже есть аккаунт?" → появится предупреждение
    • Попробуйте обновить страницу → предупреждение браузера
  3. 404 страница:

    • Зайдите на несуществующий URL → покажет 404
  4. 500 страница:

    • Можно вызвать через демо ошибок

Что получилось:

AuthGuard - защита маршрутов от неавторизованных ✅ returnUrl - редирект обратно после входа ✅ PreventUnsavedChanges - защита от потери данных ✅ Кастомный ConfirmDialog - красивое подтверждение ✅ Страницы ошибок - 404 и 500 ✅ Демо-страница - для тестирования защитников

Clone this wiki locally