diff --git a/src/app/invite/index.tsx b/src/app/invite/index.tsx index 1c56def5..519e4354 100644 --- a/src/app/invite/index.tsx +++ b/src/app/invite/index.tsx @@ -6,7 +6,12 @@ import { ChevronLeft } from "lucide-react"; import { AuthBrandLogo } from "@/app/(auth)/_components/auth-brand-logo"; import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header"; import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell"; -import { INVALID_INVITE_MESSAGE } from "@/domain/team-member-invite/types/team-member-invite"; +import { Button } from "@/components/ui/button"; +import { inviteErrorCode } from "@/domain/team-member-invite/api/team-member-invite-api"; +import { + INVALID_INVITE_MESSAGE, + INVITE_UNREACHABLE_MESSAGE, +} from "@/domain/team-member-invite/types/team-member-invite"; import { useTeamMemberInviteDetails } from "@/domain/team-member-invite/use-cases/use-team-member-invite"; import { InviteWizard } from "./_components/invite-wizard"; @@ -28,6 +33,27 @@ function InviteBody({ token }: { token: string }) { ); } + // Only the server's own `invalid_invite` answer means the link is dead. A request that never + // got that answer (offline, timeout, 5xx) says nothing about the token, so offer a retry + // instead of sending the member to their admin for a new invite. + if (!inviteEnded && details.isError && inviteErrorCode(details.error) !== "invalid_invite") { + return ( + + + + + ); + } + if (inviteEnded || details.data === undefined) { return ; } diff --git a/src/domain/team-member-invite/types/team-member-invite.ts b/src/domain/team-member-invite/types/team-member-invite.ts index c89ac3e4..a661ef49 100644 --- a/src/domain/team-member-invite/types/team-member-invite.ts +++ b/src/domain/team-member-invite/types/team-member-invite.ts @@ -2,6 +2,10 @@ export const INVALID_INVITE_MESSAGE = "This invite link is invalid or has expired. Ask your admin to send a new invite."; +/** Shown when the invite could not be checked at all (offline, timeout, server error); the link may still be valid. */ +export const INVITE_UNREACHABLE_MESSAGE = + "We couldn't reach the server to check your invite. Check your connection and try again."; + /** The read-only identity an admin set when inviting the member. */ export interface TeamMemberInviteDetails { name: string; diff --git a/src/test/app/invite/invite-registration-page.test.tsx b/src/test/app/invite/invite-registration-page.test.tsx index fa8b35fc..d640dc3c 100644 --- a/src/test/app/invite/invite-registration-page.test.tsx +++ b/src/test/app/invite/invite-registration-page.test.tsx @@ -38,6 +38,8 @@ type ServerOptions = { /** The admin re-sent the invite after the code went out, revoking this token. */ revokedBeforeVerify?: boolean; rejectPasswordAtFinish?: boolean; + /** How the first `resolve` call fails before the server answers normally. */ + resolveFailsOnce?: "network" | "server-error"; }; const INVALID_INVITE_BODY = { @@ -59,9 +61,11 @@ function installInviteServer({ resendTooSoon = false, revokedBeforeVerify = false, rejectPasswordAtFinish = false, + resolveFailsOnce, }: ServerOptions = {}) { const calls: Call[] = []; let sends = 0; + let resolves = 0; vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); @@ -69,6 +73,13 @@ function installInviteServer({ const body = (await request.json()) as Record; calls.push({ path, url: request.url, body }); + if (path === "resolve" && resolveFailsOnce && ++resolves === 1) { + if (resolveFailsOnce === "network") { + throw new TypeError("Failed to fetch"); + } + return json(500, { message: "Internal Server Error" }); + } + if (body.token !== TOKEN || (revokedBeforeVerify && path === "verify-code")) { return json(400, INVALID_INVITE_BODY); } @@ -168,6 +179,38 @@ describe("Invite registration", { timeout: 15_000 }, () => { expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute("href", "/login"); }); + it.each(["network", "server-error"] as const)( + "offers a retry instead of the dead-invite message when resolve fails (%s)", + async (failure) => { + const calls = installInviteServer({ resolveFailsOnce: failure }); + const user = userEvent.setup(); + renderInvite(`#${TOKEN}`); + + expect( + await screen.findByRole("heading", { name: "Couldn't load your invite" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + "We couldn't reach the server to check your invite. Check your connection and try again.", + ), + ).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Invite unavailable" })).not.toBeInTheDocument(); + expect( + screen.queryByText( + "This invite link is invalid or has expired. Ask your admin to send a new invite.", + ), + ).not.toBeInTheDocument(); + expect(vi.mocked(toast.error)).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Try again" })); + + expect( + await screen.findByRole("heading", { name: "Create your password" }), + ).toBeInTheDocument(); + expect(calls.map((call) => call.path)).toEqual(["resolve", "resolve"]); + }, + ); + it("does not call the server when the link carries no token", async () => { const calls = installInviteServer(); renderInvite("");