diff --git a/CHECKLIST.md b/CHECKLIST.md
index b7369bb..8c8524d 100644
--- a/CHECKLIST.md
+++ b/CHECKLIST.md
@@ -177,7 +177,7 @@
- [x] Frontend Build (`pnpm build`)
- [x] Frontend Lint (`pnpm lint`)
-- [x] Frontend Tests (69/69, `pnpm test`)
+- [x] Frontend Tests (74/74, `pnpm test`)
- [x] Backend Tests (80/80)
- [x] Manual End-to-End Testing
- [x] Swagger Verification
diff --git a/frontend/src/pages/PublicLeadPage.test.tsx b/frontend/src/pages/PublicLeadPage.test.tsx
new file mode 100644
index 0000000..2af50c0
--- /dev/null
+++ b/frontend/src/pages/PublicLeadPage.test.tsx
@@ -0,0 +1,27 @@
+import { screen } from "@testing-library/react"
+import { describe, expect, it } from "vitest"
+
+import { renderRoute } from "../test/render"
+import { setTokens } from "../utils/token"
+import PublicLeadPage from "./PublicLeadPage"
+
+function renderPublic() {
+ renderRoute(, { path: "/", url: "/" })
+}
+
+describe("PublicLeadPage header", () => {
+ it("offers Login to visitors", () => {
+ renderPublic()
+
+ expect(screen.getByRole("link", { name: "Login" })).toHaveAttribute("href", "/login")
+ expect(screen.queryByRole("link", { name: "Go to dashboard" })).not.toBeInTheDocument()
+ })
+
+ it("offers the dashboard to a signed-in user instead of Login", () => {
+ setTokens("access-1", "refresh-1")
+ renderPublic()
+
+ expect(screen.getByRole("link", { name: "Go to dashboard" })).toHaveAttribute("href", "/dashboard")
+ expect(screen.queryByRole("link", { name: "Login" })).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/pages/PublicLeadPage.tsx b/frontend/src/pages/PublicLeadPage.tsx
index 1e508fb..e52e9d3 100644
--- a/frontend/src/pages/PublicLeadPage.tsx
+++ b/frontend/src/pages/PublicLeadPage.tsx
@@ -15,6 +15,7 @@ import Button from "../components/ui/Button"
import TextField from "../components/ui/TextField"
import Footer from "../components/layout/Footer"
+import { getAccessToken } from "../utils/token"
export default function PublicLeadPage() {
const [submitted, setSubmitted] = useState(false)
@@ -52,6 +53,8 @@ export default function PublicLeadPage() {
const onSubmit = (data: LeadCreateRequest) => {
mutation.mutate(data)
}
+ const signedIn = getAccessToken() !== null
+
return (
@@ -61,10 +64,10 @@ export default function PublicLeadPage() {
- Login
+ {signedIn ? "Go to dashboard" : "Login"}
diff --git a/frontend/src/pages/auth/LoginPage.test.tsx b/frontend/src/pages/auth/LoginPage.test.tsx
index 3d6c641..a58a23a 100644
--- a/frontend/src/pages/auth/LoginPage.test.tsx
+++ b/frontend/src/pages/auth/LoginPage.test.tsx
@@ -2,12 +2,15 @@ import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { AxiosError, type AxiosResponse } from "axios"
import { createMemoryRouter, RouterProvider } from "react-router-dom"
+import toast from "react-hot-toast"
import { describe, expect, it, vi } from "vitest"
import api from "../../api/axios"
-import { getAccessToken, getRefreshToken } from "../../utils/token"
+import { getAccessToken, getRefreshToken, setTokens } from "../../utils/token"
import LoginPage from "./LoginPage"
+vi.mock("react-hot-toast", () => ({ default: { success: vi.fn(), error: vi.fn() } }))
+
function renderLogin(from?: string) {
const router = createMemoryRouter(
[
@@ -118,3 +121,39 @@ describe("LoginPage", () => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument()
})
})
+
+describe("LoginPage feedback and redirects", () => {
+ it("confirms a successful sign-in and shows progress while signing in", async () => {
+ let resolve!: (value: unknown) => void
+ vi.spyOn(api, "post").mockReturnValue(new Promise((r) => (resolve = r)) as never)
+
+ renderLogin()
+ await submit()
+
+ expect(screen.getByRole("button", { name: "Signing in..." })).toBeDisabled()
+
+ resolve({ data: { access: "access-1", refresh: "refresh-1" } })
+
+ expect(await screen.findByText("Dashboard")).toBeInTheDocument()
+ expect(toast.success).toHaveBeenCalledWith("Signed in successfully.")
+ })
+
+ it("sends an already signed-in user to the dashboard", async () => {
+ setTokens("access-1", "refresh-1")
+ const post = vi.spyOn(api, "post")
+
+ renderLogin()
+
+ expect(await screen.findByText("Dashboard")).toBeInTheDocument()
+ expect(screen.queryByPlaceholderText("Password")).not.toBeInTheDocument()
+ expect(post).not.toHaveBeenCalled()
+ })
+
+ it("sends an already signed-in user back to the page they were headed for", async () => {
+ setTokens("access-1", "refresh-1")
+
+ renderLogin("/leads/7")
+
+ expect(await screen.findByText("Lead detail")).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx
index 0a84154..15c57d7 100644
--- a/frontend/src/pages/auth/LoginPage.tsx
+++ b/frontend/src/pages/auth/LoginPage.tsx
@@ -1,10 +1,11 @@
import { useState } from "react"
-import { useLocation, useNavigate } from "react-router-dom"
+import { Navigate, useLocation, useNavigate } from "react-router-dom"
import { isAxiosError } from "axios"
+import toast from "react-hot-toast"
import { login } from "../../api/auth"
-import { setTokens } from "../../utils/token"
+import { getAccessToken, setTokens } from "../../utils/token"
function loginErrorMessage(error: unknown) {
if (isAxiosError(error) && error.response) {
@@ -46,6 +47,8 @@ export default function LoginPage() {
setTokens(response.data.access, response.data.refresh)
+ toast.success("Signed in successfully.")
+
navigate(from, { replace: true })
} catch (err) {
setError(loginErrorMessage(err))
@@ -54,6 +57,12 @@ export default function LoginPage() {
}
}
+ // Already signed in: skip the form. Expired tokens are refreshed (or the
+ // session ended) by the API client on the next request.
+ if (getAccessToken()) {
+ return
+ }
+
return (