From daa9694bc7489a9722d7cd5f493a0c39e5c0afe8 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 11 Jul 2026 18:19:25 +0200 Subject: [PATCH] fix(auth): redirect unauthenticated deep links to home, not /login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell (the single auth guard wrapping every protected route under /*) redirected unauthenticated visitors straight to /login instead of the home page, contrary to the intended behaviour. Root cause was one line in App.tsx's Shell render gate. Everything else in the guard was already correct: a single centralized check (no per-page duplication), a loading gate that blocks render until /auth/config + /auth/me resolve (no flicker-redirect), and 401-triggered re-checks via the axios interceptor + auth-changed event for expired sessions mid-session. Fix: - Shell now redirects to "/" (home) instead of "/login", still passing state={{ from: path }} so the originally-requested page isn't lost. - LandingPage forwards that location.state through to /login on every "Sign in" CTA (6 call sites collapsed into one goToLogin() helper), so the home-page bounce doesn't drop the deep-link intent — sign-in still returns the user to the page they wanted instead of dropping them on the default /jobs. - Added LandingPage.authRedirect.test.tsx covering the from-state handoff end to end (Landing -> click Sign in -> /login receives from). Full suite: 25 suites, 56 tests green. Co-Authored-By: Claude Opus 4.8 --- job-tracker-ui/src/App.tsx | 2 +- .../src/LandingPage.authRedirect.test.tsx | 49 +++++++++++++++++++ job-tracker-ui/src/pages/LandingPage.tsx | 18 ++++--- 3 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 job-tracker-ui/src/LandingPage.authRedirect.test.tsx diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 15e49cc..4afeee7 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -203,7 +203,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo }, []); if (requireAuth === null || !authResolved) return Loading...; - if (requireAuth && !me) return ; + if (requireAuth && !me) return ; const pageTitle = titleFor(path, t); const breadcrumbs = breadcrumbsFor(path, t); diff --git a/job-tracker-ui/src/LandingPage.authRedirect.test.tsx b/job-tracker-ui/src/LandingPage.authRedirect.test.tsx new file mode 100644 index 0000000..323ff77 --- /dev/null +++ b/job-tracker-ui/src/LandingPage.authRedirect.test.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { api } from "./api"; +import LandingPage from "./pages/LandingPage"; + +jest.mock("./api", () => ({ + api: { + get: jest.fn(), + post: jest.fn(), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, +})); + +const mockedApi = api as jest.Mocked; + +function LoginStub() { + const location = useLocation() as { state?: { from?: string } }; + return
login page, from={location.state?.from ?? "none"}
; +} + +// Regression check for the auth-guard fix: a protected route bounces an +// unauthenticated visitor to "/" with `state.from` set to the page they +// wanted. The home page must forward that state to /login so sign-in +// returns them to the originally-requested page instead of dropping them +// on /jobs. +describe("LandingPage forwards deep-link intent to /login", () => { + it("preserves location.state.from through the Sign in CTA", async () => { + mockedApi.get.mockRejectedValueOnce(new Error("401")); // /auth/me: not signed in + + render( + + + } /> + } /> + + , + ); + + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/auth/me")); + + const signInButtons = await screen.findAllByText("Sign in", { selector: "button" }); + await userEvent.click(signInButtons[0]); + + expect(await screen.findByText("login page, from=/jobs/42")).toBeInTheDocument(); + }); +}); diff --git a/job-tracker-ui/src/pages/LandingPage.tsx b/job-tracker-ui/src/pages/LandingPage.tsx index 70f674d..7029a26 100644 --- a/job-tracker-ui/src/pages/LandingPage.tsx +++ b/job-tracker-ui/src/pages/LandingPage.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import { Box, Button, Container, Stack, Typography } from "@mui/material"; import { alpha } from "@mui/material/styles"; -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import DashboardIcon from "@mui/icons-material/SpaceDashboardOutlined"; import AlarmIcon from "@mui/icons-material/NotificationsActiveOutlined"; @@ -42,6 +42,7 @@ const PRICING: { name: string; price: string; cadence: string; highlight: boolea export default function LandingPage() { const navigate = useNavigate(); + const location = useLocation() as { state?: { from?: string } }; const [checking, setChecking] = useState(true); // If the visitor already has a session, send them straight into the app. @@ -54,6 +55,11 @@ export default function LandingPage() { return () => { active = false; }; }, [navigate]); + // A protected route redirects unauthenticated visitors here with the page they + // wanted in location state; forward it to /login so sign-in returns them there + // instead of dropping them on /jobs. + const goToLogin = () => navigate("/login", { state: location.state }); + if (checking) { return ( @@ -78,7 +84,7 @@ export default function LandingPage() { JobTrack - @@ -100,7 +106,7 @@ export default function LandingPage() { right job — all in one focused workspace. Assistive, never autonomous: you approve every draft. - @@ -248,7 +254,7 @@ export default function LandingPage() { © {new Date().getFullYear()} JobTrack — a focused workspace for the modern job search. - +