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("");