mirror of
https://github.com/vee1e/workorder-desk.git
synced 2026-09-01 09:50:13 +00:00
fix(client): single-flight the redirect and preserve the intended route
This commit is contained in:
parent
0a4574551a
commit
bee8838360
5 changed files with 52 additions and 13 deletions
|
|
@ -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 (
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
|
||||
<Route element={<GuestRoute />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
</Route>
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppLayout />}>
|
||||
|
|
|
|||
|
|
@ -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<boolean> | 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<T>(res: Response): Promise<T> {
|
||||
if (res.status === 204) {
|
||||
|
|
@ -54,18 +64,27 @@ async function refreshTokens(): Promise<boolean> {
|
|||
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<T>(path: string, init: RequestInit, retried = false): Promise<T> {
|
||||
try {
|
||||
return await requestEnvelope<T>(path, init);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401 && !retried && !path.startsWith('/auth/')) {
|
||||
const refreshed = await refreshTokens();
|
||||
if (refreshed) {
|
||||
return request<T>(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<T>(path, init, true);
|
||||
}
|
||||
}
|
||||
redirectToLogin();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
|
|
|||
10
frontend/src/components/GuestRoute.tsx
Normal file
10
frontend/src/components/GuestRoute.tsx
Normal file
|
|
@ -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 <FullPageSpinner />;
|
||||
if (user) return <Navigate to="/app" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string>>({});
|
||||
const [formError, setFormError] = useState<string | null>(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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-ink-950 px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader title="Sign in" description="Welcome back to the work order desk." />
|
||||
<CardHeader as="h1" title="Sign in" description="Welcome back to the work order desk." />
|
||||
<CardBody>
|
||||
{formError && <ErrorBanner className="mb-4" message={formError} />}
|
||||
<form onSubmit={onSubmit} className="space-y-4" noValidate>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue