mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
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:
parent
383d5c3c6c
commit
3b7cf83332
3 changed files with 74 additions and 1 deletions
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue