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}`);
+}