From 3fb7c5fbcf0a4fafad88c0ae1481990c1d456506 Mon Sep 17 00:00:00 2001
From: Jim Hodapp
Date: Wed, 5 Aug 2026 15:53:25 -0500
Subject: [PATCH 01/17] feat(members): add an existing user to an organization
Adds the UI for the new backend capability: an admin can grant an existing
Refactor account membership of another organization, and can remove a member
from one organization without touching their account.
- UserApi.lookupByEmail hits GET /users?email= and returns 0 or 1 result. A
null result deliberately covers both "no such email" and "a real user you
may not see"; the copy says only "No user found with that email." so the UI
can't be used to enumerate accounts.
- AddMemberDialog gains an "Add existing member" tab for admins and super
admins, with an explicit Find step, a confirmation card, and a role select
whose Member option submits the User role.
- MemberCard gains a non-destructive "Remove from organization" action that
DELETEs the /role sub-route, kept visually distinct from Delete.
- Surfaces the user_already_in_organization, last_organization_admin and
user_belongs_to_multiple_organizations conflicts with their own messages.
- Drops the dead top-level useUserList/UserApi.list, which called a GET /users
route that now requires an email parameter.
---
.../add-member-dialog-existing.test.tsx | 219 +++++++++++++
.../members/member-card-remove.test.tsx | 171 ++++++++++
.../ui/members/add-member-button.tsx | 4 +
.../ui/members/add-member-dialog.tsx | 294 ++++++++++++++----
src/components/ui/members/member-card.tsx | 92 +++++-
.../ui/members/member-container.tsx | 1 +
src/lib/api/organization-errors.ts | 25 ++
src/lib/api/organizations/users.ts | 52 +++-
src/lib/api/users.ts | 38 +--
src/test-utils/msw-handlers.ts | 20 ++
src/types/user.ts | 12 +
11 files changed, 837 insertions(+), 91 deletions(-)
create mode 100644 __tests__/components/members/add-member-dialog-existing.test.tsx
create mode 100644 __tests__/components/members/member-card-remove.test.tsx
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..67f89c60
--- /dev/null
+++ b/__tests__/components/members/add-member-dialog-existing.test.tsx
@@ -0,0 +1,219 @@
+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 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() },
+}));
+
+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 });
+ });
+}
+
+function renderDialog(currentUserRoleState?: UserRoleState) {
+ 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 – 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."
+ )
+ );
+ });
+});
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..28dd289d
--- /dev/null
+++ b/__tests__/components/members/member-card-remove.test.tsx
@@ -0,0 +1,171 @@
+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 { 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: React.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."
+ )
+ );
+ });
+});
diff --git a/src/components/ui/members/add-member-button.tsx b/src/components/ui/members/add-member-button.tsx
index 06362069..10b370e1 100644
--- a/src/components/ui/members/add-member-button.tsx
+++ b/src/components/ui/members/add-member-button.tsx
@@ -4,16 +4,19 @@ import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { AddMemberDialog } from "./add-member-dialog";
+import { UserRoleState } from "@/types/user";
interface AddMemberButtonProps {
onMemberAdded: () => void;
/// Force the AddMemberDialog to open
openAddMemberDialog: boolean;
+ currentUserRoleState: UserRoleState;
}
export function AddMemberButton({
onMemberAdded,
openAddMemberDialog,
+ currentUserRoleState,
}: AddMemberButtonProps) {
const [open, setOpen] = useState(false);
@@ -31,6 +34,7 @@ export function AddMemberButton({
open={open}
onOpenChange={setOpen}
onMemberAdded={onMemberAdded}
+ currentUserRoleState={currentUserRoleState}
/>
>
);
diff --git a/src/components/ui/members/add-member-dialog.tsx b/src/components/ui/members/add-member-dialog.tsx
index 28b359f1..f11e71a5 100644
--- a/src/components/ui/members/add-member-dialog.tsx
+++ b/src/components/ui/members/add-member-dialog.tsx
@@ -14,9 +14,27 @@ 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,
+} from "@/lib/api/organization-errors";
+import {
+ NewUser,
+ Role,
+ UserLookupResult,
+ UserRoleState,
+ isAdminOrSuperAdmin,
+} from "@/types/user";
import { useCurrentOrganization } from "@/lib/hooks/use-current-organization";
import { toast } from "sonner";
import { getBrowserTimezone } from "@/lib/timezone-utils";
@@ -26,16 +44,19 @@ interface AddMemberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onMemberAdded: () => void;
+ /// Omitted for callers that only offer creating a brand new member
+ currentUserRoleState?: UserRoleState;
}
export function AddMemberDialog({
open,
onOpenChange,
onMemberAdded,
+ currentUserRoleState,
}: AddMemberDialogProps) {
const { currentOrganizationId } = useCurrentOrganization();
- const { createNested: createUserNested } = useUserMutation(
+ const { createNested: createUserNested, attachExisting } = useUserMutation(
currentOrganizationId
);
const [formData, setFormData] = useState({
@@ -44,6 +65,16 @@ export function AddMemberDialog({
displayName: "",
email: "",
});
+ const [lookupEmail, setLookupEmail] = useState("");
+ const [foundUser, setFoundUser] = useState(null);
+ const [lookupMessage, setLookupMessage] = useState(null);
+ const [isLookingUp, setIsLookingUp] = useState(false);
+ const [existingRole, setExistingRole] = useState(Role.User);
+ const [isAdding, setIsAdding] = useState(false);
+
+ // Org admins get this too, not just super admins
+ const canAddExisting =
+ !!currentUserRoleState && isAdminOrSuperAdmin(currentUserRoleState);
const handleInputChange = (e: React.ChangeEvent) => {
const { name, value } = e.target;
@@ -86,70 +117,215 @@ export function AddMemberDialog({
}
};
+ const handleFind = async () => {
+ setFoundUser(null);
+ setLookupMessage(null);
+ setIsLookingUp(true);
+
+ try {
+ const result = await UserApi.lookupByEmail(lookupEmail);
+ // A null result also covers a real user outside this admin's scope. The
+ // backend makes those cases indistinguishable, so the copy must too.
+ if (result) {
+ setFoundUser(result);
+ } else {
+ setLookupMessage("No user found with that email.");
+ }
+ } catch (error) {
+ console.error("Error looking up user:", error);
+ setLookupMessage(
+ isForbiddenError(error)
+ ? PERMISSION_DENIED_MESSAGE
+ : "There was an error looking up that email."
+ );
+ } finally {
+ setIsLookingUp(false);
+ }
+ };
+
+ const resetLookup = () => {
+ setLookupEmail("");
+ setFoundUser(null);
+ setLookupMessage(null);
+ setExistingRole(Role.User);
+ };
+
+ const handleAddExisting = async () => {
+ if (!foundUser) return;
+ setIsAdding(true);
+
+ try {
+ await attachExisting(currentOrganizationId, foundUser.id, existingRole);
+ onMemberAdded();
+ toast.success(
+ `${foundUser.first_name} ${foundUser.last_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);
+ }
+ };
+
+ const createMemberForm = (
+
+ );
+
+ const addExistingForm = (
+
+
+ This person already has a Refactor account. Adding them here gives them
+ access to this organization using their existing profile.
+
+
+
+
+ setLookupEmail(e.target.value)}
+ placeholder="Enter email address"
+ />
+
+
+
+ {lookupMessage && (
+
{lookupMessage}
+ )}
+ {foundUser && (
+
+
+ {foundUser.first_name} {foundUser.last_name}
+
+
{foundUser.email}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+
return (
);
diff --git a/src/components/ui/members/member-card.tsx b/src/components/ui/members/member-card.tsx
index 993a7f67..5c86ed6f 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,11 @@ 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,
+} from "@/lib/api/organization-errors";
import { toast } from "sonner";
interface MemberCardProps {
@@ -76,7 +90,7 @@ 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 } =
@@ -94,17 +108,43 @@ export function MemberCard({
if (!confirm("Are you sure you want to delete this member?")) {
return;
}
- await deleteUser(currentOrganizationId, userId);
+
+ try {
+ await deleteUser(currentOrganizationId, userId);
+ toast.success("Member deleted successfully");
+ } catch (error) {
+ console.error("Error deleting member:", error);
+ toast.error(
+ userBelongsToMultipleOrganizationsMessage(error) ??
+ organizationArchivedMessage(error) ??
+ (isForbiddenError(error)
+ ? PERMISSION_DENIED_MESSAGE
+ : "Error deleting member")
+ );
+ }
onRefresh();
+ };
+
+ const handleRemoveFromOrganization = async () => {
+ setIsRemoving(true);
- if (deleteError) {
- console.error("Error deleting member:", deleteError);
- toast.error("Error deleting member");
+ try {
+ await removeFromOrganization(currentOrganizationId, userId);
+ toast.success(`${firstName} ${lastName} removed from this organization`);
+ setRemoveDialogOpen(false);
onRefresh();
- return;
+ } catch (error) {
+ console.error("Error removing member from organization:", error);
+ toast.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 */}
)}
{foundUser && (
-
-
- {foundUser.first_name} {foundUser.last_name}
-
-
{foundUser.email}
+
+
+
+ {foundUser.first_name} {foundUser.last_name}
+
+
{foundUser.email}
+
+
)}
From 7f89f652578a0a6add4f86476bf6699e3b91ab1d Mon Sep 17 00:00:00 2001
From: Jim Hodapp
Date: Thu, 6 Aug 2026 12:37:58 -0500
Subject: [PATCH 03/17] feat(members): pre-assign a coach when adding a member
Both the create-new and add-existing flows now offer an optional coach
picker listing the organization's current members. The selection posts a
coaching relationship after the member exists, reusing the existing
POST /organizations/{id}/coaching_relationships endpoint.
The two calls are not atomic, so a failed assignment reports the member as
added with guidance to assign a coach from the member list. The reverse
failure cannot happen, and a coachless member is recoverable from the UI.
A found user is kept off their own coach list.
---
.../add-member-dialog-existing.test.tsx | 122 +++++++++++++++++-
.../ui/members/add-member-button.tsx | 6 +-
.../ui/members/add-member-dialog.tsx | 82 +++++++++++-
.../ui/members/member-container.tsx | 1 +
4 files changed, 201 insertions(+), 10 deletions(-)
diff --git a/__tests__/components/members/add-member-dialog-existing.test.tsx b/__tests__/components/members/add-member-dialog-existing.test.tsx
index 4a4d9098..cb0d5489 100644
--- a/__tests__/components/members/add-member-dialog-existing.test.tsx
+++ b/__tests__/components/members/add-member-dialog-existing.test.tsx
@@ -4,7 +4,7 @@ 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 UserRoleState } from "@/types/user";
+import { Role, type User, type UserRoleState } from "@/types/user";
import { toast } from "sonner";
// ── Module mocks ──────────────────────────────────────────────────────────────
@@ -20,7 +20,7 @@ vi.mock("@/site.config", () => ({
}));
vi.mock("sonner", () => ({
- toast: { error: vi.fn(), success: vi.fn() },
+ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
}));
vi.mock("@/lib/hooks/use-current-organization", () => ({
@@ -61,13 +61,24 @@ function lookupHandler(matches: typeof ADA | null) {
});
}
-function renderDialog(currentUserRoleState?: UserRoleState) {
+/** 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[]
+) {
render(
);
}
@@ -141,6 +152,111 @@ describe("AddMemberDialog – existing member lookup", () => {
});
});
+describe("AddMemberDialog – pre-assigning a coach", () => {
+ /** Captures relationship POSTs. `relationshipFails` makes them 500. */
+ function captureRelationships(relationshipFails = false) {
+ const calls: unknown[] = [];
+ server.use(
+ lookupHandler(ADA),
+ http.post("*/organizations/:organizationId/users/:userId/role", () =>
+ HttpResponse.json({ status_code: 200, data: { id: ADA.id } })
+ ),
+ http.post("*/organizations/:organizationId/users", () =>
+ HttpResponse.json({ status_code: 201, data: { id: "user-new" } })
+ ),
+ http.post(
+ "*/organizations/:organizationId/coaching_relationships",
+ async ({ request }) => {
+ calls.push(await request.json());
+ return relationshipFails
+ ? HttpResponse.json({ error: "boom" }, { status: 500 })
+ : HttpResponse.json({ status_code: 201, data: { id: "rel-1" } });
+ }
+ )
+ );
+ return calls;
+ }
+
+ async function pickCoach(user: ReturnType) {
+ await user.click(screen.getByLabelText("Coach (optional)"));
+ await user.click(await screen.findByRole("option", { name: "Grace Hopper" }));
+ }
+
+ it("assigns the chosen coach to a newly created member", async () => {
+ const calls = captureRelationships();
+ 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(calls).toHaveLength(1));
+ expect(calls[0]).toEqual({ coach_id: GRACE.id, coachee_id: "user-new" });
+ });
+
+ it("assigns the chosen coach to an attached existing member", async () => {
+ const calls = captureRelationships();
+ 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(calls).toHaveLength(1));
+ expect(calls[0]).toEqual({ coach_id: GRACE.id, coachee_id: ADA.id });
+ });
+
+ it("does not create a relationship when no coach is chosen", async () => {
+ const calls = captureRelationships();
+ const user = userEvent.setup();
+ renderDialog(adminRole, [GRACE]);
+
+ await findAda(user);
+ await user.click(screen.getByRole("button", { name: "Add to organization" }));
+
+ await waitFor(() => expect(toast.success).toHaveBeenCalled());
+ expect(calls).toHaveLength(0);
+ });
+
+ /// The member is already added at this point, so the add must not read as failed.
+ it("still reports the member as added when the coach assignment fails", async () => {
+ captureRelationships(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.warning).toHaveBeenCalled());
+ expect(toast.warning).toHaveBeenCalledWith(
+ expect.stringContaining("Assign one from the member list")
+ );
+ expect(toast.error).not.toHaveBeenCalled();
+ });
+
+ it("keeps the found user off their own coach list", async () => {
+ server.use(lookupHandler(ADA));
+ const user = userEvent.setup();
+ renderDialog(adminRole, [GRACE, ADA as unknown as User]);
+
+ await findAda(user);
+ await user.click(screen.getByLabelText("Coach (optional)"));
+
+ expect(
+ await screen.findByRole("option", { name: "Grace Hopper" })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("option", { name: "Ada Lovelace" })
+ ).not.toBeInTheDocument();
+ });
+});
+
describe("AddMemberDialog – discarding a found user", () => {
it("clears the found user when Clear is pressed", async () => {
server.use(lookupHandler(ADA));
diff --git a/src/components/ui/members/add-member-button.tsx b/src/components/ui/members/add-member-button.tsx
index 10b370e1..7a3e9f8c 100644
--- a/src/components/ui/members/add-member-button.tsx
+++ b/src/components/ui/members/add-member-button.tsx
@@ -4,19 +4,22 @@ import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { AddMemberDialog } from "./add-member-dialog";
-import { UserRoleState } from "@/types/user";
+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[];
}
export function AddMemberButton({
onMemberAdded,
openAddMemberDialog,
currentUserRoleState,
+ organizationMembers,
}: AddMemberButtonProps) {
const [open, setOpen] = useState(false);
@@ -35,6 +38,7 @@ export function AddMemberButton({
onOpenChange={setOpen}
onMemberAdded={onMemberAdded}
currentUserRoleState={currentUserRoleState}
+ organizationMembers={organizationMembers}
/>
>
);
diff --git a/src/components/ui/members/add-member-dialog.tsx b/src/components/ui/members/add-member-dialog.tsx
index ac85ff2c..457bf929 100644
--- a/src/components/ui/members/add-member-dialog.tsx
+++ b/src/components/ui/members/add-member-dialog.tsx
@@ -23,6 +23,7 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useUserMutation } from "@/lib/api/organizations/users";
+import { useCoachingRelationshipMutation } from "@/lib/api/coaching-relationships";
import { UserApi } from "@/lib/api/users";
import {
organizationArchivedMessage,
@@ -31,6 +32,7 @@ import {
import {
NewUser,
Role,
+ User,
UserLookupResult,
UserRoleState,
isAdminOrSuperAdmin,
@@ -38,7 +40,10 @@ import {
import { useCurrentOrganization } from "@/lib/hooks/use-current-organization";
import { toast } from "sonner";
import { getBrowserTimezone } from "@/lib/timezone-utils";
-import { isForbiddenError, PERMISSION_DENIED_MESSAGE } from "@/types/general";
+import { Id, 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;
@@ -46,6 +51,8 @@ interface AddMemberDialogProps {
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[];
}
export function AddMemberDialog({
@@ -53,12 +60,16 @@ export function AddMemberDialog({
onOpenChange,
onMemberAdded,
currentUserRoleState,
+ organizationMembers,
}: AddMemberDialogProps) {
const { currentOrganizationId } = useCurrentOrganization();
const { createNested: createUserNested, attachExisting } = useUserMutation(
currentOrganizationId
);
+ const { createNested: createRelationship } = useCoachingRelationshipMutation(
+ currentOrganizationId
+ );
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
@@ -71,11 +82,32 @@ export function AddMemberDialog({
const [isLookingUp, setIsLookingUp] = useState(false);
const [existingRole, setExistingRole] = useState(Role.User);
const [isAdding, setIsAdding] = useState(false);
+ const [coachId, setCoachId] = useState(NO_COACH);
// Org admins get this too, not just super admins
const canAddExisting =
!!currentUserRoleState && isAdminOrSuperAdmin(currentUserRoleState);
+ // Runs only after the member exists, so a failure here leaves a coachless
+ // member rather than blocking the add. Returns whether the coach was assigned.
+ const assignSelectedCoach = async (coacheeId: Id): Promise => {
+ if (coachId === NO_COACH) return true;
+
+ try {
+ await createRelationship(currentOrganizationId, {
+ coach_id: coachId,
+ coachee_id: coacheeId,
+ });
+ return true;
+ } catch (error) {
+ console.error("Error assigning coach:", error);
+ return false;
+ }
+ };
+
+ const coachAssignmentFailedMessage =
+ "They were added, but assigning the coach failed. Assign one from the member list.";
+
const handleInputChange = (e: React.ChangeEvent) => {
const { name, value } = e.target;
setFormData((prev) => ({
@@ -96,15 +128,22 @@ export function AddMemberDialog({
};
try {
- await createUserNested(currentOrganizationId, newUser);
+ const created = await createUserNested(currentOrganizationId, newUser);
+ const coachAssigned = await assignSelectedCoach(created.id);
setFormData({
firstName: "",
lastName: "",
displayName: "",
email: "",
});
+ setCoachId(NO_COACH);
onMemberAdded();
- toast.success(`New Member ${formData.firstName} ${formData.lastName} added successfully`);
+ const name = `${formData.firstName} ${formData.lastName}`;
+ if (coachAssigned) {
+ toast.success(`New Member ${name} added successfully`);
+ } else {
+ toast.warning(`${name} added. ${coachAssignmentFailedMessage}`);
+ }
onOpenChange(false);
} catch (error) {
console.error("Error creating user:", error);
@@ -148,6 +187,7 @@ export function AddMemberDialog({
setFoundUser(null);
setLookupMessage(null);
setExistingRole(Role.User);
+ setCoachId(NO_COACH);
};
// A found user belongs to the email that produced it, so editing the field
@@ -165,10 +205,14 @@ export function AddMemberDialog({
try {
await attachExisting(currentOrganizationId, foundUser.id, existingRole);
+ const coachAssigned = await assignSelectedCoach(foundUser.id);
onMemberAdded();
- toast.success(
- `${foundUser.first_name} ${foundUser.last_name} added to this organization`
- );
+ const name = `${foundUser.first_name} ${foundUser.last_name}`;
+ if (coachAssigned) {
+ toast.success(`${name} added to this organization`);
+ } else {
+ toast.warning(`${name} added to this organization. ${coachAssignmentFailedMessage}`);
+ }
resetLookup();
onOpenChange(false);
} catch (error) {
@@ -185,6 +229,30 @@ export function AddMemberDialog({
}
};
+ /// Optional coach picker. `excludeId` keeps a member off their own coach list.
+ const coachField = (excludeId?: string) =>
+ organizationMembers &&
+ organizationMembers.length > 0 && (
+
+
+
+
+ );
+
const createMemberForm = (
@@ -307,6 +376,7 @@ export function AddMemberDialog({
+ {coachField(foundUser?.id)}
)}
From f10347cfabe1f980cb2577343c955a108aff5094 Mon Sep 17 00:00:00 2001
From: Jim Hodapp
Date: Thu, 6 Aug 2026 13:29:43 -0500
Subject: [PATCH 04/17] feat(members): send the coach with the member request
Adding a member and assigning their coach were two requests, and the
backend sends the invitation email at the end of the first one. A failed
coach assignment therefore left the person invited with no coach, which
the dialog could only report as a warning.
Both endpoints now take an optional coach_id and create the relationship
in the same transaction, so the coach rides along with the member request
and a failure fails the whole add. The partial-failure handling
(assignSelectedCoach, the warning toasts) is gone with the state it
described. coach_id is omitted rather than sent as null when no coach is
chosen.
---
.../add-member-dialog-existing.test.tsx | 92 ++++++++++++-------
.../ui/members/add-member-dialog.tsx | 51 +++-------
src/lib/api/organizations/users.ts | 19 +++-
src/types/user.ts | 2 +
4 files changed, 88 insertions(+), 76 deletions(-)
diff --git a/__tests__/components/members/add-member-dialog-existing.test.tsx b/__tests__/components/members/add-member-dialog-existing.test.tsx
index cb0d5489..5d8bba20 100644
--- a/__tests__/components/members/add-member-dialog-existing.test.tsx
+++ b/__tests__/components/members/add-member-dialog-existing.test.tsx
@@ -153,28 +153,41 @@ describe("AddMemberDialog – existing member lookup", () => {
});
describe("AddMemberDialog – pre-assigning a coach", () => {
- /** Captures relationship POSTs. `relationshipFails` makes them 500. */
- function captureRelationships(relationshipFails = false) {
- const calls: unknown[] = [];
+ /**
+ * 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", () =>
- HttpResponse.json({ status_code: 200, data: { id: ADA.id } })
- ),
- http.post("*/organizations/:organizationId/users", () =>
- HttpResponse.json({ status_code: 201, data: { id: "user-new" } })
+ 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 }) => {
- calls.push(await request.json());
- return relationshipFails
- ? HttpResponse.json({ error: "boom" }, { status: 500 })
- : HttpResponse.json({ status_code: 201, data: { id: "rel-1" } });
+ relationships.push(await request.json());
+ return HttpResponse.json({ status_code: 201, data: { id: "rel-1" } });
}
)
);
- return calls;
+ return { created, attached, relationships };
}
async function pickCoach(user: ReturnType) {
@@ -182,8 +195,8 @@ describe("AddMemberDialog – pre-assigning a coach", () => {
await user.click(await screen.findByRole("option", { name: "Grace Hopper" }));
}
- it("assigns the chosen coach to a newly created member", async () => {
- const calls = captureRelationships();
+ 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]);
@@ -194,12 +207,16 @@ describe("AddMemberDialog – pre-assigning a coach", () => {
await pickCoach(user);
await user.click(screen.getByRole("button", { name: "Create Member" }));
- await waitFor(() => expect(calls).toHaveLength(1));
- expect(calls[0]).toEqual({ coach_id: GRACE.id, coachee_id: "user-new" });
+ await waitFor(() => expect(created).toHaveLength(1));
+ expect(created[0]).toMatchObject({
+ email: "new@example.com",
+ coach_id: GRACE.id,
+ });
+ expect(relationships).toHaveLength(0);
});
- it("assigns the chosen coach to an attached existing member", async () => {
- const calls = captureRelationships();
+ 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]);
@@ -207,25 +224,36 @@ describe("AddMemberDialog – pre-assigning a coach", () => {
await pickCoach(user);
await user.click(screen.getByRole("button", { name: "Add to organization" }));
- await waitFor(() => expect(calls).toHaveLength(1));
- expect(calls[0]).toEqual({ coach_id: GRACE.id, coachee_id: ADA.id });
+ await waitFor(() => expect(attached).toHaveLength(1));
+ expect(attached[0]).toEqual({ role: "User", coach_id: GRACE.id });
+ expect(relationships).toHaveLength(0);
});
- it("does not create a relationship when no coach is chosen", async () => {
- const calls = captureRelationships();
+ 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));
- await waitFor(() => expect(toast.success).toHaveBeenCalled());
- expect(calls).toHaveLength(0);
+ expect(created[0]).not.toHaveProperty("coach_id");
+ expect(attached[0]).not.toHaveProperty("coach_id");
+ expect(relationships).toHaveLength(0);
});
- /// The member is already added at this point, so the add must not read as failed.
- it("still reports the member as added when the coach assignment fails", async () => {
- captureRelationships(true);
+ /// 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]);
@@ -233,11 +261,9 @@ describe("AddMemberDialog – pre-assigning a coach", () => {
await pickCoach(user);
await user.click(screen.getByRole("button", { name: "Add to organization" }));
- await waitFor(() => expect(toast.warning).toHaveBeenCalled());
- expect(toast.warning).toHaveBeenCalledWith(
- expect.stringContaining("Assign one from the member list")
- );
- expect(toast.error).not.toHaveBeenCalled();
+ await waitFor(() => expect(toast.error).toHaveBeenCalled());
+ expect(toast.success).not.toHaveBeenCalled();
+ expect(toast.warning).not.toHaveBeenCalled();
});
it("keeps the found user off their own coach list", async () => {
diff --git a/src/components/ui/members/add-member-dialog.tsx b/src/components/ui/members/add-member-dialog.tsx
index 457bf929..8427dfe1 100644
--- a/src/components/ui/members/add-member-dialog.tsx
+++ b/src/components/ui/members/add-member-dialog.tsx
@@ -23,7 +23,6 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useUserMutation } from "@/lib/api/organizations/users";
-import { useCoachingRelationshipMutation } from "@/lib/api/coaching-relationships";
import { UserApi } from "@/lib/api/users";
import {
organizationArchivedMessage,
@@ -40,7 +39,7 @@ import {
import { useCurrentOrganization } from "@/lib/hooks/use-current-organization";
import { toast } from "sonner";
import { getBrowserTimezone } from "@/lib/timezone-utils";
-import { Id, isForbiddenError, PERMISSION_DENIED_MESSAGE } from "@/types/general";
+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";
@@ -67,9 +66,6 @@ export function AddMemberDialog({
const { createNested: createUserNested, attachExisting } = useUserMutation(
currentOrganizationId
);
- const { createNested: createRelationship } = useCoachingRelationshipMutation(
- currentOrganizationId
- );
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
@@ -88,25 +84,8 @@ export function AddMemberDialog({
const canAddExisting =
!!currentUserRoleState && isAdminOrSuperAdmin(currentUserRoleState);
- // Runs only after the member exists, so a failure here leaves a coachless
- // member rather than blocking the add. Returns whether the coach was assigned.
- const assignSelectedCoach = async (coacheeId: Id): Promise => {
- if (coachId === NO_COACH) return true;
-
- try {
- await createRelationship(currentOrganizationId, {
- coach_id: coachId,
- coachee_id: coacheeId,
- });
- return true;
- } catch (error) {
- console.error("Error assigning coach:", error);
- return false;
- }
- };
-
- const coachAssignmentFailedMessage =
- "They were added, but assigning the coach failed. Assign one from the member list.";
+ /// 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;
@@ -125,11 +104,11 @@ export function AddMemberDialog({
display_name: formData.displayName,
email: formData.email,
timezone: getBrowserTimezone(), // Default to browser timezone for new users
+ ...(selectedCoachId ? { coach_id: selectedCoachId } : {}),
};
try {
- const created = await createUserNested(currentOrganizationId, newUser);
- const coachAssigned = await assignSelectedCoach(created.id);
+ await createUserNested(currentOrganizationId, newUser);
setFormData({
firstName: "",
lastName: "",
@@ -139,11 +118,7 @@ export function AddMemberDialog({
setCoachId(NO_COACH);
onMemberAdded();
const name = `${formData.firstName} ${formData.lastName}`;
- if (coachAssigned) {
- toast.success(`New Member ${name} added successfully`);
- } else {
- toast.warning(`${name} added. ${coachAssignmentFailedMessage}`);
- }
+ toast.success(`New Member ${name} added successfully`);
onOpenChange(false);
} catch (error) {
console.error("Error creating user:", error);
@@ -204,15 +179,15 @@ export function AddMemberDialog({
setIsAdding(true);
try {
- await attachExisting(currentOrganizationId, foundUser.id, existingRole);
- const coachAssigned = await assignSelectedCoach(foundUser.id);
+ await attachExisting(
+ currentOrganizationId,
+ foundUser.id,
+ existingRole,
+ selectedCoachId
+ );
onMemberAdded();
const name = `${foundUser.first_name} ${foundUser.last_name}`;
- if (coachAssigned) {
- toast.success(`${name} added to this organization`);
- } else {
- toast.warning(`${name} added to this organization. ${coachAssignmentFailedMessage}`);
- }
+ toast.success(`${name} added to this organization`);
resetLookup();
onOpenChange(false);
} catch (error) {
diff --git a/src/lib/api/organizations/users.ts b/src/lib/api/organizations/users.ts
index 880adf62..656dc529 100644
--- a/src/lib/api/organizations/users.ts
+++ b/src/lib/api/organizations/users.ts
@@ -9,6 +9,9 @@ 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.
*/
@@ -87,11 +90,12 @@ export const UserApi = {
attachExisting: async (
organizationId: Id,
userId: Id,
- role: Role
+ role: Role,
+ coachId?: Id
): Promise =>
- EntityApi.createFn<{ role: Role }, User>(
+ EntityApi.createFn(
`${ORGANIZATIONS_USERS_BASEURL(organizationId)}/${userId}/role`,
- { role }
+ { role, ...(coachId ? { coach_id: coachId } : {}) }
),
/**
@@ -150,8 +154,13 @@ export const useUserMutation = (organizationId: Id) => {
return {
...mutation,
- attachExisting: async (orgId: Id, userId: Id, role: Role) => {
- const user = await UserApi.attachExisting(orgId, userId, role);
+ attachExisting: async (
+ orgId: Id,
+ userId: Id,
+ role: Role,
+ coachId?: Id
+ ) => {
+ const user = await UserApi.attachExisting(orgId, userId, role, coachId);
invalidate(orgId);
return user;
},
diff --git a/src/types/user.ts b/src/types/user.ts
index 14ca82bf..2abfe3a5 100644
--- a/src/types/user.ts
+++ b/src/types/user.ts
@@ -62,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 {
From ec6ac2a322d705ba57ce139a1441caba566f597c Mon Sep 17 00:00:00 2001
From: Jim Hodapp
Date: Thu, 6 Aug 2026 14:33:01 -0500
Subject: [PATCH 05/17] test(e2e): live multi-org membership scenario
Drives the real stack rather than mocking the API like the other e2e specs:
creates a member with a coach, creates an organization, attaches an existing
user as its admin, and schedules a session across organizations.
Skipped unless LIVE_E2E=1, since it is not idempotent and needs a freshly
seeded database.
Step d pins a finding rather than a success: a brand new organization's admin
cannot add anyone to it, because the lookup only returns users who share an
organization they already administer, and a new organization is empty.
---
__tests__/e2e/multi-org-live.spec.ts | 205 +++++++++++++++++++++++++++
1 file changed, 205 insertions(+)
create mode 100644 __tests__/e2e/multi-org-live.spec.ts
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);
+});
From 28a01dea1a339d2722ce9d1250e281f4128a711d Mon Sep 17 00:00:00 2001
From: Jim Hodapp
Date: Thu, 6 Aug 2026 14:58:39 -0500
Subject: [PATCH 06/17] fix(sessions): scope the dashboard session list by
organization
The Upcoming and Previous lists fetched every session the user participates
in, ignoring the organization switcher, so a user in two organizations saw
both organizations' sessions at once. The defect predates multi-org
membership but was unreachable until a user could belong to two
organizations.
Both hooks now take an optional organizationId and put organization_id in the
params object, which is also the SWR key. The key matters: without it,
switching organizations would serve the previous organization's cached list.
The dashboard card threads its currentOrganizationId down through the bucket
components; omitting the value keeps the previous unscoped behavior.
---
.../coaching-sessions-org-scoping.test.tsx | 132 ++++++++++++++++++
.../ui/dashboard/coaching-sessions-card.tsx | 1 +
.../session-buckets/bucket-accordion.tsx | 5 +-
.../dashboard/session-buckets/bucket-list.tsx | 3 +
.../session-buckets/buckets-container.tsx | 14 +-
.../session-buckets/this-week-accordion.tsx | 5 +-
.../session-buckets/today-section.tsx | 5 +-
src/lib/api/coaching-sessions.ts | 33 ++++-
8 files changed, 187 insertions(+), 11 deletions(-)
create mode 100644 __tests__/lib/api/coaching-sessions-org-scoping.test.tsx
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/src/components/ui/dashboard/coaching-sessions-card.tsx b/src/components/ui/dashboard/coaching-sessions-card.tsx
index db4046e4..587dade1 100644
--- a/src/components/ui/dashboard/coaching-sessions-card.tsx
+++ b/src/components/ui/dashboard/coaching-sessions-card.tsx
@@ -275,6 +275,7 @@ export function CoachingSessionsCard({
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/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([]);
From c710692cd1e800d0c653a641ffcc6a3bffe8182c Mon Sep 17 00:00:00 2001
From: Jim Hodapp
Date: Thu, 6 Aug 2026 15:22:34 -0500
Subject: [PATCH 07/17] fix(sidebar): derive the org switcher avatar initials
from the selected org
The avatar rendered a hardcoded "RG" at all three sites: the collapsed
sidebar, the expanded trigger, and every row of the dropdown. Every
organization looked like Refactor Group, which only became visible once a
user could belong to more than one.
organizationInitials derives up to two letters from the name, taking the
first letter of the first two words ("Refactor Group" -> "RG") or the first
two letters of a single-word name ("BigTable" -> "BI"), and falls back to
"?" when there is no name.
Also drops the LOGO placeholder constant. It pointed at /placeholder.svg,
which does not exist in public/, so every avatar issued a 404 before
falling through to the initials. Leaving src undefined reaches the fallback
directly.
The switcher's existing tests now scope their assertions to the listbox:
the trigger renders the selected organization's name too, so a bare
getByText matched twice.
---
.../components/organization-switcher.test.tsx | 111 ++++++++++++++----
__tests__/types/organization.test.ts | 38 ++++++
src/components/ui/organization-switcher.tsx | 24 ++--
src/types/organization.ts | 13 ++
4 files changed, 154 insertions(+), 32 deletions(-)
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__/types/organization.test.ts b/__tests__/types/organization.test.ts
index e6910828..04a9b6c6 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,43 @@ 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("takes the first two letters of a single-word name", () => {
+ expect(organizationInitials("BigTable")).toBe("BI");
+ 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 lowercase names", () => {
+ expect(organizationInitials("acme corp")).toBe("AC");
+ });
+
+ 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/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/types/organization.ts b/src/types/organization.ts
index 6c7b8eac..a8475ae4 100644
--- a/src/types/organization.ts
+++ b/src/types/organization.ts
@@ -78,6 +78,19 @@ export function defaultOrganizations(): Organization[] {
return [defaultOrganization()];
}
+/** Up-to-two-letter avatar initials: "Refactor Group" -> "RG", "BigTable" -> "BI", "" -> "?". */
+export function organizationInitials(name: string | undefined): string {
+ const words = (name ?? "").split(/\s+/).filter(Boolean);
+ if (words.length === 0) return "?";
+
+ const letters =
+ words.length === 1
+ ? Array.from(words[0]).slice(0, 2)
+ : words.slice(0, 2).map((word) => Array.from(word)[0]);
+
+ return letters.join("").toUpperCase();
+}
+
export function organizationToString(organization: Organization): string {
return JSON.stringify(organization);
}
From a0a7f0d9fab4ce3596422c6e67737906f49b28f3 Mon Sep 17 00:00:00 2001
From: Jim Hodapp
Date: Thu, 6 Aug 2026 15:36:32 -0500
Subject: [PATCH 08/17] fix(dashboard): scope the Upcoming Session card by
organization
Live verification of the session org-scoping fix found the dashboard still
bleeding, just not on the surface that was reported. The Coaching Sessions
card's Upcoming and Previous tabs were correct, but the Upcoming Session
card and the Goals Overview beside it still showed another organization's
session, because useTodaysSessions never passed the selected organization
to the hook that now accepts one.
Also treats internal capitals as word boundaries when deriving avatar
initials, so a camel or Pascal case name reads as its own parts
("BigTable" -> "BT", not "BI"). Multi-word names are unchanged.
Adds a repeatable read-only live e2e spec covering both the avatar and the
absence of cross-organization session bleed. Unlike multi-org-live.spec.ts
it creates nothing, so it can be re-run against an already-seeded database.
join-session-popover needs no change: it filters by coaching_relationship_id,
and a relationship belongs to exactly one organization.
---
__tests__/e2e/multi-org-scoping-live.spec.ts | 132 +++++++++++++++++++
__tests__/hooks/use-todays-sessions.test.tsx | 44 ++++++-
__tests__/types/organization.test.ts | 21 ++-
src/lib/hooks/use-todays-sessions.ts | 7 +-
src/types/organization.ts | 20 ++-
5 files changed, 213 insertions(+), 11 deletions(-)
create mode 100644 __tests__/e2e/multi-org-scoping-live.spec.ts
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..78c1d49b
--- /dev/null
+++ b/__tests__/e2e/multi-org-scoping-live.spec.ts
@@ -0,0 +1,132 @@
+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";
+
+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.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,
+ });
+});
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__/types/organization.test.ts b/__tests__/types/organization.test.ts
index 04a9b6c6..4c45fe7c 100644
--- a/__tests__/types/organization.test.ts
+++ b/__tests__/types/organization.test.ts
@@ -35,8 +35,18 @@ describe("organizationInitials", () => {
expect(organizationInitials("Big Table Industries")).toBe("BT");
});
- it("takes the first two letters of a single-word name", () => {
- expect(organizationInitials("BigTable")).toBe("BI");
+ 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");
});
@@ -47,8 +57,13 @@ describe("organizationInitials", () => {
expect(new Set(initials).size).toBe(3);
});
- it("uppercases lowercase names", () => {
+ 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", () => {
diff --git a/src/lib/hooks/use-todays-sessions.ts b/src/lib/hooks/use-todays-sessions.ts
index 9a8f1fb7..79928ce9 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);
@@ -60,7 +62,10 @@ export function useTodaysSessions(
endOfDayUTC,
include,
"date",
- "asc"
+ "asc",
+ undefined,
+ undefined,
+ currentOrganizationId ?? undefined
);
return {
diff --git a/src/types/organization.ts b/src/types/organization.ts
index a8475ae4..c11b7c4f 100644
--- a/src/types/organization.ts
+++ b/src/types/organization.ts
@@ -78,17 +78,27 @@ export function defaultOrganizations(): Organization[] {
return [defaultOrganization()];
}
-/** Up-to-two-letter avatar initials: "Refactor Group" -> "RG", "BigTable" -> "BI", "" -> "?". */
+/** 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 =
- words.length === 1
- ? Array.from(words[0]).slice(0, 2)
- : words.slice(0, 2).map((word) => Array.from(word)[0]);
+ parts.length > 1 ? parts.map((part) => part[0]) : Array.from(words[0]);
- return letters.join("").toUpperCase();
+ return letters.slice(0, 2).join("").toUpperCase();
}
export function organizationToString(organization: Organization): string {
From ea88281b2aaa27da6c6e277101b9163cf909bba1 Mon Sep 17 00:00:00 2001
From: Jim Hodapp
Date: Thu, 6 Aug 2026 16:09:11 -0500
Subject: [PATCH 09/17] refactor(members): address frontend review feedback
- lookupByEmail returns Option instead of a nullable
union, per the Strict Typing and Nullability standard. New API boundary,
so there was no legacy pressure to match.
- TODO(rs#374) at the two remaining unscoped session fetches, both in the
actions path, recording that they are deliberate and what unblocks them.
- The Find button shows "Finding..." while in flight, matching Remove.
- attachExisting returns Promise. The response was typed User but
never parsed or consumed, so the type claimed more than it delivered.
- Deleting a member refreshes only on success; the failure path left the
list unchanged.
- One surrogate-safe idiom in both branches of organizationInitials.
- Drop a stray console.log in member-card, and the isACoach binding it was
the only consumer of.
---
.../ui/members/add-member-dialog.tsx | 38 ++++++++++---------
src/components/ui/members/member-card.tsx | 6 +--
src/lib/api/organizations/users.ts | 7 ++--
src/lib/api/users.ts | 5 ++-
src/lib/hooks/use-assigned-actions.ts | 3 ++
src/lib/hooks/use-session-context-fetch.ts | 3 ++
src/types/organization.ts | 4 +-
7 files changed, 38 insertions(+), 28 deletions(-)
diff --git a/src/components/ui/members/add-member-dialog.tsx b/src/components/ui/members/add-member-dialog.tsx
index 8427dfe1..ced87ec9 100644
--- a/src/components/ui/members/add-member-dialog.tsx
+++ b/src/components/ui/members/add-member-dialog.tsx
@@ -37,6 +37,7 @@ import {
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";
@@ -73,7 +74,7 @@ export function AddMemberDialog({
email: "",
});
const [lookupEmail, setLookupEmail] = useState("");
- const [foundUser, setFoundUser] = useState(null);
+ const [foundUser, setFoundUser] = useState
{lookupMessage && (
{lookupMessage}
)}
- {foundUser && (
+ {foundUser.some && (
- {foundUser.first_name} {foundUser.last_name}
+ {foundUser.val.first_name} {foundUser.val.last_name}
+
+
+ {foundUser.val.email}
-
{foundUser.email}
@@ -351,12 +355,12 @@ export function AddMemberDialog({
- {coachField(foundUser?.id)}
+ {coachField(foundUser.some ? foundUser.val.id : undefined)}
diff --git a/src/components/ui/members/member-card.tsx b/src/components/ui/members/member-card.tsx
index 5c86ed6f..c500b66e 100644
--- a/src/components/ui/members/member-card.tsx
+++ b/src/components/ui/members/member-card.tsx
@@ -80,7 +80,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;
@@ -96,8 +96,6 @@ export function MemberCard({
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 &&
@@ -112,6 +110,7 @@ export function MemberCard({
try {
await deleteUser(currentOrganizationId, userId);
toast.success("Member deleted successfully");
+ onRefresh();
} catch (error) {
console.error("Error deleting member:", error);
toast.error(
@@ -122,7 +121,6 @@ export function MemberCard({
: "Error deleting member")
);
}
- onRefresh();
};
const handleRemoveFromOrganization = async () => {
diff --git a/src/lib/api/organizations/users.ts b/src/lib/api/organizations/users.ts
index 656dc529..7c50b5f8 100644
--- a/src/lib/api/organizations/users.ts
+++ b/src/lib/api/organizations/users.ts
@@ -92,8 +92,8 @@ export const UserApi = {
userId: Id,
role: Role,
coachId?: Id
- ): Promise =>
- EntityApi.createFn(
+ ): Promise =>
+ EntityApi.createFn(
`${ORGANIZATIONS_USERS_BASEURL(organizationId)}/${userId}/role`,
{ role, ...(coachId ? { coach_id: coachId } : {}) }
),
@@ -160,9 +160,8 @@ export const useUserMutation = (organizationId: Id) => {
role: Role,
coachId?: Id
) => {
- const user = await UserApi.attachExisting(orgId, userId, role, coachId);
+ await UserApi.attachExisting(orgId, userId, role, coachId);
invalidate(orgId);
- return user;
},
removeFromOrganization: async (orgId: Id, userId: Id) => {
await UserApi.removeFromOrganization(orgId, userId);
diff --git a/src/lib/api/users.ts b/src/lib/api/users.ts
index 74a5db8f..c4563153 100644
--- a/src/lib/api/users.ts
+++ b/src/lib/api/users.ts
@@ -10,6 +10,7 @@ import {
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`;
@@ -25,11 +26,11 @@ export const UserApi = {
* may not see them. The backend makes those two cases indistinguishable so
* the endpoint can't be used to enumerate accounts.
*/
- lookupByEmail: async (email: string): Promise => {
+ lookupByEmail: async (email: string): Promise