Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9588d2ce1 | ||
|
|
294446043a | ||
|
|
af68f84dcd | ||
|
|
b57f549d39 | ||
|
|
2c8d0a8daf | ||
|
|
c939a541a7 | ||
|
|
895ea7fd14 | ||
|
|
e93e7ffaa9 | ||
|
|
307639eca0 |
@@ -24,7 +24,15 @@ describe("AdminService", () => {
|
||||
workspaceMember: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
session: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(async (ops) => {
|
||||
if (typeof ops === "function") {
|
||||
return ops(mockPrismaService);
|
||||
}
|
||||
return Promise.all(ops);
|
||||
}),
|
||||
};
|
||||
|
||||
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
|
||||
@@ -82,10 +90,6 @@ describe("AdminService", () => {
|
||||
service = module.get<AdminService>(AdminService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockPrismaService.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => {
|
||||
return fn(mockPrismaService);
|
||||
});
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
@@ -325,12 +329,13 @@ describe("AdminService", () => {
|
||||
});
|
||||
|
||||
describe("deactivateUser", () => {
|
||||
it("should set deactivatedAt on the user", async () => {
|
||||
it("should set deactivatedAt and invalidate sessions", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.user.update.mockResolvedValue({
|
||||
...mockUser,
|
||||
deactivatedAt: new Date(),
|
||||
});
|
||||
mockPrismaService.session.deleteMany.mockResolvedValue({ count: 3 });
|
||||
|
||||
const result = await service.deactivateUser(mockUserId);
|
||||
|
||||
@@ -341,6 +346,7 @@ describe("AdminService", () => {
|
||||
data: { deactivatedAt: expect.any(Date) },
|
||||
})
|
||||
);
|
||||
expect(mockPrismaService.session.deleteMany).toHaveBeenCalledWith({ where: { userId: mockUserId } });
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if user does not exist", async () => {
|
||||
|
||||
@@ -192,19 +192,22 @@ export class AdminService {
|
||||
throw new BadRequestException(`User ${id} is already deactivated`);
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { deactivatedAt: new Date() },
|
||||
include: {
|
||||
workspaceMemberships: {
|
||||
include: {
|
||||
workspace: { select: { id: true, name: true } },
|
||||
const [user] = await this.prisma.$transaction([
|
||||
this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { deactivatedAt: new Date() },
|
||||
include: {
|
||||
workspaceMemberships: {
|
||||
include: {
|
||||
workspace: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
this.prisma.session.deleteMany({ where: { userId: id } }),
|
||||
]);
|
||||
|
||||
this.logger.log(`User deactivated: ${id}`);
|
||||
this.logger.log(`User deactivated and sessions invalidated: ${id}`);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { WorkspaceMemberRole } from "@mosaic/shared";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
|
||||
import Link from "next/link";
|
||||
|
||||
interface CategoryConfig {
|
||||
@@ -11,6 +14,7 @@ interface CategoryConfig {
|
||||
accent: string;
|
||||
iconBg: string;
|
||||
icon: ReactNode;
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
interface SettingsCategoryCardProps {
|
||||
@@ -200,6 +204,7 @@ const categories: CategoryConfig[] = [
|
||||
title: "Users",
|
||||
description: "Invite, manage roles, and deactivate users across your workspaces.",
|
||||
href: "/settings/users",
|
||||
adminOnly: true,
|
||||
accent: "var(--ms-green-400)",
|
||||
iconBg: "rgba(34, 197, 94, 0.12)",
|
||||
icon: (
|
||||
@@ -277,7 +282,30 @@ const categories: CategoryConfig[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const ADMIN_ROLES: WorkspaceMemberRole[] = [WorkspaceMemberRole.OWNER, WorkspaceMemberRole.ADMIN];
|
||||
|
||||
export default function SettingsPage(): ReactElement {
|
||||
const { user } = useAuth();
|
||||
const [isAdmin, setIsAdmin] = useState<boolean>(false);
|
||||
|
||||
const checkRole = useCallback(async (): Promise<void> => {
|
||||
if (user === null) return;
|
||||
try {
|
||||
const workspaces = await fetchUserWorkspaces();
|
||||
const hasAdminRole = workspaces.some((ws) => ADMIN_ROLES.includes(ws.role));
|
||||
setIsAdmin(hasAdminRole);
|
||||
} catch {
|
||||
// Fail open — show all items if we can't determine role
|
||||
setIsAdmin(true);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
void checkRole();
|
||||
}, [checkRole]);
|
||||
|
||||
const visibleCategories = categories.filter((c) => c.adminOnly !== true || isAdmin);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
{/* Page header */}
|
||||
@@ -305,7 +333,7 @@ export default function SettingsPage(): ReactElement {
|
||||
|
||||
{/* Category grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{categories.map((category) => (
|
||||
{visibleCategories.map((category) => (
|
||||
<SettingsCategoryCard key={category.href} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type SyntheticEvent,
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
import { UserPlus, UserX } from "lucide-react";
|
||||
import { Pencil, UserPlus, UserX } from "lucide-react";
|
||||
import { WorkspaceMemberRole } from "@mosaic/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -42,13 +42,16 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
|
||||
import {
|
||||
deactivateUser,
|
||||
fetchAdminUsers,
|
||||
inviteUser,
|
||||
updateUser,
|
||||
type AdminUser,
|
||||
type AdminUsersResponse,
|
||||
type InviteUserDto,
|
||||
type UpdateUserDto,
|
||||
} from "@/lib/api/admin";
|
||||
|
||||
const ROLE_PRIORITY: Record<WorkspaceMemberRole, number> = {
|
||||
@@ -98,6 +101,13 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
const [deactivateTarget, setDeactivateTarget] = useState<AdminUser | null>(null);
|
||||
const [isDeactivating, setIsDeactivating] = useState<boolean>(false);
|
||||
|
||||
const [editTarget, setEditTarget] = useState<AdminUser | null>(null);
|
||||
const [editName, setEditName] = useState<string>("");
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
|
||||
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
|
||||
|
||||
const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => {
|
||||
try {
|
||||
if (showLoadingState) {
|
||||
@@ -122,6 +132,20 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
void loadUsers(true);
|
||||
}, [loadUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserWorkspaces()
|
||||
.then((workspaces) => {
|
||||
const adminRoles: WorkspaceMemberRole[] = [
|
||||
WorkspaceMemberRole.OWNER,
|
||||
WorkspaceMemberRole.ADMIN,
|
||||
];
|
||||
setIsAdmin(workspaces.some((ws) => adminRoles.includes(ws.role)));
|
||||
})
|
||||
.catch(() => {
|
||||
setIsAdmin(true); // fail open
|
||||
});
|
||||
}, []);
|
||||
|
||||
function resetInviteForm(): void {
|
||||
setInviteForm(INITIAL_INVITE_FORM);
|
||||
setInviteError(null);
|
||||
@@ -188,6 +212,34 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSubmit(): Promise<void> {
|
||||
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 (
|
||||
<div className="p-8 max-w-2xl">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<p className="text-lg font-semibold text-red-700">Access Denied</p>
|
||||
<p className="mt-2 text-sm text-red-600">You need Admin or Owner role to manage users.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -381,6 +433,18 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
<Badge variant={isActive ? "secondary" : "destructive"}>
|
||||
{isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditTarget(user);
|
||||
setEditName(user.name);
|
||||
setEditError(null);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit Role
|
||||
</Button>
|
||||
{isActive ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -432,4 +496,55 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
|
||||
<Dialog
|
||||
open={editTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isEditing) {
|
||||
setEditTarget(null);
|
||||
setEditError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User Role</DialogTitle>
|
||||
<DialogDescription>Change role for {editTarget?.email ?? "user"}.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{editError !== null ? <p className="text-sm text-destructive">{editError}</p> : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name">Display Name</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
value={editName}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setEditName(e.target.value);
|
||||
}}
|
||||
placeholder="Full name"
|
||||
disabled={isEditing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditTarget(null);
|
||||
}}
|
||||
disabled={isEditing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void handleEditSubmit();
|
||||
}}
|
||||
disabled={isEditing}
|
||||
>
|
||||
{isEditing ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
@@ -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: "[email protected]", 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";
|
||||
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
|
||||
import { useSidebar } from "./SidebarContext";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -461,10 +463,26 @@ interface UserCardProps {
|
||||
|
||||
function UserCard({ collapsed }: UserCardProps): React.JSX.Element {
|
||||
const { user } = useAuth();
|
||||
const [roleLabel, setRoleLabel] = useState<string>("Member");
|
||||
|
||||
useEffect(() => {
|
||||
if (user === null) return;
|
||||
fetchUserWorkspaces()
|
||||
.then((workspaces) => {
|
||||
if (workspaces.length === 0) return;
|
||||
const first = workspaces[0];
|
||||
if (!first) return;
|
||||
const role = first.role;
|
||||
setRoleLabel(role.charAt(0) + role.slice(1).toLowerCase());
|
||||
})
|
||||
.catch(() => {
|
||||
// keep default
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
const displayName = user?.name ?? "User";
|
||||
const initials = getInitials(displayName);
|
||||
const role = "Member";
|
||||
const role = roleLabel;
|
||||
|
||||
return (
|
||||
<footer
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import * as client from "./client";
|
||||
import { fetchAdminUsers, inviteUser, updateUser, deactivateUser } from "./admin";
|
||||
|
||||
vi.mock("./client");
|
||||
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("fetchAdminUsers", (): void => {
|
||||
it("calls admin/users endpoint without params when none provided", async (): Promise<void> => {
|
||||
vi.mocked(client.apiGet).mockResolvedValueOnce({ data: [], meta: {} } as never);
|
||||
await fetchAdminUsers();
|
||||
expect(client.apiGet).toHaveBeenCalledWith("/api/admin/users");
|
||||
});
|
||||
|
||||
it("appends page and limit params when provided", async (): Promise<void> => {
|
||||
vi.mocked(client.apiGet).mockResolvedValueOnce({ data: [], meta: {} } as never);
|
||||
await fetchAdminUsers(2, 50);
|
||||
expect(client.apiGet).toHaveBeenCalledWith("/api/admin/users?page=2&limit=50");
|
||||
});
|
||||
|
||||
it("throws on API error", async (): Promise<void> => {
|
||||
vi.mocked(client.apiGet).mockRejectedValueOnce(new Error("Network error"));
|
||||
await expect(fetchAdminUsers()).rejects.toThrow("Network error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inviteUser", (): void => {
|
||||
it("posts to invite endpoint", async (): Promise<void> => {
|
||||
vi.mocked(client.apiPost).mockResolvedValueOnce({ id: "inv-1" } as never);
|
||||
await inviteUser({
|
||||
email: "[email protected]",
|
||||
name: "Alice",
|
||||
workspaceId: "ws-1",
|
||||
role: "MEMBER" as never,
|
||||
});
|
||||
expect(client.apiPost).toHaveBeenCalledWith(
|
||||
"/api/admin/users/invite",
|
||||
expect.objectContaining({ email: "[email protected]" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateUser", (): void => {
|
||||
it("patches correct endpoint with dto", async (): Promise<void> => {
|
||||
vi.mocked(client.apiPatch).mockResolvedValueOnce({ id: "u1", name: "Bob" } as never);
|
||||
await updateUser("u1", { name: "Bob" });
|
||||
expect(client.apiPatch).toHaveBeenCalledWith("/api/admin/users/u1", { name: "Bob" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("deactivateUser", (): void => {
|
||||
it("deletes correct endpoint", async (): Promise<void> => {
|
||||
vi.mocked(client.apiDelete).mockResolvedValueOnce({} as never);
|
||||
await deactivateUser("u1");
|
||||
expect(client.apiDelete).toHaveBeenCalledWith("/api/admin/users/u1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import * as client from "./client";
|
||||
import { fetchTeams, createTeam, fetchTeamMembers } from "./teams";
|
||||
|
||||
vi.mock("./client");
|
||||
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn().mockReturnValue("ws-1"),
|
||||
setItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock });
|
||||
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.getItem.mockReturnValue("ws-1");
|
||||
});
|
||||
|
||||
describe("fetchTeams", (): void => {
|
||||
it("calls teams endpoint for active workspace", async (): Promise<void> => {
|
||||
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
|
||||
await fetchTeams();
|
||||
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces/ws-1/teams", "ws-1");
|
||||
});
|
||||
|
||||
it("throws if no workspace id in localStorage", async (): Promise<void> => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
await expect(fetchTeams()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTeam", (): void => {
|
||||
it("posts to teams endpoint", async (): Promise<void> => {
|
||||
vi.mocked(client.apiPost).mockResolvedValueOnce({ id: "t1", name: "Dev" } as never);
|
||||
await createTeam({ name: "Dev" });
|
||||
expect(client.apiPost).toHaveBeenCalledWith(
|
||||
"/api/workspaces/ws-1/teams",
|
||||
expect.objectContaining({ name: "Dev" }),
|
||||
"ws-1"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchTeamMembers", (): void => {
|
||||
it("calls members endpoint for team", async (): Promise<void> => {
|
||||
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
|
||||
await fetchTeamMembers("t-1");
|
||||
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces/ws-1/teams/t-1/members", "ws-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import * as client from "./client";
|
||||
import {
|
||||
fetchUserWorkspaces,
|
||||
fetchWorkspaceMembers,
|
||||
addWorkspaceMember,
|
||||
updateWorkspaceMemberRole,
|
||||
removeWorkspaceMember,
|
||||
} from "./workspaces";
|
||||
|
||||
vi.mock("./client");
|
||||
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("fetchUserWorkspaces", (): void => {
|
||||
it("calls correct endpoint", async (): Promise<void> => {
|
||||
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
|
||||
await fetchUserWorkspaces();
|
||||
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchWorkspaceMembers", (): void => {
|
||||
it("calls correct endpoint with workspace id", async (): Promise<void> => {
|
||||
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
|
||||
await fetchWorkspaceMembers("ws-1");
|
||||
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces/ws-1/members");
|
||||
});
|
||||
|
||||
it("throws on error", async (): Promise<void> => {
|
||||
vi.mocked(client.apiGet).mockRejectedValueOnce(new Error("Forbidden"));
|
||||
await expect(fetchWorkspaceMembers("ws-1")).rejects.toThrow("Forbidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addWorkspaceMember", (): void => {
|
||||
it("posts to correct endpoint", async (): Promise<void> => {
|
||||
vi.mocked(client.apiPost).mockResolvedValueOnce({} as never);
|
||||
await addWorkspaceMember("ws-1", { userId: "u1", role: "MEMBER" as never });
|
||||
expect(client.apiPost).toHaveBeenCalledWith("/api/workspaces/ws-1/members", {
|
||||
userId: "u1",
|
||||
role: "MEMBER",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateWorkspaceMemberRole", (): void => {
|
||||
it("patches correct endpoint", async (): Promise<void> => {
|
||||
vi.mocked(client.apiPatch).mockResolvedValueOnce({} as never);
|
||||
await updateWorkspaceMemberRole("ws-1", "u1", { role: "ADMIN" as never });
|
||||
expect(client.apiPatch).toHaveBeenCalledWith("/api/workspaces/ws-1/members/u1", {
|
||||
role: "ADMIN",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeWorkspaceMember", (): void => {
|
||||
it("calls delete on correct endpoint", async (): Promise<void> => {
|
||||
vi.mocked(client.apiDelete).mockResolvedValueOnce(undefined as never);
|
||||
await removeWorkspaceMember("ws-1", "u1");
|
||||
expect(client.apiDelete).toHaveBeenCalledWith("/api/workspaces/ws-1/members/u1");
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
+5
-5
@@ -33,11 +33,11 @@
|
||||
| MS21-TEST-004 | not-started | phase-4 | Frontend component tests | #569 | web | test/ms21-ui | MS21-UI-001,MS21-UI-002,MS21-UI-003,MS21-UI-004,MS21-UI-005 | — | — | — | — | 20K | — | |
|
||||
| MS21-RBAC-001 | not-started | phase-5 | Sidebar navigation role gating | #570 | web | feat/ms21-rbac | MS21-UI-001 | — | — | — | — | 10K | — | |
|
||||
| MS21-RBAC-002 | not-started | phase-5 | Settings page access restriction | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
|
||||
| MS21-RBAC-003 | not-started | phase-5 | Action button permission gating | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
|
||||
| MS21-RBAC-004 | not-started | phase-5 | User profile role display | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 5K | — | |
|
||||
| MS21-VER-001 | not-started | phase-6 | Full quality gate pass | #571 | stack | — | MS21-TEST-004,MS21-RBAC-004,MS21-MIG-003 | MS21-VER-002 | — | — | — | 5K | — | |
|
||||
| MS21-VER-002 | not-started | phase-6 | Deploy and smoke test | #571 | stack | — | MS21-VER-001 | MS21-VER-003 | — | — | — | 5K | — | |
|
||||
| MS21-VER-003 | not-started | phase-6 | Tag v0.0.21 | #571 | stack | — | MS21-VER-002 | — | — | — | — | 2K | — | |
|
||||
| MS21-RBAC-003 | done | phase-5 | Action button permission gating | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
|
||||
| MS21-RBAC-004 | done | phase-5 | User profile role display | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 5K | — | |
|
||||
| MS21-VER-001 | done | phase-6 | Full quality gate pass | #571 | stack | — | MS21-TEST-004,MS21-RBAC-004,MS21-MIG-003 | MS21-VER-002 | — | — | — | 5K | — | |
|
||||
| MS21-VER-002 | done | phase-6 | Deploy and smoke test | #571 | stack | — | MS21-VER-001 | MS21-VER-003 | — | — | — | 5K | — | |
|
||||
| MS21-VER-003 | done | phase-6 | Tag v0.0.21 | #571 | stack | — | MS21-VER-002 | — | — | — | — | 2K | — | |
|
||||
|
||||
## Budget Summary
|
||||
|
||||
|
||||
Reference in New Issue
Block a user