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"; "use client";
import { useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useParams } 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 Link from "next/link"; 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 { function getErrorMessage(error: unknown, fallback: string): string {
params: { if (error instanceof Error) return error.message;
id: string; 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 export default function WorkspaceDetailPage(): React.ReactElement {
const mockWorkspace: Workspace = { const params = useParams<{ id: string }>();
id: "ws-1", const workspaceId = params.id;
name: "Personal Workspace", const { user: authUser } = useAuth();
ownerId: "user-1",
settings: {},
createdAt: new Date("2024-01-15"),
updatedAt: new Date("2024-01-15"),
};
const mockMembers: WorkspaceMemberWithUser[] = [ const [workspace, setWorkspace] = useState<UserWorkspace | null>(null);
{ const [members, setMembers] = useState<WorkspaceMemberEntry[]>([]);
workspaceId: "ws-1", const [isLoading, setIsLoading] = useState(true);
userId: "user-1", const [error, setError] = useState<string | null>(null);
role: WorkspaceMemberRole.OWNER,
joinedAt: new Date("2024-01-15"), const load = useCallback(async (): Promise<void> => {
user: { setIsLoading(true);
id: "user-1", setError(null);
email: "owner@example.com", try {
name: "John Doe", const [workspaces, memberList] = await Promise.all([
emailVerified: true, fetchUserWorkspaces(),
image: null, fetchWorkspaceMembers(workspaceId),
authProviderId: null, ]);
preferences: {}, const ws = workspaces.find((w) => w.id === workspaceId) ?? null;
deactivatedAt: null, setWorkspace(ws);
isLocalAuth: false, setMembers(memberList);
passwordHash: null, } catch (err) {
invitedBy: null, setError(getErrorMessage(err, "Failed to load workspace"));
invitationToken: null, } finally {
invitedAt: null, setIsLoading(false);
createdAt: new Date("2024-01-15"), }
updatedAt: new Date("2024-01-15"), }, [workspaceId]);
useEffect(() => {
void load();
}, [load]);
const handleRoleChange = useCallback(
async (userId: string, newRole: WorkspaceMemberRole): Promise<void> => {
await updateWorkspaceMemberRole(workspaceId, userId, { role: newRole });
await load();
}, },
}, [workspaceId, load]
{ );
workspaceId: "ws-1",
userId: "user-2", const handleRemove = useCallback(
role: WorkspaceMemberRole.ADMIN, async (userId: string): Promise<void> => {
joinedAt: new Date("2024-01-16"), await removeWorkspaceMember(workspaceId, userId);
user: { await load();
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]
{ );
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"),
},
},
];
export default function WorkspaceDetailPage({ if (isLoading) {
params, return (
}: WorkspaceDetailPageProps): React.JSX.Element { <div className="p-8 text-center text-gray-500" role="status" aria-label="Loading workspace">
const router = useRouter(); Loading workspace
const [workspace, setWorkspace] = useState<Workspace>(mockWorkspace); </div>
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))
); );
}; }
const handleRemoveMember = async (userId: string): Promise<void> => { if (error) {
// TODO: Replace with real API call return (
console.log("Removing member:", userId); <div className="p-8">
await new Promise((resolve) => setTimeout(resolve, 500)); <div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-700" role="alert">
setMembers(members.filter((member) => member.userId !== userId)); <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> => { const currentUserId = authUser?.id ?? "";
// TODO: Replace with real API call const currentMember = members.find((m) => m.userId === currentUserId);
console.log("Inviting member:", { email, role, workspaceId: params.id }); const currentUserRole = currentMember?.role ?? workspace?.role ?? WorkspaceMemberRole.MEMBER;
await new Promise((resolve) => setTimeout(resolve, 1000)); const ownerId =
// In real implementation, this would send an invitation email members.find((m) => m.role === WorkspaceMemberRole.OWNER)?.userId ?? workspace?.ownerId ?? "";
};
return ( return (
<main className="container mx-auto px-4 py-8 max-w-5xl"> <div className="p-8 max-w-3xl">
<div className="mb-8"> <div className="mb-6">
<div className="flex items-center justify-between mb-2"> <Link href="/settings/workspaces" className="text-sm text-gray-500 hover:text-gray-700">
<h1 className="text-3xl font-bold text-gray-900">{workspace.name}</h1> Back to Workspaces
<Link href="/settings/workspaces" className="text-sm text-blue-600 hover:text-blue-700"> </Link>
Back to Workspaces <h1 className="mt-2 text-2xl font-bold text-gray-900">{workspace?.name ?? "Workspace"}</h1>
</Link>
</div>
<p className="text-gray-600">Manage workspace settings and team members</p>
</div> </div>
<div className="space-y-6"> <MemberList
{/* Workspace Settings */} members={members.map(toMemberWithUser)}
<WorkspaceSettings currentUserId={currentUserId}
workspace={workspace} currentUserRole={currentUserRole}
userRole={currentUserRole} workspaceOwnerId={ownerId}
onUpdate={handleUpdateWorkspace} onRoleChange={handleRoleChange}
onDelete={handleDeleteWorkspace} onRemove={handleRemove}
/> />
</div>
{/* 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>
); );
} }

View File

@@ -4,12 +4,8 @@
*/ */
import type { WorkspaceMemberRole } from "@mosaic/shared"; 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 { export interface UserWorkspace {
id: string; id: string;
name: string; name: string;
@@ -32,17 +28,57 @@ export interface CreatedWorkspace {
memberCount: number; memberCount: number;
} }
/** export interface WorkspaceMemberUser {
* Fetch all workspaces the authenticated user is a member of. id: string;
* The API auto-provisions a default workspace if the user has none. 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[]> { export async function fetchUserWorkspaces(): Promise<UserWorkspace[]> {
return apiGet<UserWorkspace[]>("/api/workspaces"); return apiGet<UserWorkspace[]>("/api/workspaces");
} }
/**
* Create a workspace through the admin endpoint.
*/
export async function createWorkspace(dto: CreateWorkspaceDto): Promise<CreatedWorkspace> { export async function createWorkspace(dto: CreateWorkspaceDto): Promise<CreatedWorkspace> {
return apiPost<CreatedWorkspace>("/api/admin/workspaces", dto); 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}`);
}