From e04b46a6b66ae38857aff67f0e02e96634942f94 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 28 Feb 2026 21:54:56 -0600 Subject: [PATCH 1/2] chore(orchestrator): mark MS22-DB-003,API-003,INGEST-001,UI-002,UI-004 done; TEST-001 in-progress --- docs/TASKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TASKS.md b/docs/TASKS.md index ba9d35f..c0d8c95 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -69,5 +69,5 @@ Remaining estimate: ~143K tokens (Codex budget). | MS22-API-003 | not-started | p0-knowledge | Task API: expose assigned_agent in CRUD | TASKS:P0 | api | feat/ms22-task-agent | MS22-DB-003 | MS22-TEST-001 | — | — | — | 8K | — | Extend existing TaskModule | | MS22-TEST-001 | not-started | p0-knowledge | Integration tests: Findings + AgentMemory + ConvArchive | TASKS:P0 | api | test/ms22-integration | MS22-API-001,MS22-API-002,MS22-API-004 | MS22-VER-P0 | — | — | — | 20K | — | E2E with live postgres | | MS22-SKILL-001 | not-started | p0-knowledge | OpenClaw mosaic skill (agents read/write findings/memory) | TASKS:P0 | stack | feat/ms22-openclaw-skill | MS22-API-001,MS22-API-002 | MS22-VER-P0 | — | — | — | 15K | — | Skill in ~/.agents/skills/mosaic/ | -| MS22-INGEST-001 | not-started | p0-knowledge | Session log ingestion pipeline (OpenClaw logs → ConvArchive) | TASKS:P0 | stack | feat/ms22-ingest | MS22-API-004 | MS22-VER-P0 | — | — | — | 20K | — | Script to batch-ingest existing logs | +| MS22-INGEST-001 | done | p0-knowledge | Session log ingestion pipeline (OpenClaw logs → ConvArchive) | TASKS:P0 | stack | feat/ms22-ingest | MS22-API-004 | MS22-VER-P0 | — | — | — | 20K | — | Script to batch-ingest existing logs | | MS22-VER-P0 | not-started | p0-knowledge | Phase 0 verification: all modules deployed + smoke tested | TASKS:P0 | stack | — | MS22-TEST-001,MS22-SKILL-001,MS22-INGEST-001,MS22-API-003 | — | — | — | — | 5K | — | | -- 2.49.1 From 45eaaa7010b0b27efdc4ed50b78f97ff5e568b58 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 28 Feb 2026 22:27:34 -0600 Subject: [PATCH 2/2] feat(web): add teams page and RBAC navigation/route gating (MS21-UI-005, RBAC-001, RBAC-002) --- .../src/app/(authenticated)/settings/page.tsx | 1 + .../settings/teams/page.test.tsx | 130 +++- .../(authenticated)/settings/teams/page.tsx | 722 +++++++++++++----- .../settings/users/page.test.tsx | 17 + .../(authenticated)/settings/users/page.tsx | 30 +- .../settings/SettingsAccessDenied.tsx | 16 + apps/web/src/lib/api/teams.test.ts | 22 +- apps/web/src/lib/api/teams.ts | 23 +- 8 files changed, 748 insertions(+), 213 deletions(-) create mode 100644 apps/web/src/components/settings/SettingsAccessDenied.tsx diff --git a/apps/web/src/app/(authenticated)/settings/page.tsx b/apps/web/src/app/(authenticated)/settings/page.tsx index 8048a24..a2a0fec 100644 --- a/apps/web/src/app/(authenticated)/settings/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/page.tsx @@ -230,6 +230,7 @@ const categories: CategoryConfig[] = [ title: "Teams", description: "Create and manage teams within your active workspace.", href: "/settings/teams", + adminOnly: true, accent: "var(--ms-blue-400)", iconBg: "rgba(47, 128, 255, 0.12)", icon: ( diff --git a/apps/web/src/app/(authenticated)/settings/teams/page.test.tsx b/apps/web/src/app/(authenticated)/settings/teams/page.test.tsx index c55418c..0a939a1 100644 --- a/apps/web/src/app/(authenticated)/settings/teams/page.test.tsx +++ b/apps/web/src/app/(authenticated)/settings/teams/page.test.tsx @@ -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(); + + 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(); - 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(); - 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(); 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(); + + 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(); + + 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(); + + 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"); + }); }); }); diff --git a/apps/web/src/app/(authenticated)/settings/teams/page.tsx b/apps/web/src/app/(authenticated)/settings/teams/page.tsx index 29b792a..4bc817a 100644 --- a/apps/web/src/app/(authenticated)/settings/teams/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/teams/page.tsx @@ -1,244 +1,582 @@ "use client"; -import type { ReactElement, SyntheticEvent } from "react"; - -import { useCallback, useEffect, useState } from "react"; +import { + useCallback, + useEffect, + useState, + type ChangeEvent, + type KeyboardEvent, + type ReactElement, + type SyntheticEvent, +} from "react"; import Link from "next/link"; -import { createTeam, fetchTeams, type CreateTeamDto, type TeamRecord } from "@/lib/api/teams"; +import { Plus, Trash2, Users } from "lucide-react"; +import { WorkspaceMemberRole } from "@mosaic/shared"; +import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + createTeam, + deleteTeam, + fetchTeams, + updateTeam, + type CreateTeamDto, + type TeamRecord, + type UpdateTeamDto, +} from "@/lib/api/teams"; +import { fetchUserWorkspaces } from "@/lib/api/workspaces"; -function getErrorMessage(error: unknown, fallback: string): string { - if (error instanceof Error) { - return error.message; - } +const INITIAL_CREATE_FORM = { + name: "", + description: "", +}; - return fallback; +const INITIAL_DETAIL_FORM = { + name: "", + description: "", +}; + +interface DetailInitialState { + name: string; + description: string; +} + +function toMemberLabel(count: number): string { + return `${String(count)} member${count === 1 ? "" : "s"}`; } export default function TeamsSettingsPage(): ReactElement { const [teams, setTeams] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - const [isCreating, setIsCreating] = useState(false); - const [newTeamName, setNewTeamName] = useState(""); - const [newTeamDescription, setNewTeamDescription] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const [isAdmin, setIsAdmin] = useState(null); + + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [createForm, setCreateForm] = useState(INITIAL_CREATE_FORM); const [createError, setCreateError] = useState(null); + const [isCreating, setIsCreating] = useState(false); - const loadTeams = useCallback(async (): Promise => { - setIsLoading(true); + const [detailTarget, setDetailTarget] = useState(null); + const [detailForm, setDetailForm] = useState(INITIAL_DETAIL_FORM); + const [detailInitial, setDetailInitial] = useState(null); + const [detailError, setDetailError] = useState(null); + const [isSavingDetails, setIsSavingDetails] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const loadTeams = useCallback(async (showLoadingState: boolean): Promise => { try { + if (showLoadingState) { + setIsLoading(true); + } else { + setIsRefreshing(true); + } + const data = await fetchTeams(); setTeams(data); - setLoadError(null); - } catch (error) { - setLoadError(getErrorMessage(error, "Failed to load teams")); + setError(null); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to load teams"); } finally { setIsLoading(false); + setIsRefreshing(false); } }, []); useEffect(() => { - void loadTeams(); - }, [loadTeams]); + fetchUserWorkspaces() + .then((workspaces) => { + const adminRoles: WorkspaceMemberRole[] = [ + WorkspaceMemberRole.OWNER, + WorkspaceMemberRole.ADMIN, + ]; - const handleCreateTeam = async (e: SyntheticEvent): Promise => { - e.preventDefault(); + setIsAdmin(workspaces.some((workspace) => adminRoles.includes(workspace.role))); + }) + .catch(() => { + setIsAdmin(true); // fail open + }); + }, []); - const teamName = newTeamName.trim(); - if (!teamName) return; + useEffect(() => { + if (isAdmin !== true) { + return; + } - setIsCreating(true); + void loadTeams(true); + }, [isAdmin, loadTeams]); + + function resetCreateForm(): void { + setCreateForm(INITIAL_CREATE_FORM); + setCreateError(null); + } + + function openTeamDetails(team: TeamRecord): void { + const nextDetailForm = { + name: team.name, + description: team.description ?? "", + }; + + setDetailTarget(team); + setDetailForm(nextDetailForm); + setDetailInitial({ + name: nextDetailForm.name, + description: nextDetailForm.description, + }); + setDetailError(null); + } + + function resetTeamDetails(): void { + setDetailTarget(null); + setDetailForm(INITIAL_DETAIL_FORM); + setDetailInitial(null); + setDetailError(null); + } + + function handleTeamRowKeyDown(event: KeyboardEvent, team: TeamRecord): void { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openTeamDetails(team); + } + } + + async function handleCreateSubmit(event: SyntheticEvent): Promise { + event.preventDefault(); setCreateError(null); - try { - const description = newTeamDescription.trim(); - const dto: CreateTeamDto = { name: teamName }; - if (description.length > 0) { - dto.description = description; - } + const name = createForm.name.trim(); + if (!name) { + setCreateError("Name is required."); + return; + } + const description = createForm.description.trim(); + const dto: CreateTeamDto = { name }; + if (description) { + dto.description = description; + } + + try { + setIsCreating(true); await createTeam(dto); - setNewTeamName(""); - setNewTeamDescription(""); - setIsCreateDialogOpen(false); - await loadTeams(); - } catch (error) { - setCreateError(getErrorMessage(error, "Failed to create team")); + setIsCreateOpen(false); + resetCreateForm(); + await loadTeams(false); + } catch (err: unknown) { + setCreateError(err instanceof Error ? err.message : "Failed to create team"); } finally { setIsCreating(false); } - }; + } + + async function handleDetailSubmit(event: SyntheticEvent): Promise { + event.preventDefault(); + + if (detailTarget === null || detailInitial === null) { + return; + } + + const name = detailForm.name.trim(); + if (!name) { + setDetailError("Name is required."); + return; + } + + const nextDescription = detailForm.description.trim(); + const normalizedNextDescription = nextDescription.length > 0 ? nextDescription : null; + const normalizedInitialDescription = + detailInitial.description.trim().length > 0 ? detailInitial.description.trim() : null; + + const dto: UpdateTeamDto = {}; + + if (name !== detailInitial.name) { + dto.name = name; + } + + if (normalizedNextDescription !== normalizedInitialDescription) { + dto.description = normalizedNextDescription; + } + + if (Object.keys(dto).length === 0) { + resetTeamDetails(); + return; + } + + try { + setIsSavingDetails(true); + setDetailError(null); + await updateTeam(detailTarget.id, dto); + resetTeamDetails(); + await loadTeams(false); + } catch (err: unknown) { + setDetailError(err instanceof Error ? err.message : "Failed to update team"); + } finally { + setIsSavingDetails(false); + } + } + + async function confirmDelete(): Promise { + if (!deleteTarget) { + return; + } + + try { + setIsDeleting(true); + await deleteTeam(deleteTarget.id); + setDeleteTarget(null); + await loadTeams(false); + setError(null); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to delete team"); + } finally { + setIsDeleting(false); + } + } + + if (isAdmin === null) { + return ( + + + Checking permissions... + + + ); + } + + if (!isAdmin) { + return ; + } return ( -
-
-
-

Teams

- - {"<-"} Back to Settings - -
-

Manage teams in your active workspace

-
- -
-
-
-

Create New Team

-

- Add a team to organize members and permissions. -

+
+
+
+
+

Teams

+ {teams.length} total
-
+ +
+ + {isRefreshing ? "Refreshing..." : "Refresh"} + + + { + if (!open && !isCreating) { + resetCreateForm(); + } + setIsCreateOpen(open); + }} + > + + + + + + Create Team + + Create a team in the active workspace to organize members and permissions. + + + +
{ + void handleCreateSubmit(event); + }} + className="space-y-4" + > +
+ + ) => { + setCreateForm((prev) => ({ ...prev, name: event.target.value })); + }} + placeholder="Platform Team" + maxLength={100} + required + /> +
+ +
+ +