Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3fb7c5f
feat(members): add an existing user to an organization
jhodapp Aug 5, 2026
ea5354e
fix(members): let admins discard a found user before adding them
jhodapp Aug 6, 2026
7f89f65
feat(members): pre-assign a coach when adding a member
jhodapp Aug 6, 2026
f10347c
feat(members): send the coach with the member request
jhodapp Aug 6, 2026
ec6ac2a
test(e2e): live multi-org membership scenario
jhodapp Aug 6, 2026
28a01de
fix(sessions): scope the dashboard session list by organization
jhodapp Aug 6, 2026
c710692
fix(sidebar): derive the org switcher avatar initials from the select…
jhodapp Aug 6, 2026
a0a7f0d
fix(dashboard): scope the Upcoming Session card by organization
jhodapp Aug 6, 2026
ea88281
refactor(members): address frontend review feedback
jhodapp Aug 6, 2026
4163914
fix(members): source the product and organization names from config
jhodapp Aug 7, 2026
a6b9e61
fix(members): discard a lookup that resolves after the email changes
jhodapp Aug 7, 2026
ee49fec
test(members): import ReactNode directly instead of the React namespace
jhodapp Aug 7, 2026
fda7966
feat(members): reject an existing member at lookup, not on submit
jhodapp Aug 7, 2026
ece45bc
fix(members): show the coaching-history conflict when removal is refused
jhodapp Aug 7, 2026
99e405a
feat(members): hide the existing-member tab when it has nothing to offer
jhodapp Aug 7, 2026
ae001c2
test(e2e): live coverage for the existing-member tab gate
jhodapp Aug 7, 2026
c31c360
fix(dashboard): wait for the organization before fetching sessions
jhodapp Aug 7, 2026
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
480 changes: 480 additions & 0 deletions __tests__/components/members/add-member-dialog-existing.test.tsx

Large diffs are not rendered by default.

21 changes: 18 additions & 3 deletions __tests__/components/members/add-member-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ describe("AddMemberDialog write-freeze handling", () => {
})
);
render(
<AddMemberDialog open onOpenChange={vi.fn()} onMemberAdded={vi.fn()} />
<AddMemberDialog
open
onOpenChange={vi.fn()}
onMemberAdded={vi.fn()}
productName="Refactor Coach"
/>
);

