feat(web): wire workspace member management UI (MS21-UI-004) (#577)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"),
|
|
||||||
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"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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: "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({
|
const load = useCallback(async (): Promise<void> => {
|
||||||
params,
|
setIsLoading(true);
|
||||||
}: WorkspaceDetailPageProps): React.JSX.Element {
|
setError(null);
|
||||||
const router = useRouter();
|
try {
|
||||||
const [workspace, setWorkspace] = useState<Workspace>(mockWorkspace);
|
const [workspaces, memberList] = await Promise.all([
|
||||||
const [members, setMembers] = useState<WorkspaceMemberWithUser[]>(mockMembers);
|
fetchUserWorkspaces(),
|
||||||
const currentUserId = "user-1"; // TODO: Get from auth context
|
fetchWorkspaceMembers(workspaceId),
|
||||||
const currentUserRole: WorkspaceMemberRole = WorkspaceMemberRole.OWNER; // TODO: Get from API
|
]);
|
||||||
|
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]);
|
||||||
|
|
||||||
// TODO: Replace with actual role check when API is implemented
|
useEffect(() => {
|
||||||
// Currently hardcoded to OWNER in mock data (line 89)
|
void load();
|
||||||
const canInvite =
|
}, [load]);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
||||||
currentUserRole === WorkspaceMemberRole.OWNER || currentUserRole === WorkspaceMemberRole.ADMIN;
|
|
||||||
|
|
||||||
const handleUpdateWorkspace = async (name: string): Promise<void> => {
|
const handleRoleChange = useCallback(
|
||||||
// TODO: Replace with real API call
|
async (userId: string, newRole: WorkspaceMemberRole): Promise<void> => {
|
||||||
console.log("Updating workspace:", { id: params.id, name });
|
await updateWorkspaceMemberRole(workspaceId, userId, { role: newRole });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await load();
|
||||||
setWorkspace({ ...workspace, name, updatedAt: new Date() });
|
},
|
||||||
};
|
[workspaceId, load]
|
||||||
|
|
||||||
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> => {
|
const handleRemove = useCallback(
|
||||||
// TODO: Replace with real API call
|
async (userId: string): Promise<void> => {
|
||||||
console.log("Removing member:", userId);
|
await removeWorkspaceMember(workspaceId, userId);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await load();
|
||||||
setMembers(members.filter((member) => member.userId !== userId));
|
},
|
||||||
};
|
[workspaceId, load]
|
||||||
|
);
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8 max-w-5xl">
|
<div className="p-8 text-center text-gray-500" role="status" aria-label="Loading workspace">
|
||||||
<div className="mb-8">
|
Loading workspace…
|
||||||
<div className="flex items-center justify-between mb-2">
|
</div>
|
||||||
<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">
|
}
|
||||||
|
|
||||||
|
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
|
← Back to Workspaces
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600">Manage workspace settings and team members</p>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
|
||||||
<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
|
<MemberList
|
||||||
members={members}
|
members={members.map(toMemberWithUser)}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
currentUserRole={currentUserRole}
|
currentUserRole={currentUserRole}
|
||||||
workspaceOwnerId={workspace.ownerId}
|
workspaceOwnerId={ownerId}
|
||||||
onRoleChange={handleRoleChange}
|
onRoleChange={handleRoleChange}
|
||||||
onRemove={handleRemoveMember}
|
onRemove={handleRemove}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invite Member */}
|
|
||||||
{canInvite && (
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<InviteMember onInvite={handleInviteMember} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user