fix(client): single-flight the redirect and preserve the intended route

This commit is contained in:
lakshit verma 2026-08-17 02:06:49 +05:30
parent 0a4574551a
commit bee8838360
No known key found for this signature in database
5 changed files with 52 additions and 13 deletions

View file

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

View file

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

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

View file

@ -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';

View file

@ -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>