diff --git a/e2e/fixtures/supabase-admin.ts b/e2e/fixtures/supabase-admin.ts index cb6e6434..26eee38c 100644 --- a/e2e/fixtures/supabase-admin.ts +++ b/e2e/fixtures/supabase-admin.ts @@ -150,3 +150,34 @@ export async function cleanupInvitesForEmail(email: string): Promise { .ilike("email", email) .is("accepted_at", null); } + +/** + * Removes stale test users that match a given display name. + * Used in beforeAll to clean up leftovers from previous test runs whose + * afterAll cleanup was interrupted (e.g. process killed, timeout). + * Skips the provided excludeUserId (the current test owner) to avoid + * accidentally deleting the primary test user. + */ +export async function cleanupStaleTestUsers( + displayName: string, + excludeUserId?: string +): Promise { + const admin = getAdminClient(); + + const query = admin + .from("profiles") + .select("id") + .eq("display_name", displayName); + + const { data: staleProfiles } = excludeUserId + ? await query.neq("id", excludeUserId) + : await query; + + if (!staleProfiles || staleProfiles.length === 0) return; + + for (const profile of staleProfiles) { + await deleteTestUser(profile.id).catch((err) => { + console.warn(`Failed to clean up stale test user ${profile.id}: ${err}`); + }); + } +} diff --git a/e2e/members.spec.ts b/e2e/members.spec.ts index 577b2be9..968c9727 100644 --- a/e2e/members.spec.ts +++ b/e2e/members.spec.ts @@ -4,6 +4,7 @@ import { deleteTestUser, getInviteToken, cleanupInvitesForEmail, + cleanupStaleTestUsers, } from "./fixtures/supabase-admin"; import { createClient } from "@supabase/supabase-js"; import { type Browser, type Page } from "@playwright/test"; @@ -72,11 +73,21 @@ test.describe("Workspace member management", () => { let invitedUserId: string | undefined; let workspaceSlug: string; + // Remove stale test users from previous runs whose cleanup was interrupted. + // Without this, a leftover member with display_name "E2E Member" causes + // the re-invite test to find 2 matching elements (strict mode violation). + test.beforeAll(async () => { + await cleanupStaleTestUsers(INVITE_DISPLAY_NAME).catch(() => {}); + await cleanupInvitesForEmail(INVITE_EMAIL).catch(() => {}); + }); + test.afterAll(async () => { await cleanupInvitesForEmail(INVITE_EMAIL).catch(() => {}); if (invitedUserId) { await deleteTestUser(invitedUserId).catch(() => {}); } + // Fallback: clean up by display name in case invitedUserId was never set + await cleanupStaleTestUsers(INVITE_DISPLAY_NAME).catch(() => {}); }); test("owner can invite a user by email", async ({ diff --git a/src/components/members/member-list.tsx b/src/components/members/member-list.tsx index 96bc9611..4bd346f0 100644 --- a/src/components/members/member-list.tsx +++ b/src/components/members/member-list.tsx @@ -55,11 +55,18 @@ export function MemberList({ onRemove, }: MemberListProps) { const [removingId, setRemovingId] = useState(null); + // Track which member's remove dialog is open so we can close it after the + // async delete completes. AlertDialogAction is a plain Button in Base UI + // and does not auto-close the dialog. + const [openDialogMemberId, setOpenDialogMemberId] = useState( + null + ); const isAdmin = currentUserRole === "owner" || currentUserRole === "admin"; async function handleRemove(memberId: string) { setRemovingId(memberId); await onRemove(memberId); + setOpenDialogMemberId(null); setRemovingId(null); } @@ -130,7 +137,12 @@ export function MemberList({ {isAdmin && ( {canRemove(member) && ( - + + setOpenDialogMemberId(open ? member.id : null) + } + >