diff --git a/apps/web/src/app/(authenticated)/settings/workspaces/[id]/page.test.tsx b/apps/web/src/app/(authenticated)/settings/workspaces/[id]/page.test.tsx new file mode 100644 index 0000000..c427cb9 --- /dev/null +++ b/apps/web/src/app/(authenticated)/settings/workspaces/[id]/page.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import WorkspaceDetailPage from "./page"; +import * as workspacesApi from "@/lib/api/workspaces"; + +vi.mock("next/navigation", () => ({ + useParams: (): { id: string } => ({ id: "ws-test-1" }), +})); + +vi.mock("@/lib/auth/auth-context", () => ({ + useAuth: (): { user: { id: string } } => ({ user: { id: "u1" } }), +})); + +vi.mock("@/lib/api/workspaces"); + +vi.mock("@/components/workspace/MemberList", () => ({ + MemberList: ({ members }: { members: unknown[] }): React.ReactElement => ( +
Members: {members.length}
+ ), +})); + +const mockWorkspace = { + id: "ws-test-1", + name: "Test Workspace", + ownerId: "u1", + role: "OWNER", + createdAt: "2024-01-01", +}; + +const mockMembers = [ + { + workspaceId: "ws-test-1", + userId: "u1", + role: "OWNER", + joinedAt: "2024-01-01", + user: { id: "u1", email: "a@b.com", name: "Alice", image: null }, + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("WorkspaceDetailPage", () => { + it("loads and renders member list", async (): Promise => { + vi.mocked(workspacesApi.fetchUserWorkspaces).mockResolvedValue([mockWorkspace] as never); + vi.mocked(workspacesApi.fetchWorkspaceMembers).mockResolvedValue(mockMembers as never); + render(); + await waitFor(() => { + expect(screen.getByTestId("member-list")).toBeInTheDocument(); + }); + expect(screen.getByText("Test Workspace")).toBeInTheDocument(); + }); + + it("shows error state on fetch failure, not member list", async (): Promise => { + vi.mocked(workspacesApi.fetchUserWorkspaces).mockRejectedValue(new Error("Network error")); + vi.mocked(workspacesApi.fetchWorkspaceMembers).mockRejectedValue(new Error("Network error")); + render(); + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("member-list")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(authenticated)/settings/workspaces/[id]/page.tsx b/apps/web/src/app/(authenticated)/settings/workspaces/[id]/page.tsx index 79dcca8..0104094 100644 --- a/apps/web/src/app/(authenticated)/settings/workspaces/[id]/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/workspaces/[id]/page.tsx @@ -1,196 +1,148 @@ "use client"; -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { WorkspaceSettings } from "@/components/workspace/WorkspaceSettings"; -import { MemberList } from "@/components/workspace/MemberList"; -import { InviteMember } from "@/components/workspace/InviteMember"; -import { WorkspaceMemberRole } from "@mosaic/shared"; -import type { Workspace, WorkspaceMemberWithUser } from "@mosaic/shared"; +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "next/navigation"; import Link from "next/link"; +import { MemberList } from "@/components/workspace/MemberList"; +import { useAuth } from "@/lib/auth/auth-context"; +import { WorkspaceMemberRole } from "@mosaic/shared"; +import { + fetchWorkspaceMembers, + fetchUserWorkspaces, + updateWorkspaceMemberRole, + removeWorkspaceMember, + type WorkspaceMemberEntry, + type UserWorkspace, +} from "@/lib/api/workspaces"; +import type { WorkspaceMemberWithUser } from "@/components/workspace/MemberList"; -interface WorkspaceDetailPageProps { - params: { - id: string; +function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error) return error.message; + return fallback; +} + +function toMemberWithUser(m: WorkspaceMemberEntry): WorkspaceMemberWithUser { + return { + workspaceId: m.workspaceId, + userId: m.userId, + role: m.role, + joinedAt: new Date(m.joinedAt), + user: { + id: m.user.id, + email: m.user.email, + name: m.user.name ?? "", + image: m.user.image, + emailVerified: true, + authProviderId: null, + preferences: {}, + deactivatedAt: null, + isLocalAuth: false, + passwordHash: null, + invitedBy: null, + invitationToken: null, + invitedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, }; } -// Mock data - TODO: Replace with real API calls -const mockWorkspace: Workspace = { - id: "ws-1", - name: "Personal Workspace", - ownerId: "user-1", - settings: {}, - createdAt: new Date("2024-01-15"), - updatedAt: new Date("2024-01-15"), -}; +export default function WorkspaceDetailPage(): React.ReactElement { + const params = useParams<{ id: string }>(); + const workspaceId = params.id; + const { user: authUser } = useAuth(); -const mockMembers: WorkspaceMemberWithUser[] = [ - { - workspaceId: "ws-1", - userId: "user-1", - role: WorkspaceMemberRole.OWNER, - joinedAt: new Date("2024-01-15"), - user: { - id: "user-1", - email: "owner@example.com", - name: "John Doe", - emailVerified: true, - image: null, - authProviderId: null, - preferences: {}, - deactivatedAt: null, - isLocalAuth: false, - passwordHash: null, - invitedBy: null, - invitationToken: null, - invitedAt: null, - createdAt: new Date("2024-01-15"), - updatedAt: new Date("2024-01-15"), + const [workspace, setWorkspace] = useState(null); + const [members, setMembers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async (): Promise => { + setIsLoading(true); + setError(null); + try { + const [workspaces, memberList] = await Promise.all([ + fetchUserWorkspaces(), + fetchWorkspaceMembers(workspaceId), + ]); + const ws = workspaces.find((w) => w.id === workspaceId) ?? null; + setWorkspace(ws); + setMembers(memberList); + } catch (err) { + setError(getErrorMessage(err, "Failed to load workspace")); + } finally { + setIsLoading(false); + } + }, [workspaceId]); + + useEffect(() => { + void load(); + }, [load]); + + const handleRoleChange = useCallback( + async (userId: string, newRole: WorkspaceMemberRole): Promise => { + await updateWorkspaceMemberRole(workspaceId, userId, { role: newRole }); + await load(); }, - }, - { - workspaceId: "ws-1", - userId: "user-2", - role: WorkspaceMemberRole.ADMIN, - joinedAt: new Date("2024-01-16"), - user: { - id: "user-2", - email: "admin@example.com", - name: "Jane Smith", - emailVerified: true, - image: null, - authProviderId: null, - preferences: {}, - deactivatedAt: null, - isLocalAuth: false, - passwordHash: null, - invitedBy: null, - invitationToken: null, - invitedAt: null, - createdAt: new Date("2024-01-16"), - updatedAt: new Date("2024-01-16"), + [workspaceId, load] + ); + + const handleRemove = useCallback( + async (userId: string): Promise => { + await removeWorkspaceMember(workspaceId, userId); + await load(); }, - }, - { - workspaceId: "ws-1", - userId: "user-3", - role: WorkspaceMemberRole.MEMBER, - joinedAt: new Date("2024-01-17"), - user: { - id: "user-3", - email: "member@example.com", - name: "Bob Johnson", - emailVerified: true, - image: null, - authProviderId: null, - preferences: {}, - deactivatedAt: null, - isLocalAuth: false, - passwordHash: null, - invitedBy: null, - invitationToken: null, - invitedAt: null, - createdAt: new Date("2024-01-17"), - updatedAt: new Date("2024-01-17"), - }, - }, -]; + [workspaceId, load] + ); -export default function WorkspaceDetailPage({ - params, -}: WorkspaceDetailPageProps): React.JSX.Element { - const router = useRouter(); - const [workspace, setWorkspace] = useState(mockWorkspace); - const [members, setMembers] = useState(mockMembers); - const currentUserId = "user-1"; // TODO: Get from auth context - const currentUserRole: WorkspaceMemberRole = WorkspaceMemberRole.OWNER; // TODO: Get from API - - // TODO: Replace with actual role check when API is implemented - // Currently hardcoded to OWNER in mock data (line 89) - const canInvite = - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - currentUserRole === WorkspaceMemberRole.OWNER || currentUserRole === WorkspaceMemberRole.ADMIN; - - const handleUpdateWorkspace = async (name: string): Promise => { - // TODO: Replace with real API call - console.log("Updating workspace:", { id: params.id, name }); - await new Promise((resolve) => setTimeout(resolve, 500)); - setWorkspace({ ...workspace, name, updatedAt: new Date() }); - }; - - const handleDeleteWorkspace = async (): Promise => { - // TODO: Replace with real API call - console.log("Deleting workspace:", params.id); - await new Promise((resolve) => setTimeout(resolve, 1000)); - router.push("/settings/workspaces"); - }; - - const handleRoleChange = async (userId: string, newRole: WorkspaceMemberRole): Promise => { - // TODO: Replace with real API call - console.log("Changing role:", { userId, newRole }); - await new Promise((resolve) => setTimeout(resolve, 500)); - setMembers( - members.map((member) => (member.userId === userId ? { ...member, role: newRole } : member)) + if (isLoading) { + return ( +
+ Loading workspace… +
); - }; + } - const handleRemoveMember = async (userId: string): Promise => { - // TODO: Replace with real API call - console.log("Removing member:", userId); - await new Promise((resolve) => setTimeout(resolve, 500)); - setMembers(members.filter((member) => member.userId !== userId)); - }; + if (error) { + return ( +
+
+

Failed to load workspace

+

Please try again later.

+
+ + ← Back to Workspaces + +
+ ); + } - const handleInviteMember = async (email: string, role: WorkspaceMemberRole): Promise => { - // TODO: Replace with real API call - console.log("Inviting member:", { email, role, workspaceId: params.id }); - await new Promise((resolve) => setTimeout(resolve, 1000)); - // In real implementation, this would send an invitation email - }; + const currentUserId = authUser?.id ?? ""; + const currentMember = members.find((m) => m.userId === currentUserId); + const currentUserRole = currentMember?.role ?? workspace?.role ?? WorkspaceMemberRole.MEMBER; + const ownerId = + members.find((m) => m.role === WorkspaceMemberRole.OWNER)?.userId ?? workspace?.ownerId ?? ""; return ( -
-
-
-

{workspace.name}

- - ← Back to Workspaces - -
-

Manage workspace settings and team members

+
+
+ + ← Back to Workspaces + +

{workspace?.name ?? "Workspace"}

-
- {/* Workspace Settings */} - - - {/* Members Section */} -
-
- -
- - {/* Invite Member */} - {canInvite && ( -
- -
- )} -
-
-
+ + ); } diff --git a/apps/web/src/lib/api/workspaces.ts b/apps/web/src/lib/api/workspaces.ts index 02fcd33..724e004 100644 --- a/apps/web/src/lib/api/workspaces.ts +++ b/apps/web/src/lib/api/workspaces.ts @@ -4,12 +4,8 @@ */ import type { WorkspaceMemberRole } from "@mosaic/shared"; -import { apiGet, apiPost } from "./client"; +import { apiDelete, apiGet, apiPatch, apiPost } from "./client"; -/** - * A workspace entry from the user's membership list. - * Matches WorkspaceResponseDto from the API. - */ export interface UserWorkspace { id: string; name: string; @@ -32,17 +28,57 @@ export interface CreatedWorkspace { memberCount: number; } -/** - * Fetch all workspaces the authenticated user is a member of. - * The API auto-provisions a default workspace if the user has none. - */ +export interface WorkspaceMemberUser { + id: string; + email: string; + name: string | null; + image: string | null; +} + +export interface WorkspaceMemberEntry { + workspaceId: string; + userId: string; + role: WorkspaceMemberRole; + joinedAt: string; + user: WorkspaceMemberUser; +} + +export interface AddMemberDto { + userId: string; + role: WorkspaceMemberRole; +} + +export interface UpdateMemberRoleDto { + role: WorkspaceMemberRole; +} + export async function fetchUserWorkspaces(): Promise { return apiGet("/api/workspaces"); } -/** - * Create a workspace through the admin endpoint. - */ export async function createWorkspace(dto: CreateWorkspaceDto): Promise { return apiPost("/api/admin/workspaces", dto); } + +export async function fetchWorkspaceMembers(workspaceId: string): Promise { + return apiGet(`/api/workspaces/${workspaceId}/members`); +} + +export async function addWorkspaceMember( + workspaceId: string, + dto: AddMemberDto +): Promise { + return apiPost(`/api/workspaces/${workspaceId}/members`, dto); +} + +export async function updateWorkspaceMemberRole( + workspaceId: string, + userId: string, + dto: UpdateMemberRoleDto +): Promise { + return apiPatch(`/api/workspaces/${workspaceId}/members/${userId}`, dto); +} + +export async function removeWorkspaceMember(workspaceId: string, userId: string): Promise { + await apiDelete(`/api/workspaces/${workspaceId}/members/${userId}`); +}