From bc89952f10edb28009f679640bcf8aca71fa3dab Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 28 Feb 2026 21:26:24 -0600 Subject: [PATCH] feat(web): add user edit/invite dialogs and workspace member management (MS21-UI-002, MS21-UI-004) --- .../settings/users/page.test.tsx | 160 +++++ .../(authenticated)/settings/users/page.tsx | 456 ++++++++----- .../settings/workspaces/page.test.tsx | 140 ++-- .../settings/workspaces/page.tsx | 597 +++++++++++++++--- apps/web/src/lib/api/admin.ts | 1 + 5 files changed, 1046 insertions(+), 308 deletions(-) create mode 100644 apps/web/src/app/(authenticated)/settings/users/page.test.tsx diff --git a/apps/web/src/app/(authenticated)/settings/users/page.test.tsx b/apps/web/src/app/(authenticated)/settings/users/page.test.tsx new file mode 100644 index 0000000..e65167e --- /dev/null +++ b/apps/web/src/app/(authenticated)/settings/users/page.test.tsx @@ -0,0 +1,160 @@ +import type { ReactElement, ReactNode } from "react"; + +import { WorkspaceMemberRole } from "@mosaic/shared"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + deactivateUser, + fetchAdminUsers, + inviteUser, + updateUser, + type AdminUsersResponse, +} from "@/lib/api/admin"; +import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces"; +import UsersSettingsPage from "./page"; + +vi.mock("next/link", () => ({ + default: function LinkMock({ + children, + href, + }: { + children: ReactNode; + href: string; + }): ReactElement { + return {children}; + }, +})); + +vi.mock("@/lib/api/admin", () => ({ + fetchAdminUsers: vi.fn(), + inviteUser: vi.fn(), + updateUser: vi.fn(), + deactivateUser: vi.fn(), +})); + +vi.mock("@/lib/api/workspaces", () => ({ + fetchUserWorkspaces: vi.fn(), + updateWorkspaceMemberRole: vi.fn(), +})); + +const fetchAdminUsersMock = vi.mocked(fetchAdminUsers); +const inviteUserMock = vi.mocked(inviteUser); +const updateUserMock = vi.mocked(updateUser); +const deactivateUserMock = vi.mocked(deactivateUser); +const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces); +const updateWorkspaceMemberRoleMock = vi.mocked(updateWorkspaceMemberRole); + +const adminUsersResponse: AdminUsersResponse = { + data: [ + { + id: "user-1", + name: "Alice", + email: "alice@example.com", + emailVerified: true, + image: null, + createdAt: "2026-01-01T00:00:00.000Z", + deactivatedAt: null, + isLocalAuth: false, + invitedAt: null, + invitedBy: null, + workspaceMemberships: [ + { + workspaceId: "workspace-1", + workspaceName: "Personal Workspace", + role: WorkspaceMemberRole.ADMIN, + joinedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }, + ], + meta: { + total: 1, + page: 1, + limit: 50, + totalPages: 1, + }, +}; + +describe("UsersSettingsPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + + fetchAdminUsersMock.mockResolvedValue(adminUsersResponse); + fetchUserWorkspacesMock.mockResolvedValue([ + { + id: "workspace-1", + name: "Personal Workspace", + ownerId: "owner-1", + role: WorkspaceMemberRole.OWNER, + createdAt: "2026-01-01T00:00:00.000Z", + }, + ]); + inviteUserMock.mockResolvedValue({ + userId: "user-2", + invitationToken: "token-1", + email: "new@example.com", + invitedAt: "2026-01-02T00:00:00.000Z", + }); + const firstUser = adminUsersResponse.data[0]; + if (!firstUser) { + throw new Error("Expected at least one admin user in test fixtures"); + } + + updateUserMock.mockResolvedValue(firstUser); + deactivateUserMock.mockResolvedValue(firstUser); + updateWorkspaceMemberRoleMock.mockResolvedValue({ + workspaceId: "workspace-1", + userId: "user-1", + role: WorkspaceMemberRole.ADMIN, + joinedAt: "2026-01-01T00:00:00.000Z", + user: { + id: "user-1", + email: "alice@example.com", + name: "Alice", + image: null, + }, + }); + }); + + it("invites a user with email and role from the dialog", async () => { + const user = userEvent.setup(); + render(); + + expect(await screen.findByText("User Directory")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Invite User" })); + await user.type(screen.getByLabelText("Email"), "new@example.com"); + await user.click(screen.getByRole("button", { name: "Send Invite" })); + + await waitFor(() => { + expect(inviteUserMock).toHaveBeenCalledWith({ + email: "new@example.com", + role: WorkspaceMemberRole.MEMBER, + workspaceId: "workspace-1", + }); + }); + }); + + it("opens user detail dialog from row click and saves edited profile fields", async () => { + const user = userEvent.setup(); + render(); + + expect(await screen.findByText("alice@example.com")).toBeInTheDocument(); + + await user.click(screen.getByText("Alice")); + + const nameInput = await screen.findByLabelText("Name"); + await user.clear(nameInput); + await user.type(nameInput, "Alice Updated"); + + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(updateUserMock).toHaveBeenCalledWith("user-1", { name: "Alice Updated" }); + }); + + expect(updateWorkspaceMemberRoleMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/(authenticated)/settings/users/page.tsx b/apps/web/src/app/(authenticated)/settings/users/page.tsx index e177bf4..bc3bde8 100644 --- a/apps/web/src/app/(authenticated)/settings/users/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/users/page.tsx @@ -5,12 +5,14 @@ import { useEffect, useState, type ChangeEvent, + type KeyboardEvent, type ReactElement, type SyntheticEvent, } from "react"; import Link from "next/link"; -import { Pencil, UserPlus, UserX } from "lucide-react"; +import { UserPlus, UserX } from "lucide-react"; import { WorkspaceMemberRole } from "@mosaic/shared"; +import { isValidEmail } from "@/components/workspace/validation"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -42,7 +44,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { fetchUserWorkspaces } from "@/lib/api/workspaces"; import { deactivateUser, fetchAdminUsers, @@ -50,9 +51,11 @@ import { updateUser, type AdminUser, type AdminUsersResponse, + type AdminWorkspaceMembership, type InviteUserDto, type UpdateUserDto, } from "@/lib/api/admin"; +import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces"; const ROLE_PRIORITY: Record = { [WorkspaceMemberRole.OWNER]: 4, @@ -63,27 +66,40 @@ const ROLE_PRIORITY: Record = { const INITIAL_INVITE_FORM = { email: "", - name: "", - workspaceId: "", role: WorkspaceMemberRole.MEMBER, }; +const INITIAL_DETAIL_FORM = { + name: "", + email: "", + role: WorkspaceMemberRole.MEMBER, + workspaceId: null as string | null, + workspaceName: null as string | null, +}; + +interface DetailInitialState { + name: string; + email: string; + role: WorkspaceMemberRole; + workspaceId: string | null; +} + function toRoleLabel(role: WorkspaceMemberRole): string { return `${role.charAt(0)}${role.slice(1).toLowerCase()}`; } -function getPrimaryRole(user: AdminUser): WorkspaceMemberRole | null { +function getPrimaryMembership(user: AdminUser): AdminWorkspaceMembership | null { const [firstMembership, ...restMemberships] = user.workspaceMemberships; if (!firstMembership) { return null; } return restMemberships.reduce((highest, membership) => { - if (ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highest]) { - return membership.role; + if (ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highest.role]) { + return membership; } return highest; - }, firstMembership.role); + }, firstMembership); } export default function UsersSettingsPage(): ReactElement { @@ -93,21 +109,23 @@ export default function UsersSettingsPage(): ReactElement { const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); + const [defaultWorkspaceId, setDefaultWorkspaceId] = useState(null); + const [isAdmin, setIsAdmin] = useState(null); + const [isInviteOpen, setIsInviteOpen] = useState(false); const [inviteForm, setInviteForm] = useState(INITIAL_INVITE_FORM); const [inviteError, setInviteError] = useState(null); const [isInviting, setIsInviting] = useState(false); + const [detailTarget, setDetailTarget] = useState(null); + const [detailForm, setDetailForm] = useState(INITIAL_DETAIL_FORM); + const [detailInitial, setDetailInitial] = useState(null); + const [detailError, setDetailError] = useState(null); + const [isSavingDetails, setIsSavingDetails] = useState(false); + const [deactivateTarget, setDeactivateTarget] = useState(null); const [isDeactivating, setIsDeactivating] = useState(false); - const [editTarget, setEditTarget] = useState(null); - const [editName, setEditName] = useState(""); - const [editError, setEditError] = useState(null); - const [isEditing, setIsEditing] = useState(false); - - const [isAdmin, setIsAdmin] = useState(null); - const loadUsers = useCallback(async (showLoadingState: boolean): Promise => { try { if (showLoadingState) { @@ -139,9 +157,12 @@ export default function UsersSettingsPage(): ReactElement { WorkspaceMemberRole.OWNER, WorkspaceMemberRole.ADMIN, ]; - setIsAdmin(workspaces.some((ws) => adminRoles.includes(ws.role))); + + setDefaultWorkspaceId(workspaces[0]?.id ?? null); + setIsAdmin(workspaces.some((workspace) => adminRoles.includes(workspace.role))); }) .catch(() => { + setDefaultWorkspaceId(null); setIsAdmin(true); // fail open }); }, []); @@ -151,15 +172,44 @@ export default function UsersSettingsPage(): ReactElement { setInviteError(null); } - function handleInviteOpenChange(open: boolean): void { - if (!open && !isInviting) { - resetInviteForm(); - } - setIsInviteOpen(open); + function openUserDetails(user: AdminUser): void { + const primaryMembership = getPrimaryMembership(user); + + const nextDetailForm = { + name: user.name, + email: user.email, + role: primaryMembership?.role ?? WorkspaceMemberRole.MEMBER, + workspaceId: primaryMembership?.workspaceId ?? null, + workspaceName: primaryMembership?.workspaceName ?? null, + }; + + setDetailTarget(user); + setDetailForm(nextDetailForm); + setDetailInitial({ + name: nextDetailForm.name, + email: nextDetailForm.email, + role: nextDetailForm.role, + workspaceId: nextDetailForm.workspaceId, + }); + setDetailError(null); } - async function handleInviteSubmit(e: SyntheticEvent): Promise { - e.preventDefault(); + function resetUserDetails(): void { + setDetailTarget(null); + setDetailForm(INITIAL_DETAIL_FORM); + setDetailInitial(null); + setDetailError(null); + } + + function handleUserRowKeyDown(event: KeyboardEvent, user: AdminUser): void { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openUserDetails(user); + } + } + + async function handleInviteSubmit(event: SyntheticEvent): Promise { + event.preventDefault(); setInviteError(null); const email = inviteForm.email.trim(); @@ -168,17 +218,18 @@ export default function UsersSettingsPage(): ReactElement { return; } - const dto: InviteUserDto = { email }; - - const name = inviteForm.name.trim(); - if (name) { - dto.name = name; + if (!isValidEmail(email)) { + setInviteError("Please enter a valid email address."); + return; } - const workspaceId = inviteForm.workspaceId.trim(); - if (workspaceId) { - dto.workspaceId = workspaceId; - dto.role = inviteForm.role; + const dto: InviteUserDto = { + email, + role: inviteForm.role, + }; + + if (defaultWorkspaceId) { + dto.workspaceId = defaultWorkspaceId; } try { @@ -194,6 +245,75 @@ export default function UsersSettingsPage(): ReactElement { } } + async function handleDetailSubmit(event: SyntheticEvent): Promise { + event.preventDefault(); + + if (detailTarget === null || detailInitial === null) { + return; + } + + const name = detailForm.name.trim(); + const email = detailForm.email.trim(); + + if (!name) { + setDetailError("Name is required."); + return; + } + + if (!email) { + setDetailError("Email is required."); + return; + } + + if (!isValidEmail(email)) { + setDetailError("Please enter a valid email address."); + return; + } + + const didUpdateUser = name !== detailInitial.name || email !== detailInitial.email; + const didUpdateRole = + detailForm.workspaceId !== null && + detailForm.workspaceId === detailInitial.workspaceId && + detailForm.role !== detailInitial.role; + + if (!didUpdateUser && !didUpdateRole) { + resetUserDetails(); + return; + } + + try { + setIsSavingDetails(true); + setDetailError(null); + + if (didUpdateUser) { + const dto: UpdateUserDto = {}; + + if (name !== detailInitial.name) { + dto.name = name; + } + + if (email !== detailInitial.email) { + dto.email = email; + } + + await updateUser(detailTarget.id, dto); + } + + if (didUpdateRole && detailForm.workspaceId !== null) { + await updateWorkspaceMemberRole(detailForm.workspaceId, detailTarget.id, { + role: detailForm.role, + }); + } + + resetUserDetails(); + await loadUsers(false); + } catch (err: unknown) { + setDetailError(err instanceof Error ? err.message : "Failed to update user"); + } finally { + setIsSavingDetails(false); + } + } + async function confirmDeactivate(): Promise { if (!deactivateTarget) { return; @@ -212,23 +332,6 @@ export default function UsersSettingsPage(): ReactElement { } } - async function handleEditSubmit(): Promise { - if (editTarget === null) return; - setIsEditing(true); - setEditError(null); - try { - const dto: UpdateUserDto = {}; - if (editName.trim()) dto.name = editName.trim(); - await updateUser(editTarget.id, dto); - setEditTarget(null); - await loadUsers(false); - } catch (err: unknown) { - setEditError(err instanceof Error ? err.message : "Failed to update user"); - } finally { - setIsEditing(false); - } - } - if (isAdmin === false) { return (
@@ -262,7 +365,15 @@ export default function UsersSettingsPage(): ReactElement { {isRefreshing ? "Refreshing..." : "Refresh"} - + { + if (!open && !isInviting) { + resetInviteForm(); + } + setIsInviteOpen(open); + }} + >
-
- - ) => { - setInviteForm((prev) => ({ ...prev, name: e.target.value })); - }} - placeholder="Jane Doe" - maxLength={255} - /> -
- -
- - ) => { - setInviteForm((prev) => ({ ...prev, workspaceId: e.target.value })); - }} - placeholder="UUID workspace id" - /> -
-
-

- Role is only applied when workspace ID is provided. -

+ {defaultWorkspaceId ? ( +

Role will be applied on invite.

+ ) : ( +

+ No default workspace found. User will be invited without workspace assignment. +

+ )}
{inviteError ? ( @@ -360,7 +448,10 @@ export default function UsersSettingsPage(): ReactElement { type="button" variant="outline" onClick={() => { - handleInviteOpenChange(false); + if (!isInviting) { + setIsInviteOpen(false); + resetInviteForm(); + } }} disabled={isInviting} > @@ -409,17 +500,25 @@ export default function UsersSettingsPage(): ReactElement { User Directory - Name, email, role, and account status. + Click a user to view details or edit profile fields. {users.map((user) => { - const primaryRole = getPrimaryRole(user); + const primaryMembership = getPrimaryMembership(user); const isActive = user.deactivatedAt === null; return (
{ + openUserDetails(user); + }} + onKeyDown={(event) => { + handleUserRowKeyDown(event, user); + }} >

{user.name || "Unnamed User"}

@@ -428,28 +527,17 @@ export default function UsersSettingsPage(): ReactElement {
- {primaryRole ? toRoleLabel(primaryRole) : "No role"} + {primaryMembership ? toRoleLabel(primaryMembership.role) : "No role"} {isActive ? "Active" : "Inactive"} - {isActive ? ( + + + + + + { @@ -496,55 +695,4 @@ export default function UsersSettingsPage(): ReactElement {
); - - { - if (!open && !isEditing) { - setEditTarget(null); - setEditError(null); - } - }} - > - - - Edit User Role - Change role for {editTarget?.email ?? "user"}. - -
- {editError !== null ?

{editError}

: null} -
- - ) => { - setEditName(e.target.value); - }} - placeholder="Full name" - disabled={isEditing} - /> -
-
- - - - -
-
; } diff --git a/apps/web/src/app/(authenticated)/settings/workspaces/page.test.tsx b/apps/web/src/app/(authenticated)/settings/workspaces/page.test.tsx index c8310f1..cf1f2cd 100644 --- a/apps/web/src/app/(authenticated)/settings/workspaces/page.test.tsx +++ b/apps/web/src/app/(authenticated)/settings/workspaces/page.test.tsx @@ -1,12 +1,12 @@ -import type { UserWorkspace } from "@/lib/api/workspaces"; +import type { UserWorkspace, WorkspaceMemberEntry } from "@/lib/api/workspaces"; import type { ReactElement, ReactNode } from "react"; import { WorkspaceMemberRole } from "@mosaic/shared"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createWorkspace, fetchUserWorkspaces } from "@/lib/api/workspaces"; +import { createWorkspace, fetchUserWorkspaces, fetchWorkspaceMembers } from "@/lib/api/workspaces"; import WorkspacesPage from "./page"; vi.mock("next/link", () => ({ @@ -21,33 +21,23 @@ vi.mock("next/link", () => ({ }, })); -vi.mock("@/components/workspace/WorkspaceCard", () => ({ - WorkspaceCard: function WorkspaceCardMock({ - workspace, - userRole, - memberCount, - }: { - workspace: { name: string }; - userRole: WorkspaceMemberRole; - memberCount: number; - }): ReactElement { - return ( -
- {workspace.name} | {userRole} | {String(memberCount)} -
- ); - }, -})); - vi.mock("@/lib/api/workspaces", () => ({ fetchUserWorkspaces: vi.fn(), createWorkspace: vi.fn(), + fetchWorkspaceMembers: vi.fn(), + addWorkspaceMember: vi.fn(), + removeWorkspaceMember: vi.fn(), +})); + +vi.mock("@/lib/api/admin", () => ({ + fetchAdminUsers: vi.fn(), })); const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces); const createWorkspaceMock = vi.mocked(createWorkspace); +const fetchWorkspaceMembersMock = vi.mocked(fetchWorkspaceMembers); -const baseWorkspace: UserWorkspace = { +const workspaceA: UserWorkspace = { id: "workspace-1", name: "Personal Workspace", ownerId: "owner-1", @@ -55,45 +45,93 @@ const baseWorkspace: UserWorkspace = { createdAt: "2026-01-01T00:00:00.000Z", }; +const workspaceB: UserWorkspace = { + id: "workspace-2", + name: "Client Workspace", + ownerId: "owner-2", + role: WorkspaceMemberRole.ADMIN, + createdAt: "2026-01-02T00:00:00.000Z", +}; + +const membersA: WorkspaceMemberEntry[] = [ + { + workspaceId: "workspace-1", + userId: "user-a", + role: WorkspaceMemberRole.OWNER, + joinedAt: "2026-01-03T00:00:00.000Z", + user: { + id: "user-a", + email: "alice@example.com", + name: "Alice", + image: null, + }, + }, +]; + +const membersB: WorkspaceMemberEntry[] = [ + { + workspaceId: "workspace-2", + userId: "user-b", + role: WorkspaceMemberRole.MEMBER, + joinedAt: "2026-01-04T00:00:00.000Z", + user: { + id: "user-b", + email: "bob@example.com", + name: "Bob", + image: null, + }, + }, +]; + describe("WorkspacesPage", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("loads and renders user workspaces from the API", async () => { - fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]); + it("loads workspaces and fetches members for the first workspace", async () => { + fetchUserWorkspacesMock.mockResolvedValue([workspaceA, workspaceB]); + fetchWorkspaceMembersMock.mockResolvedValue(membersA); render(); - expect(screen.getByText("Loading workspaces...")).toBeInTheDocument(); + expect(await screen.findByText("Your Workspaces (2)")).toBeInTheDocument(); + expect(await screen.findByText("Personal Workspace Members")).toBeInTheDocument(); - expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument(); - expect(screen.getByTestId("workspace-card")).toHaveTextContent("Personal Workspace"); - expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(fetchWorkspaceMembersMock).toHaveBeenCalledWith("workspace-1"); + }); + + expect(screen.getByText("alice@example.com")).toBeInTheDocument(); }); - it("shows fetch errors in the UI", async () => { - fetchUserWorkspacesMock.mockRejectedValue(new Error("Unable to load workspaces")); + it("switches selected workspace and reloads member list", async () => { + fetchUserWorkspacesMock.mockResolvedValue([workspaceA, workspaceB]); + fetchWorkspaceMembersMock.mockResolvedValueOnce(membersA).mockResolvedValueOnce(membersB); + const user = userEvent.setup(); render(); - expect(await screen.findByText("Unable to load workspaces")).toBeInTheDocument(); + expect(await screen.findByText("Personal Workspace Members")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /client workspace/i })); + + await waitFor(() => { + expect(fetchWorkspaceMembersMock).toHaveBeenLastCalledWith("workspace-2"); + }); + + expect(await screen.findByText("Client Workspace Members")).toBeInTheDocument(); + expect(screen.getByText("bob@example.com")).toBeInTheDocument(); }); it("creates a workspace and refreshes the list", async () => { - fetchUserWorkspacesMock.mockResolvedValueOnce([baseWorkspace]).mockResolvedValueOnce([ - baseWorkspace, - { - ...baseWorkspace, - id: "workspace-2", - name: "New Workspace", - role: WorkspaceMemberRole.MEMBER, - }, - ]); + fetchUserWorkspacesMock + .mockResolvedValueOnce([workspaceA]) + .mockResolvedValueOnce([workspaceA, workspaceB]); + fetchWorkspaceMembersMock.mockResolvedValue(membersA); createWorkspaceMock.mockResolvedValue({ id: "workspace-2", - name: "New Workspace", - ownerId: "owner-1", + name: "Client Workspace", + ownerId: "owner-2", settings: {}, createdAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z", @@ -105,31 +143,17 @@ describe("WorkspacesPage", () => { expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument(); - await user.type(screen.getByPlaceholderText("Enter workspace name..."), "New Workspace"); + await user.type(screen.getByPlaceholderText("Enter workspace name..."), "Client Workspace"); await user.click(screen.getByRole("button", { name: "Create Workspace" })); await waitFor(() => { - expect(createWorkspaceMock).toHaveBeenCalledWith({ name: "New Workspace" }); + expect(createWorkspaceMock).toHaveBeenCalledWith({ name: "Client Workspace" }); }); + await waitFor(() => { expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(2); }); expect(await screen.findByText("Your Workspaces (2)")).toBeInTheDocument(); }); - - it("shows create errors in the UI", async () => { - fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]); - createWorkspaceMock.mockRejectedValue(new Error("Workspace creation failed")); - - const user = userEvent.setup(); - render(); - - expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument(); - - await user.type(screen.getByPlaceholderText("Enter workspace name..."), "Bad Workspace"); - await user.click(screen.getByRole("button", { name: "Create Workspace" })); - - expect(await screen.findByText("Workspace creation failed")).toBeInTheDocument(); - }); }); diff --git a/apps/web/src/app/(authenticated)/settings/workspaces/page.tsx b/apps/web/src/app/(authenticated)/settings/workspaces/page.tsx index d50761e..58d06ab 100644 --- a/apps/web/src/app/(authenticated)/settings/workspaces/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/workspaces/page.tsx @@ -2,10 +2,51 @@ import type { ReactElement, SyntheticEvent } from "react"; -import { useCallback, useEffect, useState } from "react"; -import { WorkspaceCard } from "@/components/workspace/WorkspaceCard"; -import { createWorkspace, fetchUserWorkspaces, type UserWorkspace } from "@/lib/api/workspaces"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; +import { UserPlus, UserX } from "lucide-react"; +import { WorkspaceMemberRole } from "@mosaic/shared"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + addWorkspaceMember, + createWorkspace, + fetchUserWorkspaces, + fetchWorkspaceMembers, + removeWorkspaceMember, + type UserWorkspace, + type WorkspaceMemberEntry, +} from "@/lib/api/workspaces"; +import { fetchAdminUsers, type AdminUser } from "@/lib/api/admin"; function getErrorMessage(error: unknown, fallback: string): string { if (error instanceof Error) { @@ -15,18 +56,53 @@ function getErrorMessage(error: unknown, fallback: string): string { return fallback; } -/** - * Workspaces Page - * Fetches and creates workspaces through the real API. - */ +function toRoleLabel(role: WorkspaceMemberRole): string { + return `${role.charAt(0)}${role.slice(1).toLowerCase()}`; +} + +interface RemoveMemberTarget { + userId: string; + email: string; +} + +const ROLE_BADGE_CLASS: Record = { + [WorkspaceMemberRole.OWNER]: "border-purple-200 bg-purple-50 text-purple-700", + [WorkspaceMemberRole.ADMIN]: "border-blue-200 bg-blue-50 text-blue-700", + [WorkspaceMemberRole.MEMBER]: "border-green-200 bg-green-50 text-green-700", + [WorkspaceMemberRole.GUEST]: "border-gray-200 bg-gray-50 text-gray-700", +}; + export default function WorkspacesPage(): ReactElement { const [workspaces, setWorkspaces] = useState([]); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(null); + const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(null); + const [isCreating, setIsCreating] = useState(false); const [newWorkspaceName, setNewWorkspaceName] = useState(""); const [createError, setCreateError] = useState(null); + const [members, setMembers] = useState([]); + const [isMembersLoading, setIsMembersLoading] = useState(false); + const [membersError, setMembersError] = useState(null); + + const [isAddMemberOpen, setIsAddMemberOpen] = useState(false); + const [isAddingMember, setIsAddingMember] = useState(false); + const [addMemberError, setAddMemberError] = useState(null); + const [memberUserId, setMemberUserId] = useState(""); + const [memberRole, setMemberRole] = useState(WorkspaceMemberRole.MEMBER); + const [availableUsers, setAvailableUsers] = useState([]); + const [isLoadingUsers, setIsLoadingUsers] = useState(false); + + const [removeTarget, setRemoveTarget] = useState(null); + const [isRemovingMember, setIsRemovingMember] = useState(false); + + const selectedWorkspace = useMemo( + () => workspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? null, + [selectedWorkspaceId, workspaces] + ); + const loadWorkspaces = useCallback(async (): Promise => { setIsLoading(true); @@ -34,31 +110,57 @@ export default function WorkspacesPage(): ReactElement { const data = await fetchUserWorkspaces(); setWorkspaces(data); setLoadError(null); + setSelectedWorkspaceId((current) => { + if (current && data.some((workspace) => workspace.id === current)) { + return current; + } + + return data[0]?.id ?? null; + }); } catch (error) { setLoadError(getErrorMessage(error, "Failed to load workspaces")); + setSelectedWorkspaceId(null); } finally { setIsLoading(false); } }, []); + const loadMembers = useCallback(async (workspaceId: string): Promise => { + setIsMembersLoading(true); + setMembersError(null); + + try { + const data = await fetchWorkspaceMembers(workspaceId); + setMembers(data); + } catch (error) { + setMembersError(getErrorMessage(error, "Failed to load workspace members")); + setMembers([]); + } finally { + setIsMembersLoading(false); + } + }, []); + useEffect(() => { void loadWorkspaces(); }, [loadWorkspaces]); - const workspacesWithRoles = workspaces.map((workspace) => ({ - ...workspace, - settings: {}, - createdAt: new Date(workspace.createdAt), - updatedAt: new Date(workspace.createdAt), - userRole: workspace.role, - memberCount: 1, - })); + useEffect(() => { + if (!selectedWorkspaceId) { + setMembers([]); + setMembersError(null); + return; + } - const handleCreateWorkspace = async (e: SyntheticEvent): Promise => { - e.preventDefault(); + void loadMembers(selectedWorkspaceId); + }, [loadMembers, selectedWorkspaceId]); + + const handleCreateWorkspace = async (event: SyntheticEvent): Promise => { + event.preventDefault(); const workspaceName = newWorkspaceName.trim(); - if (!workspaceName) return; + if (!workspaceName) { + return; + } setIsCreating(true); setCreateError(null); @@ -74,91 +176,394 @@ export default function WorkspacesPage(): ReactElement { } }; + const eligibleUsers = useMemo(() => { + const memberIds = new Set(members.map((member) => member.userId)); + return availableUsers.filter((user) => !memberIds.has(user.id)); + }, [availableUsers, members]); + + const loadAvailableUsers = useCallback(async (): Promise => { + setIsLoadingUsers(true); + + try { + const response = await fetchAdminUsers(1, 200); + const activeUsers = response.data.filter((user) => user.deactivatedAt === null); + setAvailableUsers(activeUsers); + + if (memberUserId && activeUsers.some((user) => user.id === memberUserId)) { + return; + } + + const memberIds = new Set(members.map((member) => member.userId)); + const firstEligible = activeUsers.find((user) => !memberIds.has(user.id)); + setMemberUserId(firstEligible?.id ?? ""); + } catch (error) { + setAddMemberError(getErrorMessage(error, "Failed to load users for member assignment")); + setAvailableUsers([]); + setMemberUserId(""); + } finally { + setIsLoadingUsers(false); + } + }, [memberUserId, members]); + + const openAddMemberDialog = async (): Promise => { + setAddMemberError(null); + setMemberRole(WorkspaceMemberRole.MEMBER); + setIsAddMemberOpen(true); + await loadAvailableUsers(); + }; + + const handleAddMember = async (event: SyntheticEvent): Promise => { + event.preventDefault(); + + if (!selectedWorkspaceId) { + setAddMemberError("Select a workspace before adding members."); + return; + } + + if (!memberUserId) { + setAddMemberError("Select a user to add."); + return; + } + + setIsAddingMember(true); + setAddMemberError(null); + + try { + await addWorkspaceMember(selectedWorkspaceId, { + userId: memberUserId, + role: memberRole, + }); + setIsAddMemberOpen(false); + await loadMembers(selectedWorkspaceId); + } catch (error) { + setAddMemberError(getErrorMessage(error, "Failed to add member")); + } finally { + setIsAddingMember(false); + } + }; + + const handleRemoveMember = async (): Promise => { + if (!selectedWorkspaceId || !removeTarget) { + return; + } + + setIsRemovingMember(true); + + try { + await removeWorkspaceMember(selectedWorkspaceId, removeTarget.userId); + setRemoveTarget(null); + await loadMembers(selectedWorkspaceId); + } catch (error) { + setMembersError(getErrorMessage(error, "Failed to remove member")); + } finally { + setIsRemovingMember(false); + } + }; + return ( -
-
-
-

Workspaces

- - ← Back to Settings - +
+
+
+

Workspaces

+

Manage workspaces and workspace members

-

Manage your workspaces and collaborate with your team

+ + ← Back to Settings +
- {/* Create New Workspace */} -
-

Create New Workspace

-
- { - setNewWorkspaceName(e.target.value); - }} - placeholder="Enter workspace name..." - disabled={isCreating} - className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100" - /> - -
- {createError !== null && ( -
- {createError} -
- )} + + + Create New Workspace + Create a workspace for a new team or project. + + +
+ { + setNewWorkspaceName(event.target.value); + }} + placeholder="Enter workspace name..." + disabled={isCreating} + /> + +
+ {createError !== null ? ( +

+ {createError} +

+ ) : null} +
+
+ + {loadError !== null ? ( + + +

+ {loadError} +

+
+
+ ) : null} + +
+ + + Your Workspaces ({isLoading ? "..." : workspaces.length}) + Click a workspace to manage its members. + + + {isLoading ? ( +

Loading workspaces...

+ ) : workspaces.length === 0 ? ( +

+ No workspaces yet. Create one to begin. +

+ ) : ( + workspaces.map((workspace) => { + const isSelected = selectedWorkspaceId === workspace.id; + + return ( + + ); + }) + )} +
+
+ + + +
+
+ + {selectedWorkspace ? `${selectedWorkspace.name} Members` : "Workspace Members"} + + + {selectedWorkspace + ? "Manage member roles and access for this workspace." + : "Select a workspace to view its members."} + +
+ + { + if (!open && !isAddingMember) { + setIsAddMemberOpen(false); + setAddMemberError(null); + } + }} + > + + + + + + Add Workspace Member + + Add an existing user to {selectedWorkspace?.name ?? "this workspace"}. + + + +
{ + void handleAddMember(event); + }} + className="space-y-4" + > +
+ + +
+ +
+ + +
+ + {addMemberError !== null ? ( +

+ {addMemberError} +

+ ) : null} + + + + + +
+
+
+
+
+ + + {selectedWorkspace === null ? ( +

Select a workspace to view members.

+ ) : membersError !== null ? ( +

+ {membersError} +

+ ) : isMembersLoading ? ( +

Loading members...

+ ) : members.length === 0 ? ( +

No members found for this workspace.

+ ) : ( + members.map((member) => ( +
+
+

{member.user.name ?? "Unnamed User"}

+

{member.user.email}

+
+ +
+ + {toRoleLabel(member.role)} + + + +
+
+ )) + )} +
+
- {/* Workspace List */} -
-

- Your Workspaces ({isLoading ? "..." : workspacesWithRoles.length}) -

- {loadError !== null ? ( -
- {loadError} -
- ) : isLoading ? ( -
- Loading workspaces... -
- ) : workspacesWithRoles.length === 0 ? ( -
- { + if (!open && !isRemovingMember) { + setRemoveTarget(null); + } + }} + > + + + Remove Workspace Member + + Remove {removeTarget?.email} from {selectedWorkspace?.name}? They will lose access to + this workspace. + + + + Cancel + { + void handleRemoveMember(); + }} > - - -

No workspaces yet

-

Create your first workspace to get started

-
- ) : ( -
- {workspacesWithRoles.map((workspace) => ( - - ))} -
- )} -
+ {isRemovingMember ? "Removing..." : "Remove"} + + + +
); } diff --git a/apps/web/src/lib/api/admin.ts b/apps/web/src/lib/api/admin.ts index cb032ac..4ecc3aa 100644 --- a/apps/web/src/lib/api/admin.ts +++ b/apps/web/src/lib/api/admin.ts @@ -53,6 +53,7 @@ export interface InvitationResponse { export interface UpdateUserDto { name?: string; + email?: string; deactivatedAt?: string | null; emailVerified?: boolean; preferences?: Record;