Skip to content
Closed
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
22 changes: 5 additions & 17 deletions js/src/features/auth/Verify.page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
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";
import { Link, Navigate, useSearchParams } from "react-router-dom";

/**
* Landing page for the emailed link (`/auth/verify?token=...`). The link is a
Expand All @@ -11,22 +10,11 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
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);
const verify = useVerifyMagicLink(token);

useEffect(() => {
if (fired.current || !token) {
return;
}
fired.current = true;
verify.mutate(token, {
onSuccess: () => {
navigate("/", { replace: true });
},
});
}, [token, verify, navigate]);
if (verify.isSuccess) {
return <Navigate replace to="/" />;
}

if (!token || verify.isError) {
return (
Expand Down
27 changes: 19 additions & 8 deletions js/src/features/auth/api/useVerifyMagicLink.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,34 @@
import { Session, sessionQueryKey } from "@/features/auth/api/useSession";
import { apiFetch } from "@/lib/api/client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
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 and we seed the session cache so guards
* 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() {
export function useVerifyMagicLink(token: string | null) {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (token: string) =>
apiFetch<Session>("/auth/verify", {
return useQuery({
queryKey: ["auth", "verify", token],
queryFn: async () => {
const session = await apiFetch<Session>("/auth/verify", {
method: "POST",
body: JSON.stringify({ token }),
}),
onSuccess: (session) => {
});
queryClient.setQueryData(sessionQueryKey, session);
return session;
},
enabled: token !== null,
Comment thread
RandyJDean marked this conversation as resolved.
retry: false,
// Never refetch a consumed token: the entry stays fresh for the page's lifetime.
staleTime: Infinity,
});
}
20 changes: 12 additions & 8 deletions js/src/lib/test/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -14,15 +14,19 @@ function createWrapper(initialEntries?: 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 (
<QueryClientProvider client={queryClient}>
<MantineProvider theme={themeOverride} forceColorScheme="dark">
<MemoryRouter initialEntries={initialEntries}>
{children}
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>
<StrictMode>
<QueryClientProvider client={queryClient}>
<MantineProvider theme={themeOverride} forceColorScheme="dark">
<MemoryRouter initialEntries={initialEntries}>
{children}
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>
</StrictMode>
);
};
}
Expand Down