diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e6f95fd..f4785b0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { Routes, Route } from 'react-router-dom'; import { ProtectedRoute } from './components/ProtectedRoute'; import { AdminRoute } from './components/AdminRoute'; +import { GuestRoute } from './components/GuestRoute'; import { AppLayout } from './components/AppLayout'; import { LandingPage } from './pages/LandingPage'; import { NotFoundPage } from './pages/NotFoundPage'; @@ -19,10 +20,13 @@ export function App() { return ( } /> - } /> - } /> - } /> - } /> + + }> + } /> + } /> + } /> + } /> + }> }> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2c9cea6..2a32ae6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,8 +2,18 @@ import type { ErrorEnvelope, SuccessEnvelope } from '@workorders/shared'; import { ApiError } from '../lib/errors'; const BASE_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? '/api/v1'; +export const REDIRECT_STORAGE_KEY = 'workorders.redirect'; let refreshPromise: Promise | null = null; +let redirecting = false; + +function saveRedirect(path: string): void { + try { + sessionStorage.setItem(REDIRECT_STORAGE_KEY, path); + } catch { + // storage unavailable; intent is lost, which is acceptable + } +} async function parseEnvelope(res: Response): Promise { if (res.status === 204) { @@ -54,18 +64,27 @@ async function refreshTokens(): Promise { return refreshPromise; } +function redirectToLogin(): void { + if (redirecting) return; + redirecting = true; + if (typeof window !== 'undefined') { + saveRedirect(window.location.pathname + window.location.search); + window.location.assign('/login'); + } +} + async function request(path: string, init: RequestInit, retried = false): Promise { try { return await requestEnvelope(path, init); } catch (err) { - if (err instanceof ApiError && err.status === 401 && !retried && !path.startsWith('/auth/')) { - const refreshed = await refreshTokens(); - if (refreshed) { - return request(path, init, true); - } - if (typeof window !== 'undefined') { - window.location.assign('/login'); + if (err instanceof ApiError && err.status === 401 && !path.startsWith('/auth/')) { + if (!retried) { + const refreshed = await refreshTokens(); + if (refreshed) { + return request(path, init, true); + } } + redirectToLogin(); } throw err; } diff --git a/frontend/src/components/GuestRoute.tsx b/frontend/src/components/GuestRoute.tsx new file mode 100644 index 0000000..2853962 --- /dev/null +++ b/frontend/src/components/GuestRoute.tsx @@ -0,0 +1,10 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useMe } from '../hooks/useAuth'; +import { FullPageSpinner } from './primitives/Spinner'; + +export function GuestRoute() { + const { data: user, isPending } = useMe(); + if (isPending) return ; + if (user) return ; + return ; +} \ No newline at end of file diff --git a/frontend/src/features/auth/LoginPage.test.tsx b/frontend/src/features/auth/LoginPage.test.tsx index a9375a9..02addcb 100644 --- a/frontend/src/features/auth/LoginPage.test.tsx +++ b/frontend/src/features/auth/LoginPage.test.tsx @@ -6,6 +6,7 @@ import { LoginPage } from './LoginPage'; vi.mock('../../api/client', () => ({ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() }, + REDIRECT_STORAGE_KEY: 'test.redirect', })); import { api } from '../../api/client'; diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx index 1585225..264b57a 100644 --- a/frontend/src/features/auth/LoginPage.tsx +++ b/frontend/src/features/auth/LoginPage.tsx @@ -1,7 +1,9 @@ +import { usePageTitle } from '../../hooks/usePageTitle'; import { useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { loginSchema } from '@workorders/shared'; import { useLogin } from '../../hooks/useAuth'; +import { REDIRECT_STORAGE_KEY } from '../../api/client'; import { ApiError } from '../../lib/errors'; import { Button } from '../../components/primitives/Spinner'; import { Field, Input } from '../../components/primitives/Input'; @@ -9,6 +11,7 @@ import { Card, CardBody, CardHeader } from '../../components/primitives/Card'; import { ErrorBanner } from '../../components/primitives/Feedback'; export function LoginPage() { + usePageTitle('Sign in'); const login = useLogin(); const navigate = useNavigate(); const location = useLocation(); @@ -16,7 +19,9 @@ export function LoginPage() { const [errors, setErrors] = useState>({}); const [formError, setFormError] = useState(null); - const from = (location.state as { from?: string } | null)?.from ?? '/app'; + const storedFrom = sessionStorage.getItem(REDIRECT_STORAGE_KEY); + const from = (location.state as { from?: string } | null)?.from ?? storedFrom ?? '/app'; + if (storedFrom) sessionStorage.removeItem(REDIRECT_STORAGE_KEY); async function onSubmit(e: React.FormEvent) { e.preventDefault(); @@ -40,7 +45,7 @@ export function LoginPage() { return (
- + {formError && }