fillForm();
Expand All @@ -75,7 +80,12 @@ describe("AddMemberDialog write-freeze handling", () => {
it("shows the permission-denied message on a 403", async () => {
mockCreateNested.mockRejectedValueOnce(apiError(403, { error: "forbidden" }));
render(
<AddMemberDialog open onOpenChange={vi.fn()} onMemberAdded={vi.fn()} />
<AddMemberDialog
open
onOpenChange={vi.fn()}
onMemberAdded={vi.fn()}
productName="Refactor Coach"
/>
);

fillForm();
Expand All @@ -91,7 +101,12 @@ describe("AddMemberDialog write-freeze handling", () => {
it("falls back to the generic message for other errors", async () => {
mockCreateNested.mockRejectedValueOnce(new Error("network"));
render(
<AddMemberDialog open onOpenChange={vi.fn()} onMemberAdded={vi.fn()} />
<AddMemberDialog
open
onOpenChange={vi.fn()}
onMemberAdded={vi.fn()}
productName="Refactor Coach"
/>
);

fillForm();
Expand Down
199 changes: 199 additions & 0 deletions __tests__/components/members/member-card-remove.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { http, HttpResponse } from "msw";
import { server } from "@/test-utils/msw-server";
import { MemberCard } from "@/components/ui/members/member-card";
import { Role, type UserRoleState } from "@/types/user";
import { toast } from "sonner";
import { createMockUser } from "../../test-utils";

// ── Module mocks ──────────────────────────────────────────────────────────────

// A concrete base URL so msw can match the real API calls this card makes.
vi.mock("@/site.config", () => ({
siteConfig: {
env: {
backendServiceURL: "http://localhost:4000",
backendApiVersion: "1.0.0-test",
},
},
}));

vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));

vi.mock("@/lib/hooks/use-current-organization", () => ({
useCurrentOrganization: () => ({ currentOrganizationId: "org-1" }),
}));

const mockAuthStore = vi.fn();
vi.mock("@/lib/providers/auth-store-provider", () => ({
AuthStoreProvider: ({ children }: { children: ReactNode }) => children,
useAuthStore: (selector: (state: unknown) => unknown) =>
selector(mockAuthStore()),
}));

vi.mock("@/lib/api/coaching-relationships", () => ({
useCoachingRelationshipMutation: () => ({ createNested: vi.fn() }),
}));

// Role derivation is pure and covered elsewhere; stub it so the card renders
// without needing full relationship fixtures.
vi.mock("@/lib/utils/user-roles", () => ({
getUserDisplayRoles: () => [],
getUserCoaches: () => [],
}));

// ── Helpers ───────────────────────────────────────────────────────────────────

const adminRole: UserRoleState = {
status: "success",
role: Role.Admin,
hasAccess: true,
};

function renderCard() {
const cardUser = createMockUser({
id: "user-1",
first_name: "Ada",
last_name: "Lovelace",
invite_status: null,
});
render(
<MemberCard
user={cardUser}
currentUserId="me"
userRelationships={[]}
onRefresh={vi.fn()}
users={[cardUser]}
currentUserRoleState={adminRole}
/>
);
}

/**
* Records both the membership DELETE and the account DELETE, so a regression
* that removes the whole account instead of the membership is visible.
*/
function captureDeletes(membershipResponse: () => Response) {
const roleCalls: string[] = [];
const accountCalls: string[] = [];
server.use(
http.delete(
"*/organizations/:organizationId/users/:userId/role",
({ request }) => {
roleCalls.push(request.url);
return membershipResponse();
}
),
http.delete("*/organizations/:organizationId/users/:userId", ({ request }) => {
accountCalls.push(request.url);
return HttpResponse.json({ status_code: 200, data: null });
})
);
return { roleCalls, accountCalls };
}

async function openRemoveDialog(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button")); // row actions menu (icon-only)
await user.click(await screen.findByText("Remove from organization"));
}

beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
mockAuthStore.mockReturnValue({ isACoach: true, userSession: { id: "me" } });
});

// ── Tests ─────────────────────────────────────────────────────────────────────

describe("MemberCard – remove from organization", () => {
it("offers the action to an admin viewing another member", async () => {
const user = userEvent.setup();
renderCard();

await user.click(screen.getByRole("button"));

expect(
await screen.findByText("Remove from organization")
).toBeInTheDocument();
});

it("deletes the membership sub-route, never the member's account", async () => {
const { roleCalls, accountCalls } = captureDeletes(() =>
HttpResponse.json({ status_code: 200, data: null })
);
const user = userEvent.setup();
renderCard();

await openRemoveDialog(user);
expect(
await screen.findByText(
/Remove them from this organization only\. Their account and any other organizations are unaffected\./
)
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Remove" }));

await waitFor(() => expect(roleCalls).toHaveLength(1));
expect(roleCalls[0]).toBe(
"http://localhost:4000/organizations/org-1/users/user-1/role"
);
expect(roleCalls[0].endsWith("/role")).toBe(true);
expect(accountCalls).toEqual([]);
});

it("surfaces the last-admin conflict rather than a generic error", async () => {
captureDeletes(() =>
HttpResponse.json(
{
error: "last_organization_admin",
message:
"This user is the only admin of this organization. Assign another admin before removing them.",
},
{ status: 409 }
)
);
const user = userEvent.setup();
renderCard();

await openRemoveDialog(user);
await user.click(screen.getByRole("button", { name: "Remove" }));

await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(
"This user is the only admin of this organization. Assign another admin before removing them."
)
);
});
it("surfaces the coaching-history conflict rather than a generic error", async () => {
captureDeletes(() =>
HttpResponse.json(
{
error: "user_has_coaching_history",
message:
"This member still has coaching sessions in this organization. Remove or reassign those sessions before removing them.",
details: {
coaching_relationship_count: 1,
coaching_session_count: 10,
},
},
{ status: 409 }
)
);
const user = userEvent.setup();
renderCard();

await openRemoveDialog(user);
await user.click(screen.getByRole("button", { name: "Remove" }));

await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(
"This member still has coaching sessions in this organization. Remove or reassign those sessions before removing them."
)
);
});
});
Loading
Loading