Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions e2e/fixtures/supabase-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,34 @@ export async function cleanupInvitesForEmail(email: string): Promise<void> {
.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<void> {
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}`);
});
}
}
11 changes: 11 additions & 0 deletions e2e/members.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ({
Expand Down
14 changes: 13 additions & 1 deletion src/components/members/member-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,18 @@ export function MemberList({
onRemove,
}: MemberListProps) {
const [removingId, setRemovingId] = useState<string | null>(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<string | null>(
null
);
const isAdmin = currentUserRole === "owner" || currentUserRole === "admin";

async function handleRemove(memberId: string) {
setRemovingId(memberId);
await onRemove(memberId);
setOpenDialogMemberId(null);
setRemovingId(null);
}

Expand Down Expand Up @@ -130,7 +137,12 @@ export function MemberList({
{isAdmin && (
<TableCell>
{canRemove(member) && (
<AlertDialog>
<AlertDialog
open={openDialogMemberId === member.id}
onOpenChange={(open) =>
setOpenDialogMemberId(open ? member.id : null)
}
>
<AlertDialogTrigger
render={
<Button
Expand Down
Loading