Compare commits

...

1 Commits

Author SHA1 Message Date
cesnimda daa9694bc7 fix(auth): redirect unauthenticated deep links to home, not /login
CI and Deploy / test (pull_request) Successful in 2m5s
CI and Deploy / deploy (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-07-11 18:19:25 +02:00
3 changed files with 62 additions and 7 deletions
+1 -1
View File
@@ -203,7 +203,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
}, []);
if (requireAuth === null || !authResolved) return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
if (requireAuth && !me) return <Navigate to="/login" replace state={{ from: path }} />;
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />;
const pageTitle = titleFor(path, t);
const breadcrumbs = breadcrumbsFor(path, t);
@@ -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<typeof api>;
function LoginStub() {
const location = useLocation() as { state?: { from?: string } };
return <div>login page, from={location.state?.from ?? "none"}</div>;
}
// 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(
<MemoryRouter initialEntries={[{ pathname: "/", state: { from: "/jobs/42" } }]}>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<LoginStub />} />
</Routes>
</MemoryRouter>,
);
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();
});
});
+12 -6
View File
@@ -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 (
<Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", bgcolor: BRAND_DARK }}>
@@ -78,7 +84,7 @@ export default function LandingPage() {
<Box sx={{ width: 30, height: 30, borderRadius: "8px", background: "linear-gradient(135deg,#6366f1,#22d3ee)", display: "grid", placeItems: "center", color: BRAND_DARK, fontWeight: 900 }}></Box>
<Typography sx={{ color: "#fff", fontWeight: 800, fontSize: 20 }}>JobTrack</Typography>
</Stack>
<Button variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
<Button variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
Sign in
</Button>
</Stack>
@@ -100,7 +106,7 @@ export default function LandingPage() {
right job all in one focused workspace. Assistive, never autonomous: you approve every draft.
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
<Button size="large" variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
Get started
</Button>
<Button size="large" variant="outlined" href="#features" sx={{ color: "#e2e8f0", borderColor: alpha("#ffffff", 0.25), px: 3, py: 1.25 }}>
@@ -217,7 +223,7 @@ export default function LandingPage() {
<Button
fullWidth
variant={tier.highlight ? "contained" : "outlined"}
onClick={() => navigate("/login")}
onClick={goToLogin}
sx={tier.highlight ? { background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800 } : { fontWeight: 700 }}
>
{tier.cta}
@@ -237,7 +243,7 @@ export default function LandingPage() {
<Typography sx={{ fontWeight: 800, fontSize: { xs: 24, md: 30 }, mb: 1 }}>Ready to organize your search?</Typography>
<Typography sx={{ color: "#94a3b8", fontSize: 17 }}>Sign in to start tracking applications, tailoring CVs, and following up with intent.</Typography>
</Box>
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
<Button size="large" variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
Sign in
</Button>
</Box>
@@ -248,7 +254,7 @@ export default function LandingPage() {
<Container maxWidth="lg">
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" alignItems="center" spacing={1}>
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>© {new Date().getFullYear()} JobTrack a focused workspace for the modern job search.</Typography>
<Button variant="text" onClick={() => navigate("/login")} sx={{ fontWeight: 700 }}>Sign in</Button>
<Button variant="text" onClick={goToLogin} sx={{ fontWeight: 700 }}>Sign in</Button>
</Stack>
</Container>
</Box>