feat(web): wire workspace member management UI (MS21-UI-004) (#577)
All checks were successful
ci/woodpecker/push/web 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 #577.
This commit is contained in:
2026-02-28 22:12:05 +00:00
committed by jason.woltje
parent 307639eca0
commit e93e7ffaa9
3 changed files with 240 additions and 188 deletions

View File

@@ -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 => (
<div data-testid="member-list">Members: {members.length}</div>
),
}));
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<void> => {
vi.mocked(workspacesApi.fetchUserWorkspaces).mockResolvedValue([mockWorkspace] as never);
vi.mocked(workspacesApi.fetchWorkspaceMembers).mockResolvedValue(mockMembers as never);
render(<WorkspaceDetailPage />);
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<void> => {
vi.mocked(workspacesApi.fetchUserWorkspaces).mockRejectedValue(new Error("Network error"));
vi.mocked(workspacesApi.fetchWorkspaceMembers).mockRejectedValue(new Error("Network error"));
render(<WorkspaceDetailPage />);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
expect(screen.queryByTestId("member-list")).not.toBeInTheDocument();
});
});

View File

@@ -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<UserWorkspace | null>(null);
const [members, setMembers] = useState<WorkspaceMemberEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (): Promise<void> => {
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<void> => {
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<void> => {
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<Workspace>(mockWorkspace);
const [members, setMembers] = useState<WorkspaceMemberWithUser[]>(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<void> => {
// 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<void> => {
// 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<void> => {
// 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 (
<div className="p-8 text-center text-gray-500" role="status" aria-label="Loading workspace">
Loading workspace
</div>
);
};
}
const handleRemoveMember = async (userId: string): Promise<void> => {
// 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 (
<div className="p-8">
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-700" role="alert">
<p className="font-medium">Failed to load workspace</p>
<p className="mt-1 text-sm">Please try again later.</p>
</div>
<Link
href="/settings/workspaces"
className="mt-4 inline-block text-sm text-blue-600 hover:underline"
>
Back to Workspaces
</Link>
</div>
);
}
const handleInviteMember = async (email: string, role: WorkspaceMemberRole): Promise<void> => {
// 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 (
<main className="container mx-auto px-4 py-8 max-w-5xl">
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-3xl font-bold text-gray-900">{workspace.name}</h1>
<Link href="/settings/workspaces" className="text-sm text-blue-600 hover:text-blue-700">
Back to Workspaces
</Link>
</div>
<p className="text-gray-600">Manage workspace settings and team members</p>
<div className="p-8 max-w-3xl">
<div className="mb-6">
<Link href="/settings/workspaces" className="text-sm text-gray-500 hover:text-gray-700">
Back to Workspaces
</Link>
<h1 className="mt-2 text-2xl font-bold text-gray-900">{workspace?.name ?? "Workspace"}</h1>
</div>
<div className="space-y-6">
{/* Workspace Settings */}
<WorkspaceSettings
workspace={workspace}
userRole={currentUserRole}
onUpdate={handleUpdateWorkspace}
onDelete={handleDeleteWorkspace}
/>
{/* Members Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="lg:col-span-2">
<MemberList
members={members}
currentUserId={currentUserId}
currentUserRole={currentUserRole}
workspaceOwnerId={workspace.ownerId}
onRoleChange={handleRoleChange}
onRemove={handleRemoveMember}
/>
</div>
{/* Invite Member */}
{canInvite && (
<div className="lg:col-span-2">
<InviteMember onInvite={handleInviteMember} />
</div>
)}
</div>
</div>
</main>
<MemberList
members={members.map(toMemberWithUser)}
currentUserId={currentUserId}
currentUserRole={currentUserRole}
workspaceOwnerId={ownerId}
onRoleChange={handleRoleChange}
onRemove={handleRemove}
/>
</div>
);
}

View File

@@ -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<UserWorkspace[]> {
return apiGet<UserWorkspace[]>("/api/workspaces");
}
/**
* Create a workspace through the admin endpoint.
*/
export async function createWorkspace(dto: CreateWorkspaceDto): Promise<CreatedWorkspace> {
return apiPost<CreatedWorkspace>("/api/admin/workspaces", dto);
}
export async function fetchWorkspaceMembers(workspaceId: string): Promise<WorkspaceMemberEntry[]> {
return apiGet<WorkspaceMemberEntry[]>(`/api/workspaces/${workspaceId}/members`);
}
export async function addWorkspaceMember(
workspaceId: string,
dto: AddMemberDto
): Promise<WorkspaceMemberEntry> {
return apiPost<WorkspaceMemberEntry>(`/api/workspaces/${workspaceId}/members`, dto);
}
export async function updateWorkspaceMemberRole(
workspaceId: string,
userId: string,
dto: UpdateMemberRoleDto
): Promise<WorkspaceMemberEntry> {
return apiPatch<WorkspaceMemberEntry>(`/api/workspaces/${workspaceId}/members/${userId}`, dto);
}
export async function removeWorkspaceMember(workspaceId: string, userId: string): Promise<void> {
await apiDelete<unknown>(`/api/workspaces/${workspaceId}/members/${userId}`);
}