diff --git a/js/src/app/layouts/AppLayout.tsx b/js/src/app/layouts/AppLayout.tsx index 5165214..af04e4c 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 feb7e09..c127768 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 0000000..03f154f --- /dev/null +++ b/js/src/app/router/guards/RequireAuth.test.tsx @@ -0,0 +1,46 @@ +import { RequireAuth } from "@/app/router/guards/RequireAuth"; +import { + completedSession, + sessionResponse, + shellSession, +} 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} /> + sign-up page} /> + , + { initialEntries: ["/private"] }, + ); +} + +test("signed-out visitors are redirected to the login page", async () => { + renderGuarded(); + + expect(await screen.findByText("login page")).toBeInTheDocument(); +}); + +test("a completed member sees the guarded page", async () => { + server.use(http.get("/api/session", () => sessionResponse(completedSession))); + + renderGuarded(); + + expect(await screen.findByText("private page")).toBeInTheDocument(); +}); + +test("a shell member is sent to complete their profile first", async () => { + server.use(http.get("/api/session", () => sessionResponse(shellSession))); + + renderGuarded(); + + expect(await screen.findByText("sign-up page")).toBeInTheDocument(); +}); diff --git a/js/src/app/router/guards/RequireAuth.tsx b/js/src/app/router/guards/RequireAuth.tsx index db3e977..3c71de2 100644 --- a/js/src/app/router/guards/RequireAuth.tsx +++ b/js/src/app/router/guards/RequireAuth.tsx @@ -1,11 +1,12 @@ -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, and shell accounts (magic link verified, profile not yet + * filled in) are sent to the sign-up form first. */ export function RequireAuth() { const { data: session, isPending } = useSession(); @@ -19,7 +20,11 @@ export function RequireAuth() { } if (!session) { - return ; + return ; + } + + if (!session.profileCompleted) { + return ; } return ; diff --git a/js/src/app/router/router.tsx b/js/src/app/router/router.tsx index fcfad74..3193b07 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 HomePage from "@/features/home/Home.page"; import SamplePage from "@/features/sample/Sample.page"; @@ -22,6 +24,8 @@ export const router = createBrowserRouter([ children: [ { index: true, element: }, { path: "sign-up", element: }, + { path: "login", element: }, + { path: "auth/verify", element: }, ], }, //TESTING 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 0000000..c283b4f --- /dev/null +++ b/js/src/features/auth/Login.page.test.tsx @@ -0,0 +1,31 @@ +import LoginPage from "@/features/auth/Login.page"; +import { renderWithProviders, screen } from "@/lib/test/render"; +import userEvent from "@testing-library/user-event"; +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("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(); +}); diff --git a/js/src/features/auth/Login.page.tsx b/js/src/features/auth/Login.page.tsx new file mode 100644 index 0000000..f90156f --- /dev/null +++ b/js/src/features/auth/Login.page.tsx @@ -0,0 +1,88 @@ +import { loginSchema, LoginFormValues } from "@/features/auth/api/schemas"; +import { useRequestLink } from "@/features/auth/api/useRequestLink"; +import { + Alert, + Button, + Paper, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { zodResolver } from "mantine-form-zod-resolver"; + +/** + * Passwordless login: ask for an email, request a magic link, and show the + * same "check your email" panel no matter what — account existence is never + * revealed here. + */ +export default function LoginPage() { + const requestLink = useRequestLink(); + + const form = useForm({ + initialValues: { email: "" }, + validate: zodResolver(loginSchema), + }); + + const handleSubmit = form.onSubmit((values) => { + requestLink.mutate(values.email.trim()); + }); + + if (requestLink.isSuccess) { + return ( + + Check your email + + If you entered a valid address, a sign-in link is on its way to{" "} + + {form.getValues().email.trim()} + + . The link expires in 15 minutes and can only be used once. + + + Nothing arriving? Check your spam folder, or{" "} + requestLink.reset()} + > + request another link + + . + + + ); + } + + return ( + +
+ + Sign in to PatChats + + Enter your email and we'll send you a sign-in link — no + password needed. + + + {requestLink.isError && ( + + Something went wrong sending your link. Please try again. + + )} + + +
+
+ ); +} 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 0000000..0256c75 --- /dev/null +++ b/js/src/features/auth/Verify.page.test.tsx @@ -0,0 +1,60 @@ +import { + invalidLinkResponse, + sessionResponse, + shellSession, +} 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} /> + sign-up page} /> + , + { initialEntries: [url] }, + ); +} + +test("a completed member lands on the home page", async () => { + renderVerify("/auth/verify?token=good-token"); + + expect(await screen.findByText("home page")).toBeInTheDocument(); +}); + +test("a shell member is sent to complete their profile", async () => { + server.use( + http.post("/api/auth/verify", () => sessionResponse(shellSession)), + ); + + renderVerify("/auth/verify?token=good-token"); + + expect(await screen.findByText("sign-up 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 0000000..da584ab --- /dev/null +++ b/js/src/features/auth/Verify.page.tsx @@ -0,0 +1,56 @@ +import { useVerifyMagicLink } from "@/features/auth/api/useVerifyMagicLink"; +import { Alert, Button, Center, Loader, Stack, Text } from "@mantine/core"; +import { useEffect, useRef } from "react"; +import { Link, useNavigate, 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. On + * success, shell accounts go to the sign-up form to complete their profile. + */ +export default function VerifyPage() { + const [params] = useSearchParams(); + const token = params.get("token"); + const navigate = useNavigate(); + const verify = useVerifyMagicLink(); + // StrictMode double-invokes effects in dev; the token is single-use, so guard the POST. + const fired = useRef(false); + + useEffect(() => { + if (fired.current || !token) { + return; + } + fired.current = true; + verify.mutate(token, { + onSuccess: (session) => { + navigate(session.profileCompleted ? "/" : "/sign-up", { + replace: true, + }); + }, + }); + }, [token, verify, navigate]); + + 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 0000000..a824fcf --- /dev/null +++ b/js/src/features/auth/api/auth.mock.ts @@ -0,0 +1,58 @@ +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 generically, verify signs in a completed + * member, and there is no session (401). Tests override per case with + * `server.use(...)` and the exported fixtures. + */ + +export const completedSession: Session = { + id: "6f9a4f4e-0000-4000-8000-000000000001", + name: "Ann Example", + email: "ann@example.com", + isAdmin: false, + profileCompleted: true, +}; + +export const shellSession: Session = { + id: "6f9a4f4e-0000-4000-8000-000000000002", + name: null, + email: "new@example.com", + isAdmin: false, + profileCompleted: 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 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(completedSession)), + 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 0000000..f105d2e --- /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 0000000..cb4a239 --- /dev/null +++ b/js/src/features/auth/api/useLogout.ts @@ -0,0 +1,18 @@ +import { sessionQueryKey } from "@/features/auth/api/useSession"; +import { apiFetch } 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 0000000..eaa5b8b --- /dev/null +++ b/js/src/features/auth/api/useRequestLink.ts @@ -0,0 +1,16 @@ +import { apiFetch } 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 0000000..1ce6bcf --- /dev/null +++ b/js/src/features/auth/api/useSession.ts @@ -0,0 +1,39 @@ +import { ApiError, apiFetch } from "@/lib/api/client"; +import { useQuery } from "@tanstack/react-query"; + +/** + * The current authenticated member. `name` is null and `profileCompleted` + * false for a "shell" account that verified a magic link but has not filled + * in the sign-up form yet. + */ +export interface Session { + id: string; + name: string | null; + email: string; + isAdmin: boolean; + profileCompleted: 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"); + } 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 0000000..a52d47f --- /dev/null +++ b/js/src/features/auth/api/useVerifyMagicLink.ts @@ -0,0 +1,23 @@ +import { Session, sessionQueryKey } from "@/features/auth/api/useSession"; +import { apiFetch } from "@/lib/api/client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +/** + * Exchanges the raw token from the emailed link for a session. On success the + * backend sets the session cookie and we seed the session cache so guards + * pass without a second round-trip. + */ +export function useVerifyMagicLink() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (token: string) => + apiFetch("/auth/verify", { + method: "POST", + body: JSON.stringify({ token }), + }), + onSuccess: (session) => { + queryClient.setQueryData(sessionQueryKey, session); + }, + }); +} diff --git a/js/src/features/sample/api/sample.mock.ts b/js/src/features/sample/api/sample.mock.ts index 9bb53db..13f4d78 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/lib/api/client.ts b/js/src/lib/api/client.ts index 18ac83f..667cbb4 100644 --- a/js/src/lib/api/client.ts +++ b/js/src/lib/api/client.ts @@ -1,9 +1,11 @@ /** * 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). */ export class ApiError extends Error { @@ -16,18 +18,32 @@ export class ApiError extends Error { } } +interface ApiEnvelope { + success: boolean; + message?: string; + payload?: T; +} + export async function apiFetch( path: string, init?: RequestInit, ): Promise { const response = await fetch(`/api${path}`, { + credentials: "same-origin", ...init, headers: { "Content-Type": "application/json", ...init?.headers }, }); + const envelope = (await response.json().catch(() => undefined)) as + | ApiEnvelope + | undefined; + if (!response.ok) { - throw new ApiError(response.status, `Request to ${path} failed`); + throw new ApiError( + response.status, + envelope?.message ?? `Request to ${path} failed`, + ); } - return response.json() as Promise; + return envelope?.payload as T; } diff --git a/js/src/lib/api/useSession.ts b/js/src/lib/api/useSession.ts deleted file mode 100644 index f48ec69..0000000 --- a/js/src/lib/api/useSession.ts +++ /dev/null @@ -1,26 +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: () => apiFetch("/session"), - retry: false, - staleTime: Infinity, - }); -} diff --git a/js/src/lib/test/render.tsx b/js/src/lib/test/render.tsx index 946079d..9445aef 100644 --- a/js/src/lib/test/render.tsx +++ b/js/src/lib/test/render.tsx @@ -9,7 +9,7 @@ import { MemoryRouter } from "react-router-dom"; * A fresh QueryClient per render so tests never share cache. Retries are off so * failed requests surface immediately instead of hanging the test. */ -function createWrapper() { +function createWrapper(initialEntries?: string[]) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -18,19 +18,28 @@ function createWrapper() { return ( - {children} + + {children} + ); }; } -/** Render a component wrapped in the app's providers (Query, Mantine, Router). */ +/** + * Render a component wrapped in the app's providers (Query, Mantine, Router). + * Pass `initialEntries` when the component reads the URL (params, search). + */ export function renderWithProviders( ui: ReactElement, - options?: Omit, + options?: Omit & { initialEntries?: string[] }, ) { - return render(ui, { wrapper: createWrapper(), ...options }); + const { initialEntries, ...renderOptions } = options ?? {}; + return render(ui, { + wrapper: createWrapper(initialEntries), + ...renderOptions, + }); } // eslint-disable-next-line react-refresh/only-export-components diff --git a/js/src/lib/test/server.ts b/js/src/lib/test/server.ts index 3c2f213..62f83af 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 { sampleHandlers } from "@/features/sample/api/sample.mock"; import { setupServer } from "msw/node"; @@ -5,4 +6,4 @@ 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(...sampleHandlers); +export const server = setupServer(...sampleHandlers, ...authHandlers);