Перейти к содержанию

Защита маршрутов — полный код

Маршрутизация

ProtectedRoute

Файл: frontend/src/routes/ProtectedRoute.tsx

import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../stores/auth';

export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
  const location = useLocation();

  // Если пользователь не авторизован — перенаправляем на страницу входа,
  // сохраняя текущий путь для обратного редиректа после логина
  if (!isAuthenticated) {
    const redirect = encodeURIComponent(location.pathname + location.search);
    return <Navigate to={`/login?redirect=${redirect}`} replace />;
  }

  return <>{children}</>;
}

Обёртка в App.tsx

Файл: frontend/src/App.tsx

// Вспомогательный компонент-обёртка для краткости
function Protected({ children }: { children: React.ReactNode }) {
  return <ProtectedRoute>{children}</ProtectedRoute>;
}

export default function App() {
  const { needRefresh, applyUpdate } = useServiceWorker();

  return (
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        {needRefresh && <UpdateAvailableToast onUpdate={applyUpdate} />}
        <Routes>
          {/* Защищённые маршруты с общим layout (BottomNav) */}
          <Route element={<Protected><OfflineErrorBoundary><AppLayout /></OfflineErrorBoundary></Protected>}>
            <Route path="/" element={<HomePage />} />
            <Route path="/feed" element={<FeedPage />} />
            <Route path="/wardrobe" element={<WardrobePage />} />
            <Route path="/wardrobe/:itemId" element={<WardrobeItemPage />} />
            <Route path="/add" element={<AddItemSourcePage />} />
            <Route path="/add/details" element={<AddItemDetailsPage />} />
            <Route path="/outfits" element={<OutfitsPage />} />
            <Route path="/outfits/build" element={<OutfitBuilderPage />} />
            <Route path="/outfits/build/select" element={<ItemSelectPage />} />
            <Route path="/outfits/:outfitId" element={<OutfitDetailPage />} />
            <Route path="/profile" element={<ProfilePage />} />
            <Route path="/profile/settings" element={<SettingsPage />} />
            <Route path="/share/:outfitId" element={<SharePage />} />
          </Route>

          {/* Публичные маршруты без layout */}
          <Route path="/login" element={<LoginPage />} />
          <Route path="/register" element={<RegisterPage />} />
          <Route path="/share/view/:token" element={<ShareRecipientPage />} />
        </Routes>
      </BrowserRouter>
    </QueryClientProvider>
  );
}

AppLayout

Файл: frontend/src/components/layout/AppLayout.tsx

import { Outlet } from 'react-router-dom';
import BottomNav from './BottomNav';
import OfflineBanner from '../ui/OfflineBanner';
import s from './AppLayout.module.css';

export default function AppLayout() {
  return (
    <div className={s.layout}>
      <OfflineBanner />
      <main className={s.content} role="main">
        <Outlet />
      </main>
      <BottomNav />
    </div>
  );
}

Логика редиректов

Ситуация Поведение
Неавторизованный → защищённый маршрут Редирект на /login?redirect=<путь>
Успешный вход (LoginPage) navigate(decodeURIComponent(redirectTarget), { replace: true })
Успешная регистрация Аналогичный редирект на redirectTarget
ShareRecipientPage → claim без авторизации Редирект на /login?redirect=/share/view/:token

Параметр replace: true используется во всех редиректах для предотвращения зацикливания при нажатии кнопки «Назад» в браузере.