From 512a29a240e2bf26f08801d212e793b216fa251c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 1 Mar 2026 13:52:15 +0000 Subject: [PATCH] fix(web): QA fixes on users settings page (MS21-UI-001-QA) (#599) fix(web): QA fixes on users settings page (MS21-UI-001-QA) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- .../settings/users/page.test.tsx | 243 +++++++++++++++--- .../(authenticated)/settings/users/page.tsx | 117 ++++++--- 2 files changed, 296 insertions(+), 64 deletions(-) diff --git a/apps/web/src/app/(authenticated)/settings/users/page.test.tsx b/apps/web/src/app/(authenticated)/settings/users/page.test.tsx index 8568748..4b69f57 100644 --- a/apps/web/src/app/(authenticated)/settings/users/page.test.tsx +++ b/apps/web/src/app/(authenticated)/settings/users/page.test.tsx @@ -1,17 +1,19 @@ import type { ReactElement, ReactNode } from "react"; import { WorkspaceMemberRole } from "@mosaic/shared"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { + type AdminUser, deactivateUser, fetchAdminUsers, inviteUser, updateUser, type AdminUsersResponse, } from "@/lib/api/admin"; +import { useAuth } from "@/lib/auth/auth-context"; import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces"; import UsersSettingsPage from "./page"; @@ -39,48 +41,80 @@ vi.mock("@/lib/api/workspaces", () => ({ updateWorkspaceMemberRole: vi.fn(), })); +vi.mock("@/lib/auth/auth-context", () => ({ + useAuth: vi.fn(), +})); + const fetchAdminUsersMock = vi.mocked(fetchAdminUsers); const inviteUserMock = vi.mocked(inviteUser); const updateUserMock = vi.mocked(updateUser); const deactivateUserMock = vi.mocked(deactivateUser); const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces); const updateWorkspaceMemberRoleMock = vi.mocked(updateWorkspaceMemberRole); +const useAuthMock = vi.mocked(useAuth); -const adminUsersResponse: AdminUsersResponse = { - data: [ - { - id: "user-1", - name: "Alice", - email: "alice@example.com", - emailVerified: true, - image: null, - createdAt: "2026-01-01T00:00:00.000Z", - deactivatedAt: null, - isLocalAuth: false, - invitedAt: null, - invitedBy: null, - workspaceMemberships: [ - { - workspaceId: "workspace-1", - workspaceName: "Personal Workspace", - role: WorkspaceMemberRole.ADMIN, - joinedAt: "2026-01-01T00:00:00.000Z", - }, - ], +function makeAdminUser(overrides?: Partial): AdminUser { + return { + id: "user-1", + name: "Alice", + email: "alice@example.com", + emailVerified: true, + image: null, + createdAt: "2026-01-01T00:00:00.000Z", + deactivatedAt: null, + isLocalAuth: false, + invitedAt: null, + invitedBy: null, + workspaceMemberships: [ + { + workspaceId: "workspace-1", + workspaceName: "Personal Workspace", + role: WorkspaceMemberRole.ADMIN, + joinedAt: "2026-01-01T00:00:00.000Z", + }, + ], + ...overrides, + }; +} + +function makeAdminUsersResponse(options?: { + data?: AdminUser[]; + page?: number; + totalPages?: number; + total?: number; + limit?: number; +}): AdminUsersResponse { + const data = options?.data ?? [makeAdminUser()]; + return { + data, + meta: { + total: options?.total ?? data.length, + page: options?.page ?? 1, + limit: options?.limit ?? 50, + totalPages: options?.totalPages ?? 1, }, - ], - meta: { - total: 1, - page: 1, - limit: 50, - totalPages: 1, - }, -}; + }; +} + +function makeAuthState(userId: string): ReturnType { + return { + user: { id: userId, email: `${userId}@example.com`, name: "Current User" }, + isLoading: false, + isAuthenticated: true, + authError: null, + sessionExpiring: false, + sessionMinutesRemaining: 0, + signOut: vi.fn(() => Promise.resolve()), + refreshSession: vi.fn(() => Promise.resolve()), + }; +} describe("UsersSettingsPage", () => { beforeEach(() => { vi.clearAllMocks(); + const adminUsersResponse = makeAdminUsersResponse(); + fetchAdminUsersMock.mockResolvedValue(adminUsersResponse); fetchUserWorkspacesMock.mockResolvedValue([ { @@ -97,10 +131,7 @@ describe("UsersSettingsPage", () => { email: "new@example.com", invitedAt: "2026-01-02T00:00:00.000Z", }); - const firstUser = adminUsersResponse.data[0]; - if (!firstUser) { - throw new Error("Expected at least one admin user in test fixtures"); - } + const firstUser = adminUsersResponse.data[0] ?? makeAdminUser(); updateUserMock.mockResolvedValue(firstUser); deactivateUserMock.mockResolvedValue(firstUser); @@ -116,6 +147,8 @@ describe("UsersSettingsPage", () => { image: null, }, }); + + useAuthMock.mockReturnValue(makeAuthState("user-current")); }); it("shows access denied to non-admin users", async () => { @@ -174,4 +207,146 @@ describe("UsersSettingsPage", () => { expect(updateWorkspaceMemberRoleMock).not.toHaveBeenCalled(); }); + + it("caps pagination to the last valid page after deactivation shrinks the dataset", async () => { + const user = userEvent.setup(); + const pageOneUser = makeAdminUser({ + id: "user-1", + name: "Alice", + email: "alice@example.com", + }); + const pageTwoUser = makeAdminUser({ + id: "user-2", + name: "Bob", + email: "bob@example.com", + }); + + fetchAdminUsersMock.mockReset(); + const responses = [ + { + expectedPage: 1, + response: makeAdminUsersResponse({ + data: [pageOneUser], + page: 1, + totalPages: 2, + total: 2, + }), + }, + { + expectedPage: 2, + response: makeAdminUsersResponse({ + data: [pageTwoUser], + page: 2, + totalPages: 2, + total: 2, + }), + }, + { + expectedPage: 2, + response: makeAdminUsersResponse({ + data: [], + page: 2, + totalPages: 1, + total: 1, + }), + }, + { + expectedPage: 1, + response: makeAdminUsersResponse({ + data: [pageOneUser], + page: 1, + totalPages: 1, + total: 1, + }), + }, + ]; + + fetchAdminUsersMock.mockImplementation((page = 1) => { + const next = responses.shift(); + if (!next) { + throw new Error("Unexpected fetchAdminUsers call in pagination-cap test"); + } + + expect(page).toBe(next.expectedPage); + return Promise.resolve(next.response); + }); + + render(); + + expect(await screen.findByText("alice@example.com")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Next" })); + expect(await screen.findByText("bob@example.com")).toBeInTheDocument(); + + const pageTwoRow = screen.getByText("bob@example.com").closest('[role="button"]'); + if (!(pageTwoRow instanceof HTMLElement)) { + throw new Error("Expected Bob's row to exist"); + } + + await user.click(within(pageTwoRow).getByRole("button", { name: "Deactivate" })); + const deactivateButtons = await screen.findAllByRole("button", { name: "Deactivate" }); + const confirmDeactivateButton = deactivateButtons[deactivateButtons.length - 1]; + if (!confirmDeactivateButton) { + throw new Error("Expected confirmation deactivate button to be rendered"); + } + await user.click(confirmDeactivateButton); + + expect(await screen.findByText("alice@example.com")).toBeInTheDocument(); + expect(screen.queryByText("No Users Yet")).not.toBeInTheDocument(); + expect(deactivateUserMock).toHaveBeenCalledWith("user-2"); + const requestedPages = fetchAdminUsersMock.mock.calls.map(([requestedPage]) => requestedPage); + expect(requestedPages.slice(-2)).toEqual([2, 1]); + }); + + it("shows the API error state without rendering the empty-state message", async () => { + fetchAdminUsersMock.mockRejectedValueOnce(new Error("Unable to load users")); + + render(); + + expect(await screen.findByText("Unable to load users")).toBeInTheDocument(); + expect(screen.queryByText("No Users Yet")).not.toBeInTheDocument(); + expect(screen.queryByText("Invite the first user to get started.")).not.toBeInTheDocument(); + }); + + it("prevents the current user from deactivating their own account", async () => { + useAuthMock.mockReturnValue(makeAuthState("user-1")); + + const selfUser = makeAdminUser({ + id: "user-1", + name: "Alice", + email: "alice@example.com", + }); + const otherUser = makeAdminUser({ + id: "user-2", + name: "Bob", + email: "bob@example.com", + }); + + fetchAdminUsersMock.mockResolvedValueOnce( + makeAdminUsersResponse({ + data: [selfUser, otherUser], + page: 1, + totalPages: 1, + total: 2, + }) + ); + + render(); + + expect(await screen.findByText("alice@example.com")).toBeInTheDocument(); + expect(screen.getByText("bob@example.com")).toBeInTheDocument(); + + const selfRow = screen.getByText("alice@example.com").closest('[role="button"]'); + if (!(selfRow instanceof HTMLElement)) { + throw new Error("Expected current-user row to exist"); + } + expect(within(selfRow).queryByRole("button", { name: "Deactivate" })).not.toBeInTheDocument(); + + const otherRow = screen.getByText("bob@example.com").closest('[role="button"]'); + if (!(otherRow instanceof HTMLElement)) { + throw new Error("Expected other-user row to exist"); + } + expect(within(otherRow).getByRole("button", { name: "Deactivate" })).toBeInTheDocument(); + expect(deactivateUserMock).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/app/(authenticated)/settings/users/page.tsx b/apps/web/src/app/(authenticated)/settings/users/page.tsx index 0b35130..8406d9a 100644 --- a/apps/web/src/app/(authenticated)/settings/users/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/users/page.tsx @@ -55,6 +55,7 @@ import { type InviteUserDto, type UpdateUserDto, } from "@/lib/api/admin"; +import { useAuth } from "@/lib/auth/auth-context"; import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces"; import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied"; @@ -77,6 +78,7 @@ const INITIAL_DETAIL_FORM = { workspaceId: null as string | null, workspaceName: null as string | null, }; +const USERS_PAGE_SIZE = 50; interface DetailInitialState { name: string; @@ -104,8 +106,11 @@ function getPrimaryMembership(user: AdminUser): AdminWorkspaceMembership | null } export default function UsersSettingsPage(): ReactElement { + const { user: authUser } = useAuth(); + const [users, setUsers] = useState([]); const [meta, setMeta] = useState(null); + const [page, setPage] = useState(1); const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); @@ -127,25 +132,35 @@ export default function UsersSettingsPage(): ReactElement { const [deactivateTarget, setDeactivateTarget] = useState(null); const [isDeactivating, setIsDeactivating] = useState(false); - const loadUsers = useCallback(async (showLoadingState: boolean): Promise => { - try { - if (showLoadingState) { - setIsLoading(true); - } else { - setIsRefreshing(true); - } + const loadUsers = useCallback( + async (showLoadingState: boolean): Promise => { + try { + if (showLoadingState) { + setIsLoading(true); + } else { + setIsRefreshing(true); + } - const response = await fetchAdminUsers(1, 50); - setUsers(response.data); - setMeta(response.meta); - setError(null); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Failed to load admin users"); - } finally { - setIsLoading(false); - setIsRefreshing(false); - } - }, []); + const response = await fetchAdminUsers(page, USERS_PAGE_SIZE); + const lastValidPage = Math.max(1, response.meta.totalPages); + + if (page > lastValidPage) { + setPage(lastValidPage); + return; + } + + setUsers(response.data); + setMeta(response.meta); + setError(null); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to load admin users"); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, + [page] + ); useEffect(() => { fetchUserWorkspaces() @@ -170,7 +185,7 @@ export default function UsersSettingsPage(): ReactElement { } void loadUsers(true); - }, [isAdmin, loadUsers]); + }, [isAdmin, loadUsers, page]); function resetInviteForm(): void { setInviteForm(INITIAL_INVITE_FORM); @@ -324,6 +339,12 @@ export default function UsersSettingsPage(): ReactElement { return; } + if (authUser?.id === deactivateTarget.id) { + setDeactivateTarget(null); + setError("You cannot deactivate your own account."); + return; + } + try { setIsDeactivating(true); await deactivateUser(deactivateTarget.id); @@ -481,7 +502,13 @@ export default function UsersSettingsPage(): ReactElement { - {error ? ( + {isLoading ? ( + + + Loading users... + + + ) : error ? (

@@ -489,14 +516,6 @@ export default function UsersSettingsPage(): ReactElement {

- ) : null} - - {isLoading ? ( - - - Loading users... - - ) : users.length === 0 ? ( @@ -514,6 +533,7 @@ export default function UsersSettingsPage(): ReactElement { {users.map((user) => { const primaryMembership = getPrimaryMembership(user); const isActive = user.deactivatedAt === null; + const isCurrentUser = authUser?.id === user.id; return (
-

{user.name || "Unnamed User"}

+

+ {user.name || "Unnamed User"} + {isCurrentUser ? ( + + (You) + + ) : null} +

{user.email}

@@ -540,7 +567,7 @@ export default function UsersSettingsPage(): ReactElement { {isActive ? "Active" : "Inactive"} - {isActive ? ( + {isActive && !isCurrentUser ? ( + +
+ + ) : null}
)}