diff --git a/.example.env b/.example.env index dbdd07c7..fd1feb57 100644 --- a/.example.env +++ b/.example.env @@ -2,11 +2,11 @@ DATABASE_HOST=localhost DATABASE_PORT=5432 -DATABASE_NAME=codebloom +DATABASE_NAME=patchats DATABASE_USER=postgres DATABASE_PASSWORD=enterpasswordhere # With the example values, this gets combined inside of the application.properties to make -# jdbc://postgresql://localhost:5432/codebloom?user=postgres&password=enterpasswordhere +# jdbc://postgresql://localhost:5432/patchats?user=postgres&password=enterpasswordhere # SMTP — consumed by spring.mail.* in non-dev profiles (the dev profile logs instead of sending) SMTP_HOST=smtp.example.com @@ -16,3 +16,8 @@ SMTP_PASSWORD=enterpasswordhere # The verified From sender (a real, monitored mailbox on a domain you control) EMAIL_FROM=coffeechats@patinanetwork.org EMAIL_FROM_NAME=PatChats + +# Auth — public origin of the SPA; magic links point at $APP_BASE_URL/auth/verify?token=... +APP_BASE_URL=http://localhost:5173 +# Set to false only when serving over plain HTTP (the dev profile already does this) +AUTH_COOKIE_SECURE=true diff --git a/AGENTS.md b/AGENTS.md index 013a0d6e..6db1658a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ patchats/ | Backend build | Maven (`./mvnw`) | | Database | PostgreSQL + Flyway migrations | | Data access | Spring JDBC (plain SQL — no ORM) | -| Auth | Spring Security + OAuth2 (no passwords stored) | +| Auth | Magic links + Spring Session JDBC cookie sessions (no passwords) — see `docs/auth-feature.md` | | API docs | SpringDoc / OpenAPI → `/v3/api-docs` | | Frontend language | TypeScript (strict mode) | | Frontend framework | React 18, functional components + hooks | diff --git a/docs/auth-feature.md b/docs/auth-feature.md new file mode 100644 index 00000000..d28b3220 --- /dev/null +++ b/docs/auth-feature.md @@ -0,0 +1,109 @@ +# Auth feature (magic links) + +How PatChats signs members in: **magic links only** — no passwords, no OAuth. A user enters their +email, receives a single-use link, and clicking it establishes a server-side session delivered as an +httpOnly cookie. + +**Form-first membership.** The sign-up form is the only way a member row is created; magic links +purely sign in **existing** members. Requesting a link for an email with no member row therefore +**fails with 404**, and the login page turns that into a dead-end panel offering the two ways +forward: try another address, or go sign up. + +## The shape + +``` +src/main/java/org/patinanetwork/patchats/auth/ + AuthController.java POST /api/auth/request-link | verify | logout, GET /api/session + AuthService.java request-link + verify orchestration + TokenGenerator.java SecureRandom 256-bit raw token + SHA-256 hex digest + MagicLinkEmailComposer.java builds the sign-in email via the EmailSender PORT + RequestLinkRateLimiter.java Bucket4j: 3/email + 10/IP per 15 min, in-memory buckets + AuthProperties.java @ConfigurationProperties("app.auth") → base-url, cookie-secure, magic-link-ttl + repo/ + MagicLinkTokenRepo.java JdbcClient; atomic UPDATE..RETURNING consume + security/ + SecurityConfig.java filter chains, cookie serializer, CSRF rationale (read its javadoc) + AuthenticatedMember.java Serializable session principal (memberId + email) + ApiAuthenticationEntryPoint.java 401s in the ApiResponder envelope + +js/src/features/auth/ + Login.page.tsx /login — email → generic "check your email" panel + Verify.page.tsx /auth/verify?token=... — POSTs the token once on mount + api/ useSession, useRequestLink, useVerifyMagicLink, useLogout, auth.mock.ts +``` + +## How a login works + +1. `POST /api/auth/request-link {email}` — normalizes the email, then rate-limits **visibly**: an + exhausted budget (3/email + 10/IP per 15 min) returns HTTP 429 with a friendly message, for + **all** emails alike — the limiter runs before the member-existence check, so the 429 is + registration-blind and legitimate users know to stop retrying. Keep that ordering: it is also + what throttles probing. An email with no member row + then fails with HTTP 404 (`UnregisteredEmailException`). For a + registered member it stores a **SHA-256 digest** of a + fresh 256-bit token (raw is never persisted), and emails + `/auth/verify?token=`. Links expire after 15 minutes + (`app.auth.magic-link-ttl`). Issuing a link **does not** invalidate earlier ones — a member who + asks for a second link and then clicks the first email still gets in. Every link stands on its own + until it is used or expires, and the rate limiter is what bounds how many can be outstanding. +2. The link lands on the **frontend** verify page, which POSTs the token. Email scanners only + prefetch GETs, so they cannot burn the single-use token. +3. `POST /api/auth/verify {token}` — consumes the token atomically + (`UPDATE .. WHERE consumed_at IS NULL AND expires_at > now RETURNING email`), resolves the + member (missing member → same generic invalid-link error), and performs a programmatic Spring + Security login. Spring Session JDBC + persists the session (`spring_session` tables) and sets the `patchats_session` cookie + (httpOnly, SameSite=Lax, Secure outside dev, 30-day Max-Age). +4. Sessions expire after 30 days of inactivity (`spring.session.timeout`, sliding) and are purged by + Spring Session's built-in cleanup job. `POST /api/auth/logout` invalidates the session row. + +`GET /api/session` returns the member **fresh from the database** (never stale session state): +`{ id, name, email, isAdmin }`; 401 in the envelope when signed out. The frontend `RequireAuth` +guard sends signed-out visitors to `/login`. + +**CSRF.** Double-submit protection (the Spring-documented SPA pattern): every response sets a +JS-readable `XSRF-TOKEN` cookie, and `apiFetch` echoes it back as an `X-XSRF-TOKEN` header on +state-changing requests. The two pre-auth endpoints (`request-link`, `verify`) are exempt — their +only credential travels in the body, and a first-time visitor has no CSRF cookie yet. Details and +rationale live in `SecurityConfig`'s javadoc. + +## Manual test walkthrough (dev) + +```bash +just migrate # needs local Postgres; .env points DATABASE_NAME at the patchats DB +just dev # backend :8080 (dev profile) + frontend :5173 +``` + +1. Create a test member (only needed until the sign-up form is wired to the backend): + ```bash + psql -h localhost -U postgres -d patchats -c \ + "INSERT INTO members (id, first_name, last_name, email, introduction, active) \ + VALUES (gen_random_uuid(), 'You', 'Tester', 'you@example.com', 'Testing locally', TRUE);" + ``` +2. Open `http://localhost:5173/login`, submit that email. (An **unregistered** email instead gets a + 404 and the "No account for that email" panel, with a link to `/sign-up`; the backend log shows + no email composed.) +3. The dev profile does not send real email — `LoggingEmailSender` prints the full body to the + **backend terminal**. Copy the `http://localhost:5173/auth/verify?token=...` URL from the log. +4. Open it: you land on `/`. Check DevTools → Application → Cookies for `patchats_session` + (httpOnly, Lax, not Secure in dev). +5. Open the same link again → "invalid or expired" (single-use). Request a **second** link before + using the first, then open the first: it still signs you in — outstanding links are not + invalidated by a new one. A 4th rapid request for the same email → the login page shows the 429 + message ("too many sign-in requests"), whether or not the email is registered — an unregistered + address hits the 429 before the 404, which is the ordering that keeps probing throttled. +6. Log out from the header (visible on guarded pages like `/sample`); guarded routes now redirect + to `/login`. + +## Configuration + +| Property | Env var | Default | Meaning | +| ------------------------- | -------------------- | ----------------------- | --------------------------------------- | +| `app.auth.base-url` | `APP_BASE_URL` | `http://localhost:5173` | Public SPA origin used in emailed links | +| `app.auth.cookie-secure` | `AUTH_COOKIE_SECURE` | `true` (`false` in dev) | `Secure` flag on the session cookie | +| `app.auth.magic-link-ttl` | — | `15m` | Link validity window | +| `spring.session.timeout` | — | `30d` | Session inactivity timeout | + +Schema lives in Flyway (`db/migration/V0006`–`V0007`); `spring.session.jdbc.initialize-schema` is +`never` so the app never races migrations, and runtime Flyway is disabled (migrations stay +out-of-band via `just migrate`). diff --git a/docs/email-async/01-async-send-pipeline.md b/docs/email-async/01-async-send-pipeline.md index fd4442d2..ee6ecbf1 100644 --- a/docs/email-async/01-async-send-pipeline.md +++ b/docs/email-async/01-async-send-pipeline.md @@ -5,7 +5,8 @@ actually deliver it — fully functional and testable via API + dev-profile logg See [00-overview.md](00-overview.md) for full context and the decision table. ## Decisions that apply here -- **DB-as-queue (outbox), no SQS** (#1) — the `emails` table *is* the queue. + +- **DB-as-queue (outbox), no SQS** (#1) — the `emails` table _is_ the queue. - **Render at send-time** (#4) — store `template_id` + `template_values`; the runner renders `subject`/`body` per row just before sending (rendered text is **not** stored). - **Single instance** (#5) — no row-locking needed; deploys must be **stop-then-start**. @@ -84,12 +85,14 @@ except where noted). Create `@Repository` classes: - `EmailTemplateRepo` — **read/list only** here (`findById`, `findAll`); writes arrive in Increment 5. Pattern: + ```java jdbcClient.sql("SELECT * FROM email_templates WHERE id = :id") .param("id", id) .query(new EmailTemplateRowMapper()) .optional(); ``` + Map `JSONB` (`template_values`) ↔ Java via a small Jackson helper. For the **N-child batch insert**, drop to the underlying `JdbcTemplate.batchUpdate(...)` (JdbcClient has no batch API yet) — inject `JdbcTemplate` only in `EmailRepo` for that one method. @@ -110,8 +113,8 @@ only in `EmailRepo` for that one method. happens later, in the runner (1e). - **The service does not start the runner.** After the `202` returns (transaction committed), the **caller** kicks the drain via `POST /api/email/process` (see below). This is the "manual/frontend kick only" model (#6). -- *(Optional)* dry-run render at enqueue for **early validation only** — reject a template that can't render up - front. Only the *values* are stored, never the output. Skip it for the minimal path; otherwise render errors +- _(Optional)_ dry-run render at enqueue for **early validation only** — reject a template that can't render up + front. Only the _values_ are stored, never the output. Skip it for the minimal path; otherwise render errors surface asynchronously as `ERROR` rows. - **Endpoints** (new `EmailAsyncController` or extend the existing controller): - `POST /api/email/send/async` → `enqueue(...)` with `source=MANUAL` → **`202 Accepted`** `{ requestId, accepted }`. @@ -136,9 +139,9 @@ parses CSV ([parseCSV.ts](../../js/src/features/emails/api/parseCSV.ts)) and pos - **Bean:** a single-thread `ThreadPoolTaskExecutor` named `emailDrainExecutor` (core=max=1 so drains serialize and overlapping triggers coalesce), configured with `setWaitForTasksToCompleteOnShutdown(true)` - + an await timeout. `@EnableAsync` is already present on - [PatChatsApplication](../../src/main/java/org/patinanetwork/patchats/PatChatsApplication.java); **no** - `@EnableScheduling` / `TaskScheduler` is needed (there are no timed retries). + - an await timeout. `@EnableAsync` is already present on + [PatChatsApplication](../../src/main/java/org/patinanetwork/patchats/PatChatsApplication.java); **no** + `@EnableScheduling` / `TaskScheduler` is needed (there are no timed retries). - **`EmailDrainer.trigger()`** submits a drain job to `emailDrainExecutor` **only if one isn't already running** — guard with an `AtomicBoolean` via `compareAndSet`; if `trigger()` fires while a drain is running, set a `rerun` flag so the current drain loops again instead of exiting. @@ -162,8 +165,8 @@ parses CSV ([parseCSV.ts](../../js/src/features/emails/api/parseCSV.ts)) and pos [`EmailSender.send`](../../src/main/java/org/patinanetwork/patchats/email/EmailSender.java). On success → `status='SENT'`, `sent_at=now()` (**commit per row** — keeps any duplicate window to ≤1 email). On failure — including a **render failure** (template edited into an invalid state, missing variable) — → `status='ERROR'`, - `error_message=ex.getMessage()` (**no retry**). *(Cache templates per drain to avoid reloading the same one - for every row in a batch.)* + `error_message=ex.getMessage()` (**no retry**). _(Cache templates per drain to avoid reloading the same one + for every row in a batch.)_ 3. Re-claim; when a claim returns 0 rows, stop (honor the `rerun` flag if set). **Runner tradeoffs:** on-demand kick (manual/frontend API request) + single-instance + sequential is chosen @@ -178,6 +181,7 @@ or ShedLock for multi-instance. --- ## Files to touch + - **Create:** `db/migration/V0004__Create_email_tables.sql`; `email/` — `EmailRepo`, `EmailRequestRepo`, `EmailTemplateRepo`, row mappers, a Jackson JSONB helper; `EmailEnqueueService`, `dto/EnqueueEmailRequest`, `dto/EnqueueEmailResponse`; `RecipientSource` (+ CSV impl); @@ -188,6 +192,7 @@ or ShedLock for multi-instance. (add `/send/async`, `/templates` list, update `/preview`). ## Verification + - **Unit** (fake `EmailSender`, like [EmailServiceTest](../../src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java)): - `EmailEnqueueServiceTest` — enqueue stores `template_id` + `template_values` (no rendered output); an unknown diff --git a/js/src/app/layouts/AppLayout.tsx b/js/src/app/layouts/AppLayout.tsx index 51652142..af04e4c9 100644 --- a/js/src/app/layouts/AppLayout.tsx +++ b/js/src/app/layouts/AppLayout.tsx @@ -1,13 +1,24 @@ -import { AppShell, Group, Text } from "@mantine/core"; +import { useLogout } from "@/features/auth/api/useLogout"; +import { AppShell, Button, Group, Text } from "@mantine/core"; import { Outlet } from "react-router-dom"; /** Chrome for authenticated pages: a header plus the routed page content. */ export function AppLayout() { + const logout = useLogout(); + return ( PatChats + diff --git a/js/src/app/router/guards/RequireAdmin.tsx b/js/src/app/router/guards/RequireAdmin.tsx index feb7e09c..c1277689 100644 --- a/js/src/app/router/guards/RequireAdmin.tsx +++ b/js/src/app/router/guards/RequireAdmin.tsx @@ -1,4 +1,4 @@ -import { useSession } from "@/lib/api/useSession"; +import { useSession } from "@/features/auth/api/useSession"; import { Navigate, Outlet } from "react-router-dom"; /** diff --git a/js/src/app/router/guards/RequireAuth.test.tsx b/js/src/app/router/guards/RequireAuth.test.tsx new file mode 100644 index 00000000..6dc3a049 --- /dev/null +++ b/js/src/app/router/guards/RequireAuth.test.tsx @@ -0,0 +1,33 @@ +import { RequireAuth } from "@/app/router/guards/RequireAuth"; +import { memberSession, sessionResponse } from "@/features/auth/api/auth.mock"; +import { renderWithProviders, screen } from "@/lib/test/render"; +import { server } from "@/lib/test/server"; +import { http } from "msw"; +import { Route, Routes } from "react-router-dom"; +import { expect, test } from "vitest"; + +function renderGuarded() { + return renderWithProviders( + + }> + private page} /> + + login page} /> + , + { route: "/private" }, + ); +} + +test("signed-out visitors are redirected to the login page", async () => { + renderGuarded(); + + expect(await screen.findByText("login page")).toBeInTheDocument(); +}); + +test("a signed-in member sees the guarded page", async () => { + server.use(http.get("/api/session", () => sessionResponse(memberSession))); + + renderGuarded(); + + expect(await screen.findByText("private page")).toBeInTheDocument(); +}); diff --git a/js/src/app/router/guards/RequireAuth.tsx b/js/src/app/router/guards/RequireAuth.tsx index db3e9774..880fce5c 100644 --- a/js/src/app/router/guards/RequireAuth.tsx +++ b/js/src/app/router/guards/RequireAuth.tsx @@ -1,11 +1,11 @@ -import { useSession } from "@/lib/api/useSession"; +import { useSession } from "@/features/auth/api/useSession"; import { Center, Loader } from "@mantine/core"; import { Navigate, Outlet } from "react-router-dom"; /** - * Route guard: render the nested routes only for an authenticated user. - * While the session is loading, show a spinner; if there is no session, redirect - * to the public home. + * Route guard: render the nested routes only for an authenticated member. + * While the session is loading, show a spinner; signed-out visitors go to the + * login page. */ export function RequireAuth() { const { data: session, isPending } = useSession(); @@ -19,7 +19,7 @@ export function RequireAuth() { } if (!session) { - return ; + return ; } return ; diff --git a/js/src/app/router/router.tsx b/js/src/app/router/router.tsx index faaa0452..25f3d099 100644 --- a/js/src/app/router/router.tsx +++ b/js/src/app/router/router.tsx @@ -5,6 +5,8 @@ import { RequireAdmin } from "@/app/router/guards/RequireAdmin"; import { RequireAuth } from "@/app/router/guards/RequireAuth"; import AdminPage from "@/features/admin/Admin.page"; import AdminLoginPage from "@/features/admin/AdminLogin.page"; +import LoginPage from "@/features/auth/Login.page"; +import VerifyPage from "@/features/auth/Verify.page"; import EmailAdminPage from "@/features/emails/EmailAdminPage"; import { EmailHistoryDetailPage } from "@/features/emails/EmailHistoryDetailPage"; import { EmailHistoryPage } from "@/features/emails/EmailHistoryPage"; @@ -41,6 +43,8 @@ export const router = createBrowserRouter([ { index: true, element: }, { path: "sign-up", element: }, { path: "profile/:id", element: }, + { path: "login", element: }, + { path: "auth/verify", element: }, ], }, // Temporary public email routes for TESTING (before auth is wired) diff --git a/js/src/features/auth/Login.page.test.tsx b/js/src/features/auth/Login.page.test.tsx new file mode 100644 index 00000000..cbb29a9c --- /dev/null +++ b/js/src/features/auth/Login.page.test.tsx @@ -0,0 +1,83 @@ +import { + rateLimitedResponse, + unregisteredEmailResponse, +} from "@/features/auth/api/auth.mock"; +import LoginPage from "@/features/auth/Login.page"; +import { renderWithProviders, screen } from "@/lib/test/render"; +import { server } from "@/lib/test/server"; +import userEvent from "@testing-library/user-event"; +import { http } from "msw"; +import { expect, test } from "vitest"; + +test("rejects an invalid email without calling the API", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText(/email/i), "not-an-email"); + await user.click( + screen.getByRole("button", { name: /email me a sign-in link/i }), + ); + + expect( + await screen.findByText("Enter a valid email address"), + ).toBeInTheDocument(); +}); + +test("offers sign-up and a retry when the email has no account", async () => { + server.use( + http.post("/api/auth/request-link", () => unregisteredEmailResponse()), + ); + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText(/email/i), "stranger@example.com"); + await user.click( + screen.getByRole("button", { name: /email me a sign-in link/i }), + ); + + expect( + await screen.findByText("No account for that email"), + ).toBeInTheDocument(); + expect(screen.getByText("stranger@example.com")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /complete the sign-up form/i }), + ).toHaveAttribute("href", "/sign-up"); + + await user.click( + screen.getByRole("button", { name: /try a different email/i }), + ); + + expect(await screen.findByLabelText(/email/i)).toHaveValue( + "stranger@example.com", + ); +}); + +test("shows the generic check-your-email panel after submitting", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText(/email/i), "ann@example.com"); + await user.click( + screen.getByRole("button", { name: /email me a sign-in link/i }), + ); + + expect(await screen.findByText("Check your email")).toBeInTheDocument(); + expect(screen.getByText("ann@example.com")).toBeInTheDocument(); +}); + +test("surfaces the server's message when rate limited", async () => { + server.use(http.post("/api/auth/request-link", () => rateLimitedResponse())); + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText(/email/i), "ann@example.com"); + await user.click( + screen.getByRole("button", { name: /email me a sign-in link/i }), + ); + + expect( + await screen.findByText( + "Too many sign-in requests. Please wait a few minutes and try again.", + ), + ).toBeInTheDocument(); +}); diff --git a/js/src/features/auth/Login.page.tsx b/js/src/features/auth/Login.page.tsx new file mode 100644 index 00000000..d4224d65 --- /dev/null +++ b/js/src/features/auth/Login.page.tsx @@ -0,0 +1,136 @@ +import { loginSchema, LoginFormValues } from "@/features/auth/api/schemas"; +import { useRequestLink } from "@/features/auth/api/useRequestLink"; +import { ApiError } from "@/lib/api/client"; +import { + Alert, + Anchor, + Button, + Paper, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { zodResolver } from "mantine-form-zod-resolver"; +import { Link } from "react-router-dom"; + +/** + * Passwordless login: ask for an email and request a magic link. Three states — + * the form, "check your email" once a link is on its way, and a dead end for an + * address with no account, which offers the only two ways forward (another + * address, or sign up). + */ +export default function LoginPage() { + const requestLink = useRequestLink(); + + const form = useForm({ + initialValues: { email: "" }, + validate: zodResolver(loginSchema), + }); + + const submittedEmail = form.getValues().email.trim(); + + const handleSubmit = form.onSubmit((values) => { + requestLink.mutate(values.email.trim()); + }); + + /** The backend 404s an email with no member row; every other failure falls + * through to the alert inside the form. */ + const isUnregistered = + requestLink.error instanceof ApiError && requestLink.error.status === 404; + + if (requestLink.isSuccess) { + return ( + + Check your email + + If you entered a valid address, a sign-in link is on its way to{" "} + + {submittedEmail} + + . The link expires in 15 minutes and can only be used once. + + + Nothing arriving? Check your spam folder, or{" "} + requestLink.reset()} + > + request another link + + . + + + ); + } + + if (isUnregistered) { + return ( + + No account for that email + + We couldn't find a PatChats account for{" "} + + {submittedEmail} + + . Sign-in links are only sent to registered members. + + + + Never signed up?{" "} + + Complete the sign-up form + {" "} + to join. + + + ); + } + + return ( + +
+ + Sign in to PatChats + + Enter your email and we'll send you a sign-in link — no + password needed. + + + {requestLink.isError && ( + + {( + requestLink.error instanceof ApiError && + requestLink.error.status === 429 + ) ? + requestLink.error.message + : "Something went wrong sending your link. Please try again."} + + )} + + + New to PatChats?{" "} + + Complete the sign-up form + {" "} + first — sign-in links are only sent to registered members. + + +
+
+ ); +} diff --git a/js/src/features/auth/Verify.page.test.tsx b/js/src/features/auth/Verify.page.test.tsx new file mode 100644 index 00000000..a3a10977 --- /dev/null +++ b/js/src/features/auth/Verify.page.test.tsx @@ -0,0 +1,45 @@ +import { invalidLinkResponse } from "@/features/auth/api/auth.mock"; +import VerifyPage from "@/features/auth/Verify.page"; +import { renderWithProviders, screen } from "@/lib/test/render"; +import { server } from "@/lib/test/server"; +import { http } from "msw"; +import { Route, Routes } from "react-router-dom"; +import { expect, test } from "vitest"; + +/** Mounts the verify page plus probe routes so navigation can be asserted. */ +function renderVerify(url: string) { + return renderWithProviders( + + } /> + home page} /> + , + { route: url }, + ); +} + +test("a verified member lands on the home page", async () => { + renderVerify("/auth/verify?token=good-token"); + + expect(await screen.findByText("home page")).toBeInTheDocument(); +}); + +test("an invalid or expired link shows the server message and a retry path", async () => { + server.use(http.post("/api/auth/verify", () => invalidLinkResponse())); + + renderVerify("/auth/verify?token=spent-token"); + + expect( + await screen.findByText( + "This sign-in link is invalid or has expired. Request a new one.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /request a new link/i }), + ).toBeInTheDocument(); +}); + +test("a missing token shows the error state without calling the API", async () => { + renderVerify("/auth/verify"); + + expect(await screen.findByText("Sign-in link problem")).toBeInTheDocument(); +}); diff --git a/js/src/features/auth/Verify.page.tsx b/js/src/features/auth/Verify.page.tsx new file mode 100644 index 00000000..df2ed37a --- /dev/null +++ b/js/src/features/auth/Verify.page.tsx @@ -0,0 +1,41 @@ +import { useVerifyMagicLink } from "@/features/auth/api/useVerifyMagicLink"; +import { Alert, Button, Center, Loader, Stack, Text } from "@mantine/core"; +import { Link, Navigate, useSearchParams } from "react-router-dom"; + +/** + * Landing page for the emailed link (`/auth/verify?token=...`). The link is a + * plain GET so email scanners can prefetch it harmlessly; the actual + * single-use consumption happens here via POST when the page mounts. + */ +export default function VerifyPage() { + const [params] = useSearchParams(); + const token = params.get("token"); + const verify = useVerifyMagicLink(token); + + if (verify.isSuccess) { + return ; + } + + if (!token || verify.isError) { + return ( + + + {verify.error?.message ?? + "This sign-in link is invalid or has expired. Request a new one."} + + + + ); + } + + return ( +
+ + + Signing you in… + +
+ ); +} diff --git a/js/src/features/auth/api/auth.mock.ts b/js/src/features/auth/api/auth.mock.ts new file mode 100644 index 00000000..7c5e33fd --- /dev/null +++ b/js/src/features/auth/api/auth.mock.ts @@ -0,0 +1,68 @@ +import { Session } from "@/features/auth/api/useSession"; +import { http, HttpResponse } from "msw"; + +/** + * MSW handlers for the auth domain, envelope-shaped like the real backend. + * Defaults: request-link succeeds, verify signs in a member, and + * there is no session (401). Tests override per case with `server.use(...)` + * and the exported fixtures. + */ + +export const memberSession: Session = { + id: "6f9a4f4e-0000-4000-8000-000000000001", + name: "Ann Example", + email: "ann@example.com", + isAdmin: false, +}; + +export const invalidLinkResponse = () => + HttpResponse.json( + { + success: false, + message: + "This sign-in link is invalid or has expired. Request a new one.", + }, + { status: 400 }, + ); + +export const unregisteredEmailResponse = () => + HttpResponse.json( + { + success: false, + message: "We couldn't find an account for that email.", + }, + { status: 404 }, + ); + +export const rateLimitedResponse = () => + HttpResponse.json( + { + success: false, + message: + "Too many sign-in requests. Please wait a few minutes and try again.", + }, + { status: 429 }, + ); + +export const sessionResponse = (session: Session) => + HttpResponse.json({ success: true, message: "Signed in.", payload: session }); + +export const authHandlers = [ + http.post("/api/auth/request-link", () => + HttpResponse.json({ + success: true, + message: "Check your email for a sign-in link.", + payload: null, + }), + ), + http.post("/api/auth/verify", () => sessionResponse(memberSession)), + http.get("/api/session", () => + HttpResponse.json( + { success: false, message: "Not signed in" }, + { status: 401 }, + ), + ), + http.post("/api/auth/logout", () => + HttpResponse.json({ success: true, message: "Signed out.", payload: null }), + ), +]; diff --git a/js/src/features/auth/api/schemas.ts b/js/src/features/auth/api/schemas.ts new file mode 100644 index 00000000..f105d2e8 --- /dev/null +++ b/js/src/features/auth/api/schemas.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +/** Login form: just an email — magic links are the only sign-in method. */ +export const loginSchema = z.object({ + email: z.string().trim().email("Enter a valid email address"), +}); + +export type LoginFormValues = z.infer; diff --git a/js/src/features/auth/api/useLogout.ts b/js/src/features/auth/api/useLogout.ts new file mode 100644 index 00000000..009c4f82 --- /dev/null +++ b/js/src/features/auth/api/useLogout.ts @@ -0,0 +1,20 @@ +import { sessionQueryKey } from "@/features/auth/api/useSession"; +import { apiFetch } from "@/lib/api/client"; +import { ApiResponder } from "@/lib/api/client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; + +/** Signs out: the backend invalidates the session and expires the cookie. */ +export function useLogout() { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + return useMutation({ + mutationFn: () => + apiFetch>("/auth/logout", { method: "POST" }), + onSuccess: () => { + queryClient.setQueryData(sessionQueryKey, null); + navigate("/"); + }, + }); +} diff --git a/js/src/features/auth/api/useRequestLink.ts b/js/src/features/auth/api/useRequestLink.ts new file mode 100644 index 00000000..8526238c --- /dev/null +++ b/js/src/features/auth/api/useRequestLink.ts @@ -0,0 +1,17 @@ +import { apiFetch } from "@/lib/api/client"; +import { ApiResponder } from "@/lib/api/client"; +import { useMutation } from "@tanstack/react-query"; + +/** + * Asks the backend to email a sign-in link. The response is intentionally + * identical whether or not the email has an account. + */ +export function useRequestLink() { + return useMutation({ + mutationFn: (email: string) => + apiFetch>("/auth/request-link", { + method: "POST", + body: JSON.stringify({ email }), + }), + }); +} diff --git a/js/src/features/auth/api/useSession.ts b/js/src/features/auth/api/useSession.ts new file mode 100644 index 00000000..6b29dbeb --- /dev/null +++ b/js/src/features/auth/api/useSession.ts @@ -0,0 +1,37 @@ +import { ApiError, apiFetch, ApiResponder } from "@/lib/api/client"; +import { useQuery } from "@tanstack/react-query"; + +/** + * The current authenticated member. Members are created exclusively by the + * sign-up form, so a session always carries a complete profile. + */ +export interface Session { + id: string; + name: string; + email: string; + isAdmin: boolean; +} + +export const sessionQueryKey = ["session"] as const; + +/** + * Fetches the session from the cookie-backed backend. A 401 resolves to + * `null` — "signed out" is data, not an error, so guards can branch on it. + */ +export function useSession() { + return useQuery({ + queryKey: sessionQueryKey, + queryFn: async () => { + try { + return (await apiFetch>("/session")).payload; + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + return null; + } + throw error; + } + }, + retry: false, + staleTime: Infinity, + }); +} diff --git a/js/src/features/auth/api/useVerifyMagicLink.ts b/js/src/features/auth/api/useVerifyMagicLink.ts new file mode 100644 index 00000000..9de44436 --- /dev/null +++ b/js/src/features/auth/api/useVerifyMagicLink.ts @@ -0,0 +1,34 @@ +import { Session, sessionQueryKey } from "@/features/auth/api/useSession"; +import { apiFetch, ApiResponder } from "@/lib/api/client"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +/** + * Exchanges the raw token from the emailed link for a session. On success the + * backend sets the session cookie; we also seed the session cache so guards + * pass without a second round-trip. + * + * Modeled as a query (not a mutation) on purpose: the token is single-use and + * this fires on mount, and StrictMode's simulated remount detaches a mutation + * observer from its in-flight request — the component would never see the + * result. A query keyed by the token is deduped across the double-mount (one + * POST) and the remounted observer re-attaches to the cached entry. + */ +export function useVerifyMagicLink(token: string | null) { + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: ["auth", "verify", token], + queryFn: async () => { + const session = await apiFetch>("/auth/verify", { + method: "POST", + body: JSON.stringify({ token }), + }); + queryClient.setQueryData(sessionQueryKey, session.payload); + return session.payload; + }, + enabled: token !== null, + retry: false, + // Never refetch a consumed token: the entry stays fresh for the page's lifetime. + staleTime: Infinity, + }); +} diff --git a/js/src/features/members/api/useMembers.ts b/js/src/features/members/api/useMembers.ts index 2d95d4ab..de9e64e1 100644 --- a/js/src/features/members/api/useMembers.ts +++ b/js/src/features/members/api/useMembers.ts @@ -1,4 +1,4 @@ -import { apiFetch } from "@/lib/api/client"; +import { apiFetch, ApiResponder } from "@/lib/api/client"; import { useQuery } from "@tanstack/react-query"; export interface Member { @@ -19,12 +19,6 @@ export interface Member { updatedAt: string; } -interface MembersResponse { - message: string; - payload: Member[]; - success: boolean; -} - export interface MemberFilters { active?: string; email?: string; @@ -54,7 +48,7 @@ export function useMembers(filters: MemberFilters = {}) { const query = searchParams.toString(); const path = query ? `/members?${query}` : "/members"; - const response = await apiFetch(path); + const response = await apiFetch>(path); return response.payload; }, }); diff --git a/js/src/features/sample/api/sample.mock.ts b/js/src/features/sample/api/sample.mock.ts index 9bb53db0..13f4d78b 100644 --- a/js/src/features/sample/api/sample.mock.ts +++ b/js/src/features/sample/api/sample.mock.ts @@ -6,6 +6,10 @@ import { http, HttpResponse } from "msw"; */ export const sampleHandlers = [ http.get("/api/sample/message", () => - HttpResponse.json({ message: "Hello from MSW" }), + HttpResponse.json({ + success: true, + message: "", + payload: { message: "Hello from MSW" }, + }), ), ]; diff --git a/js/src/features/sample/api/useSampleMessage.ts b/js/src/features/sample/api/useSampleMessage.ts index 7fc53d45..c396b121 100644 --- a/js/src/features/sample/api/useSampleMessage.ts +++ b/js/src/features/sample/api/useSampleMessage.ts @@ -1,4 +1,4 @@ -import { apiFetch } from "@/lib/api/client"; +import { apiFetch, ApiResponder } from "@/lib/api/client"; import { useQuery } from "@tanstack/react-query"; export interface SampleMessage { @@ -11,6 +11,7 @@ export const sampleMessageQueryKey = ["sample", "message"] as const; export function useSampleMessage() { return useQuery({ queryKey: sampleMessageQueryKey, - queryFn: () => apiFetch("/sample/message"), + queryFn: async () => + (await apiFetch>("/sample/message")).payload, }); } diff --git a/js/src/lib/api/client.ts b/js/src/lib/api/client.ts index 6bdff2c4..bfa04764 100644 --- a/js/src/lib/api/client.ts +++ b/js/src/lib/api/client.ts @@ -1,9 +1,16 @@ /** * Thin typed fetch wrapper over the backend API (proxied at `/api` in dev). * - * This is the single place to handle JSON parsing, error mapping, and (later) - * auth headers. Once `schema.d.ts` is generated from the OpenAPI spec, the - * generics here can be tightened against `paths`. + * Every backend response is wrapped in the ApiResponder envelope + * (`{ success, message, payload }`); this is the single place that unwraps it, + * so hooks receive typed payloads and errors carry the server's message. + * Auth rides on an httpOnly session cookie, hence `credentials: "same-origin"` + * (the SPA and API share an origin — the Vite proxy provides that in dev). + * + * CSRF: the backend sets a JS-readable `XSRF-TOKEN` cookie on every response; + * state-changing requests must echo it back as an `X-XSRF-TOKEN` header + * (double-submit pattern). The pre-auth endpoints (request-link, verify) are + * exempt server-side, so a missing cookie on first visit is fine. */ export interface ApiResponder { @@ -15,24 +22,48 @@ export class ApiError extends Error { constructor( public readonly status: number, message: string, + public readonly body: unknown, ) { super(message); this.name = "ApiError"; } } +function readCookie(name: string): string | undefined { + return document.cookie + .split("; ") + .find((row) => row.startsWith(`${name}=`)) + ?.slice(name.length + 1); +} + +function csrfHeader(method: string): Record { + if (method === "GET" || method === "HEAD") { + return {}; + } + const token = readCookie("XSRF-TOKEN"); + return token ? { "X-XSRF-TOKEN": decodeURIComponent(token) } : {}; +} + export async function apiFetch( path: string, init?: RequestInit, ): Promise { + const method = (init?.method ?? "GET").toUpperCase(); const response = await fetch(`/api${path}`, { + credentials: "same-origin", ...init, - headers: { "Content-Type": "application/json", ...init?.headers }, + headers: { + "Content-Type": "application/json", + ...csrfHeader(method), + ...init?.headers, + }, }); - if (!response.ok) { - throw new ApiError(response.status, `Request to ${path} failed`); + const body = await response.json().catch(() => undefined); + const message = + (body as ApiResponder | undefined)?.message ?? + `Request to ${path} failed`; + throw new ApiError(response.status, message, body); } - return response.json() as Promise; } diff --git a/js/src/lib/api/useSession.ts b/js/src/lib/api/useSession.ts deleted file mode 100644 index bcbcfc62..00000000 --- a/js/src/lib/api/useSession.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { apiFetch } from "@/lib/api/client"; -import { useQuery } from "@tanstack/react-query"; - -/** - * The current authenticated user. - * - * PLACEHOLDER: this lives in `lib/api` so the route guards have a session source - * today. Once auth endpoints are wired, move this into a real `features/auth/api/` - * and import it from there. - */ -export interface Session { - id: string; - name: string; - isAdmin: boolean; -} - -export const sessionQueryKey = ["session"] as const; - -export function useSession() { - return useQuery({ - queryKey: sessionQueryKey, - queryFn: () => { - // Authentication is not wired yet. Local development uses an admin - // identity so guarded pages can be exercised; production still fails - // closed when the session endpoint is unavailable. - if (import.meta.env.DEV) { - return Promise.resolve({ - id: "local-dev-admin", - isAdmin: true, - name: "Local admin", - }); - } - - return apiFetch("/session"); - }, - retry: false, - staleTime: Infinity, - }); -} diff --git a/js/src/lib/test/render.tsx b/js/src/lib/test/render.tsx index 031c84c8..cc33a2f2 100644 --- a/js/src/lib/test/render.tsx +++ b/js/src/lib/test/render.tsx @@ -2,7 +2,7 @@ import { themeOverride } from "@/app/providers/theme"; import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, RenderOptions } from "@testing-library/react"; -import { ReactElement, ReactNode } from "react"; +import { ReactElement, ReactNode, StrictMode } from "react"; import { MemoryRouter } from "react-router-dom"; /** @@ -14,13 +14,17 @@ function createWrapper(route: string) { defaultOptions: { queries: { retry: false } }, }); + // StrictMode mirrors main.tsx: double-invoked effects surface bugs (e.g. + // observers detached from in-flight requests) that a bare render hides. return function Wrapper({ children }: { children: ReactNode }) { return ( - - - {children} - - + + + + {children} + + + ); }; } diff --git a/js/src/lib/test/server.ts b/js/src/lib/test/server.ts index f51ed1c8..36c67967 100644 --- a/js/src/lib/test/server.ts +++ b/js/src/lib/test/server.ts @@ -1,3 +1,4 @@ +import { authHandlers } from "@/features/auth/api/auth.mock"; import { membersHandlers } from "@/features/members/api/members.mock"; import { sampleHandlers } from "@/features/sample/api/sample.mock"; import { setupServer } from "msw/node"; @@ -6,4 +7,8 @@ import { setupServer } from "msw/node"; * MSW server for tests. Each domain owns its request handlers in * `features//api/.mock.ts`; compose them here. */ -export const server = setupServer(...membersHandlers, ...sampleHandlers); +export const server = setupServer( + ...sampleHandlers, + ...authHandlers, + ...membersHandlers, +); diff --git a/pom.xml b/pom.xml index c269b1f4..53b379b0 100644 --- a/pom.xml +++ b/pom.xml @@ -199,6 +199,19 @@ org.springframework.boot spring-boot-starter-jdbc + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.session + spring-session-jdbc + + + org.springframework.security + spring-security-test + test +