Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion js/src/app/layouts/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AppShell header={{ height: 56 }} padding="md">
<AppShell.Header>
<Group h="100%" justify="space-between" px="md">
<Text fw={700}>PatChats</Text>
<Button
variant="subtle"
size="compact-sm"
loading={logout.isPending}
onClick={() => logout.mutate()}
>
Log out
</Button>
</Group>
</AppShell.Header>
<AppShell.Main>
Expand Down
2 changes: 1 addition & 1 deletion js/src/app/router/guards/RequireAdmin.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useSession } from "@/lib/api/useSession";
import { useSession } from "@/features/auth/api/useSession";
import { Navigate, Outlet } from "react-router-dom";

/**
Expand Down
46 changes: 46 additions & 0 deletions js/src/app/router/guards/RequireAuth.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Routes>
<Route element={<RequireAuth />}>
<Route path="/private" element={<div>private page</div>} />
</Route>
<Route path="/login" element={<div>login page</div>} />
<Route path="/sign-up" element={<div>sign-up page</div>} />
</Routes>,
{ 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();
});
15 changes: 10 additions & 5 deletions js/src/app/router/guards/RequireAuth.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -19,7 +20,11 @@ export function RequireAuth() {
}

if (!session) {
return <Navigate replace to="/" />;
return <Navigate replace to="/login" />;
}

if (!session.profileCompleted) {
return <Navigate replace to="/sign-up" />;
}

return <Outlet />;
Expand Down
4 changes: 4 additions & 0 deletions js/src/app/router/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,6 +24,8 @@ export const router = createBrowserRouter([
children: [
{ index: true, element: <HomePage /> },
{ path: "sign-up", element: <SignUpPage /> },
{ path: "login", element: <LoginPage /> },
{ path: "auth/verify", element: <VerifyPage /> },
],
},
//TESTING
Expand Down
31 changes: 31 additions & 0 deletions js/src/features/auth/Login.page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LoginPage />);

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(<LoginPage />);

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();
});
88 changes: 88 additions & 0 deletions js/src/features/auth/Login.page.tsx
Original file line number Diff line number Diff line change
@@ -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<LoginFormValues>({
initialValues: { email: "" },
validate: zodResolver(loginSchema),
});

const handleSubmit = form.onSubmit((values) => {
requestLink.mutate(values.email.trim());
});

if (requestLink.isSuccess) {
return (
<Stack gap="md">
<Title order={2}>Check your email</Title>
<Text>
If you entered a valid address, a sign-in link is on its way to{" "}
<Text component="span" fw={700}>
{form.getValues().email.trim()}
</Text>
. The link expires in 15 minutes and can only be used once.
</Text>
<Text c="dimmed" size="sm">
Nothing arriving? Check your spam folder, or{" "}
<Text
component="button"
type="button"
inherit
td="underline"
onClick={() => requestLink.reset()}
>
request another link
</Text>
.
</Text>
</Stack>
);
}

return (
<Paper p="lg" withBorder>
<form onSubmit={handleSubmit} noValidate>
<Stack gap="md">
<Title order={2}>Sign in to PatChats</Title>
<Text c="dimmed" size="sm">
Enter your email and we&apos;ll send you a sign-in link — no
password needed.
</Text>
<TextInput
label="Email"
placeholder="you@example.com"
type="email"
required
{...form.getInputProps("email")}
/>
{requestLink.isError && (
<Alert color="red">
Something went wrong sending your link. Please try again.
</Alert>
)}
<Button type="submit" loading={requestLink.isPending}>
Email me a sign-in link
</Button>
</Stack>
</form>
</Paper>
);
}
60 changes: 60 additions & 0 deletions js/src/features/auth/Verify.page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Routes>
<Route path="/auth/verify" element={<VerifyPage />} />
<Route path="/" element={<div>home page</div>} />
<Route path="/sign-up" element={<div>sign-up page</div>} />
</Routes>,
{ 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();
});
56 changes: 56 additions & 0 deletions js/src/features/auth/Verify.page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Stack gap="md">
<Alert color="red" title="Sign-in link problem">
{verify.error?.message ??
"This sign-in link is invalid or has expired. Request a new one."}
</Alert>
<Button component={Link} to="/login" w="fit-content">
Request a new link
</Button>
</Stack>
);
}

return (
<Center h="50vh">
<Stack align="center" gap="sm">
<Loader />
<Text c="dimmed">Signing you in…</Text>
</Stack>
</Center>
);
}
Loading