feat(web): add teams page and RBAC navigation/route gating (MS21-UI-005, RBAC-001, RBAC-002) (#595)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #595.
This commit is contained in:
2026-03-01 04:54:25 +00:00
committed by jason.woltje
parent 0e74b03d9c
commit 6521f655a8
8 changed files with 748 additions and 213 deletions

View File

@@ -1,9 +1,12 @@
import type { ReactElement, ReactNode } from "react";
import type { TeamRecord } from "@/lib/api/teams";
import { render, screen } from "@testing-library/react";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchTeams } from "@/lib/api/teams";
import { createTeam, deleteTeam, fetchTeams, updateTeam } from "@/lib/api/teams";
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
import TeamsSettingsPage from "./page";
@@ -22,9 +25,19 @@ vi.mock("next/link", () => ({
vi.mock("@/lib/api/teams", () => ({
fetchTeams: vi.fn(),
createTeam: vi.fn(),
updateTeam: vi.fn(),
deleteTeam: vi.fn(),
}));
vi.mock("@/lib/api/workspaces", () => ({
fetchUserWorkspaces: vi.fn(),
}));
const fetchTeamsMock = vi.mocked(fetchTeams);
const createTeamMock = vi.mocked(createTeam);
const updateTeamMock = vi.mocked(updateTeam);
const deleteTeamMock = vi.mocked(deleteTeam);
const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
const baseTeam: TeamRecord = {
id: "team-1",
@@ -42,6 +55,33 @@ const baseTeam: TeamRecord = {
describe("TeamsSettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
fetchTeamsMock.mockResolvedValue([]);
fetchUserWorkspacesMock.mockResolvedValue([
{
id: "workspace-1",
name: "Personal Workspace",
ownerId: "owner-1",
role: WorkspaceMemberRole.OWNER,
createdAt: "2026-01-01T00:00:00.000Z",
},
]);
});
it("shows access denied to non-admin users", async () => {
fetchUserWorkspacesMock.mockResolvedValueOnce([
{
id: "workspace-1",
name: "Personal Workspace",
ownerId: "owner-1",
role: WorkspaceMemberRole.MEMBER,
createdAt: "2026-01-01T00:00:00.000Z",
},
]);
render(<TeamsSettingsPage />);
expect(await screen.findByText("Access Denied")).toBeInTheDocument();
expect(fetchTeamsMock).not.toHaveBeenCalled();
});
it("loads and renders teams from the API", async () => {
@@ -49,9 +89,7 @@ describe("TeamsSettingsPage", () => {
render(<TeamsSettingsPage />);
expect(screen.getByText("Loading teams...")).toBeInTheDocument();
expect(await screen.findByText("Your Teams (1)")).toBeInTheDocument();
expect(await screen.findByText("Team Directory")).toBeInTheDocument();
expect(screen.getByText("Platform Team")).toBeInTheDocument();
expect(fetchTeamsMock).toHaveBeenCalledTimes(1);
});
@@ -61,8 +99,8 @@ describe("TeamsSettingsPage", () => {
render(<TeamsSettingsPage />);
expect(await screen.findByText("Your Teams (0)")).toBeInTheDocument();
expect(screen.getByText("No teams yet")).toBeInTheDocument();
expect(await screen.findByText("No Teams Yet")).toBeInTheDocument();
expect(screen.getByText("Create the first team to get started.")).toBeInTheDocument();
});
it("shows error state and does not show empty state", async () => {
@@ -71,6 +109,82 @@ describe("TeamsSettingsPage", () => {
render(<TeamsSettingsPage />);
expect(await screen.findByText("Unable to load teams")).toBeInTheDocument();
expect(screen.queryByText("No teams yet")).not.toBeInTheDocument();
});
it("creates a team from the create dialog", async () => {
const user = userEvent.setup();
fetchTeamsMock.mockResolvedValue([baseTeam]);
createTeamMock.mockResolvedValue({
...baseTeam,
id: "team-2",
name: "Design Team",
description: "Owns design quality",
});
render(<TeamsSettingsPage />);
expect(await screen.findByText("Platform Team")).toBeInTheDocument();
const triggerButton = screen.getByRole("button", { name: "Create Team" });
await user.click(triggerButton);
await user.type(screen.getByLabelText("Name"), "Design Team");
await user.type(screen.getByLabelText("Description"), "Owns design quality");
const submitButton = screen.getAllByRole("button", { name: "Create Team" })[1];
if (!submitButton) {
throw new Error("Expected create-team submit button to be rendered");
}
await user.click(submitButton);
await waitFor(() => {
expect(createTeamMock).toHaveBeenCalledWith({
name: "Design Team",
description: "Owns design quality",
});
});
});
it("opens team details and updates name", async () => {
const user = userEvent.setup();
fetchTeamsMock.mockResolvedValue([baseTeam]);
updateTeamMock.mockResolvedValue({
...baseTeam,
name: "Platform Engineering",
});
render(<TeamsSettingsPage />);
expect(await screen.findByText("Platform Team")).toBeInTheDocument();
await user.click(screen.getByText("Platform Team"));
const nameInput = await screen.findByLabelText("Name");
await user.clear(nameInput);
await user.type(nameInput, "Platform Engineering");
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(updateTeamMock).toHaveBeenCalledWith("team-1", {
name: "Platform Engineering",
});
});
});
it("deletes a team from the confirmation dialog", async () => {
const user = userEvent.setup();
fetchTeamsMock.mockResolvedValue([baseTeam]);
deleteTeamMock.mockResolvedValue();
render(<TeamsSettingsPage />);
expect(await screen.findByText("Platform Team")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(screen.getByRole("button", { name: "Delete Team" }));
await waitFor(() => {
expect(deleteTeamMock).toHaveBeenCalledWith("team-1");
});
});
});