diff --git a/__tests__/components/members/add-member-dialog-existing.test.tsx b/__tests__/components/members/add-member-dialog-existing.test.tsx new file mode 100644 index 00000000..ac3385d3 --- /dev/null +++ b/__tests__/components/members/add-member-dialog-existing.test.tsx @@ -0,0 +1,480 @@ +import { render, screen, waitFor } from "@testing-library/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 { AddMemberDialog } from "@/components/ui/members/add-member-dialog"; +import { Role, type User, type UserRoleState } from "@/types/user"; +import { toast } from "sonner"; + +// ── Module mocks ────────────────────────────────────────────────────────────── + +// A concrete base URL so msw can match the real API calls this dialog 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(), warning: vi.fn() }, +})); + +vi.mock("@/lib/hooks/use-current-organization", () => ({ + useCurrentOrganization: () => ({ currentOrganizationId: "org-1" }), +})); + +vi.mock("@/lib/timezone-utils", () => ({ + getBrowserTimezone: () => "UTC", +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const adminRole: UserRoleState = { + status: "success", + role: Role.Admin, + hasAccess: true, +}; + +const memberRole: UserRoleState = { + status: "success", + role: Role.User, + hasAccess: true, +}; + +const ADA = { + id: "user-9", + first_name: "Ada", + last_name: "Lovelace", + email: "ada@example.com", +}; + +/** Lookup handler that only answers for Ada's exact email. */ +function lookupHandler(matches: typeof ADA | null) { + return http.get("*/users", ({ request }) => { + const email = new URL(request.url).searchParams.get("email"); + const found = matches && email === matches.email ? [matches] : []; + return HttpResponse.json({ status_code: 200, data: found }); + }); +} + +/** Existing members offered as coach candidates. */ +const GRACE = { + id: "user-2", + first_name: "Grace", + last_name: "Hopper", +} as unknown as User; + +function renderDialog( + currentUserRoleState?: UserRoleState, + organizationMembers?: User[], + canAddExistingMembers = true +) { + render( + + ); +} + +/** Switches to the "Add existing member" tab, finds Ada, waits for the card. */ +async function findAda(user: ReturnType) { + await user.click(screen.getByRole("tab", { name: "Add existing member" })); + await user.type(screen.getByLabelText("Email"), ADA.email); + await user.click(screen.getByRole("button", { name: "Find" })); + await screen.findByText("Ada Lovelace"); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("AddMemberDialog – add existing member gating", () => { + it("does not offer the existing-member mode to a plain member", () => { + renderDialog(memberRole); + + expect( + screen.queryByRole("tab", { name: "Add existing member" }) + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Create Member" }) + ).toBeInTheDocument(); + }); + + it("offers the existing-member mode to an org admin, not only a super admin", () => { + renderDialog(adminRole); + + expect( + screen.getByRole("tab", { name: "Add existing member" }) + ).toBeInTheDocument(); + }); +}); + +describe("AddMemberDialog – existing member lookup", () => { + it("reports no match without hinting at visibility, and keeps the add button disabled", async () => { + server.use(lookupHandler(null)); + const user = userEvent.setup(); + renderDialog(adminRole); + + await user.click(screen.getByRole("tab", { name: "Add existing member" })); + await user.type(screen.getByLabelText("Email"), "nobody@example.com"); + await user.click(screen.getByRole("button", { name: "Find" })); + + expect( + await screen.findByText("No user found with that email.") + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Add to organization" }) + ).toBeDisabled(); + }); + + it("shows the returned name and email on a match", async () => { + server.use(lookupHandler(ADA)); + const user = userEvent.setup(); + renderDialog(adminRole); + + await findAda(user); + + expect(screen.getByText("Ada Lovelace")).toBeInTheDocument(); + expect(screen.getByText(ADA.email)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Add to organization" }) + ).toBeEnabled(); + }); +}); + +describe("AddMemberDialog – pre-assigning a coach", () => { + /** + * Captures the create and attach bodies, plus any relationship POST. The + * coach now rides along with the member request, so a relationship POST would + * mean the old two-call contract had come back. + */ + function captureAdds(addFails = false) { + const created: unknown[] = []; + const attached: unknown[] = []; + const relationships: unknown[] = []; + server.use( + lookupHandler(ADA), + http.post( + "*/organizations/:organizationId/users/:userId/role", + async ({ request }) => { + attached.push(await request.json()); + return addFails + ? HttpResponse.json({ error: "boom" }, { status: 500 }) + : HttpResponse.json({ status_code: 200, data: { id: ADA.id } }); + } + ), + http.post("*/organizations/:organizationId/users", async ({ request }) => { + created.push(await request.json()); + return addFails + ? HttpResponse.json({ error: "boom" }, { status: 500 }) + : HttpResponse.json({ status_code: 201, data: { id: "user-new" } }); + }), + http.post( + "*/organizations/:organizationId/coaching_relationships", + async ({ request }) => { + relationships.push(await request.json()); + return HttpResponse.json({ status_code: 201, data: { id: "rel-1" } }); + } + ) + ); + return { created, attached, relationships }; + } + + async function pickCoach(user: ReturnType) { + await user.click(screen.getByLabelText("Coach (optional)")); + await user.click(await screen.findByRole("option", { name: "Grace Hopper" })); + } + + it("sends the chosen coach in the create request, not a second one", async () => { + const { created, relationships } = captureAdds(); + const user = userEvent.setup(); + renderDialog(adminRole, [GRACE]); + + await user.type(screen.getByLabelText("First Name"), "Ada"); + await user.type(screen.getByLabelText("Last Name"), "Lovelace"); + await user.type(screen.getByLabelText("Display Name"), "Ada"); + await user.type(screen.getByLabelText("Email"), "new@example.com"); + await pickCoach(user); + await user.click(screen.getByRole("button", { name: "Create Member" })); + + await waitFor(() => expect(created).toHaveLength(1)); + expect(created[0]).toMatchObject({ + email: "new@example.com", + coach_id: GRACE.id, + }); + expect(relationships).toHaveLength(0); + }); + + it("sends the chosen coach in the attach request, not a second one", async () => { + const { attached, relationships } = captureAdds(); + const user = userEvent.setup(); + renderDialog(adminRole, [GRACE]); + + await findAda(user); + await pickCoach(user); + await user.click(screen.getByRole("button", { name: "Add to organization" })); + + await waitFor(() => expect(attached).toHaveLength(1)); + expect(attached[0]).toEqual({ role: "User", coach_id: GRACE.id }); + expect(relationships).toHaveLength(0); + }); + + it("omits coach_id entirely when no coach is chosen", async () => { + const { created, attached, relationships } = captureAdds(); + const user = userEvent.setup(); + renderDialog(adminRole, [GRACE]); + + await user.type(screen.getByLabelText("First Name"), "Ada"); + await user.type(screen.getByLabelText("Last Name"), "Lovelace"); + await user.type(screen.getByLabelText("Display Name"), "Ada"); + await user.type(screen.getByLabelText("Email"), "new@example.com"); + await user.click(screen.getByRole("button", { name: "Create Member" })); + await waitFor(() => expect(created).toHaveLength(1)); + + await findAda(user); + await user.click(screen.getByRole("button", { name: "Add to organization" })); + await waitFor(() => expect(attached).toHaveLength(1)); + + expect(created[0]).not.toHaveProperty("coach_id"); + expect(attached[0]).not.toHaveProperty("coach_id"); + expect(relationships).toHaveLength(0); + }); + + /// The coach now shares the member request's transaction, so its failure is + /// the add's failure. There is no partial state left to soften the message. + it("reports the whole add as failed when the request is rejected", async () => { + captureAdds(true); + const user = userEvent.setup(); + renderDialog(adminRole, [GRACE]); + + await findAda(user); + await pickCoach(user); + await user.click(screen.getByRole("button", { name: "Add to organization" })); + + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(toast.success).not.toHaveBeenCalled(); + expect(toast.warning).not.toHaveBeenCalled(); + }); + + it("offers the organization's existing members as coaches", async () => { + server.use(lookupHandler(ADA)); + const user = userEvent.setup(); + renderDialog(adminRole, [GRACE]); + + await findAda(user); + await user.click(screen.getByLabelText("Coach (optional)")); + + expect( + await screen.findByRole("option", { name: "Grace Hopper" }) + ).toBeInTheDocument(); + }); +}); + +describe("AddMemberDialog – discarding a found user", () => { + it("clears the found user when Clear is pressed", async () => { + server.use(lookupHandler(ADA)); + const user = userEvent.setup(); + renderDialog(adminRole); + + await findAda(user); + await user.click(screen.getByRole("button", { name: /^Clear / })); + + expect(screen.queryByText("Ada Lovelace")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Add to organization" }) + ).toBeDisabled(); + expect(screen.getByLabelText("Email")).toHaveValue(""); + }); + + /// Editing the email must invalidate the match it produced, or the add button + /// acts on a stale selection while the field shows a different address. + it("discards the found user when the email is edited after finding", async () => { + server.use(lookupHandler(ADA)); + const user = userEvent.setup(); + renderDialog(adminRole); + + await findAda(user); + await user.type(screen.getByLabelText("Email"), "x"); + + expect(screen.queryByText("Ada Lovelace")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Add to organization" }) + ).toBeDisabled(); + }); +}); + +describe("AddMemberDialog – attaching an existing member", () => { + /** Captures every POST to the membership sub-route. */ + function captureAttach() { + const calls: { url: string; body: unknown }[] = []; + server.use( + lookupHandler(ADA), + http.post( + "*/organizations/:organizationId/users/:userId/role", + async ({ request, params }) => { + calls.push({ url: request.url, body: await request.json() }); + return HttpResponse.json({ + status_code: 200, + data: { id: params.userId }, + }); + } + ) + ); + return calls; + } + + it("posts the selected role to the member's /role sub-route", async () => { + const calls = captureAttach(); + const user = userEvent.setup(); + renderDialog(adminRole); + + await findAda(user); + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByRole("option", { name: "Admin" })); + await user.click(screen.getByRole("button", { name: "Add to organization" })); + + await waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0].url).toBe( + `http://localhost:4000/organizations/org-1/users/${ADA.id}/role` + ); + expect(calls[0].body).toEqual({ role: "Admin" }); + }); + + it("defaults to the Member option, which submits the User role", async () => { + const calls = captureAttach(); + const user = userEvent.setup(); + renderDialog(adminRole); + + await findAda(user); + expect(screen.getByRole("combobox")).toHaveTextContent("Member"); + await user.click(screen.getByRole("button", { name: "Add to organization" })); + + await waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0].body).toEqual({ role: "User" }); + }); + + it("surfaces the already-a-member conflict rather than a generic error", async () => { + server.use( + lookupHandler(ADA), + http.post("*/organizations/:organizationId/users/:userId/role", () => + HttpResponse.json( + { + error: "user_already_in_organization", + message: "This user is already a member of this organization.", + }, + { status: 409 } + ) + ) + ); + const user = userEvent.setup(); + renderDialog(adminRole); + + await findAda(user); + await user.click(screen.getByRole("button", { name: "Add to organization" })); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + "This user is already a member of this organization." + ) + ); + }); + it("discards a lookup that lands after the email was edited", async () => { + // The reply is held open so the email can be edited while it is in flight. + let release: () => void = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + server.use( + http.get("*/users", async ({ request }) => { + const email = new URL(request.url).searchParams.get("email"); + if (email === ADA.email) await held; + return HttpResponse.json({ + status_code: 200, + data: email === ADA.email ? [ADA] : [], + }); + }) + ); + const user = userEvent.setup(); + renderDialog(adminRole); + + await user.click(screen.getByRole("tab", { name: "Add existing member" })); + await user.type(screen.getByLabelText("Email"), ADA.email); + await user.click(screen.getByRole("button", { name: "Find" })); + + // Retype before the reply arrives, then let it land. + await user.clear(screen.getByLabelText("Email")); + await user.type(screen.getByLabelText("Email"), "someone.else@example.com"); + release(); + + // Ada must not reappear: the card belongs to an address the field no longer + // shows, and Add would attach her instead of the address on screen. + await waitFor(() => + expect(screen.getByRole("button", { name: "Add to organization" })).toBeDisabled() + ); + expect(screen.queryByText("Ada Lovelace")).not.toBeInTheDocument(); + }); + it("reports an existing member at lookup time instead of on submit", async () => { + server.use(lookupHandler(ADA)); + const alreadyAMember = { + id: ADA.id, + first_name: ADA.first_name, + last_name: ADA.last_name, + } as unknown as User; + const user = userEvent.setup(); + renderDialog(adminRole, [GRACE, alreadyAMember]); + + await user.click(screen.getByRole("tab", { name: "Add existing member" })); + await user.type(screen.getByLabelText("Email"), ADA.email); + await user.click(screen.getByRole("button", { name: "Find" })); + + await screen.findByText("This user is already a member of this organization."); + // No confirmation card, so there is no role or coach to fill in and no + // request to send. + expect(screen.queryByText("Ada Lovelace")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Add to organization" }) + ).toBeDisabled(); + }); +}); + +describe("AddMemberDialog – gating the existing-member tab", () => { + it("hides the tab from an admin who administers only one organization", () => { + // They would be able to open it and look people up, but every result is + // already a member, so the tab can only lead to a conflict. + renderDialog(adminRole, [GRACE], false); + + expect( + screen.queryByRole("tab", { name: "Add existing member" }) + ).not.toBeInTheDocument(); + expect(screen.getByLabelText("First Name")).toBeInTheDocument(); + }); + + it("shows the tab when the lookup has candidates to offer", () => { + renderDialog(adminRole, [GRACE], true); + + expect( + screen.getByRole("tab", { name: "Add existing member" }) + ).toBeInTheDocument(); + }); + + it("stays hidden for a plain member even when the lookup could offer candidates", () => { + renderDialog(memberRole, [GRACE], true); + + expect( + screen.queryByRole("tab", { name: "Add existing member" }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/members/add-member-dialog.test.tsx b/__tests__/components/members/add-member-dialog.test.tsx index cbc80722..d2ab9b9f 100644 --- a/__tests__/components/members/add-member-dialog.test.tsx +++ b/__tests__/components/members/add-member-dialog.test.tsx @@ -59,7 +59,12 @@ describe("AddMemberDialog write-freeze handling", () => { }) ); render( - + ); fillForm(); @@ -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( - + ); fillForm(); @@ -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( - + ); fillForm(); diff --git a/__tests__/components/members/member-card-remove.test.tsx b/__tests__/components/members/member-card-remove.test.tsx new file mode 100644 index 00000000..11d22193 --- /dev/null +++ b/__tests__/components/members/member-card-remove.test.tsx @@ -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( + + ); +} + +/** + * 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) { + 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." + ) + ); + }); +}); diff --git a/__tests__/components/organization-switcher.test.tsx b/__tests__/components/organization-switcher.test.tsx index 638255c2..7a9978fc 100644 --- a/__tests__/components/organization-switcher.test.tsx +++ b/__tests__/components/organization-switcher.test.tsx @@ -1,20 +1,25 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' import { OrganizationSwitcher } from '@/components/ui/organization-switcher' import { TestProviders } from '@/test-utils/providers' -// Mock the organization list hook +const { ORGANIZATIONS } = vi.hoisted(() => ({ + ORGANIZATIONS: [ + { id: 'org-1', name: 'Acme Corp', logo: '/logo1.png' }, + { id: 'org-2', name: 'Beta Inc', logo: '/logo2.png' }, + ], +})) + +// Mock the organization list hook. useOrganization resolves by id, as the real +// hook does, so the trigger reflects whichever organization is selected. vi.mock('@/lib/api/organizations', () => ({ useOrganizationList: () => ({ - organizations: [ - { id: 'org-1', name: 'Acme Corp', logo: '/logo1.png' }, - { id: 'org-2', name: 'Beta Inc', logo: '/logo2.png' }, - ], + organizations: ORGANIZATIONS, isLoading: false, isError: false, }), - useOrganization: () => ({ - organization: null, + useOrganization: (id: string) => ({ + organization: ORGANIZATIONS.find((org) => org.id === id) ?? null, isLoading: false, isError: false, refresh: vi.fn(), @@ -38,6 +43,13 @@ Object.defineProperty(window.HTMLElement.prototype, 'scrollIntoView', { writable: true, }) +// The trigger renders the selected organization's name as well, so assertions +// about the dropdown have to be scoped to the list to stay unambiguous. +async function openList() { + fireEvent.click(screen.getByRole('combobox')) + return screen.findByRole('listbox') +} + describe('OrganizationSwitcher', () => { it('should render with default state', () => { render( @@ -56,30 +68,30 @@ describe('OrganizationSwitcher', () => { ) - fireEvent.click(screen.getByRole('combobox')) + const list = await openList() await waitFor(() => { - expect(screen.getByText('Acme Corp')).toBeInTheDocument() - expect(screen.getByText('Beta Inc')).toBeInTheDocument() + expect(within(list).getByText('Acme Corp')).toBeInTheDocument() + expect(within(list).getByText('Beta Inc')).toBeInTheDocument() }) }) it('should call onSelect when organization is selected', async () => { const onSelect = vi.fn() - + render( ) - fireEvent.click(screen.getByRole('combobox')) - + const list = await openList() + await waitFor(() => { - expect(screen.getByText('Acme Corp')).toBeInTheDocument() + expect(within(list).getByText('Acme Corp')).toBeInTheDocument() }) - fireEvent.click(screen.getByText('Acme Corp')) + fireEvent.click(within(list).getByText('Acme Corp')) expect(onSelect).toHaveBeenCalledWith('org-1') }) @@ -91,14 +103,67 @@ describe('OrganizationSwitcher', () => { ) - fireEvent.click(screen.getByRole('combobox')) + const list = await openList() const searchInput = screen.getByPlaceholderText('Search organization...') fireEvent.change(searchInput, { target: { value: 'Acme' } }) await waitFor(() => { - expect(screen.getByText('Acme Corp')).toBeInTheDocument() - expect(screen.queryByText('Beta Inc')).not.toBeInTheDocument() + expect(within(list).getByText('Acme Corp')).toBeInTheDocument() + expect(within(list).queryByText('Beta Inc')).not.toBeInTheDocument() + }) + }) + + it('shows the selected organization\'s initials on the trigger avatar', async () => { + render( + + + + ) + + // Auto-initializes to the first organization, Acme Corp. + const trigger = screen.getByRole('combobox') + await waitFor(() => { + expect(within(trigger).getByText('AC')).toBeInTheDocument() + }) + expect(within(trigger).queryByText('RG')).not.toBeInTheDocument() + }) + + it('updates the trigger avatar initials when a different organization is selected', async () => { + render( + + + + ) + + const trigger = screen.getByRole('combobox') + await waitFor(() => { + expect(within(trigger).getByText('AC')).toBeInTheDocument() + }) + + const list = await openList() + await waitFor(() => { + expect(within(list).getByText('Beta Inc')).toBeInTheDocument() + }) + fireEvent.click(within(list).getByText('Beta Inc')) + + await waitFor(() => { + expect(within(trigger).getByText('BI')).toBeInTheDocument() + }) + expect(within(trigger).queryByText('AC')).not.toBeInTheDocument() + }) + + it('gives each organization in the list its own initials', async () => { + render( + + + + ) + + const list = await openList() + await waitFor(() => { + expect(within(list).getByText('AC')).toBeInTheDocument() + expect(within(list).getByText('BI')).toBeInTheDocument() }) }) @@ -109,16 +174,16 @@ describe('OrganizationSwitcher', () => { ) - fireEvent.click(screen.getByRole('combobox')) + const list = await openList() const searchInput = screen.getByPlaceholderText('Search organization...') - + // Test arrow down navigation fireEvent.keyDown(searchInput, { key: 'ArrowDown' }) - + // The first organization should be focused (implementation depends on actual focus behavior) await waitFor(() => { - expect(screen.getByText('Acme Corp')).toBeInTheDocument() + expect(within(list).getByText('Acme Corp')).toBeInTheDocument() }) }) }) \ No newline at end of file diff --git a/__tests__/e2e/existing-member-tab-live.spec.ts b/__tests__/e2e/existing-member-tab-live.spec.ts new file mode 100644 index 00000000..11619571 --- /dev/null +++ b/__tests__/e2e/existing-member-tab-live.spec.ts @@ -0,0 +1,185 @@ +import { test, expect, type Page } from "@playwright/test"; + +/** + * Live end-to-end for the add-existing-member tab gate, against the real backend + * and the seeded local users. + * + * Read-only for the @single-org cases. The @multi-org cases require an operator + * to have promoted ehab to Admin of Refactor Group first (he already admins + * BigTable), which the runner script does around them. + */ + +const PASSWORD = "password"; +const ORG = { + refactorGroup: "617e8b03-0c1c-49a6-b151-74e54e9e2de4", + bigTable: "9c5cd245-cf67-432c-b78c-2f567cae99f2", + acme: "f67343c9-6c15-4878-a171-be321fdaf34b", +}; + +test.skip( + !process.env.LIVE_E2E, + "live end-to-end, set LIVE_E2E=1 with the seeded local database" +); +test.describe.configure({ mode: "serial" }); + +async function login(page: Page, email: string, password = PASSWORD) { + await page.goto("/"); + await page.fill("#email", email); + await page.fill("#password", password); + await page.click('button:has-text("Sign In with Email")'); + await page.waitForURL(/dashboard/, { timeout: 20000 }); +} + +async function openMembers(page: Page, organizationId: string) { + await page.goto(`/organizations/${organizationId}/members`); + await page.click('button:has-text("Add Member")'); + await expect(page.getByRole("heading", { name: "Add New Member" })).toBeVisible(); +} + +const existingTab = (page: Page) => + page.getByRole("tab", { name: "Add existing member" }); + +// ── Tab hidden: admins of exactly one organization ─────────────────────────── + +test("@single-org jim, admin of Refactor Group only, sees no existing-member tab", async ({ + page, +}) => { + await login(page, "jim@refactorgroup.com"); + await openMembers(page, ORG.refactorGroup); + + await expect(existingTab(page)).toHaveCount(0); + // The create form is still fully available. + await expect(page.getByLabel("First Name")).toBeVisible(); + await expect(page.getByRole("button", { name: "Create Member" })).toBeVisible(); +}); + +test("@single-org ehab, admin of BigTable only, sees no existing-member tab", async ({ + page, +}) => { + await login(page, "ehab.bandar@gmail.com"); + await openMembers(page, ORG.bigTable); + + await expect(existingTab(page)).toHaveCount(0); +}); + +test("@single-org caleb, admin of Acme only, sees no existing-member tab", async ({ + page, +}) => { + await login(page, "calebbourg2@gmail.com"); + await openMembers(page, ORG.acme); + + await expect(existingTab(page)).toHaveCount(0); +}); + +test("@single-org a plain member cannot reach the members page at all", async ({ + page, +}) => { + await login(page, "james.hodapp@gmail.com"); + await page.goto(`/organizations/${ORG.refactorGroup}/members`); + + // Access control denies the page outright, so there is no dialog to gate. + await expect(page.getByRole("button", { name: "Add Member" })).toHaveCount(0); +}); + +// ── Tab shown: super admin ─────────────────────────────────────────────────── + +test("@single-org the super admin sees the existing-member tab", async ({ page }) => { + await login(page, "admin@refactorcoach.com"); + await openMembers(page, ORG.refactorGroup); + + await expect(existingTab(page)).toBeVisible(); +}); + +// ── Lookup outcomes, exercised as the super admin ──────────────────────────── + +test("@single-org an unknown email reports no user found", async ({ page }) => { + await login(page, "admin@refactorcoach.com"); + await openMembers(page, ORG.refactorGroup); + await existingTab(page).click(); + + await page.fill("#lookupEmail", "nobody.here@nowhere.test"); + await page.click('button:has-text("Find")'); + + await expect(page.getByText("No user found with that email.")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Add to organization" }) + ).toBeDisabled(); +}); + +test("@single-org an existing member is rejected at lookup, not on submit", async ({ + page, +}) => { + await login(page, "admin@refactorcoach.com"); + await openMembers(page, ORG.refactorGroup); + await existingTab(page).click(); + + // james is already a member of Refactor Group. + await page.fill("#lookupEmail", "james.hodapp@gmail.com"); + await page.click('button:has-text("Find")'); + + await expect( + page.getByText("This user is already a member of this organization.") + ).toBeVisible(); + // Scoped to the dialog: his member card is on the page behind it. + await expect( + page.getByRole("dialog").getByText("Jim Hodapp", { exact: true }) + ).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Add to organization" }) + ).toBeDisabled(); +}); + +test("@single-org a findable non-member shows a card that Clear discards", async ({ + page, +}) => { + await login(page, "admin@refactorcoach.com"); + await openMembers(page, ORG.bigTable); + await existingTab(page).click(); + + // caleb is in Acme and Refactor Group, not BigTable. + await page.fill("#lookupEmail", "calebbourg2@gmail.com"); + await page.click('button:has-text("Find")'); + + await expect(page.getByText("Caleb Bourg")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Add to organization" }) + ).toBeEnabled(); + + await page.getByRole("button", { name: /^Clear/ }).click(); + await expect(page.getByText("Caleb Bourg")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Add to organization" }) + ).toBeDisabled(); +}); + +// ── Multi-org admin: the driving use case ──────────────────────────────────── + +test("@multi-org ehab, now admin of two organizations, sees the tab", async ({ + page, +}) => { + await login(page, "ehab.bandar@gmail.com"); + await openMembers(page, ORG.bigTable); + + await expect(existingTab(page)).toBeVisible(); +}); + +test("@multi-org ehab adds a Refactor Group member into BigTable", async ({ + page, +}) => { + await login(page, "ehab.bandar@gmail.com"); + await openMembers(page, ORG.bigTable); + await existingTab(page).click(); + + // caleb is a Refactor Group member, and ehab now administers Refactor Group, + // so caleb is visible to him and is not yet in BigTable. + await page.fill("#lookupEmail", "calebbourg2@gmail.com"); + await page.click('button:has-text("Find")'); + await expect(page.getByText("Caleb Bourg")).toBeVisible(); + + await page.click('button:has-text("Add to organization")'); + + await expect(page.getByRole("dialog")).toBeHidden({ timeout: 20000 }); + await expect(page.getByText("Caleb Bourg").first()).toBeVisible({ + timeout: 15000, + }); +}); diff --git a/__tests__/e2e/multi-org-live.spec.ts b/__tests__/e2e/multi-org-live.spec.ts new file mode 100644 index 00000000..418a5097 --- /dev/null +++ b/__tests__/e2e/multi-org-live.spec.ts @@ -0,0 +1,205 @@ +import { test, expect, type Page } from "@playwright/test"; + +/** + * Live end-to-end against the real backend and database, unlike the other e2e + * specs in this directory which mock the API. Requires the backend on :4000 and + * a seeded dev database. + */ + +const PASSWORD = "password"; +const SUPER_ADMIN = "admin@refactorcoach.com"; +const EHAB = "ehab.bandar@gmail.com"; +const REFACTOR_GROUP = "617e8b03-0c1c-49a6-b151-74e54e9e2de4"; + +async function login(page: Page, email: string) { + await page.goto("/"); + await page.fill("#email", email); + await page.fill("#password", PASSWORD); + await page.click('button:has-text("Sign In with Email")'); + await page.waitForURL(/dashboard/, { timeout: 20000 }); +} + +async function openAddMember(page: Page, organizationId: string) { + await page.goto(`/organizations/${organizationId}/members`); + await page.click('button:has-text("Add Member")'); + await expect(page.getByRole("heading", { name: "Add New Member" })).toBeVisible(); +} + +test.describe.configure({ mode: "serial" }); + +// Not idempotent: it creates Ehab, BigTable and a session, so a second run fails +// on the duplicate email. Opt in with LIVE_E2E=1 against a freshly seeded database. +test.skip( + !process.env.LIVE_E2E, + "live end-to-end, set LIVE_E2E=1 with a freshly seeded database" +); + +test("a: super admin adds Ehab to Refactor Group as a coachee", async ({ page }) => { + await login(page, SUPER_ADMIN); + await openAddMember(page, REFACTOR_GROUP); + + await page.fill("#firstName", "Ehab"); + await page.fill("#lastName", "Bandar"); + await page.fill("#displayName", "Ehab Bandar"); + await page.fill("#email", EHAB); + + // The new coach picker makes him a coachee in the same request. Exact match: + // the org also has a "Jim Hodapp (Refactor Group)" member. + await page.click("#coach"); + await page.getByRole("option", { name: "Jim Hodapp", exact: true }).click(); + + await page.click('button:has-text("Create Member")'); + + await expect(page.getByText("Ehab Bandar").first()).toBeVisible({ timeout: 15000 }); +}); + +test("b: super admin creates the BigTable organization", async ({ page }) => { + await login(page, SUPER_ADMIN); + await page.goto("/admin/organizations"); + await page.click('button:has-text("Add organization")'); + await page.fill("#name", "BigTable"); + await page.click('button:has-text("Create organization")'); + + await expect(page.getByText("BigTable").first()).toBeVisible({ timeout: 15000 }); +}); + +test("c: super admin adds Ehab to BigTable as an existing member, role Admin", async ({ + page, +}) => { + await login(page, SUPER_ADMIN); + + // Resolve BigTable's id from the API rather than guessing it. + const orgs = await page.evaluate(async () => { + const res = await fetch("http://localhost:4000/organizations", { + headers: { "x-version": "1.0.0-beta1" }, + credentials: "include", + }); + return (await res.json()).data as { id: string; name: string }[]; + }); + const bigTable = orgs.find((o) => o.name === "BigTable"); + expect(bigTable, "BigTable must exist from step b").toBeTruthy(); + + await openAddMember(page, bigTable!.id); + await page.getByRole("tab", { name: "Add existing member" }).click(); + await page.fill("#lookupEmail", EHAB); + await page.click('button:has-text("Find")'); + + await expect(page.getByText("Ehab Bandar")).toBeVisible({ timeout: 15000 }); + + await page.click("#existingRole"); + await page.getByRole("option", { name: "Admin" }).click(); + await page.click('button:has-text("Add to organization")'); + + await expect(page.getByText("Ehab Bandar").first()).toBeVisible({ timeout: 15000 }); +}); + +test("d: Ehab, as BigTable admin, adds Jim to BigTable with himself as coach", async ({ + page, +}) => { + await login(page, EHAB); + + const orgs = await page.evaluate(async () => { + const res = await fetch("http://localhost:4000/organizations", { + headers: { "x-version": "1.0.0-beta1" }, + credentials: "include", + }); + return (await res.json()).data as { id: string; name: string }[]; + }); + const bigTable = orgs.find((o) => o.name === "BigTable"); + expect(bigTable, "Ehab must see BigTable, which he administers").toBeTruthy(); + + await openAddMember(page, bigTable!.id); + await page.getByRole("tab", { name: "Add existing member" }).click(); + await page.fill("#lookupEmail", "james.hodapp@gmail.com"); + await page.click('button:has-text("Find")'); + + // Records what Ehab actually sees, which is the point of this step. + const notFound = page.getByText("No user found with that email."); + const found = page.getByText("Jim Hodapp"); + await expect(notFound.or(found).first()).toBeVisible({ timeout: 15000 }); + + // FINDING: Ehab administers only BigTable, which is empty, and he is a plain + // member (not admin) of Refactor Group. So he shares no administered org with + // Jim and the lookup correctly hides him. A brand new organization is a + // bootstrapping dead end for its own admin: nobody is in it yet, so there is + // nobody they are allowed to add. Pinned so a future scope change is deliberate. + await expect(notFound).toBeVisible(); +}); + +test("d2: workaround, the super admin adds Jim to BigTable with Ehab as coach", async ({ + page, +}) => { + await login(page, SUPER_ADMIN); + + const orgs = await page.evaluate(async () => { + const res = await fetch("http://localhost:4000/organizations", { + headers: { "x-version": "1.0.0-beta1" }, + credentials: "include", + }); + return (await res.json()).data as { id: string; name: string }[]; + }); + const bigTable = orgs.find((o) => o.name === "BigTable")!; + + await openAddMember(page, bigTable.id); + await page.getByRole("tab", { name: "Add existing member" }).click(); + await page.fill("#lookupEmail", "james.hodapp@gmail.com"); + await page.click('button:has-text("Find")'); + await expect(page.getByText("Jim Hodapp", { exact: true })).toBeVisible({ timeout: 15000 }); + + await page.click("#coach"); + await page.getByRole("option", { name: "Ehab Bandar", exact: true }).click(); + await page.click('button:has-text("Add to organization")'); + + await expect(page.getByText("Jim Hodapp").first()).toBeVisible({ timeout: 15000 }); +}); + +test("e: Ehab schedules a BigTable coaching session with Jim", async ({ page }) => { + await login(page, EHAB); + + // The dashboard opens on Refactor Group, where Ehab is only a coachee and so + // has no Add New button. Switch to BigTable, where he is admin and Jim's coach. + await page.getByRole("combobox").first().click(); + await page.getByRole("option", { name: /BigTable/ }).click(); + await expect(page.getByRole("combobox").first()).toContainText("BigTable"); + + await page.click('button:has-text("Add New")'); + await page.getByRole("menuitem", { name: /session/i }).click(); + + await page.click("#coachee-select"); + await page.getByRole("option", { name: "Jim Hodapp", exact: true }).click(); + + // Create Session stays disabled until a date and time are both chosen. + await page.getByRole("button", { name: "Go to next month" }).click(); + await page.getByRole("gridcell", { name: "15", exact: true }).click(); + await page.fill("#session-time", "10:00"); + + const submit = page.getByRole("button", { name: "Create Session" }); + await expect(submit).toBeEnabled({ timeout: 10000 }); + await submit.click(); + + // Assert the session actually exists, not merely that the word "session" is on + // screen: the dashboard already says "Coaching Sessions", which makes a loose + // text assertion pass against a no-op. + await expect(page.getByRole("dialog")).toBeHidden({ timeout: 20000 }); + + const sessions = await page.evaluate(async () => { + const orgRes = await fetch("http://localhost:4000/organizations", { + headers: { "x-version": "1.0.0-beta1" }, + credentials: "include", + }); + const orgs = (await orgRes.json()).data as { id: string; name: string }[]; + const bigTable = orgs.find((o) => o.name === "BigTable")!; + const relRes = await fetch( + `http://localhost:4000/organizations/${bigTable.id}/coaching_relationships`, + { headers: { "x-version": "1.0.0-beta1" }, credentials: "include" } + ); + const rels = (await relRes.json()).data as { id: string }[]; + const sesRes = await fetch( + `http://localhost:4000/coaching_sessions?coaching_relationship_id=${rels[0].id}&from_date=2000-01-01&to_date=2100-01-01`, + { headers: { "x-version": "1.0.0-beta1" }, credentials: "include" } + ); + return (await sesRes.json()).data as unknown[]; + }); + + expect(sessions.length, "a BigTable session must exist for Ehab and Jim").toBeGreaterThan(0); +}); diff --git a/__tests__/e2e/multi-org-scoping-live.spec.ts b/__tests__/e2e/multi-org-scoping-live.spec.ts new file mode 100644 index 00000000..3d4f7b1f --- /dev/null +++ b/__tests__/e2e/multi-org-scoping-live.spec.ts @@ -0,0 +1,167 @@ +import { test, expect, type Page } from "@playwright/test"; + +/** + * Live end-to-end against the real backend and database, unlike the other e2e + * specs in this directory which mock the API. Read-only, so unlike + * multi-org-live.spec.ts it is repeatable: it creates nothing and asserts on + * data an earlier run already seeded. + * + * Requires the backend on :4000, the frontend on :3000, and ehab.bandar@gmail.com + * belonging to both Refactor Group and BigTable with his sessions in BigTable. + */ + +const PASSWORD = "password"; +const EHAB = "ehab.bandar@gmail.com"; + +/** + * Browser time is pinned for every test in this file. The seeded BigTable + * sessions sit at fixed instants (2026-08-07 and 2026-09-15), so which bucket + * they land in, and whether either is still "upcoming", otherwise depends on the + * day the suite happens to run. Both assertions below broke exactly that way + * once the date rolled over. + */ +const PINNED_NOW = new Date("2026-08-06T15:00:00.000Z"); + +test.skip( + !process.env.LIVE_E2E, + "live end-to-end, set LIVE_E2E=1 with a seeded multi-org database" +); + +async function login(page: Page, email: string) { + await page.clock.setFixedTime(PINNED_NOW); + await page.goto("/"); + await page.fill("#email", email); + await page.fill("#password", PASSWORD); + await page.click('button:has-text("Sign In with Email")'); + await page.waitForURL(/dashboard/, { timeout: 20000 }); +} + +function switcher(page: Page) { + return page.getByRole("combobox").first(); +} + +async function selectOrganization(page: Page, name: string) { + await switcher(page).click(); + await page.getByRole("listbox").getByText(name, { exact: true }).click(); + await expect(switcher(page)).toContainText(name); +} + +test("the switcher avatar shows the selected organization's initials, not a fixed RG", async ({ + page, +}) => { + await login(page, EHAB); + + await selectOrganization(page, "Refactor Group"); + await expect(switcher(page)).toContainText("RG"); + + await selectOrganization(page, "BigTable"); + // The regression: this stayed "RG" for every organization. + await expect(switcher(page)).toContainText("BT"); + await expect(switcher(page)).not.toContainText("RG"); +}); + +test("the dropdown gives each organization its own initials", async ({ page }) => { + await login(page, EHAB); + + await switcher(page).click(); + const list = page.getByRole("listbox"); + await expect(list.getByText("RG", { exact: true })).toBeVisible(); + await expect(list.getByText("BT", { exact: true })).toBeVisible(); +}); + +test("session requests carry the selected organization and refetch on switch", async ({ + page, +}) => { + await login(page, EHAB); + + const sessionRequests: string[] = []; + page.on("request", (request) => { + const url = request.url(); + if (url.includes("/coaching_sessions")) sessionRequests.push(url); + }); + + await selectOrganization(page, "Refactor Group"); + await expect + .poll(() => sessionRequests.some((url) => url.includes("organization_id=")), { + timeout: 15000, + }) + .toBe(true); + + const beforeSwitch = sessionRequests.length; + await selectOrganization(page, "BigTable"); + + // Pins the SWR cache key. Without organization_id in it, switching would + // serve the previous organization's cached list and issue nothing new. + await expect + .poll(() => sessionRequests.length, { timeout: 15000 }) + .toBeGreaterThan(beforeSwitch); + + const organizationIds = new Set( + sessionRequests + .map((url) => new URL(url).searchParams.get("organization_id")) + .filter(Boolean) + ); + expect(organizationIds.size, "both organizations must appear").toBe(2); +}); + +// Ehab's sessions all live in BigTable, so Refactor Group must render empty. +test("the Upcoming and Previous tabs are empty in the organization that has no sessions", async ({ + page, +}) => { + await login(page, EHAB); + + // The week bucket's trigger carries a count and stays in the DOM whether or + // not the accordion is expanded, unlike the session rows themselves. Its text + // is title case in the DOM and uppercased only by CSS. + const weekBucket = page.getByRole("button").filter({ hasText: /This Week ·/ }); + + await selectOrganization(page, "Refactor Group"); + await expect(page.getByText(/No upcoming sessions scheduled for today/)).toBeVisible({ + timeout: 15000, + }); + await expect(weekBucket).toHaveCount(0); + + await selectOrganization(page, "BigTable"); + await expect(weekBucket).toContainText("(1)", { timeout: 15000 }); +}); + +test("the Upcoming Session card is empty in the organization that has no sessions", async ({ + page, +}) => { + await login(page, EHAB); + + await selectOrganization(page, "Refactor Group"); + await expect(page.getByText("Session with Jim Hodapp")).toHaveCount(0, { + timeout: 15000, + }); + + await selectOrganization(page, "BigTable"); + await expect(page.getByText("Session with Jim Hodapp").first()).toBeVisible({ + timeout: 15000, + }); +}); + +test("a cold dashboard load never requests sessions without an organization", async ({ + page, +}) => { + const unscoped: string[] = []; + page.on("request", (request) => { + const url = request.url(); + if (!url.includes("/coaching_sessions")) return; + if (!new URL(url).searchParams.get("organization_id")) unscoped.push(url); + }); + + // Fresh context, so the organization is not known until /organizations returns. + await login(page, EHAB); + await expect(switcher(page)).toContainText(/Refactor Group|BigTable/); + await expect(page.getByText(/Coaching Sessions/).first()).toBeVisible({ + timeout: 15000, + }); + + // Firing before the organization resolves returns every organization's + // sessions, and they render before the scoped result replaces them. + expect( + unscoped.map((url) => new URL(url).pathname + new URL(url).search), + "session requests must wait for the organization" + ).toEqual([]); +}); diff --git a/__tests__/hooks/use-todays-sessions.test.tsx b/__tests__/hooks/use-todays-sessions.test.tsx index ce622526..0afb4627 100644 --- a/__tests__/hooks/use-todays-sessions.test.tsx +++ b/__tests__/hooks/use-todays-sessions.test.tsx @@ -1,6 +1,7 @@ import { renderHook, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { useTodaysSessions } from "@/lib/hooks/use-todays-sessions"; +import { useEnrichedCoachingSessionsForUser } from "@/lib/api/coaching-sessions"; import { TestProviders } from "@/test-utils/providers"; import { DateTime } from "ts-luxon"; import { @@ -12,9 +13,11 @@ import { /** * Test Suite: useTodaysSessions Hook - * Story: "Fetch and enrich all of today's coaching sessions across organizations" + * Story: "Fetch and enrich today's coaching sessions for the current organization" */ +const CURRENT_ORGANIZATION_ID = "org-1"; + // Mock the auth store const mockUser = createMockUser({ id: "user-1", @@ -63,6 +66,25 @@ vi.mock("@/lib/api/organizations", () => ({ isLoading: false, isError: undefined, })), + useOrganization: vi.fn(() => ({ + organization: null, + isLoading: false, + isError: false, + refresh: vi.fn(), + })), +})); + +// The sidebar's selected organization, which scopes the request. +vi.mock("@/lib/hooks/use-current-organization", () => ({ + useCurrentOrganization: vi.fn(() => ({ + currentOrganizationId: "org-1", + currentOrganization: null, + isLoading: false, + isError: false, + setCurrentOrganizationId: vi.fn(), + resetOrganizationState: vi.fn(), + refresh: vi.fn(), + })), })); // Mock the coaching relationships API @@ -138,7 +160,25 @@ describe("useTodaysSessions", () => { expect(result.current.isLoading).toBeDefined(); }); - it("should fetch sessions from all organizations", async () => { + it("scopes the request to the currently selected organization", () => { + renderHook(() => useTodaysSessions(), { wrapper: TestProviders }); + + // Ninth argument is organizationId. Omitting it returns every organization's + // sessions, which is what made the Upcoming Session card bleed across orgs. + expect(vi.mocked(useEnrichedCoachingSessionsForUser)).toHaveBeenCalledWith( + mockUser.id, + expect.anything(), + expect.anything(), + expect.anything(), + "date", + "asc", + undefined, + undefined, + CURRENT_ORGANIZATION_ID + ); + }); + + it("should return every session the scoped request yields", async () => { const { result } = renderHook(() => useTodaysSessions(), { wrapper: TestProviders, }); diff --git a/__tests__/lib/api/coaching-sessions-org-scoping.test.tsx b/__tests__/lib/api/coaching-sessions-org-scoping.test.tsx new file mode 100644 index 00000000..de7b8537 --- /dev/null +++ b/__tests__/lib/api/coaching-sessions-org-scoping.test.tsx @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ReactNode } from "react"; +import { DateTime } from "ts-luxon"; +import { renderHook, waitFor } from "@testing-library/react"; +import { SWRConfig } from "swr"; +import { + useEnrichedCoachingSessionsForUser, + useEnrichedCoachingSessionsForUserCounts, +} from "@/lib/api/coaching-sessions"; +import { EntityApi } from "@/lib/api/entity-api"; + +// Real SWR, stubbed HTTP boundary: asserts the outgoing request and lets an +// organization switch exercise the real cache key. +function SwrWrapper({ children }: { children: ReactNode }) { + return ( + new Map(), dedupingInterval: 0 }}> + {children} + + ); +} + +const FROM = DateTime.fromISO("2026-07-01"); +const TO = DateTime.fromISO("2026-07-31"); +const USER_ID = "user-1"; + +describe("user coaching sessions are scoped by organization", () => { + let listNestedFn: ReturnType; + let getFn: ReturnType; + + beforeEach(() => { + listNestedFn = vi + .spyOn(EntityApi, "listNestedFn") + .mockResolvedValue([] as never); + getFn = vi + .spyOn(EntityApi, "getFn") + .mockResolvedValue({ counts: [] } as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sends organization_id on the session list request", async () => { + renderHook( + () => + useEnrichedCoachingSessionsForUser( + USER_ID, + FROM, + TO, + [], + undefined, + undefined, + undefined, + "America/Los_Angeles", + "org-1" + ), + { wrapper: SwrWrapper } + ); + + await waitFor(() => expect(listNestedFn).toHaveBeenCalled()); + const [, , , options] = listNestedFn.mock.calls[0]; + expect(options.params.organization_id).toBe("org-1"); + }); + + it("omits organization_id when there is no current organization", async () => { + renderHook( + () => + useEnrichedCoachingSessionsForUser( + USER_ID, + FROM, + TO, + [], + undefined, + undefined, + undefined, + "America/Los_Angeles" + ), + { wrapper: SwrWrapper } + ); + + await waitFor(() => expect(listNestedFn).toHaveBeenCalled()); + const [, , , options] = listNestedFn.mock.calls[0]; + expect(options.params).not.toHaveProperty("organization_id"); + }); + + // Pins organization_id into the SWR key. Without it the switch would serve + // the previous organization's cached list, reproducing the original bug. + it("issues a new request when the current organization changes", async () => { + const { rerender } = renderHook( + ({ organizationId }: { organizationId: string }) => + useEnrichedCoachingSessionsForUser( + USER_ID, + FROM, + TO, + [], + undefined, + undefined, + undefined, + "America/Los_Angeles", + organizationId + ), + { wrapper: SwrWrapper, initialProps: { organizationId: "org-1" } } + ); + + await waitFor(() => expect(listNestedFn).toHaveBeenCalledTimes(1)); + + rerender({ organizationId: "org-2" }); + + await waitFor(() => expect(listNestedFn).toHaveBeenCalledTimes(2)); + const [, , , options] = listNestedFn.mock.calls[1]; + expect(options.params.organization_id).toBe("org-2"); + }); + + it("sends organization_id on the counts request", async () => { + renderHook( + () => + useEnrichedCoachingSessionsForUserCounts( + USER_ID, + FROM, + TO, + "America/Los_Angeles", + undefined, + "org-1" + ), + { wrapper: SwrWrapper } + ); + + await waitFor(() => expect(getFn).toHaveBeenCalled()); + const [, options] = getFn.mock.calls[0]; + expect(options.params.organization_id).toBe("org-1"); + }); +}); diff --git a/__tests__/types/organization.test.ts b/__tests__/types/organization.test.ts index e6910828..4c45fe7c 100644 --- a/__tests__/types/organization.test.ts +++ b/__tests__/types/organization.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { defaultOrganization, isOrganizationArchived, + organizationInitials, OrganizationStatusFilter, } from "@/types/organization"; @@ -28,6 +29,58 @@ describe("defaultOrganization", () => { }); }); +describe("organizationInitials", () => { + it("takes the first letter of the first two words", () => { + expect(organizationInitials("Refactor Group")).toBe("RG"); + expect(organizationInitials("Big Table Industries")).toBe("BT"); + }); + + it("treats the capitals of a camel or Pascal case word as word boundaries", () => { + expect(organizationInitials("BigTable")).toBe("BT"); + expect(organizationInitials("bigTable")).toBe("BT"); + expect(organizationInitials("GitHub")).toBe("GH"); + }); + + it("takes only the first two capitals when a word has more", () => { + expect(organizationInitials("BigTableCorp")).toBe("BT"); + expect(organizationInitials("IBM")).toBe("IB"); + }); + + it("falls back to the first two letters when a single word has one capital", () => { + expect(organizationInitials("Acme")).toBe("AC"); + }); + + it("varies with the name rather than returning a fixed value", () => { + const initials = ["Refactor Group", "BigTable", "Zeta Labs"].map( + organizationInitials + ); + expect(new Set(initials).size).toBe(3); + }); + + it("uppercases names with no capitals at all", () => { + expect(organizationInitials("acme corp")).toBe("AC"); + expect(organizationInitials("acme")).toBe("AC"); + }); + + it("prefers word boundaries over capitals for multi-word names", () => { + expect(organizationInitials("BigTable Inc")).toBe("BI"); + }); + + it("does not pad a one-letter name", () => { + expect(organizationInitials("X")).toBe("X"); + }); + + it("ignores surrounding and repeated whitespace", () => { + expect(organizationInitials(" Refactor Group ")).toBe("RG"); + }); + + it("falls back to ? for an empty or missing name", () => { + expect(organizationInitials("")).toBe("?"); + expect(organizationInitials(" ")).toBe("?"); + expect(organizationInitials(undefined)).toBe("?"); + }); +}); + describe("OrganizationStatusFilter", () => { it("maps to the backend ?status= values", () => { expect(OrganizationStatusFilter.Active).toBe("active"); diff --git a/__tests__/types/user.test.ts b/__tests__/types/user.test.ts index 1971fb44..89498d8e 100644 --- a/__tests__/types/user.test.ts +++ b/__tests__/types/user.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { getUserRoleForOrganization, parseUser, Role } from '@/types/user'; +import { + canAddExistingMembers, + getUserRoleForOrganization, + parseUser, + Role, +} from '@/types/user'; import type { UserRole } from '@/types/user'; describe('parseUser', () => { @@ -148,3 +153,48 @@ describe('getUserRoleForOrganization', () => { expect(getUserRoleForOrganization(roles, 'org-3')).toBe(Role.User); }); }); + +describe('canAddExistingMembers', () => { + const role = (r: Role, organization_id: string | null): UserRole => ({ + id: `role-${r}-${organization_id}`, + user_id: 'user-1', + role: r, + organization_id, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }); + + it('is false for an admin of a single organization', () => { + // Everyone they can look up is already a member there, so the flow can only + // ever return "already a member". + expect(canAddExistingMembers([role(Role.Admin, 'org-1')])).toBe(false); + }); + + it('is true for an admin of two organizations', () => { + expect( + canAddExistingMembers([role(Role.Admin, 'org-1'), role(Role.Admin, 'org-2')]) + ).toBe(true); + }); + + it('does not count organizations where the user is only a member', () => { + // Visibility requires Admin in the shared organization, not membership. + expect( + canAddExistingMembers([role(Role.Admin, 'org-1'), role(Role.User, 'org-2')]) + ).toBe(false); + }); + + it('counts distinct organizations, not role rows', () => { + expect( + canAddExistingMembers([role(Role.Admin, 'org-1'), role(Role.Admin, 'org-1')]) + ).toBe(false); + }); + + it('is true for a super admin holding no organization roles', () => { + expect(canAddExistingMembers([role(Role.SuperAdmin, null)])).toBe(true); + }); + + it('is false for a plain member and for no roles at all', () => { + expect(canAddExistingMembers([role(Role.User, 'org-1')])).toBe(false); + expect(canAddExistingMembers([])).toBe(false); + }); +}); diff --git a/src/app/organizations/[id]/members/page.tsx b/src/app/organizations/[id]/members/page.tsx index d3faf37f..6482dc3f 100644 --- a/src/app/organizations/[id]/members/page.tsx +++ b/src/app/organizations/[id]/members/page.tsx @@ -13,6 +13,7 @@ import { ForbiddenError } from "@/components/ui/errors/forbidden-error"; import { MemberContainer } from "@/components/ui/members/member-container"; import { PageContainer } from "@/components/ui/page-container"; import { shouldDenyMembersPageAccess } from "./access-control"; +import { siteConfig } from "@/site.config"; export default function MembersPage({ params, @@ -92,6 +93,7 @@ export default function MembersPage({ onRefresh={handleRefresh} isLoading={isRelationshipsLoading || isUsersLoading} openAddMemberDialog={openAddMemberDialog} + productName={siteConfig.name} /> ); diff --git a/src/components/ui/dashboard/coaching-sessions-card.tsx b/src/components/ui/dashboard/coaching-sessions-card.tsx index db4046e4..5b6a05ba 100644 --- a/src/components/ui/dashboard/coaching-sessions-card.tsx +++ b/src/components/ui/dashboard/coaching-sessions-card.tsx @@ -271,10 +271,13 @@ export function CoachingSessionsCard({ relationshipOptions={relationshipOptions} /> - {userSession && userId && ( + {/* Waits for the organization: mounting before it resolves fetches and + briefly renders every organization's sessions. */} + {userSession && userId && currentOrganizationId && ( void; userId: Id; relationshipId: Id | undefined; + organizationId: Id | undefined; viewerId: Id; userTimezone: string; selectedId: Id | undefined; @@ -79,6 +80,7 @@ export function BucketAccordion({ onToggle, userId, relationshipId, + organizationId, viewerId, userTimezone, selectedId, @@ -104,7 +106,8 @@ export function BucketAccordion({ "date", isPastView ? "desc" : "asc", relationshipId, - userTimezone + userTimezone, + organizationId ); const filteredSessions = (enrichedSessions ?? []).filter((s) => diff --git a/src/components/ui/dashboard/session-buckets/bucket-list.tsx b/src/components/ui/dashboard/session-buckets/bucket-list.tsx index f7ce2bd2..e517388c 100644 --- a/src/components/ui/dashboard/session-buckets/bucket-list.tsx +++ b/src/components/ui/dashboard/session-buckets/bucket-list.tsx @@ -24,6 +24,7 @@ export interface BucketListProps { mountNow: DateTime; userId: Id; relationshipId: Id | undefined; + organizationId: Id | undefined; viewerId: Id; userTimezone: string; selectedId: Id | undefined; @@ -56,6 +57,7 @@ export function BucketList({ mountNow, userId, relationshipId, + organizationId, viewerId, userTimezone, selectedId, @@ -162,6 +164,7 @@ export function BucketList({ onToggle={() => toggleKey(bucket.key)} userId={userId} relationshipId={relationshipId} + organizationId={organizationId} viewerId={viewerId} userTimezone={userTimezone} selectedId={selectedId} diff --git a/src/components/ui/dashboard/session-buckets/buckets-container.tsx b/src/components/ui/dashboard/session-buckets/buckets-container.tsx index 795ae97d..9186b992 100644 --- a/src/components/ui/dashboard/session-buckets/buckets-container.tsx +++ b/src/components/ui/dashboard/session-buckets/buckets-container.tsx @@ -43,6 +43,7 @@ import { UserActionsScope } from "@/types/assigned-actions"; export interface BucketsContainerProps { userId: Id; relationshipFilter: Id | undefined; + organizationId: Id | undefined; viewerId: Id; userTimezone: string; mountNow: DateTime; @@ -74,6 +75,7 @@ const WEEK_INCLUDES: CoachingSessionInclude[] = []; export function BucketsContainer({ userId, relationshipFilter, + organizationId, viewerId, userTimezone, mountNow, @@ -151,7 +153,8 @@ export function BucketsContainer({ fetchRangeStart, fetchRangeEnd, userTimezone, - relationshipFilter + relationshipFilter, + organizationId ); type ShowMoreDirection = "later" | "earlier"; @@ -259,7 +262,8 @@ export function BucketsContainer({ undefined, undefined, relationshipFilter, - userTimezone + userTimezone, + organizationId ); const { thisWeekUpcomingCount, thisWeekPreviousCount } = useMemo(() => { const all = weekSessions ?? []; @@ -374,6 +378,7 @@ export function BucketsContainer({ now={now} userId={userId} relationshipId={relationshipFilter} + organizationId={organizationId} viewerId={viewerId} userTimezone={userTimezone} selectedId={selectedId} @@ -388,6 +393,7 @@ export function BucketsContainer({ now={now} userId={userId} relationshipId={relationshipFilter} + organizationId={organizationId} viewerId={viewerId} userTimezone={userTimezone} selectedId={selectedId} @@ -405,6 +411,7 @@ export function BucketsContainer({ mountNow={mountNow} userId={userId} relationshipId={relationshipFilter} + organizationId={organizationId} viewerId={viewerId} userTimezone={userTimezone} selectedId={selectedId} @@ -430,6 +437,7 @@ export function BucketsContainer({ now={now} userId={userId} relationshipId={relationshipFilter} + organizationId={organizationId} viewerId={viewerId} userTimezone={userTimezone} selectedId={selectedId} @@ -444,6 +452,7 @@ export function BucketsContainer({ now={now} userId={userId} relationshipId={relationshipFilter} + organizationId={organizationId} viewerId={viewerId} userTimezone={userTimezone} selectedId={selectedId} @@ -461,6 +470,7 @@ export function BucketsContainer({ mountNow={mountNow} userId={userId} relationshipId={relationshipFilter} + organizationId={organizationId} viewerId={viewerId} userTimezone={userTimezone} recentlyAddedKeys={recentlyAddedKeys} diff --git a/src/components/ui/dashboard/session-buckets/this-week-accordion.tsx b/src/components/ui/dashboard/session-buckets/this-week-accordion.tsx index 6fdf4db6..65398212 100644 --- a/src/components/ui/dashboard/session-buckets/this-week-accordion.tsx +++ b/src/components/ui/dashboard/session-buckets/this-week-accordion.tsx @@ -30,6 +30,7 @@ export interface ThisWeekAccordionProps { now: DateTime; userId: Id; relationshipId: Id | undefined; + organizationId: Id | undefined; viewerId: Id; userTimezone: string; selectedId: Id | undefined; @@ -50,6 +51,7 @@ export function ThisWeekAccordion({ now, userId, relationshipId, + organizationId, viewerId, userTimezone, selectedId, @@ -76,7 +78,8 @@ export function ThisWeekAccordion({ "date", isPastView ? "desc" : "asc", relationshipId, - userTimezone + userTimezone, + organizationId ); const filteredSessions = useMemo(() => { diff --git a/src/components/ui/dashboard/session-buckets/today-section.tsx b/src/components/ui/dashboard/session-buckets/today-section.tsx index 82925dec..30797f37 100644 --- a/src/components/ui/dashboard/session-buckets/today-section.tsx +++ b/src/components/ui/dashboard/session-buckets/today-section.tsx @@ -26,6 +26,7 @@ export interface TodaySectionProps { now: DateTime; userId: Id; relationshipId: Id | undefined; + organizationId: Id | undefined; viewerId: Id; userTimezone: string; selectedId: Id | undefined; @@ -46,6 +47,7 @@ export function TodaySection({ now, userId, relationshipId, + organizationId, viewerId, userTimezone, selectedId, @@ -72,7 +74,8 @@ export function TodaySection({ "date", isPastView ? "desc" : "asc", relationshipId, - userTimezone + userTimezone, + organizationId ); const visibleSessions = useMemo(() => { diff --git a/src/components/ui/members/add-member-button.tsx b/src/components/ui/members/add-member-button.tsx index 06362069..73bc96a8 100644 --- a/src/components/ui/members/add-member-button.tsx +++ b/src/components/ui/members/add-member-button.tsx @@ -4,16 +4,28 @@ import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Plus } from "lucide-react"; import { AddMemberDialog } from "./add-member-dialog"; +import { User, UserRoleState } from "@/types/user"; interface AddMemberButtonProps { onMemberAdded: () => void; /// Force the AddMemberDialog to open openAddMemberDialog: boolean; + currentUserRoleState: UserRoleState; + /// Candidates offered when pre-assigning a coach + organizationMembers?: User[]; + productName: string; + /// False hides the add-existing-member tab, which has nothing to offer an + /// admin of a single organization. + canAddExistingMembers: boolean; } export function AddMemberButton({ onMemberAdded, openAddMemberDialog, + currentUserRoleState, + organizationMembers, + productName, + canAddExistingMembers, }: AddMemberButtonProps) { const [open, setOpen] = useState(false); @@ -31,6 +43,10 @@ export function AddMemberButton({ open={open} onOpenChange={setOpen} onMemberAdded={onMemberAdded} + currentUserRoleState={currentUserRoleState} + organizationMembers={organizationMembers} + productName={productName} + canAddExistingMembers={canAddExistingMembers} /> ); diff --git a/src/components/ui/members/add-member-dialog.tsx b/src/components/ui/members/add-member-dialog.tsx index 28b359f1..17d626eb 100644 --- a/src/components/ui/members/add-member-dialog.tsx +++ b/src/components/ui/members/add-member-dialog.tsx @@ -2,7 +2,7 @@ import type React from "react"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -14,29 +14,67 @@ import { } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useUserMutation } from "@/lib/api/organizations/users"; -import { organizationArchivedMessage } from "@/lib/api/organization-errors"; -import { NewUser } from "@/types/user"; +import { UserApi } from "@/lib/api/users"; +import { + organizationArchivedMessage, + userAlreadyInOrganizationMessage, + USER_ALREADY_IN_ORGANIZATION_MESSAGE, +} from "@/lib/api/organization-errors"; +import { + NewUser, + Role, + User, + UserLookupResult, + UserRoleState, + isAdminOrSuperAdmin, +} from "@/types/user"; import { useCurrentOrganization } from "@/lib/hooks/use-current-organization"; +import { type Option, None } from "@/types/option"; import { toast } from "sonner"; import { getBrowserTimezone } from "@/lib/timezone-utils"; import { isForbiddenError, PERMISSION_DENIED_MESSAGE } from "@/types/general"; +/// Sentinel for the "no coach" option, since Select cannot hold an empty value. +const NO_COACH = "none"; + interface AddMemberDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onMemberAdded: () => void; + /// Omitted for callers that only offer creating a brand new member + currentUserRoleState?: UserRoleState; + /// Candidates offered when pre-assigning a coach. Omitted hides the field. + organizationMembers?: User[]; + /// Product name, threaded from the page rather than read from global config. + productName: string; + /// False hides the add-existing-member tab, which has nothing to offer an + /// admin of a single organization. + canAddExistingMembers: boolean; } export function AddMemberDialog({ open, onOpenChange, onMemberAdded, + currentUserRoleState, + organizationMembers, + productName, + canAddExistingMembers, }: AddMemberDialogProps) { - const { currentOrganizationId } = useCurrentOrganization(); + const { currentOrganizationId, currentOrganization } = + useCurrentOrganization(); - const { createNested: createUserNested } = useUserMutation( - currentOrganizationId + const { createNested: createUserNested, attachExisting } = useUserMutation( + currentOrganizationId, ); const [formData, setFormData] = useState({ firstName: "", @@ -44,6 +82,25 @@ export function AddMemberDialog({ displayName: "", email: "", }); + const [lookupEmail, setLookupEmail] = useState(""); + const [foundUser, setFoundUser] = useState>(None); + const [lookupMessage, setLookupMessage] = useState(null); + const [isLookingUp, setIsLookingUp] = useState(false); + const [existingRole, setExistingRole] = useState(Role.User); + const [isAdding, setIsAdding] = useState(false); + const [coachId, setCoachId] = useState(NO_COACH); + /// Identifies the newest lookup, so a slower earlier one cannot land on top of it. + const lookupRequest = useRef(0); + + // Org admins get this too, not just super admins, but only when the lookup + // can actually find them someone who is not already a member. + const canAddExisting = + !!currentUserRoleState && + isAdminOrSuperAdmin(currentUserRoleState) && + canAddExistingMembers; + + /// The chosen coach, or undefined to leave the key off the request entirely. + const selectedCoachId = coachId === NO_COACH ? undefined : coachId; const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -62,6 +119,7 @@ export function AddMemberDialog({ display_name: formData.displayName, email: formData.email, timezone: getBrowserTimezone(), // Default to browser timezone for new users + ...(selectedCoachId ? { coach_id: selectedCoachId } : {}), }; try { @@ -72,8 +130,10 @@ export function AddMemberDialog({ displayName: "", email: "", }); + setCoachId(NO_COACH); onMemberAdded(); - toast.success(`New Member ${formData.firstName} ${formData.lastName} added successfully`); + const name = `${formData.firstName} ${formData.lastName}`; + toast.success(`New Member ${name} added successfully`); onOpenChange(false); } catch (error) { console.error("Error creating user:", error); @@ -81,75 +141,293 @@ export function AddMemberDialog({ organizationArchivedMessage(error) ?? (isForbiddenError(error) ? PERMISSION_DENIED_MESSAGE - : "There was an error adding the member") + : "There was an error adding the member"), + ); + } + }; + + const handleFind = async () => { + const request = ++lookupRequest.current; + setFoundUser(None); + setLookupMessage(null); + setIsLookingUp(true); + + try { + const result = await UserApi.lookupByEmail(lookupEmail); + // Editing the email, or starting another lookup, supersedes this one. + // Without the check a slow reply repopulates the card for an address the + // field no longer shows, and Add attaches that user instead. + if (lookupRequest.current !== request) return; + // None also covers a real user outside this admin's scope. The backend + // makes those cases indistinguishable, so the copy must too. + if (result.none) { + setLookupMessage("No user found with that email."); + return; + } + // Say so now rather than letting them pick a role and a coach first only + // for the request to come back 409. The server still enforces this, since + // the member list can be stale and may be absent entirely. + const found = result.val; + if (organizationMembers?.some((member) => member.id === found.id)) { + setLookupMessage(USER_ALREADY_IN_ORGANIZATION_MESSAGE); + return; + } + setFoundUser(result); + } catch (error) { + if (lookupRequest.current !== request) return; + console.error("Error looking up user:", error); + setLookupMessage( + isForbiddenError(error) + ? PERMISSION_DENIED_MESSAGE + : "There was an error looking up that email.", + ); + } finally { + if (lookupRequest.current === request) setIsLookingUp(false); + } + }; + + const resetLookup = () => { + setLookupEmail(""); + setFoundUser(None); + setLookupMessage(null); + setExistingRole(Role.User); + setCoachId(NO_COACH); + }; + + // A found user belongs to the email that produced it, so editing the field + // invalidates it. Without this the Add button can act on a stale selection + // while the field shows a different address. + const handleLookupEmailChange = (value: string) => { + lookupRequest.current += 1; + setLookupEmail(value); + setFoundUser(None); + setLookupMessage(null); + setIsLookingUp(false); + }; + + const handleAddExisting = async () => { + if (foundUser.none) return; + const target = foundUser.val; + setIsAdding(true); + + try { + await attachExisting( + currentOrganizationId, + target.id, + existingRole, + selectedCoachId, + ); + onMemberAdded(); + const name = `${target.first_name} ${target.last_name}`; + toast.success(`${name} added to this organization`); + resetLookup(); + onOpenChange(false); + } catch (error) { + console.error("Error adding existing user:", error); + toast.error( + userAlreadyInOrganizationMessage(error) ?? + organizationArchivedMessage(error) ?? + (isForbiddenError(error) + ? PERMISSION_DENIED_MESSAGE + : "There was an error adding the member"), ); + } finally { + setIsAdding(false); } }; + /// Optional coach picker, listing the organization's existing members. + /// + /// No self-exclusion is needed: a brand new member is not in the list yet, and + /// an existing one is rejected at lookup before the card ever renders. + const coachField = () => + organizationMembers && + organizationMembers.length > 0 && ( +
+ + +
+ ); + + const createMemberForm = ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {coachField()} +
+
+ + + +
+
+ ); + + const addExistingForm = ( +
+

+ Add a user that already has an account. Adding them here gives them + access to the organization{" "} + {currentOrganization?.name ?? "you are viewing"} using their existing + profile. +

+
+ +
+ handleLookupEmailChange(e.target.value)} + placeholder="Enter email address" + /> + +
+
+ {lookupMessage && ( +

{lookupMessage}

+ )} + {foundUser.some && ( +
+
+

+ {foundUser.val.first_name} {foundUser.val.last_name} +

+

+ {foundUser.val.email} +

+
+ +
+ )} +
+ + +
+ {coachField()} + + + +
+ ); + return ( Add New Member - Create a new member account. They'll receive an email with a - link to set up their password. + {canAddExisting + ? `Create a new member account, or add someone who already has a ${productName} account.` + : "Create a new member account. They'll receive an email with a link to set up their password."} -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - - -
-
+ {canAddExisting ? ( + + + Create new member + Add existing member + + + {createMemberForm} + + + {addExistingForm} + + + ) : ( + createMemberForm + )}
); diff --git a/src/components/ui/members/member-card.tsx b/src/components/ui/members/member-card.tsx index 993a7f67..145e58b7 100644 --- a/src/components/ui/members/member-card.tsx +++ b/src/components/ui/members/member-card.tsx @@ -12,7 +12,17 @@ import { DropdownMenuItem, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; -import { MoreHorizontal, Send, Trash2 } from "lucide-react"; +import { MoreHorizontal, Send, Trash2, UserMinus } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Dialog, DialogContent, @@ -39,7 +49,12 @@ import { } from "@/types/user"; import { RelationshipRole } from "@/types/relationship-role"; import { useCoachingRelationshipMutation } from "@/lib/api/coaching-relationships"; -import { organizationArchivedMessage } from "@/lib/api/organization-errors"; +import { + lastOrganizationAdminMessage, + organizationArchivedMessage, + userBelongsToMultipleOrganizationsMessage, + userHasCoachingHistoryMessage, +} from "@/lib/api/organization-errors"; import { toast } from "sonner"; interface MemberCardProps { @@ -66,7 +81,7 @@ export function MemberCard({ currentUserRoleState, }: MemberCardProps) { const { currentOrganizationId } = useCurrentOrganization(); - const { isACoach, userSession } = useAuthStore((state: AuthStore) => state); + const { userSession } = useAuthStore((state: AuthStore) => state); // Extract user properties const { id: userId, first_name: firstName, last_name: lastName, email } = user; @@ -76,14 +91,12 @@ export function MemberCard({ // Get coaches for this user const coaches = getUserCoaches(userId, userRelationships); - const { error: deleteError, deleteNested: deleteUser } = useUserMutation( + const { deleteNested: deleteUser, removeFromOrganization } = useUserMutation( currentOrganizationId ); const { createNested: createRelationship } = useCoachingRelationshipMutation(currentOrganizationId); - console.log("is a coach", isACoach); - // Only admins and super admins can delete users (but not themselves) const canDeleteUser = currentUserRoleState.hasAccess && @@ -94,17 +107,44 @@ export function MemberCard({ if (!confirm("Are you sure you want to delete this member?")) { return; } - await deleteUser(currentOrganizationId, userId); - onRefresh(); - if (deleteError) { - console.error("Error deleting member:", deleteError); - toast.error("Error deleting member"); + try { + await deleteUser(currentOrganizationId, userId); + toast.success("Member deleted successfully"); onRefresh(); - return; + } catch (error) { + console.error("Error deleting member:", error); + toast.error( + userBelongsToMultipleOrganizationsMessage(error) ?? + organizationArchivedMessage(error) ?? + (isForbiddenError(error) + ? PERMISSION_DENIED_MESSAGE + : "Error deleting member") + ); + } + }; + + const handleRemoveFromOrganization = async () => { + setIsRemoving(true); + + try { + await removeFromOrganization(currentOrganizationId, userId); + toast.success(`${firstName} ${lastName} removed from this organization`); + setRemoveDialogOpen(false); + onRefresh(); + } catch (error) { + console.error("Error removing member from organization:", error); + toast.error( + userHasCoachingHistoryMessage(error) ?? + lastOrganizationAdminMessage(error) ?? + organizationArchivedMessage(error) ?? + (isForbiddenError(error) + ? PERMISSION_DENIED_MESSAGE + : "Error removing member from this organization") + ); + } finally { + setIsRemoving(false); } - toast.success("Member deleted successfully"); - onRefresh(); }; const handleResendInvite = async () => { @@ -155,6 +195,8 @@ export function MemberCard({ const [assignMode, setAssignMode] = useState(RelationshipRole.Coach); const [selectedMember, setSelectedMember] = useState(null); const [assignedMember, setAssignedMember] = useState(null); + const [removeDialogOpen, setRemoveDialogOpen] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); const handleCreateCoachingRelationship = async () => { if (!selectedMember || !assignedMember) return; @@ -267,6 +309,9 @@ export function MemberCard({ {canDeleteUser && ( <> {userId !== currentUserId && } + setRemoveDialogOpen(true)}> + Remove from organization + )} + {/* Remove from organization confirmation */} + + + + + Remove {firstName} {lastName} from this organization + + + Remove them from this organization only. Their account and any + other organizations are unaffected. + + + + Cancel + { + e.preventDefault(); + handleRemoveFromOrganization(); + }} + disabled={isRemoving} + > + {isRemoving ? "Removing..." : "Remove"} + + + + + {/* Assign Coach/Coachee Modal */} diff --git a/src/components/ui/members/member-container.tsx b/src/components/ui/members/member-container.tsx index e7875f0c..4ca127b4 100644 --- a/src/components/ui/members/member-container.tsx +++ b/src/components/ui/members/member-container.tsx @@ -1,6 +1,11 @@ import { MemberList } from "./member-list"; import { AddMemberButton } from "./add-member-button"; -import { User, isAdminOrSuperAdmin, sortUsersAlphabetically } from "@/types/user"; +import { + User, + canAddExistingMembers, + isAdminOrSuperAdmin, + sortUsersAlphabetically, +} from "@/types/user"; import { CoachingRelationshipWithUserNames, isUserCoach } from "@/types/coaching-relationship"; import { UserSession } from "@/types/user-session"; import { useAuthStore } from "@/lib/providers/auth-store-provider"; @@ -15,6 +20,7 @@ interface MemberContainerProps { onRefresh: () => void; isLoading: boolean; openAddMemberDialog: boolean; + productName: string; } export function MemberContainer({ @@ -25,6 +31,7 @@ export function MemberContainer({ isLoading, /// Force the AddMemberDialog to open openAddMemberDialog, + productName, }: MemberContainerProps) { const { setIsACoach, isACoach } = useAuthStore((state) => state); const currentUserRoleState = useCurrentUserRole(); @@ -59,6 +66,10 @@ export function MemberContainer({ )} diff --git a/src/components/ui/organization-switcher.tsx b/src/components/ui/organization-switcher.tsx index 3b574954..1c253fa1 100644 --- a/src/components/ui/organization-switcher.tsx +++ b/src/components/ui/organization-switcher.tsx @@ -24,13 +24,13 @@ import { useCurrentOrganization } from "@/lib/hooks/use-current-organization"; import type { PopoverProps } from "@radix-ui/react-popover"; import type { Id } from "@/types/general"; import { useAuthStore } from "@/lib/providers/auth-store-provider"; -import { organizationToString } from "@/types/organization"; +import { + organizationInitials, + organizationToString, +} from "@/types/organization"; import { isUserCoach } from "@/types/coaching-relationship"; import { useEffect } from "react"; -const LOGO = "/placeholder.svg?height=40&width=40"; -const SHORT_NAME = "RG"; - interface OrganizationSelectorProps extends PopoverProps { /// Called when an Organization is selected onSelect?: (organizationId: Id) => void; @@ -175,10 +175,12 @@ export function OrganizationSwitcher({
- {SHORT_NAME} + + {organizationInitials(currentOrganization?.name)} +
@@ -230,10 +232,12 @@ export function OrganizationSwitcher({
- {SHORT_NAME} + + {organizationInitials(currentOrganization?.name)} + {currentOrganization?.name || "Select Organization"} @@ -297,7 +301,9 @@ export function OrganizationSwitcher({
- {SHORT_NAME} + + {organizationInitials(org.name)} + {org.name} {currentOrganizationId === org.id && ( diff --git a/src/lib/api/coaching-sessions.ts b/src/lib/api/coaching-sessions.ts index 1aa8fa33..932b9f6d 100644 --- a/src/lib/api/coaching-sessions.ts +++ b/src/lib/api/coaching-sessions.ts @@ -202,6 +202,7 @@ export const CoachingSessionApi = { * @param sortBy Optional field to sort by * @param sortOrder Optional sort order * @param relationshipId Optional coaching relationship ID to filter sessions + * @param organizationId Optional organization to scope sessions to * @returns Promise resolving to array of EnrichedCoachingSession objects */ listForUser: async ( @@ -212,7 +213,8 @@ export const CoachingSessionApi = { sortBy?: CoachingSessionSortField, sortOrder?: ApiSortOrder, relationshipId?: Id, - tz?: string + tz?: string, + organizationId?: Id ): Promise => { const params: Record = { from_date: fromDate.toISODate() || '', @@ -223,6 +225,10 @@ export const CoachingSessionApi = { params.coaching_relationship_id = relationshipId; } + if (organizationId) { + params.organization_id = organizationId; + } + if (include && include.length > 0) { params.include = include.join(','); } @@ -245,7 +251,8 @@ export const CoachingSessionApi = { fromDate: DateTime, toDate: DateTime, tz: string, - relationshipId?: Id + relationshipId?: Id, + organizationId?: Id ): Promise => { const fromIso = fromDate.toISODate(); const toIso = toDate.toISODate(); @@ -263,6 +270,9 @@ export const CoachingSessionApi = { if (relationshipId) { params.coaching_relationship_id = relationshipId; } + if (organizationId) { + params.organization_id = organizationId; + } const url = `${USERS_BASEURL}/${userId}/coaching_sessions/counts`; const response = await EntityApi.getFn<{ counts: CoachingSessionCountByMonth[] }>( @@ -395,6 +405,9 @@ export const useCoachingSessionMutation = () => { * @param sortBy Optional field to sort by * @param sortOrder Optional sort order * @param relationshipId Optional coaching relationship ID to filter sessions + * @param organizationId Optional organization to scope sessions to. Part of the + * SWR key, so switching organizations refetches instead of serving the + * previous organization's cached list. * @returns Object containing enriched sessions, loading state, error, and refresh function */ export const useEnrichedCoachingSessionsForUser = ( @@ -405,7 +418,8 @@ export const useEnrichedCoachingSessionsForUser = ( sortBy?: CoachingSessionSortField, sortOrder?: ApiSortOrder, relationshipId?: Id, - tz?: string + tz?: string, + organizationId?: Id ) => { // Only create params when userId is valid - null params skips the SWR fetch const params = userId @@ -415,6 +429,7 @@ export const useEnrichedCoachingSessionsForUser = ( to_date: toDate.toISODate(), ...(include && include.length > 0 && { include: include.join(',') }), ...(relationshipId && { coaching_relationship_id: relationshipId }), + ...(organizationId && { organization_id: organizationId }), ...(sortBy && { sort_by: sortBy }), ...(sortOrder && { sort_order: sortOrder }), ...(tz && { tz }), @@ -433,7 +448,8 @@ export const useEnrichedCoachingSessionsForUser = ( sortBy, sortOrder, relationshipId, - tz + tz, + organizationId ) : Promise.resolve([]); @@ -462,6 +478,8 @@ export const useEnrichedCoachingSessionsForUser = ( * @param toDate End date for the count window * @param tz IANA timezone for local-calendar month aggregation on the BE * @param relationshipId Optional relationship to narrow counts to one coachee + * @param organizationId Optional organization to scope counts to. Part of the + * SWR key so an organization switch refetches rather than reusing the cache. * @returns counts, loading/error state, and a refresh fn. On error or 404, * counts is an empty array — caller falls back to "no badge" rendering. */ @@ -470,7 +488,8 @@ export const useEnrichedCoachingSessionsForUserCounts = ( fromDate: DateTime, toDate: DateTime, tz: string, - relationshipId?: Id + relationshipId?: Id, + organizationId?: Id ) => { const params = userId ? { @@ -480,6 +499,7 @@ export const useEnrichedCoachingSessionsForUserCounts = ( group_by: "month", tz, ...(relationshipId && { coaching_relationship_id: relationshipId }), + ...(organizationId && { organization_id: organizationId }), } : null; @@ -494,7 +514,8 @@ export const useEnrichedCoachingSessionsForUserCounts = ( fromDate, toDate, tz, - relationshipId + relationshipId, + organizationId ) : Promise.resolve([]); diff --git a/src/lib/api/organization-errors.ts b/src/lib/api/organization-errors.ts index 7fa0cc3c..56467711 100644 --- a/src/lib/api/organization-errors.ts +++ b/src/lib/api/organization-errors.ts @@ -38,6 +38,43 @@ export const organizationArchivedMessage = (error: unknown): string | null => "This organization is archived and can't accept new changes." ); +/// Shown both by the lookup pre-check and by the server's 409, so the two +/// paths cannot drift apart. +export const USER_ALREADY_IN_ORGANIZATION_MESSAGE = + "This user is already a member of this organization."; + +export const userAlreadyInOrganizationMessage = ( + error: unknown +): string | null => + orgErrorMessage( + error, + "user_already_in_organization", + USER_ALREADY_IN_ORGANIZATION_MESSAGE + ); + +export const lastOrganizationAdminMessage = (error: unknown): string | null => + orgErrorMessage( + error, + "last_organization_admin", + "This user is the only admin of this organization. Assign another admin before removing them." + ); + +export const userHasCoachingHistoryMessage = (error: unknown): string | null => + orgErrorMessage( + error, + "user_has_coaching_history", + "This member still has coaching sessions in this organization. Remove or reassign those sessions before removing them." + ); + +export const userBelongsToMultipleOrganizationsMessage = ( + error: unknown +): string | null => + orgErrorMessage( + error, + "user_belongs_to_multiple_organizations", + "This user belongs to other organizations. Remove them from this organization instead of deleting their account." + ); + /** Longest organization name the backend accepts (characters, trimmed). */ export const ORGANIZATION_NAME_MAX_LENGTH = 255; diff --git a/src/lib/api/organizations/users.ts b/src/lib/api/organizations/users.ts index 05d645ea..7c50b5f8 100644 --- a/src/lib/api/organizations/users.ts +++ b/src/lib/api/organizations/users.ts @@ -1,13 +1,17 @@ // Interacts with the organizations/{organizationId}/users endpoints +import { useSWRConfig } from "swr"; import { Id } from "@/types/general"; import { EntityApi } from "../entity-api"; -import { User, NewUser } from "@/types/user"; +import { User, NewUser, Role } from "@/types/user"; import { ORGANIZATIONS_BASEURL } from "../organizations"; const ORGANIZATIONS_USERS_BASEURL = (organizationId: Id) => `${ORGANIZATIONS_BASEURL}/${organizationId}/users`; +/// `coach_id` is omitted rather than null when no coach is chosen. +type AttachRoleBody = { role: Role; coach_id?: Id }; + /** * API client for user-related operations in the scope of organizations. */ @@ -78,6 +82,34 @@ export const UserApi = { {} ); }, + + /** + * Grants an existing user membership of this organization with the given role. + * The account itself is shared, not copied. + */ + attachExisting: async ( + organizationId: Id, + userId: Id, + role: Role, + coachId?: Id + ): Promise => + EntityApi.createFn( + `${ORGANIZATIONS_USERS_BASEURL(organizationId)}/${userId}/role`, + { role, ...(coachId ? { coach_id: coachId } : {}) } + ), + + /** + * Removes a user's membership of this organization only. Their account and + * any other organizations are left untouched. + */ + removeFromOrganization: async ( + organizationId: Id, + userId: Id + ): Promise => { + await EntityApi.deleteFn( + `${ORGANIZATIONS_USERS_BASEURL(organizationId)}/${userId}/role` + ); + }, }; /** @@ -101,10 +133,12 @@ export const useUserList = (organizationId: Id) => { /** * Hook for user mutations. - * Provides methods to create, update, and delete users. + * Provides methods to create, update, and delete users, plus the membership + * actions (attach an existing user, remove one) that sit outside standard CRUD. */ export const useUserMutation = (organizationId: Id) => { - return EntityApi.useEntityMutation( + const { mutate } = useSWRConfig(); + const mutation = EntityApi.useEntityMutation( ORGANIZATIONS_USERS_BASEURL(organizationId), { create: UserApi.create, @@ -114,4 +148,24 @@ export const useUserMutation = (organizationId: Id) => { deleteNested: UserApi.deleteNested, } ); + + const invalidate = (id: Id) => + EntityApi.invalidateEntityCache(mutate, ORGANIZATIONS_USERS_BASEURL(id)); + + return { + ...mutation, + attachExisting: async ( + orgId: Id, + userId: Id, + role: Role, + coachId?: Id + ) => { + await UserApi.attachExisting(orgId, userId, role, coachId); + invalidate(orgId); + }, + removeFromOrganization: async (orgId: Id, userId: Id) => { + await UserApi.removeFromOrganization(orgId, userId); + invalidate(orgId); + }, + }; }; diff --git a/src/lib/api/users.ts b/src/lib/api/users.ts index 5d166aef..c4563153 100644 --- a/src/lib/api/users.ts +++ b/src/lib/api/users.ts @@ -3,7 +3,14 @@ import { siteConfig } from "@/site.config"; import { Id } from "@/types/general"; import { EntityApi } from "./entity-api"; -import { User, NewUserPassword, defaultUser } from "@/types/user"; +import { + User, + NewUserPassword, + UserLookupResult, + defaultUser, +} from "@/types/user"; +import { buildQueryString } from "./query-params"; +import { type Option, Some, None } from "@/types/option"; export const USERS_BASEURL: string = `${siteConfig.env.backendServiceURL}/users`; @@ -12,10 +19,19 @@ export const USERS_BASEURL: string = `${siteConfig.env.backendServiceURL}/users` */ export const UserApi = { /** - * Fetches a list of users. + * Looks up a single user by exact (case-insensitive) email address. + * + * @param email The email address to look for + * @returns The matching user, or null when there is no match or the caller + * may not see them. The backend makes those two cases indistinguishable so + * the endpoint can't be used to enumerate accounts. */ - list: async (): Promise => - EntityApi.listFn(USERS_BASEURL, {}), + lookupByEmail: async (email: string): Promise> => { + const results = await EntityApi.getFn( + `${USERS_BASEURL}${buildQueryString({ email })}` + ); + return results.length > 0 ? Some(results[0]) : None; + }, /** * Fetches a single user by ID. @@ -60,21 +76,6 @@ export const UserApi = { }, }; -/** - * Hook for fetching a list of users. - */ -export const useUserList = () => { - const { entities, isLoading, isError, refresh } = - EntityApi.useEntityList(USERS_BASEURL, () => UserApi.list()); - - return { - users: entities, - isLoading, - isError, - refresh, - }; -}; - /** * Hook for fetching a single user. */ diff --git a/src/lib/hooks/use-assigned-actions.ts b/src/lib/hooks/use-assigned-actions.ts index 957c19e7..30c0d884 100644 --- a/src/lib/hooks/use-assigned-actions.ts +++ b/src/lib/hooks/use-assigned-actions.ts @@ -59,6 +59,9 @@ export function useAssignedActions( enrichedSessions: sessions, isLoading: sessionsLoading, isError: sessionsError, + // TODO(rs#374): pass the current organization once GET /users/{id}/actions + // accepts one. Scoping these sessions alone would strip context off + // out-of-organization actions rather than hide them. } = useEnrichedCoachingSessionsForUser( userId, oneYearAgo, diff --git a/src/lib/hooks/use-session-context-fetch.ts b/src/lib/hooks/use-session-context-fetch.ts index 3dbe528b..cbe7c5ca 100644 --- a/src/lib/hooks/use-session-context-fetch.ts +++ b/src/lib/hooks/use-session-context-fetch.ts @@ -23,6 +23,9 @@ export function useSessionContextFetch(userId: string | null) { enrichedSessions: sessions, isLoading, isError, + // TODO(rs#374): pass the current organization once GET /users/{id}/actions + // accepts one. Scoping these sessions alone would strip context off + // out-of-organization actions rather than hide them. } = useEnrichedCoachingSessionsForUser(userId, oneYearAgo, oneYearFromNow, [ CoachingSessionInclude.Relationship, CoachingSessionInclude.Goal, diff --git a/src/lib/hooks/use-todays-sessions.ts b/src/lib/hooks/use-todays-sessions.ts index 9a8f1fb7..ee01d4ed 100644 --- a/src/lib/hooks/use-todays-sessions.ts +++ b/src/lib/hooks/use-todays-sessions.ts @@ -7,6 +7,7 @@ import { } from "@/lib/api/coaching-sessions"; import { getBrowserTimezone } from "@/lib/timezone-utils"; import { useInterval } from "@/lib/hooks/use-interval"; +import { useCurrentOrganization } from "@/lib/hooks/use-current-organization"; /** * Hook to fetch today's coaching sessions. @@ -31,6 +32,7 @@ export function useTodaysSessions( const userId = userSession?.id; const timezone = userSession?.timezone || getBrowserTimezone(); + const { currentOrganizationId } = useCurrentOrganization(); // Force re-render every 30 seconds to update urgency messages in real-time const [tick, setTick] = useState(0); @@ -53,14 +55,20 @@ export function useTodaysSessions( // Use UTC dates for backend filtering since backend stores timestamps in UTC // TypeScript non-null assertion: middleware guarantees userId exists on protected routes // If userId is briefly undefined during hydration, SWR will show loading state + // A null user id skips the fetch. Waiting for the organization matters as much + // as sending it: fetching first returns every organization's sessions and the + // cards render them before the scoped result replaces them. const { enrichedSessions, isLoading, isError, refresh } = useEnrichedCoachingSessionsForUser( - userId!, + currentOrganizationId ? userId! : null, startOfDayUTC, endOfDayUTC, include, "date", - "asc" + "asc", + undefined, + undefined, + currentOrganizationId ?? undefined ); return { diff --git a/src/test-utils/msw-handlers.ts b/src/test-utils/msw-handlers.ts index 621244f1..cfaf6c9a 100644 --- a/src/test-utils/msw-handlers.ts +++ b/src/test-utils/msw-handlers.ts @@ -60,4 +60,24 @@ export const handlers = [ return new HttpResponse(null, { status: 200 }); }), + // User lookup by exact email: 0 or 1 results, never a 404. Tests that care + // about a match override this with server.use(). + http.get("*/users", ({ request }) => { + const email = new URL(request.url).searchParams.get("email"); + if (!email) { + return new HttpResponse(null, { status: 400 }); + } + return HttpResponse.json({ status_code: 200, data: [] }); + }), + + // Grant an existing user membership of an organization + http.post("*/organizations/:organizationId/users/:userId/role", () => { + return HttpResponse.json({ status_code: 200, data: { id: "user-1" } }); + }), + + // Remove a user's membership of an organization (account untouched) + http.delete("*/organizations/:organizationId/users/:userId/role", () => { + return HttpResponse.json({ status_code: 200, data: null }); + }), + ]; diff --git a/src/types/organization.ts b/src/types/organization.ts index 6c7b8eac..5a04b2a1 100644 --- a/src/types/organization.ts +++ b/src/types/organization.ts @@ -78,6 +78,31 @@ export function defaultOrganizations(): Organization[] { return [defaultOrganization()]; } +/** Up-to-two-letter avatar initials: "Refactor Group" -> "RG", "BigTable" -> "BT", "" -> "?". */ +export function organizationInitials(name: string | undefined): string { + const words = (name ?? "").split(/\s+/).filter(Boolean); + if (words.length === 0) return "?"; + + if (words.length > 1) { + return words + .slice(0, 2) + .map((word) => Array.from(word)[0]) + .join("") + .toUpperCase(); + } + + // A single word may be camel or Pascal case, where a capital starts a new + // part ("BigTable" -> "BT"). One part means no internal boundary, so fall + // back to the word's first two letters ("Acme" -> "AC"). + const parts = words[0].match(/\p{Lu}+\p{Ll}*|\p{Ll}+/gu) ?? []; + const letters = + parts.length > 1 + ? parts.map((part) => Array.from(part)[0]) + : Array.from(words[0]); + + return letters.slice(0, 2).join("").toUpperCase(); +} + export function organizationToString(organization: Organization): string { return JSON.stringify(organization); } diff --git a/src/types/user.ts b/src/types/user.ts index 3e83be8b..d55add27 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -43,6 +43,18 @@ export interface User { invite_status: InviteStatus | null; } +/** + * Narrow projection returned by the email lookup. Deliberately not `User`: the + * server sends only these fields, so reaching for roles or timezone on a lookup + * result fails at compile time instead of rendering undefined. + */ +export interface UserLookupResult { + id: Id; + first_name: string; + last_name: string; + email: string; +} + export interface NewUser { first_name: string; last_name: string; @@ -50,6 +62,8 @@ export interface NewUser { email: string; password?: string; timezone: string; + /// Coach to assign in the same request. Omitted when no coach is chosen. + coach_id?: Id; } export interface NewUserPassword { @@ -183,6 +197,32 @@ export function isSuperAdmin(roles: UserRole[]): boolean { ); } +/** + * Whether adding an *existing* account to an organization can do anything for + * this user. + * + * The lookup behind that flow only returns users who belong to an organization + * the requester administers, and adding someone to an organization they are + * already in is rejected. An admin of a single organization therefore has a + * visible set that is exactly their existing members, so the flow can only ever + * conflict. Two or more administered organizations gives them somewhere to move + * people from; a SuperAdmin sees every account. + * + * @param roles - Every role assignment the user holds, across all organizations + * @returns true when the add-existing-member flow has candidates to offer + */ +export function canAddExistingMembers(roles: UserRole[]): boolean { + if (isSuperAdmin(roles)) return true; + + const administered = new Set( + roles + .filter((r) => r.role === Role.Admin && r.organization_id != null) + .map((r) => r.organization_id) + ); + + return administered.size > 1; +} + /** * Sorts users alphabetically by name, with the option to place a specific user first. *