From 848d48ebd72ccfbd6f234b503f2745db7fdc3c86 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 15:08:45 +0000 Subject: [PATCH 1/6] fix(invites): actually consume invite tokens so users join the group Wires the never-called use_invite_token RPC into a central useInviteAcceptance hook keyed on user + invite token, covering magic-link, OTP, and already-signed-in paths. Fixes InviteLandingPage passing invite_id instead of the real token into the auth flow (broken magic-link redirect), removes the deadlock-prone RPC call from AuthContext's onAuthStateChange, treats invite reuse by an existing member as a clean no-op, and invalidates group queries after joining. Closes #268 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011AAZzVYMN7csUv8kehZEsp --- .../useAcceptInviteMutation.ts | 56 +++++ .../useInviteValidationQuery.ts | 54 +--- src/components/invite/InviteLandingPage.tsx | 8 +- .../invite/useInviteAcceptance.test.tsx | 232 ++++++++++++++++++ src/components/invite/useInviteAcceptance.ts | 62 +++++ src/components/invite/useInviteValidation.ts | 35 +-- src/contexts/AuthContext.tsx | 64 +---- src/routes/__root.tsx | 11 +- 8 files changed, 370 insertions(+), 152 deletions(-) create mode 100644 src/api/invite-validation/useAcceptInviteMutation.ts create mode 100644 src/components/invite/useInviteAcceptance.test.tsx create mode 100644 src/components/invite/useInviteAcceptance.ts diff --git a/src/api/invite-validation/useAcceptInviteMutation.ts b/src/api/invite-validation/useAcceptInviteMutation.ts new file mode 100644 index 00000000..78ed1f7c --- /dev/null +++ b/src/api/invite-validation/useAcceptInviteMutation.ts @@ -0,0 +1,56 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { groupsKeys } from "@/api/groups/types"; +import type { InviteUsageResult } from "@/types/invites"; + +interface AcceptInviteParams { + token: string; + userId: string; +} + +export interface AcceptInviteResult extends InviteUsageResult { + alreadyMember: boolean; +} + +async function acceptInvite({ + token, + userId, +}: AcceptInviteParams): Promise { + const { data, error } = await supabase.rpc("use_invite_token", { + token, + user_id: userId, + }); + + if (error) { + console.error("Error using invite:", error); + throw new Error("Failed to join group"); + } + + const result = data?.[0]; + if (!result) { + throw new Error("Failed to join group"); + } + + if (result.success) { + return { ...result, alreadyMember: false }; + } + + if (result.message === "User already in group") { + return { ...result, alreadyMember: true }; + } + + throw new Error(result.message); +} + +export function useAcceptInviteMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: acceptInvite, + onSuccess: (result) => { + if (!result.alreadyMember) { + queryClient.invalidateQueries({ queryKey: groupsKeys.all }); + } + }, + }); +} diff --git a/src/api/invite-validation/useInviteValidationQuery.ts b/src/api/invite-validation/useInviteValidationQuery.ts index a60522b8..847e6b41 100644 --- a/src/api/invite-validation/useInviteValidationQuery.ts +++ b/src/api/invite-validation/useInviteValidationQuery.ts @@ -1,6 +1,5 @@ -import { queryOptions, useQuery, useMutation } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; -import { useToast } from "@/components/ui/use-toast"; import type { InviteValidation } from "@/types/invites"; import { inviteValidationKeys } from "./types"; @@ -39,54 +38,3 @@ export function useInviteValidationQuery(token: string | null) { enabled: !!token, }); } - -export function useInviteMutation() { - const { toast } = useToast(); - - return useMutation({ - mutationFn: async ({ - token, - userId, - }: { - token: string; - userId: string; - }) => { - const { data, error } = await supabase.rpc("use_invite_token", { - token, - user_id: userId, - }); - - if (error) { - console.error("Error using invite:", error); - throw new Error("Failed to join group"); - } - - if (data && data.length > 0) { - const result = data[0]; - if (result.success) { - return result; - } else { - throw new Error(result.message); - } - } - - throw new Error("Failed to join group"); - }, - onSuccess: () => { - // We'll need to get the group name from somewhere - let's make this flexible - toast({ - title: "Success", - description: "Successfully joined the group!", - }); - return true; - }, - onError: (error) => { - toast({ - title: "Error", - description: error.message, - variant: "destructive", - }); - return false; - }, - }); -} diff --git a/src/components/invite/InviteLandingPage.tsx b/src/components/invite/InviteLandingPage.tsx index c6fdddee..05a8994b 100644 --- a/src/components/invite/InviteLandingPage.tsx +++ b/src/components/invite/InviteLandingPage.tsx @@ -13,12 +13,12 @@ import type { InviteValidation } from "@/types/invites"; interface InviteLandingPageProps { inviteValidation: InviteValidation; - onSignupSuccess: () => void; + inviteToken: string; } export function InviteLandingPage({ inviteValidation, - onSignupSuccess, + inviteToken, }: InviteLandingPageProps) { const [showAuthDialog, setShowAuthDialog] = useState(false); @@ -73,8 +73,8 @@ export function InviteLandingPage({ setShowAuthDialog(false)} + inviteToken={inviteToken} groupName={inviteValidation.group_name} /> diff --git a/src/components/invite/useInviteAcceptance.test.tsx b/src/components/invite/useInviteAcceptance.test.tsx new file mode 100644 index 00000000..30951833 --- /dev/null +++ b/src/components/invite/useInviteAcceptance.test.tsx @@ -0,0 +1,232 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import type { User } from "@supabase/supabase-js"; +import { useInviteAcceptance } from "./useInviteAcceptance"; +import { supabase } from "@/integrations/supabase/client"; +import type { InviteValidation } from "@/types/invites"; + +const { navigateMock, toastMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), + toastMock: vi.fn(), +})); + +vi.mock("@/integrations/supabase/client", () => ({ + supabase: { rpc: vi.fn() }, +})); + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => navigateMock, +})); + +vi.mock("@/components/ui/use-toast", () => ({ + useToast: () => ({ toast: toastMock }), +})); + +const rpcMock = vi.mocked(supabase.rpc); + +const user = { id: "user-1" } as User; + +const validInvite: InviteValidation = { + invite_id: "invite-row-id", + group_id: "group-1", + group_name: "Festival Crew", + is_valid: true, + reason: "valid", +}; + +function mockRpcResult(result: { + success: boolean; + message: string; + group_id: string | null; +}) { + rpcMock.mockResolvedValue({ data: [result], error: null } as never); +} + +function renderAcceptance( + initialProps: Parameters[0], +) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const view = renderHook(useInviteAcceptance, { + initialProps, + wrapper: function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }, + }); + + return { ...view, invalidateSpy }; +} + +describe("useInviteAcceptance", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("accepts the invite when a user and a valid invite are present", async () => { + mockRpcResult({ + success: true, + message: "Successfully joined group", + group_id: "group-1", + }); + + const { invalidateSpy } = renderAcceptance({ + inviteToken: "token-1", + inviteValidation: validInvite, + user, + }); + + await waitFor(() => + expect(rpcMock).toHaveBeenCalledWith("use_invite_token", { + token: "token-1", + user_id: "user-1", + }), + ); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Success", + description: "Welcome to Festival Crew!", + }), + ); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["groups"] }); + expect(navigateMock).toHaveBeenCalledWith({ + to: ".", + search: expect.any(Function), + replace: true, + }); + + const searchUpdater = navigateMock.mock.calls[0][0].search; + expect(searchUpdater({ invite: "token-1", day: "friday" })).toEqual({ + invite: undefined, + day: "friday", + }); + }); + + it("waits for the user to sign in before accepting", async () => { + mockRpcResult({ + success: true, + message: "Successfully joined group", + group_id: "group-1", + }); + + const { rerender } = renderAcceptance({ + inviteToken: "token-1", + inviteValidation: validInvite, + user: null, + }); + + expect(rpcMock).not.toHaveBeenCalled(); + + rerender({ + inviteToken: "token-1", + inviteValidation: validInvite, + user, + }); + + await waitFor(() => expect(rpcMock).toHaveBeenCalledTimes(1)); + }); + + it("does not call the RPC for an invalid invite", async () => { + renderAcceptance({ + inviteToken: "token-1", + inviteValidation: { + ...validInvite, + is_valid: false, + reason: "invite_expired", + }, + user, + }); + + await Promise.resolve(); + expect(rpcMock).not.toHaveBeenCalled(); + }); + + it("surfaces an already-member reuse cleanly without an error", async () => { + mockRpcResult({ + success: false, + message: "User already in group", + group_id: "group-1", + }); + + const { invalidateSpy } = renderAcceptance({ + inviteToken: "token-1", + inviteValidation: validInvite, + user, + }); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Already a member", + description: "You're already a member of Festival Crew.", + }), + ); + + expect(toastMock).not.toHaveBeenCalledWith( + expect.objectContaining({ variant: "destructive" }), + ); + expect(invalidateSpy).not.toHaveBeenCalled(); + expect(navigateMock).toHaveBeenCalled(); + }); + + it("shows an error toast and keeps the invite param when the RPC fails", async () => { + mockRpcResult({ + success: false, + message: "Invalid invite token", + group_id: null, + }); + + renderAcceptance({ + inviteToken: "token-1", + inviteValidation: validInvite, + user, + }); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Couldn't join group", + description: "Invalid invite token", + variant: "destructive", + }), + ); + + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it("only accepts once per token across re-renders", async () => { + mockRpcResult({ + success: true, + message: "Successfully joined group", + group_id: "group-1", + }); + + const { rerender } = renderAcceptance({ + inviteToken: "token-1", + inviteValidation: validInvite, + user, + }); + + await waitFor(() => expect(rpcMock).toHaveBeenCalledTimes(1)); + + rerender({ + inviteToken: "token-1", + inviteValidation: validInvite, + user, + }); + + await Promise.resolve(); + expect(rpcMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/invite/useInviteAcceptance.ts b/src/components/invite/useInviteAcceptance.ts new file mode 100644 index 00000000..ce9555a4 --- /dev/null +++ b/src/components/invite/useInviteAcceptance.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import type { User } from "@supabase/supabase-js"; +import { useToast } from "@/components/ui/use-toast"; +import { useAcceptInviteMutation } from "@/api/invite-validation/useAcceptInviteMutation"; +import type { InviteValidation } from "@/types/invites"; + +interface UseInviteAcceptanceParams { + inviteToken: string | undefined; + inviteValidation: InviteValidation | null | undefined; + user: User | null; +} + +export function useInviteAcceptance({ + inviteToken, + inviteValidation, + user, +}: UseInviteAcceptanceParams) { + const { toast } = useToast(); + const navigate = useNavigate(); + const acceptInviteMutation = useAcceptInviteMutation(); + const attemptedTokenRef = useRef(null); + + const { mutate: acceptInvite } = acceptInviteMutation; + const groupName = inviteValidation?.group_name; + const isValid = inviteValidation?.is_valid === true; + + useEffect(() => { + if (!user || !inviteToken || !isValid) return; + if (attemptedTokenRef.current === inviteToken) return; + attemptedTokenRef.current = inviteToken; + + acceptInvite( + { token: inviteToken, userId: user.id }, + { + onSuccess: (result) => { + toast({ + title: result.alreadyMember ? "Already a member" : "Success", + description: result.alreadyMember + ? `You're already a member of ${groupName || "this group"}.` + : `Welcome to ${groupName || "the group"}!`, + }); + navigate({ + to: ".", + search: (prev) => ({ ...prev, invite: undefined }), + replace: true, + }); + }, + onError: (error) => { + console.error("Failed to accept invite", error); + toast({ + title: "Couldn't join group", + description: error.message, + variant: "destructive", + }); + }, + }, + ); + }, [user, inviteToken, isValid, acceptInvite, groupName, toast, navigate]); + + return { isAcceptingInvite: acceptInviteMutation.isPending }; +} diff --git a/src/components/invite/useInviteValidation.ts b/src/components/invite/useInviteValidation.ts index ccf4216b..ce0574b6 100644 --- a/src/components/invite/useInviteValidation.ts +++ b/src/components/invite/useInviteValidation.ts @@ -1,9 +1,6 @@ import { useEffect } from "react"; import { useToast } from "@/components/ui/use-toast"; -import { - useInviteValidationQuery, - useInviteMutation, -} from "@/api/invite-validation/useInviteValidationQuery"; +import { useInviteValidationQuery } from "@/api/invite-validation/useInviteValidationQuery"; export function useInviteValidation(inviteToken: string | undefined) { const { toast } = useToast(); @@ -14,9 +11,6 @@ export function useInviteValidation(inviteToken: string | undefined) { error: validationError, } = useInviteValidationQuery(inviteToken || null); - const inviteMutation = useInviteMutation(); - - // Handle validation side effects useEffect(() => { if (validationError) { toast({ @@ -49,38 +43,11 @@ export function useInviteValidation(inviteToken: string | undefined) { } }, [inviteValidation, toast]); - function useInvite(userId: string): Promise { - if (!inviteToken) return Promise.resolve(false); - - return new Promise((resolve) => { - inviteMutation.mutate( - { - token: inviteToken, - userId, - }, - { - onSuccess: () => { - toast({ - title: "Success", - description: `Welcome to ${inviteValidation?.group_name || "the group"}!`, - }); - resolve(true); - }, - onError: (error) => { - console.error("failed validating invite", error); - resolve(false); - }, - }, - ); - }); - } - return { inviteToken: inviteToken || null, inviteValidation, isValidating, validationError: validationError?.message || null, - useInvite, hasValidInvite: inviteValidation?.is_valid === true, }; } diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index efeda5be..ceabec31 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -9,7 +9,6 @@ import { import { User } from "@supabase/supabase-js"; import { supabase } from "@/integrations/supabase/client"; import { useProfileQuery } from "@/api/auth/useProfile"; -import { useToast } from "@/hooks/use-toast"; import { AuthDialog } from "@/components/AuthDialog/AuthDialog"; import { Profile } from "@/api/auth/types"; @@ -42,78 +41,25 @@ export function AuthProvider({ children }: AuthProviderProps) { const [inviteToken, setInviteToken] = useState(); const [groupName, setGroupName] = useState(); - const { toast } = useToast(); const profileQuery = useProfileQuery(user?.id); const profile = profileQuery.data; useEffect(() => { - // Set up auth state listener first + // Set up auth state listener first. Invite acceptance is handled by + // useInviteAcceptance (calling supabase inside this callback can deadlock). const { data: { subscription }, - } = supabase.auth.onAuthStateChange(async (event, session) => { - // setSession(session); + } = supabase.auth.onAuthStateChange((event, session) => { setUser(session?.user || null); setLoading(false); - // Clear cached profile on sign out for security - if (event === "SIGNED_OUT") { - // For sign out, use the current user state from closure - if (user?.id) { - // await profileOfflineService.clearCachedProfile(user.id); - } - } - - // Handle invite processing when user signs in - if (event === "SIGNED_IN" && session?.user) { - const urlParams = new URLSearchParams(window.location.search); - const inviteToken = urlParams.get("invite"); - - if (inviteToken) { - try { - const { data, error } = await supabase.rpc("use_invite_token", { - token: inviteToken, - user_id: session.user.id, - }); - - if (error) { - console.error("Error using invite:", error); - toast({ - title: "Error", - description: "Failed to join group", - variant: "destructive", - }); - } else if (data && data.length > 0) { - const result = data[0]; - if (result.success) { - toast({ - title: "Success", - description: "Welcome to the group!", - }); - // Clear invite from URL - const newUrl = new URL(window.location.href); - newUrl.searchParams.delete("invite"); - window.history.replaceState({}, "", newUrl.toString()); - } else { - toast({ - title: "Error", - description: result.message, - variant: "destructive", - }); - } - } - } catch (error) { - console.error("Error processing invite:", error); - } - } - - // Close auth dialog on successful sign in + if (event === "SIGNED_IN") { setAuthDialogOpen(false); } }); // Then check for existing session supabase.auth.getSession().then(({ data: { session } }) => { - // setSession(session); setUser(session?.user || null); setLoading(false); }); @@ -121,7 +67,7 @@ export function AuthProvider({ children }: AuthProviderProps) { return () => { subscription.unsubscribe(); }; - }, [toast, user?.id]); + }, []); async function signOut() { // Clear cached profile before signing out diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 2c112fb6..b57ddd04 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -17,6 +17,7 @@ import { HelmetProvider } from "react-helmet-async"; import { AuthProvider } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext"; import { useInviteValidation } from "@/components/invite/useInviteValidation"; +import { useInviteAcceptance } from "@/components/invite/useInviteAcceptance"; import { InviteLandingPage } from "@/components/invite/InviteLandingPage"; import { OnboardingDialog } from "@/components/onboarding/OnboardingDialog"; import { useProfileQuery } from "@/api/auth/useProfile"; @@ -81,6 +82,12 @@ function RootContent() { const { inviteValidation, isValidating, hasValidInvite } = useInviteValidation(search.invite); + useInviteAcceptance({ + inviteToken: search.invite, + inviteValidation, + user, + }); + const { isLoading: profileLoading } = useProfileQuery(user?.id); const showOnboarding = useMemo(() => { @@ -101,11 +108,11 @@ function RootContent() { ); } - if (hasValidInvite && !user && inviteValidation) { + if (hasValidInvite && !user && inviteValidation && search.invite) { return ( {}} + inviteToken={search.invite} /> ); } From 22f4a1e42584fe9e0af4de738f504d2b255b1cdf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 15:13:05 +0000 Subject: [PATCH 2/6] fix(invites): address review findings on acceptance hook Reset the attempted-token guard on failure so a transient error doesn't permanently block acceptance, drop the unused hook return value, and unify the group-name fallback copy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011AAZzVYMN7csUv8kehZEsp --- .../invite/useInviteAcceptance.test.tsx | 30 +++++++++++++++++++ src/components/invite/useInviteAcceptance.ts | 8 ++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/components/invite/useInviteAcceptance.test.tsx b/src/components/invite/useInviteAcceptance.test.tsx index 30951833..b168152d 100644 --- a/src/components/invite/useInviteAcceptance.test.tsx +++ b/src/components/invite/useInviteAcceptance.test.tsx @@ -205,6 +205,36 @@ describe("useInviteAcceptance", () => { expect(navigateMock).not.toHaveBeenCalled(); }); + it("allows a retry after a failed attempt", async () => { + mockRpcResult({ + success: false, + message: "Invalid invite token", + group_id: null, + }); + + const { rerender } = renderAcceptance({ + inviteToken: "token-1", + inviteValidation: validInvite, + user, + }); + + await waitFor(() => expect(rpcMock).toHaveBeenCalledTimes(1)); + + mockRpcResult({ + success: true, + message: "Successfully joined group", + group_id: "group-1", + }); + + rerender({ + inviteToken: "token-1", + inviteValidation: validInvite, + user: { ...user }, + }); + + await waitFor(() => expect(rpcMock).toHaveBeenCalledTimes(2)); + }); + it("only accepts once per token across re-renders", async () => { mockRpcResult({ success: true, diff --git a/src/components/invite/useInviteAcceptance.ts b/src/components/invite/useInviteAcceptance.ts index ce9555a4..72789029 100644 --- a/src/components/invite/useInviteAcceptance.ts +++ b/src/components/invite/useInviteAcceptance.ts @@ -18,10 +18,9 @@ export function useInviteAcceptance({ }: UseInviteAcceptanceParams) { const { toast } = useToast(); const navigate = useNavigate(); - const acceptInviteMutation = useAcceptInviteMutation(); + const { mutate: acceptInvite } = useAcceptInviteMutation(); const attemptedTokenRef = useRef(null); - const { mutate: acceptInvite } = acceptInviteMutation; const groupName = inviteValidation?.group_name; const isValid = inviteValidation?.is_valid === true; @@ -37,7 +36,7 @@ export function useInviteAcceptance({ toast({ title: result.alreadyMember ? "Already a member" : "Success", description: result.alreadyMember - ? `You're already a member of ${groupName || "this group"}.` + ? `You're already a member of ${groupName || "the group"}.` : `Welcome to ${groupName || "the group"}!`, }); navigate({ @@ -48,6 +47,7 @@ export function useInviteAcceptance({ }, onError: (error) => { console.error("Failed to accept invite", error); + attemptedTokenRef.current = null; toast({ title: "Couldn't join group", description: error.message, @@ -57,6 +57,4 @@ export function useInviteAcceptance({ }, ); }, [user, inviteToken, isValid, acceptInvite, groupName, toast, navigate]); - - return { isAcceptingInvite: acceptInviteMutation.isPending }; } From 5704edbea059b5d103f0847d29b11b901eb859f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 15:18:53 +0000 Subject: [PATCH 3/6] refactor(invites): detect already-member by group_id, not message copy The use_invite_token RPC returns a group_id only for the already-in-group failure, so keying on it survives server copy changes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011AAZzVYMN7csUv8kehZEsp --- src/api/invite-validation/useAcceptInviteMutation.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api/invite-validation/useAcceptInviteMutation.ts b/src/api/invite-validation/useAcceptInviteMutation.ts index 78ed1f7c..ced12a73 100644 --- a/src/api/invite-validation/useAcceptInviteMutation.ts +++ b/src/api/invite-validation/useAcceptInviteMutation.ts @@ -35,7 +35,8 @@ async function acceptInvite({ return { ...result, alreadyMember: false }; } - if (result.message === "User already in group") { + // Already-in-group is the only failure where the RPC returns a group_id + if (result.group_id) { return { ...result, alreadyMember: true }; } From 41f3ee5d272c63e7c613198fd9d03ac5158e3ca3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 13:49:29 +0000 Subject: [PATCH 4/6] refactor(invites): merge invite hooks into one useInviteFlow Collapses useInviteValidation + useInviteAcceptance (both only used by the root route) into a single flow hook, drops the AuthContext comment, and reworks the test to drive the whole flow through the real query/mutation hooks with only the supabase client mocked. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011AAZzVYMN7csUv8kehZEsp --- .../invite/useInviteAcceptance.test.tsx | 262 ------------------ src/components/invite/useInviteFlow.test.tsx | 240 ++++++++++++++++ ...seInviteAcceptance.ts => useInviteFlow.ts} | 62 ++++- src/components/invite/useInviteValidation.ts | 53 ---- src/contexts/AuthContext.tsx | 3 +- src/routes/__root.tsx | 13 +- 6 files changed, 295 insertions(+), 338 deletions(-) delete mode 100644 src/components/invite/useInviteAcceptance.test.tsx create mode 100644 src/components/invite/useInviteFlow.test.tsx rename src/components/invite/{useInviteAcceptance.ts => useInviteFlow.ts} (56%) delete mode 100644 src/components/invite/useInviteValidation.ts diff --git a/src/components/invite/useInviteAcceptance.test.tsx b/src/components/invite/useInviteAcceptance.test.tsx deleted file mode 100644 index b168152d..00000000 --- a/src/components/invite/useInviteAcceptance.test.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ReactNode } from "react"; -import type { User } from "@supabase/supabase-js"; -import { useInviteAcceptance } from "./useInviteAcceptance"; -import { supabase } from "@/integrations/supabase/client"; -import type { InviteValidation } from "@/types/invites"; - -const { navigateMock, toastMock } = vi.hoisted(() => ({ - navigateMock: vi.fn(), - toastMock: vi.fn(), -})); - -vi.mock("@/integrations/supabase/client", () => ({ - supabase: { rpc: vi.fn() }, -})); - -vi.mock("@tanstack/react-router", () => ({ - useNavigate: () => navigateMock, -})); - -vi.mock("@/components/ui/use-toast", () => ({ - useToast: () => ({ toast: toastMock }), -})); - -const rpcMock = vi.mocked(supabase.rpc); - -const user = { id: "user-1" } as User; - -const validInvite: InviteValidation = { - invite_id: "invite-row-id", - group_id: "group-1", - group_name: "Festival Crew", - is_valid: true, - reason: "valid", -}; - -function mockRpcResult(result: { - success: boolean; - message: string; - group_id: string | null; -}) { - rpcMock.mockResolvedValue({ data: [result], error: null } as never); -} - -function renderAcceptance( - initialProps: Parameters[0], -) { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); - - const view = renderHook(useInviteAcceptance, { - initialProps, - wrapper: function Wrapper({ children }: { children: ReactNode }) { - return ( - - {children} - - ); - }, - }); - - return { ...view, invalidateSpy }; -} - -describe("useInviteAcceptance", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("accepts the invite when a user and a valid invite are present", async () => { - mockRpcResult({ - success: true, - message: "Successfully joined group", - group_id: "group-1", - }); - - const { invalidateSpy } = renderAcceptance({ - inviteToken: "token-1", - inviteValidation: validInvite, - user, - }); - - await waitFor(() => - expect(rpcMock).toHaveBeenCalledWith("use_invite_token", { - token: "token-1", - user_id: "user-1", - }), - ); - - await waitFor(() => - expect(toastMock).toHaveBeenCalledWith({ - title: "Success", - description: "Welcome to Festival Crew!", - }), - ); - - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["groups"] }); - expect(navigateMock).toHaveBeenCalledWith({ - to: ".", - search: expect.any(Function), - replace: true, - }); - - const searchUpdater = navigateMock.mock.calls[0][0].search; - expect(searchUpdater({ invite: "token-1", day: "friday" })).toEqual({ - invite: undefined, - day: "friday", - }); - }); - - it("waits for the user to sign in before accepting", async () => { - mockRpcResult({ - success: true, - message: "Successfully joined group", - group_id: "group-1", - }); - - const { rerender } = renderAcceptance({ - inviteToken: "token-1", - inviteValidation: validInvite, - user: null, - }); - - expect(rpcMock).not.toHaveBeenCalled(); - - rerender({ - inviteToken: "token-1", - inviteValidation: validInvite, - user, - }); - - await waitFor(() => expect(rpcMock).toHaveBeenCalledTimes(1)); - }); - - it("does not call the RPC for an invalid invite", async () => { - renderAcceptance({ - inviteToken: "token-1", - inviteValidation: { - ...validInvite, - is_valid: false, - reason: "invite_expired", - }, - user, - }); - - await Promise.resolve(); - expect(rpcMock).not.toHaveBeenCalled(); - }); - - it("surfaces an already-member reuse cleanly without an error", async () => { - mockRpcResult({ - success: false, - message: "User already in group", - group_id: "group-1", - }); - - const { invalidateSpy } = renderAcceptance({ - inviteToken: "token-1", - inviteValidation: validInvite, - user, - }); - - await waitFor(() => - expect(toastMock).toHaveBeenCalledWith({ - title: "Already a member", - description: "You're already a member of Festival Crew.", - }), - ); - - expect(toastMock).not.toHaveBeenCalledWith( - expect.objectContaining({ variant: "destructive" }), - ); - expect(invalidateSpy).not.toHaveBeenCalled(); - expect(navigateMock).toHaveBeenCalled(); - }); - - it("shows an error toast and keeps the invite param when the RPC fails", async () => { - mockRpcResult({ - success: false, - message: "Invalid invite token", - group_id: null, - }); - - renderAcceptance({ - inviteToken: "token-1", - inviteValidation: validInvite, - user, - }); - - await waitFor(() => - expect(toastMock).toHaveBeenCalledWith({ - title: "Couldn't join group", - description: "Invalid invite token", - variant: "destructive", - }), - ); - - expect(navigateMock).not.toHaveBeenCalled(); - }); - - it("allows a retry after a failed attempt", async () => { - mockRpcResult({ - success: false, - message: "Invalid invite token", - group_id: null, - }); - - const { rerender } = renderAcceptance({ - inviteToken: "token-1", - inviteValidation: validInvite, - user, - }); - - await waitFor(() => expect(rpcMock).toHaveBeenCalledTimes(1)); - - mockRpcResult({ - success: true, - message: "Successfully joined group", - group_id: "group-1", - }); - - rerender({ - inviteToken: "token-1", - inviteValidation: validInvite, - user: { ...user }, - }); - - await waitFor(() => expect(rpcMock).toHaveBeenCalledTimes(2)); - }); - - it("only accepts once per token across re-renders", async () => { - mockRpcResult({ - success: true, - message: "Successfully joined group", - group_id: "group-1", - }); - - const { rerender } = renderAcceptance({ - inviteToken: "token-1", - inviteValidation: validInvite, - user, - }); - - await waitFor(() => expect(rpcMock).toHaveBeenCalledTimes(1)); - - rerender({ - inviteToken: "token-1", - inviteValidation: validInvite, - user, - }); - - await Promise.resolve(); - expect(rpcMock).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/invite/useInviteFlow.test.tsx b/src/components/invite/useInviteFlow.test.tsx new file mode 100644 index 00000000..ef292c1b --- /dev/null +++ b/src/components/invite/useInviteFlow.test.tsx @@ -0,0 +1,240 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import type { User } from "@supabase/supabase-js"; +import { useInviteFlow } from "./useInviteFlow"; +import { supabase } from "@/integrations/supabase/client"; +import type { InviteValidation } from "@/types/invites"; + +const { navigateMock, toastMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), + toastMock: vi.fn(), +})); + +vi.mock("@/integrations/supabase/client", () => ({ + supabase: { rpc: vi.fn() }, +})); + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => navigateMock, +})); + +vi.mock("@/components/ui/use-toast", () => ({ + useToast: () => ({ toast: toastMock }), +})); + +const rpcMock = vi.mocked(supabase.rpc); + +const user = { id: "user-1" } as User; + +const validInvite: InviteValidation = { + invite_id: "invite-row-id", + group_id: "group-1", + group_name: "Festival Crew", + is_valid: true, + reason: "valid", +}; + +const joinedResult = { + success: true, + message: "Successfully joined group", + group_id: "group-1", +}; + +function mockRpc( + validation: InviteValidation | null, + useResult: { success: boolean; message: string; group_id: string | null }, +) { + rpcMock.mockImplementation((async (fn: string) => { + if (fn === "validate_invite_token") { + return { data: validation ? [validation] : [], error: null }; + } + return { data: [useResult], error: null }; + }) as never); +} + +function useInviteTokenCalls() { + return rpcMock.mock.calls.filter(([fn]) => fn === "use_invite_token"); +} + +function renderFlow(initialProps: { + token: string | undefined; + user: User | null; +}) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const view = renderHook( + (props: { token: string | undefined; user: User | null }) => + useInviteFlow(props.token, props.user), + { + initialProps, + wrapper: function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }, + }, + ); + + return { ...view, invalidateSpy }; +} + +describe("useInviteFlow", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("validates the token then accepts the invite when a user is present", async () => { + mockRpc(validInvite, joinedResult); + + const { result, invalidateSpy } = renderFlow({ token: "token-1", user }); + + await waitFor(() => + expect(rpcMock).toHaveBeenCalledWith("validate_invite_token", { + token: "token-1", + }), + ); + await waitFor(() => + expect(rpcMock).toHaveBeenCalledWith("use_invite_token", { + token: "token-1", + user_id: "user-1", + }), + ); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Success", + description: "Welcome to Festival Crew!", + }), + ); + + expect(result.current.hasValidInvite).toBe(true); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["groups"] }); + expect(navigateMock).toHaveBeenCalledWith({ + to: ".", + search: expect.any(Function), + replace: true, + }); + + const searchUpdater = navigateMock.mock.calls[0][0].search; + expect(searchUpdater({ invite: "token-1", day: "friday" })).toEqual({ + invite: undefined, + day: "friday", + }); + }); + + it("waits for the user to sign in before accepting", async () => { + mockRpc(validInvite, joinedResult); + + const { result, rerender } = renderFlow({ token: "token-1", user: null }); + + await waitFor(() => expect(result.current.hasValidInvite).toBe(true)); + expect(useInviteTokenCalls()).toHaveLength(0); + + rerender({ token: "token-1", user }); + + await waitFor(() => expect(useInviteTokenCalls()).toHaveLength(1)); + }); + + it("shows the expired message and never consumes an invalid invite", async () => { + mockRpc( + { ...validInvite, is_valid: false, reason: "invite_expired" }, + joinedResult, + ); + + const { result } = renderFlow({ token: "token-1", user }); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Invalid Invite", + description: "This invite link has expired", + variant: "destructive", + }), + ); + + expect(result.current.hasValidInvite).toBe(false); + expect(useInviteTokenCalls()).toHaveLength(0); + }); + + it("surfaces an already-member reuse cleanly without an error", async () => { + mockRpc(validInvite, { + success: false, + message: "User already in group", + group_id: "group-1", + }); + + const { invalidateSpy } = renderFlow({ token: "token-1", user }); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Already a member", + description: "You're already a member of Festival Crew.", + }), + ); + + expect(toastMock).not.toHaveBeenCalledWith( + expect.objectContaining({ variant: "destructive" }), + ); + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ["groups"] }); + expect(navigateMock).toHaveBeenCalled(); + }); + + it("shows an error toast and keeps the invite param when acceptance fails", async () => { + mockRpc(validInvite, { + success: false, + message: "Invalid invite token", + group_id: null, + }); + + renderFlow({ token: "token-1", user }); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Couldn't join group", + description: "Invalid invite token", + variant: "destructive", + }), + ); + + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it("allows a retry after a failed attempt", async () => { + mockRpc(validInvite, { + success: false, + message: "Invalid invite token", + group_id: null, + }); + + const { rerender } = renderFlow({ token: "token-1", user }); + + await waitFor(() => expect(useInviteTokenCalls()).toHaveLength(1)); + + mockRpc(validInvite, joinedResult); + rerender({ token: "token-1", user: { ...user } }); + + await waitFor(() => expect(useInviteTokenCalls()).toHaveLength(2)); + }); + + it("only accepts once per token across re-renders", async () => { + mockRpc(validInvite, joinedResult); + + const { rerender } = renderFlow({ token: "token-1", user }); + + await waitFor(() => expect(useInviteTokenCalls()).toHaveLength(1)); + + rerender({ token: "token-1", user }); + + await Promise.resolve(); + expect(useInviteTokenCalls()).toHaveLength(1); + }); +}); diff --git a/src/components/invite/useInviteAcceptance.ts b/src/components/invite/useInviteFlow.ts similarity index 56% rename from src/components/invite/useInviteAcceptance.ts rename to src/components/invite/useInviteFlow.ts index 72789029..f0fdc942 100644 --- a/src/components/invite/useInviteAcceptance.ts +++ b/src/components/invite/useInviteFlow.ts @@ -2,28 +2,60 @@ import { useEffect, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; import type { User } from "@supabase/supabase-js"; import { useToast } from "@/components/ui/use-toast"; +import { useInviteValidationQuery } from "@/api/invite-validation/useInviteValidationQuery"; import { useAcceptInviteMutation } from "@/api/invite-validation/useAcceptInviteMutation"; -import type { InviteValidation } from "@/types/invites"; -interface UseInviteAcceptanceParams { - inviteToken: string | undefined; - inviteValidation: InviteValidation | null | undefined; - user: User | null; -} - -export function useInviteAcceptance({ - inviteToken, - inviteValidation, - user, -}: UseInviteAcceptanceParams) { +export function useInviteFlow( + inviteToken: string | undefined, + user: User | null, +) { const { toast } = useToast(); const navigate = useNavigate(); + + const { + data: inviteValidation, + isLoading: isValidating, + error: validationError, + } = useInviteValidationQuery(inviteToken || null); + const { mutate: acceptInvite } = useAcceptInviteMutation(); const attemptedTokenRef = useRef(null); const groupName = inviteValidation?.group_name; const isValid = inviteValidation?.is_valid === true; + useEffect(() => { + if (validationError) { + toast({ + title: "Invalid Invite", + description: "This invite link is not valid", + variant: "destructive", + }); + } + }, [validationError, toast]); + + useEffect(() => { + if (inviteValidation && !inviteValidation.is_valid) { + let message = "This invite link is no longer valid"; + switch (inviteValidation.reason) { + case "invite_expired": + message = "This invite link has expired"; + break; + case "invite_overused": + message = "This invite link has reached its usage limit"; + break; + case "invite_deactivated": + message = "This invite link has been deactivated"; + break; + } + toast({ + title: "Invalid Invite", + description: message, + variant: "destructive", + }); + } + }, [inviteValidation, toast]); + useEffect(() => { if (!user || !inviteToken || !isValid) return; if (attemptedTokenRef.current === inviteToken) return; @@ -57,4 +89,10 @@ export function useInviteAcceptance({ }, ); }, [user, inviteToken, isValid, acceptInvite, groupName, toast, navigate]); + + return { + inviteValidation, + isValidating, + hasValidInvite: isValid, + }; } diff --git a/src/components/invite/useInviteValidation.ts b/src/components/invite/useInviteValidation.ts deleted file mode 100644 index ce0574b6..00000000 --- a/src/components/invite/useInviteValidation.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect } from "react"; -import { useToast } from "@/components/ui/use-toast"; -import { useInviteValidationQuery } from "@/api/invite-validation/useInviteValidationQuery"; - -export function useInviteValidation(inviteToken: string | undefined) { - const { toast } = useToast(); - - const { - data: inviteValidation, - isLoading: isValidating, - error: validationError, - } = useInviteValidationQuery(inviteToken || null); - - useEffect(() => { - if (validationError) { - toast({ - title: "Invalid Invite", - description: "This invite link is not valid", - variant: "destructive", - }); - } - }, [validationError, toast]); - - useEffect(() => { - if (inviteValidation && !inviteValidation.is_valid) { - let message = "This invite link is no longer valid"; - switch (inviteValidation.reason) { - case "invite_expired": - message = "This invite link has expired"; - break; - case "invite_overused": - message = "This invite link has reached its usage limit"; - break; - case "invite_deactivated": - message = "This invite link has been deactivated"; - break; - } - toast({ - title: "Invalid Invite", - description: message, - variant: "destructive", - }); - } - }, [inviteValidation, toast]); - - return { - inviteToken: inviteToken || null, - inviteValidation, - isValidating, - validationError: validationError?.message || null, - hasValidInvite: inviteValidation?.is_valid === true, - }; -} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index ceabec31..77577907 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -45,8 +45,7 @@ export function AuthProvider({ children }: AuthProviderProps) { const profile = profileQuery.data; useEffect(() => { - // Set up auth state listener first. Invite acceptance is handled by - // useInviteAcceptance (calling supabase inside this callback can deadlock). + // Set up auth state listener first const { data: { subscription }, } = supabase.auth.onAuthStateChange((event, session) => { diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index b57ddd04..97b369d3 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -16,8 +16,7 @@ import { TanStackRouterDevtools } from "@tanstack/router-devtools"; import { HelmetProvider } from "react-helmet-async"; import { AuthProvider } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext"; -import { useInviteValidation } from "@/components/invite/useInviteValidation"; -import { useInviteAcceptance } from "@/components/invite/useInviteAcceptance"; +import { useInviteFlow } from "@/components/invite/useInviteFlow"; import { InviteLandingPage } from "@/components/invite/InviteLandingPage"; import { OnboardingDialog } from "@/components/onboarding/OnboardingDialog"; import { useProfileQuery } from "@/api/auth/useProfile"; @@ -79,14 +78,10 @@ function RootComponent() { function RootContent() { const { user, loading: authLoading, needsOnboarding } = useAuth(); const search = useSearch({ from: "__root__" }); - const { inviteValidation, isValidating, hasValidInvite } = - useInviteValidation(search.invite); - - useInviteAcceptance({ - inviteToken: search.invite, - inviteValidation, + const { inviteValidation, isValidating, hasValidInvite } = useInviteFlow( + search.invite, user, - }); + ); const { isLoading: profileLoading } = useProfileQuery(user?.id); From c248e2939ac7f4afef0bcb028d63fca1100c349d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 14:16:28 +0000 Subject: [PATCH 5/6] refactor(invites): keep invite query object instead of destructuring Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011AAZzVYMN7csUv8kehZEsp --- src/components/invite/useInviteFlow.ts | 28 ++++++++++---------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/src/components/invite/useInviteFlow.ts b/src/components/invite/useInviteFlow.ts index f0fdc942..570b4118 100644 --- a/src/components/invite/useInviteFlow.ts +++ b/src/components/invite/useInviteFlow.ts @@ -12,29 +12,22 @@ export function useInviteFlow( const { toast } = useToast(); const navigate = useNavigate(); - const { - data: inviteValidation, - isLoading: isValidating, - error: validationError, - } = useInviteValidationQuery(inviteToken || null); - + const inviteQuery = useInviteValidationQuery(inviteToken || null); const { mutate: acceptInvite } = useAcceptInviteMutation(); const attemptedTokenRef = useRef(null); - const groupName = inviteValidation?.group_name; - const isValid = inviteValidation?.is_valid === true; - useEffect(() => { - if (validationError) { + if (inviteQuery.error) { toast({ title: "Invalid Invite", description: "This invite link is not valid", variant: "destructive", }); } - }, [validationError, toast]); + }, [inviteQuery.error, toast]); useEffect(() => { + const inviteValidation = inviteQuery.data; if (inviteValidation && !inviteValidation.is_valid) { let message = "This invite link is no longer valid"; switch (inviteValidation.reason) { @@ -54,10 +47,11 @@ export function useInviteFlow( variant: "destructive", }); } - }, [inviteValidation, toast]); + }, [inviteQuery.data, toast]); useEffect(() => { - if (!user || !inviteToken || !isValid) return; + const groupName = inviteQuery.data?.group_name; + if (!user || !inviteToken || inviteQuery.data?.is_valid !== true) return; if (attemptedTokenRef.current === inviteToken) return; attemptedTokenRef.current = inviteToken; @@ -88,11 +82,11 @@ export function useInviteFlow( }, }, ); - }, [user, inviteToken, isValid, acceptInvite, groupName, toast, navigate]); + }, [user, inviteToken, inviteQuery.data, acceptInvite, toast, navigate]); return { - inviteValidation, - isValidating, - hasValidInvite: isValid, + inviteValidation: inviteQuery.data, + isValidating: inviteQuery.isLoading, + hasValidInvite: inviteQuery.data?.is_valid === true, }; } From 20c871a5dad0e5545d346c32b8d9437f53be6719 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 14:26:29 +0000 Subject: [PATCH 6/6] refactor(invites): extract getError and merge validation toast effects A single effect driven by one getError helper also removes the edge case where a stale validation result plus a refetch error could toast twice. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011AAZzVYMN7csUv8kehZEsp --- src/components/invite/useInviteFlow.ts | 50 +++++++++++++------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/src/components/invite/useInviteFlow.ts b/src/components/invite/useInviteFlow.ts index 570b4118..767b4d03 100644 --- a/src/components/invite/useInviteFlow.ts +++ b/src/components/invite/useInviteFlow.ts @@ -4,6 +4,7 @@ import type { User } from "@supabase/supabase-js"; import { useToast } from "@/components/ui/use-toast"; import { useInviteValidationQuery } from "@/api/invite-validation/useInviteValidationQuery"; import { useAcceptInviteMutation } from "@/api/invite-validation/useAcceptInviteMutation"; +import type { InviteValidation } from "@/types/invites"; export function useInviteFlow( inviteToken: string | undefined, @@ -17,37 +18,15 @@ export function useInviteFlow( const attemptedTokenRef = useRef(null); useEffect(() => { - if (inviteQuery.error) { + const error = getError(inviteQuery.error, inviteQuery.data); + if (error) { toast({ title: "Invalid Invite", - description: "This invite link is not valid", + description: error, variant: "destructive", }); } - }, [inviteQuery.error, toast]); - - useEffect(() => { - const inviteValidation = inviteQuery.data; - if (inviteValidation && !inviteValidation.is_valid) { - let message = "This invite link is no longer valid"; - switch (inviteValidation.reason) { - case "invite_expired": - message = "This invite link has expired"; - break; - case "invite_overused": - message = "This invite link has reached its usage limit"; - break; - case "invite_deactivated": - message = "This invite link has been deactivated"; - break; - } - toast({ - title: "Invalid Invite", - description: message, - variant: "destructive", - }); - } - }, [inviteQuery.data, toast]); + }, [inviteQuery.error, inviteQuery.data, toast]); useEffect(() => { const groupName = inviteQuery.data?.group_name; @@ -90,3 +69,22 @@ export function useInviteFlow( hasValidInvite: inviteQuery.data?.is_valid === true, }; } + +function getError( + error: Error | null, + inviteValidation: InviteValidation | null | undefined, +): string | null { + if (error) return "This invite link is not valid"; + if (!inviteValidation || inviteValidation.is_valid) return null; + + switch (inviteValidation.reason) { + case "invite_expired": + return "This invite link has expired"; + case "invite_overused": + return "This invite link has reached its usage limit"; + case "invite_deactivated": + return "This invite link has been deactivated"; + default: + return "This invite link is no longer valid"; + } +}