fix(team-members): offer a retry when the invite cannot be checked

The invite page showed "invalid or has expired, ask your admin for a new
invite" for any failed resolve call, including offline, timeouts and 5xx.
A member on a flaky connection was sent to their admin for a resend, which
revokes a token that was still valid.

Only the server's invalid_invite answer now ends on "Invite unavailable".
Any other failure shows "Couldn't load your invite" with a Try again
button that re-runs resolve, without a global error toast. Regression
tests cover a network rejection and a 500 followed by a successful retry.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 13:42:16 -03:00
parent 383d5c3c6c
commit 3b7cf83332
3 changed files with 74 additions and 1 deletions

View file

@ -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 (
<Box>
<AuthCardHeader title="Couldn't load your invite" subtitle={INVITE_UNREACHABLE_MESSAGE} />
<Button
type="button"
fullWidth
size="lg"
loading={details.isFetching}
loadingText="Trying again…"
onClick={() => void details.refetch()}
>
Try again
</Button>
</Box>
);
}
if (inviteEnded || details.data === undefined) {
return <AuthCardHeader title="Invite unavailable" subtitle={INVALID_INVITE_MESSAGE} />;
}

View file

@ -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;

View file

@ -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<string, unknown>;
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("");