Compare commits

..

1 Commits

Author SHA1 Message Date
7c7a821b0f feat: add OpenClaw session log ingestion script (MS22-INGEST-001)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-02-28 21:46:07 -06:00
7 changed files with 929 additions and 1045 deletions

View File

@@ -1,160 +0,0 @@
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 <a href={href}>{children}</a>;
},
}));
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(<UsersSettingsPage />);
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(<UsersSettingsPage />);
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();
});
});

View File

@@ -5,14 +5,12 @@ import {
useEffect, useEffect,
useState, useState,
type ChangeEvent, type ChangeEvent,
type KeyboardEvent,
type ReactElement, type ReactElement,
type SyntheticEvent, type SyntheticEvent,
} from "react"; } from "react";
import Link from "next/link"; import Link from "next/link";
import { UserPlus, UserX } from "lucide-react"; import { Pencil, UserPlus, UserX } from "lucide-react";
import { WorkspaceMemberRole } from "@mosaic/shared"; import { WorkspaceMemberRole } from "@mosaic/shared";
import { isValidEmail } from "@/components/workspace/validation";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -44,6 +42,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
import { import {
deactivateUser, deactivateUser,
fetchAdminUsers, fetchAdminUsers,
@@ -51,11 +50,9 @@ import {
updateUser, updateUser,
type AdminUser, type AdminUser,
type AdminUsersResponse, type AdminUsersResponse,
type AdminWorkspaceMembership,
type InviteUserDto, type InviteUserDto,
type UpdateUserDto, type UpdateUserDto,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
const ROLE_PRIORITY: Record<WorkspaceMemberRole, number> = { const ROLE_PRIORITY: Record<WorkspaceMemberRole, number> = {
[WorkspaceMemberRole.OWNER]: 4, [WorkspaceMemberRole.OWNER]: 4,
@@ -66,40 +63,27 @@ const ROLE_PRIORITY: Record<WorkspaceMemberRole, number> = {
const INITIAL_INVITE_FORM = { const INITIAL_INVITE_FORM = {
email: "", email: "",
role: WorkspaceMemberRole.MEMBER,
};
const INITIAL_DETAIL_FORM = {
name: "", name: "",
email: "", workspaceId: "",
role: WorkspaceMemberRole.MEMBER, 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 { function toRoleLabel(role: WorkspaceMemberRole): string {
return `${role.charAt(0)}${role.slice(1).toLowerCase()}`; return `${role.charAt(0)}${role.slice(1).toLowerCase()}`;
} }
function getPrimaryMembership(user: AdminUser): AdminWorkspaceMembership | null { function getPrimaryRole(user: AdminUser): WorkspaceMemberRole | null {
const [firstMembership, ...restMemberships] = user.workspaceMemberships; const [firstMembership, ...restMemberships] = user.workspaceMemberships;
if (!firstMembership) { if (!firstMembership) {
return null; return null;
} }
return restMemberships.reduce((highest, membership) => { return restMemberships.reduce((highest, membership) => {
if (ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highest.role]) { if (ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highest]) {
return membership; return membership.role;
} }
return highest; return highest;
}, firstMembership); }, firstMembership.role);
} }
export default function UsersSettingsPage(): ReactElement { export default function UsersSettingsPage(): ReactElement {
@@ -109,23 +93,21 @@ export default function UsersSettingsPage(): ReactElement {
const [isRefreshing, setIsRefreshing] = useState<boolean>(false); const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [defaultWorkspaceId, setDefaultWorkspaceId] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [isInviteOpen, setIsInviteOpen] = useState<boolean>(false); const [isInviteOpen, setIsInviteOpen] = useState<boolean>(false);
const [inviteForm, setInviteForm] = useState(INITIAL_INVITE_FORM); const [inviteForm, setInviteForm] = useState(INITIAL_INVITE_FORM);
const [inviteError, setInviteError] = useState<string | null>(null); const [inviteError, setInviteError] = useState<string | null>(null);
const [isInviting, setIsInviting] = useState<boolean>(false); const [isInviting, setIsInviting] = useState<boolean>(false);
const [detailTarget, setDetailTarget] = useState<AdminUser | null>(null);
const [detailForm, setDetailForm] = useState(INITIAL_DETAIL_FORM);
const [detailInitial, setDetailInitial] = useState<DetailInitialState | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const [isSavingDetails, setIsSavingDetails] = useState<boolean>(false);
const [deactivateTarget, setDeactivateTarget] = useState<AdminUser | null>(null); const [deactivateTarget, setDeactivateTarget] = useState<AdminUser | null>(null);
const [isDeactivating, setIsDeactivating] = useState<boolean>(false); 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> => { const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => {
try { try {
if (showLoadingState) { if (showLoadingState) {
@@ -157,12 +139,9 @@ export default function UsersSettingsPage(): ReactElement {
WorkspaceMemberRole.OWNER, WorkspaceMemberRole.OWNER,
WorkspaceMemberRole.ADMIN, WorkspaceMemberRole.ADMIN,
]; ];
setIsAdmin(workspaces.some((ws) => adminRoles.includes(ws.role)));
setDefaultWorkspaceId(workspaces[0]?.id ?? null);
setIsAdmin(workspaces.some((workspace) => adminRoles.includes(workspace.role)));
}) })
.catch(() => { .catch(() => {
setDefaultWorkspaceId(null);
setIsAdmin(true); // fail open setIsAdmin(true); // fail open
}); });
}, []); }, []);
@@ -172,44 +151,15 @@ export default function UsersSettingsPage(): ReactElement {
setInviteError(null); setInviteError(null);
} }
function openUserDetails(user: AdminUser): void { function handleInviteOpenChange(open: boolean): void {
const primaryMembership = getPrimaryMembership(user); if (!open && !isInviting) {
resetInviteForm();
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);
}
function resetUserDetails(): void {
setDetailTarget(null);
setDetailForm(INITIAL_DETAIL_FORM);
setDetailInitial(null);
setDetailError(null);
}
function handleUserRowKeyDown(event: KeyboardEvent<HTMLDivElement>, user: AdminUser): void {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openUserDetails(user);
} }
setIsInviteOpen(open);
} }
async function handleInviteSubmit(event: SyntheticEvent): Promise<void> { async function handleInviteSubmit(e: SyntheticEvent): Promise<void> {
event.preventDefault(); e.preventDefault();
setInviteError(null); setInviteError(null);
const email = inviteForm.email.trim(); const email = inviteForm.email.trim();
@@ -218,18 +168,17 @@ export default function UsersSettingsPage(): ReactElement {
return; return;
} }
if (!isValidEmail(email)) { const dto: InviteUserDto = { email };
setInviteError("Please enter a valid email address.");
return; const name = inviteForm.name.trim();
if (name) {
dto.name = name;
} }
const dto: InviteUserDto = { const workspaceId = inviteForm.workspaceId.trim();
email, if (workspaceId) {
role: inviteForm.role, dto.workspaceId = workspaceId;
}; dto.role = inviteForm.role;
if (defaultWorkspaceId) {
dto.workspaceId = defaultWorkspaceId;
} }
try { try {
@@ -245,75 +194,6 @@ export default function UsersSettingsPage(): ReactElement {
} }
} }
async function handleDetailSubmit(event: SyntheticEvent): Promise<void> {
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<void> { async function confirmDeactivate(): Promise<void> {
if (!deactivateTarget) { if (!deactivateTarget) {
return; return;
@@ -332,6 +212,23 @@ 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) { if (isAdmin === false) {
return ( return (
<div className="p-8 max-w-2xl"> <div className="p-8 max-w-2xl">
@@ -365,15 +262,7 @@ export default function UsersSettingsPage(): ReactElement {
{isRefreshing ? "Refreshing..." : "Refresh"} {isRefreshing ? "Refreshing..." : "Refresh"}
</Button> </Button>
<Dialog <Dialog open={isInviteOpen} onOpenChange={handleInviteOpenChange}>
open={isInviteOpen}
onOpenChange={(open) => {
if (!open && !isInviting) {
resetInviteForm();
}
setIsInviteOpen(open);
}}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button>
<UserPlus className="h-4 w-4 mr-2" /> <UserPlus className="h-4 w-4 mr-2" />
@@ -384,13 +273,13 @@ export default function UsersSettingsPage(): ReactElement {
<DialogHeader> <DialogHeader>
<DialogTitle>Invite User</DialogTitle> <DialogTitle>Invite User</DialogTitle>
<DialogDescription> <DialogDescription>
Invite a new user and assign their role for your default workspace. Create an invited account and optionally assign workspace access.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form <form
onSubmit={(event) => { onSubmit={(e) => {
void handleInviteSubmit(event); void handleInviteSubmit(e);
}} }}
className="space-y-4" className="space-y-4"
> >
@@ -400,8 +289,8 @@ export default function UsersSettingsPage(): ReactElement {
id="invite-email" id="invite-email"
type="email" type="email"
value={inviteForm.email} value={inviteForm.email}
onChange={(event: ChangeEvent<HTMLInputElement>) => { onChange={(e: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, email: event.target.value })); setInviteForm((prev) => ({ ...prev, email: e.target.value }));
}} }}
placeholder="user@example.com" placeholder="user@example.com"
maxLength={255} maxLength={255}
@@ -409,6 +298,33 @@ export default function UsersSettingsPage(): ReactElement {
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="invite-name">Name (optional)</Label>
<Input
id="invite-name"
type="text"
value={inviteForm.name}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, name: e.target.value }));
}}
placeholder="Jane Doe"
maxLength={255}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-workspace-id">Workspace ID (optional)</Label>
<Input
id="invite-workspace-id"
type="text"
value={inviteForm.workspaceId}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, workspaceId: e.target.value }));
}}
placeholder="UUID workspace id"
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="invite-role">Role</Label> <Label htmlFor="invite-role">Role</Label>
<Select <Select
@@ -428,13 +344,9 @@ export default function UsersSettingsPage(): ReactElement {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{defaultWorkspaceId ? ( <p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground">Role will be applied on invite.</p> Role is only applied when workspace ID is provided.
) : ( </p>
<p className="text-xs text-muted-foreground">
No default workspace found. User will be invited without workspace assignment.
</p>
)}
</div> </div>
{inviteError ? ( {inviteError ? (
@@ -448,10 +360,7 @@ export default function UsersSettingsPage(): ReactElement {
type="button" type="button"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
if (!isInviting) { handleInviteOpenChange(false);
setIsInviteOpen(false);
resetInviteForm();
}
}} }}
disabled={isInviting} disabled={isInviting}
> >
@@ -500,25 +409,17 @@ export default function UsersSettingsPage(): ReactElement {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>User Directory</CardTitle> <CardTitle>User Directory</CardTitle>
<CardDescription>Click a user to view details or edit profile fields.</CardDescription> <CardDescription>Name, email, role, and account status.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{users.map((user) => { {users.map((user) => {
const primaryMembership = getPrimaryMembership(user); const primaryRole = getPrimaryRole(user);
const isActive = user.deactivatedAt === null; const isActive = user.deactivatedAt === null;
return ( return (
<div <div
key={user.id} key={user.id}
className="rounded-md border p-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between cursor-pointer hover:bg-muted/30" className="rounded-md border p-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
role="button"
tabIndex={0}
onClick={() => {
openUserDetails(user);
}}
onKeyDown={(event) => {
handleUserRowKeyDown(event, user);
}}
> >
<div className="space-y-1 min-w-0"> <div className="space-y-1 min-w-0">
<p className="font-semibold truncate">{user.name || "Unnamed User"}</p> <p className="font-semibold truncate">{user.name || "Unnamed User"}</p>
@@ -527,17 +428,28 @@ export default function UsersSettingsPage(): ReactElement {
<div className="flex items-center gap-2 flex-wrap md:justify-end"> <div className="flex items-center gap-2 flex-wrap md:justify-end">
<Badge variant="outline"> <Badge variant="outline">
{primaryMembership ? toRoleLabel(primaryMembership.role) : "No role"} {primaryRole ? toRoleLabel(primaryRole) : "No role"}
</Badge> </Badge>
<Badge variant={isActive ? "secondary" : "destructive"}> <Badge variant={isActive ? "secondary" : "destructive"}>
{isActive ? "Active" : "Inactive"} {isActive ? "Active" : "Inactive"}
</Badge> </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 ? ( {isActive ? (
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={(event) => { onClick={() => {
event.stopPropagation();
setDeactivateTarget(user); setDeactivateTarget(user);
}} }}
> >
@@ -553,117 +465,6 @@ export default function UsersSettingsPage(): ReactElement {
</Card> </Card>
)} )}
<Dialog
open={detailTarget !== null}
onOpenChange={(open) => {
if (!open && !isSavingDetails) {
resetUserDetails();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>User Details</DialogTitle>
<DialogDescription>
Edit profile details for {detailTarget?.email ?? "selected user"}.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
void handleDetailSubmit(event);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="detail-name">Name</Label>
<Input
id="detail-name"
value={detailForm.name}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setDetailForm((prev) => ({ ...prev, name: event.target.value }));
}}
placeholder="Full name"
maxLength={255}
disabled={isSavingDetails}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="detail-email">Email</Label>
<Input
id="detail-email"
type="email"
value={detailForm.email}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setDetailForm((prev) => ({ ...prev, email: event.target.value }));
}}
placeholder="user@example.com"
maxLength={255}
disabled={isSavingDetails}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="detail-role">Role</Label>
<Select
value={detailForm.role}
disabled={detailForm.workspaceId === null || isSavingDetails}
onValueChange={(value) => {
setDetailForm((prev) => ({ ...prev, role: value as WorkspaceMemberRole }));
}}
>
<SelectTrigger id="detail-role">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{Object.values(WorkspaceMemberRole).map((role) => (
<SelectItem key={role} value={role}>
{toRoleLabel(role)}
</SelectItem>
))}
</SelectContent>
</Select>
{detailForm.workspaceName ? (
<p className="text-xs text-muted-foreground">
Role updates apply to: {detailForm.workspaceName}
</p>
) : (
<p className="text-xs text-muted-foreground">
This user has no workspace membership. Role cannot be updated.
</p>
)}
</div>
{detailError !== null ? (
<p className="text-sm text-destructive" role="alert">
{detailError}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
if (!isSavingDetails) {
resetUserDetails();
}
}}
disabled={isSavingDetails}
>
Cancel
</Button>
<Button type="submit" disabled={isSavingDetails}>
{isSavingDetails ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog <AlertDialog
open={deactivateTarget !== null} open={deactivateTarget !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -695,4 +496,55 @@ export default function UsersSettingsPage(): ReactElement {
</AlertDialog> </AlertDialog>
</div> </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>;
} }

View File

@@ -1,12 +1,12 @@
import type { UserWorkspace, WorkspaceMemberEntry } from "@/lib/api/workspaces"; import type { UserWorkspace } from "@/lib/api/workspaces";
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { WorkspaceMemberRole } from "@mosaic/shared"; import { WorkspaceMemberRole } from "@mosaic/shared";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; 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"; import WorkspacesPage from "./page";
vi.mock("next/link", () => ({ vi.mock("next/link", () => ({
@@ -21,23 +21,33 @@ vi.mock("next/link", () => ({
}, },
})); }));
vi.mock("@/components/workspace/WorkspaceCard", () => ({
WorkspaceCard: function WorkspaceCardMock({
workspace,
userRole,
memberCount,
}: {
workspace: { name: string };
userRole: WorkspaceMemberRole;
memberCount: number;
}): ReactElement {
return (
<div data-testid="workspace-card">
{workspace.name} | {userRole} | {String(memberCount)}
</div>
);
},
}));
vi.mock("@/lib/api/workspaces", () => ({ vi.mock("@/lib/api/workspaces", () => ({
fetchUserWorkspaces: vi.fn(), fetchUserWorkspaces: vi.fn(),
createWorkspace: 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 fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
const createWorkspaceMock = vi.mocked(createWorkspace); const createWorkspaceMock = vi.mocked(createWorkspace);
const fetchWorkspaceMembersMock = vi.mocked(fetchWorkspaceMembers);
const workspaceA: UserWorkspace = { const baseWorkspace: UserWorkspace = {
id: "workspace-1", id: "workspace-1",
name: "Personal Workspace", name: "Personal Workspace",
ownerId: "owner-1", ownerId: "owner-1",
@@ -45,93 +55,45 @@ const workspaceA: UserWorkspace = {
createdAt: "2026-01-01T00:00:00.000Z", 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", () => { describe("WorkspacesPage", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("loads workspaces and fetches members for the first workspace", async () => { it("loads and renders user workspaces from the API", async () => {
fetchUserWorkspacesMock.mockResolvedValue([workspaceA, workspaceB]); fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]);
fetchWorkspaceMembersMock.mockResolvedValue(membersA);
render(<WorkspacesPage />); render(<WorkspacesPage />);
expect(await screen.findByText("Your Workspaces (2)")).toBeInTheDocument(); expect(screen.getByText("Loading workspaces...")).toBeInTheDocument();
expect(await screen.findByText("Personal Workspace Members")).toBeInTheDocument();
await waitFor(() => { expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument();
expect(fetchWorkspaceMembersMock).toHaveBeenCalledWith("workspace-1"); expect(screen.getByTestId("workspace-card")).toHaveTextContent("Personal Workspace");
}); expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(1);
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
}); });
it("switches selected workspace and reloads member list", async () => { it("shows fetch errors in the UI", async () => {
fetchUserWorkspacesMock.mockResolvedValue([workspaceA, workspaceB]); fetchUserWorkspacesMock.mockRejectedValue(new Error("Unable to load workspaces"));
fetchWorkspaceMembersMock.mockResolvedValueOnce(membersA).mockResolvedValueOnce(membersB);
const user = userEvent.setup();
render(<WorkspacesPage />); render(<WorkspacesPage />);
expect(await screen.findByText("Personal Workspace Members")).toBeInTheDocument(); expect(await screen.findByText("Unable to load workspaces")).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 () => { it("creates a workspace and refreshes the list", async () => {
fetchUserWorkspacesMock fetchUserWorkspacesMock.mockResolvedValueOnce([baseWorkspace]).mockResolvedValueOnce([
.mockResolvedValueOnce([workspaceA]) baseWorkspace,
.mockResolvedValueOnce([workspaceA, workspaceB]); {
fetchWorkspaceMembersMock.mockResolvedValue(membersA); ...baseWorkspace,
id: "workspace-2",
name: "New Workspace",
role: WorkspaceMemberRole.MEMBER,
},
]);
createWorkspaceMock.mockResolvedValue({ createWorkspaceMock.mockResolvedValue({
id: "workspace-2", id: "workspace-2",
name: "Client Workspace", name: "New Workspace",
ownerId: "owner-2", ownerId: "owner-1",
settings: {}, settings: {},
createdAt: "2026-01-02T00:00:00.000Z", createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z",
@@ -143,17 +105,31 @@ describe("WorkspacesPage", () => {
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument(); expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument();
await user.type(screen.getByPlaceholderText("Enter workspace name..."), "Client Workspace"); await user.type(screen.getByPlaceholderText("Enter workspace name..."), "New Workspace");
await user.click(screen.getByRole("button", { name: "Create Workspace" })); await user.click(screen.getByRole("button", { name: "Create Workspace" }));
await waitFor(() => { await waitFor(() => {
expect(createWorkspaceMock).toHaveBeenCalledWith({ name: "Client Workspace" }); expect(createWorkspaceMock).toHaveBeenCalledWith({ name: "New Workspace" });
}); });
await waitFor(() => { await waitFor(() => {
expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(2); expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(2);
}); });
expect(await screen.findByText("Your Workspaces (2)")).toBeInTheDocument(); 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(<WorkspacesPage />);
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();
});
}); });

View File

@@ -2,51 +2,10 @@
import type { ReactElement, SyntheticEvent } from "react"; import type { ReactElement, SyntheticEvent } from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { WorkspaceCard } from "@/components/workspace/WorkspaceCard";
import { createWorkspace, fetchUserWorkspaces, type UserWorkspace } from "@/lib/api/workspaces";
import Link from "next/link"; 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 { function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error) { if (error instanceof Error) {
@@ -56,53 +15,18 @@ function getErrorMessage(error: unknown, fallback: string): string {
return fallback; return fallback;
} }
function toRoleLabel(role: WorkspaceMemberRole): string { /**
return `${role.charAt(0)}${role.slice(1).toLowerCase()}`; * Workspaces Page
} * Fetches and creates workspaces through the real API.
*/
interface RemoveMemberTarget {
userId: string;
email: string;
}
const ROLE_BADGE_CLASS: Record<WorkspaceMemberRole, string> = {
[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 { export default function WorkspacesPage(): ReactElement {
const [workspaces, setWorkspaces] = useState<UserWorkspace[]>([]); const [workspaces, setWorkspaces] = useState<UserWorkspace[]>([]);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [newWorkspaceName, setNewWorkspaceName] = useState(""); const [newWorkspaceName, setNewWorkspaceName] = useState("");
const [createError, setCreateError] = useState<string | null>(null); const [createError, setCreateError] = useState<string | null>(null);
const [members, setMembers] = useState<WorkspaceMemberEntry[]>([]);
const [isMembersLoading, setIsMembersLoading] = useState(false);
const [membersError, setMembersError] = useState<string | null>(null);
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
const [isAddingMember, setIsAddingMember] = useState(false);
const [addMemberError, setAddMemberError] = useState<string | null>(null);
const [memberUserId, setMemberUserId] = useState<string>("");
const [memberRole, setMemberRole] = useState<WorkspaceMemberRole>(WorkspaceMemberRole.MEMBER);
const [availableUsers, setAvailableUsers] = useState<AdminUser[]>([]);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [removeTarget, setRemoveTarget] = useState<RemoveMemberTarget | null>(null);
const [isRemovingMember, setIsRemovingMember] = useState(false);
const selectedWorkspace = useMemo(
() => workspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? null,
[selectedWorkspaceId, workspaces]
);
const loadWorkspaces = useCallback(async (): Promise<void> => { const loadWorkspaces = useCallback(async (): Promise<void> => {
setIsLoading(true); setIsLoading(true);
@@ -110,57 +34,31 @@ export default function WorkspacesPage(): ReactElement {
const data = await fetchUserWorkspaces(); const data = await fetchUserWorkspaces();
setWorkspaces(data); setWorkspaces(data);
setLoadError(null); setLoadError(null);
setSelectedWorkspaceId((current) => {
if (current && data.some((workspace) => workspace.id === current)) {
return current;
}
return data[0]?.id ?? null;
});
} catch (error) { } catch (error) {
setLoadError(getErrorMessage(error, "Failed to load workspaces")); setLoadError(getErrorMessage(error, "Failed to load workspaces"));
setSelectedWorkspaceId(null);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, []); }, []);
const loadMembers = useCallback(async (workspaceId: string): Promise<void> => {
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(() => { useEffect(() => {
void loadWorkspaces(); void loadWorkspaces();
}, [loadWorkspaces]); }, [loadWorkspaces]);
useEffect(() => { const workspacesWithRoles = workspaces.map((workspace) => ({
if (!selectedWorkspaceId) { ...workspace,
setMembers([]); settings: {},
setMembersError(null); createdAt: new Date(workspace.createdAt),
return; updatedAt: new Date(workspace.createdAt),
} userRole: workspace.role,
memberCount: 1,
}));
void loadMembers(selectedWorkspaceId); const handleCreateWorkspace = async (e: SyntheticEvent<HTMLFormElement>): Promise<void> => {
}, [loadMembers, selectedWorkspaceId]); e.preventDefault();
const handleCreateWorkspace = async (event: SyntheticEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
const workspaceName = newWorkspaceName.trim(); const workspaceName = newWorkspaceName.trim();
if (!workspaceName) { if (!workspaceName) return;
return;
}
setIsCreating(true); setIsCreating(true);
setCreateError(null); setCreateError(null);
@@ -176,394 +74,91 @@ 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<void> => {
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<void> => {
setAddMemberError(null);
setMemberRole(WorkspaceMemberRole.MEMBER);
setIsAddMemberOpen(true);
await loadAvailableUsers();
};
const handleAddMember = async (event: SyntheticEvent<HTMLFormElement>): Promise<void> => {
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<void> => {
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 ( return (
<main className="max-w-6xl mx-auto p-6 space-y-6"> <main className="container mx-auto px-4 py-8 max-w-5xl">
<div className="flex items-start justify-between gap-4"> <div className="mb-8">
<div> <div className="flex items-center justify-between mb-2">
<h1 className="text-3xl font-bold">Workspaces</h1> <h1 className="text-3xl font-bold text-gray-900">Workspaces</h1>
<p className="text-muted-foreground mt-1">Manage workspaces and workspace members</p> <Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700">
Back to Settings
</Link>
</div> </div>
<Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700"> <p className="text-gray-600">Manage your workspaces and collaborate with your team</p>
Back to Settings
</Link>
</div> </div>
<Card> {/* Create New Workspace */}
<CardHeader> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<CardTitle>Create New Workspace</CardTitle> <h2 className="text-lg font-semibold text-gray-900 mb-4">Create New Workspace</h2>
<CardDescription>Create a workspace for a new team or project.</CardDescription> <form onSubmit={handleCreateWorkspace} className="flex gap-3">
</CardHeader> <input
<CardContent> type="text"
<form onSubmit={handleCreateWorkspace} className="flex gap-3"> value={newWorkspaceName}
<Input onChange={(e) => {
type="text" setNewWorkspaceName(e.target.value);
value={newWorkspaceName} }}
onChange={(event) => { placeholder="Enter workspace name..."
setNewWorkspaceName(event.target.value); 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"
placeholder="Enter workspace name..." />
disabled={isCreating} <button
/> type="submit"
<Button type="submit" disabled={isCreating || !newWorkspaceName.trim()}> disabled={isCreating || !newWorkspaceName.trim()}
{isCreating ? "Creating..." : "Create Workspace"} className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
</Button> >
</form> {isCreating ? "Creating..." : "Create Workspace"}
{createError !== null ? ( </button>
<p className="mt-3 text-sm text-destructive" role="alert"> </form>
{createError} {createError !== null && (
</p> <div className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
) : null} {createError}
</CardContent> </div>
</Card> )}
{loadError !== null ? (
<Card>
<CardContent className="py-4">
<p className="text-sm text-destructive" role="alert">
{loadError}
</p>
</CardContent>
</Card>
) : null}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Your Workspaces ({isLoading ? "..." : workspaces.length})</CardTitle>
<CardDescription>Click a workspace to manage its members.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading workspaces...</p>
) : workspaces.length === 0 ? (
<p className="text-sm text-muted-foreground">
No workspaces yet. Create one to begin.
</p>
) : (
workspaces.map((workspace) => {
const isSelected = selectedWorkspaceId === workspace.id;
return (
<button
key={workspace.id}
type="button"
onClick={() => {
setSelectedWorkspaceId(workspace.id);
}}
className={`w-full rounded-lg border p-4 text-left transition-colors ${
isSelected ? "border-primary bg-muted/40" : "border-border hover:bg-muted/20"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-semibold truncate">{workspace.name}</p>
<p className="text-xs text-muted-foreground mt-1">
Created {new Date(workspace.createdAt).toLocaleDateString()}
</p>
</div>
<Badge variant="outline">{toRoleLabel(workspace.role)}</Badge>
</div>
</button>
);
})
)}
</CardContent>
</Card>
<Card className="lg:col-span-3">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>
{selectedWorkspace ? `${selectedWorkspace.name} Members` : "Workspace Members"}
</CardTitle>
<CardDescription>
{selectedWorkspace
? "Manage member roles and access for this workspace."
: "Select a workspace to view its members."}
</CardDescription>
</div>
<Dialog
open={isAddMemberOpen}
onOpenChange={(open) => {
if (!open && !isAddingMember) {
setIsAddMemberOpen(false);
setAddMemberError(null);
}
}}
>
<DialogTrigger asChild>
<Button
onClick={() => {
void openAddMemberDialog();
}}
disabled={!selectedWorkspace}
>
<UserPlus className="h-4 w-4 mr-2" />
Add Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Workspace Member</DialogTitle>
<DialogDescription>
Add an existing user to {selectedWorkspace?.name ?? "this workspace"}.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
void handleAddMember(event);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="member-user">User</Label>
<Select
value={memberUserId}
onValueChange={(value) => {
setMemberUserId(value);
}}
disabled={isLoadingUsers || eligibleUsers.length === 0 || isAddingMember}
>
<SelectTrigger id="member-user">
<SelectValue
placeholder={
isLoadingUsers
? "Loading users..."
: eligibleUsers.length === 0
? "No eligible users"
: "Select a user"
}
/>
</SelectTrigger>
<SelectContent>
{eligibleUsers.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="member-role">Role</Label>
<Select
value={memberRole}
onValueChange={(value) => {
setMemberRole(value as WorkspaceMemberRole);
}}
disabled={isAddingMember}
>
<SelectTrigger id="member-role">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{Object.values(WorkspaceMemberRole)
.filter((role) => role !== WorkspaceMemberRole.OWNER)
.map((role) => (
<SelectItem key={role} value={role}>
{toRoleLabel(role)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{addMemberError !== null ? (
<p className="text-sm text-destructive" role="alert">
{addMemberError}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
if (!isAddingMember) {
setIsAddMemberOpen(false);
setAddMemberError(null);
}
}}
disabled={isAddingMember}
>
Cancel
</Button>
<Button type="submit" disabled={isAddingMember || !memberUserId}>
{isAddingMember ? "Adding..." : "Add Member"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="space-y-3">
{selectedWorkspace === null ? (
<p className="text-sm text-muted-foreground">Select a workspace to view members.</p>
) : membersError !== null ? (
<p className="text-sm text-destructive" role="alert">
{membersError}
</p>
) : isMembersLoading ? (
<p className="text-sm text-muted-foreground">Loading members...</p>
) : members.length === 0 ? (
<p className="text-sm text-muted-foreground">No members found for this workspace.</p>
) : (
members.map((member) => (
<div
key={member.userId}
className="rounded-md border p-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
>
<div className="space-y-1 min-w-0">
<p className="font-semibold truncate">{member.user.name ?? "Unnamed User"}</p>
<p className="text-sm text-muted-foreground truncate">{member.user.email}</p>
</div>
<div className="flex items-center gap-2 md:justify-end">
<Badge variant="outline" className={ROLE_BADGE_CLASS[member.role]}>
{toRoleLabel(member.role)}
</Badge>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
setRemoveTarget({
userId: member.userId,
email: member.user.email,
});
}}
>
<UserX className="h-4 w-4 mr-2" />
Remove
</Button>
</div>
</div>
))
)}
</CardContent>
</Card>
</div> </div>
<AlertDialog {/* Workspace List */}
open={removeTarget !== null} <div className="space-y-4">
onOpenChange={(open) => { <h2 className="text-xl font-semibold text-gray-900">
if (!open && !isRemovingMember) { Your Workspaces ({isLoading ? "..." : workspacesWithRoles.length})
setRemoveTarget(null); </h2>
} {loadError !== null ? (
}} <div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-red-700">
> {loadError}
<AlertDialogContent> </div>
<AlertDialogHeader> ) : isLoading ? (
<AlertDialogTitle>Remove Workspace Member</AlertDialogTitle> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center text-gray-600">
<AlertDialogDescription> Loading workspaces...
Remove {removeTarget?.email} from {selectedWorkspace?.name}? They will lose access to </div>
this workspace. ) : workspacesWithRoles.length === 0 ? (
</AlertDialogDescription> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
</AlertDialogHeader> <svg
<AlertDialogFooter> className="mx-auto h-12 w-12 text-gray-400 mb-4"
<AlertDialogCancel disabled={isRemovingMember}>Cancel</AlertDialogCancel> fill="none"
<AlertDialogAction stroke="currentColor"
disabled={isRemovingMember} viewBox="0 0 24 24"
onClick={() => {
void handleRemoveMember();
}}
> >
{isRemovingMember ? "Removing..." : "Remove"} <path
</AlertDialogAction> strokeLinecap="round"
</AlertDialogFooter> strokeLinejoin="round"
</AlertDialogContent> strokeWidth={2}
</AlertDialog> d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<h3 className="text-lg font-medium text-gray-900 mb-2">No workspaces yet</h3>
<p className="text-gray-600">Create your first workspace to get started</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{workspacesWithRoles.map((workspace) => (
<WorkspaceCard
key={workspace.id}
workspace={workspace}
userRole={workspace.userRole}
memberCount={workspace.memberCount}
/>
))}
</div>
)}
</div>
</main> </main>
); );
} }

View File

@@ -53,7 +53,6 @@ export interface InvitationResponse {
export interface UpdateUserDto { export interface UpdateUserDto {
name?: string; name?: string;
email?: string;
deactivatedAt?: string | null; deactivatedAt?: string | null;
emailVerified?: boolean; emailVerified?: boolean;
preferences?: Record<string, unknown>; preferences?: Record<string, unknown>;

View File

@@ -35,7 +35,8 @@
"docker:ps": "docker compose ps", "docker:ps": "docker compose ps",
"docker:build": "docker compose build", "docker:build": "docker compose build",
"docker:restart": "docker compose restart", "docker:restart": "docker compose restart",
"prepare": "husky || true" "prepare": "husky || true",
"ingest:sessions": "tsx scripts/ingest-openclaw-sessions.ts"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.26.0", "@typescript-eslint/eslint-plugin": "^8.26.0",

View File

@@ -0,0 +1,621 @@
import { createReadStream, constants as fsConstants } from "node:fs";
import { access, readdir, stat } from "node:fs/promises";
import { homedir } from "node:os";
import * as path from "node:path";
import * as process from "node:process";
import { createInterface } from "node:readline";
const DEFAULT_ENDPOINT = "https://mosaic-api.woltje.com/conversation-archive/ingest";
type IngestRole = "user" | "assistant";
interface IngestMessage {
role: IngestRole;
content: string;
timestamp?: string;
}
interface IngestPayload {
sessionId: string;
workspaceId: string;
title: string;
messages: IngestMessage[];
agentId?: string;
}
interface CliOptions {
workspaceId: string;
agentId?: string;
since?: Date;
sessionsDir?: string;
endpoint: string;
}
interface ParsedSession {
sessionId: string;
title: string;
messages: IngestMessage[];
startedAt?: string;
endedAt?: string;
parseErrors: number;
inferredAgentId?: string;
}
interface SendResult {
ok: boolean;
status: number;
body: string;
}
interface IngestSummary {
discovered: number;
processed: number;
ingested: number;
skippedSince: number;
skippedEmpty: number;
skippedDuplicate: number;
failed: number;
}
function printUsage(): void {
console.log(
[
"Usage:",
" pnpm ingest:sessions --workspace-id <id> [--agent-id <id>] [--since <ISO date>] [--sessions-dir <path>] [--endpoint <url>]",
"",
"Required:",
" --workspace-id Target Mosaic workspace ID",
"",
"Optional:",
" --agent-id Agent ID to include in each ingest payload",
" --since Skip sessions before this date/time (ISO8601 or YYYY-MM-DD)",
" --sessions-dir Override session directory path",
` --endpoint Ingest endpoint (default: ${DEFAULT_ENDPOINT})`,
].join("\n")
);
}
function expandHomePath(inputPath: string): string {
if (inputPath === "~") {
return homedir();
}
if (inputPath.startsWith("~/")) {
return path.join(homedir(), inputPath.slice(2));
}
return inputPath;
}
function parseSinceDate(rawDate: string): Date {
const parsed = new Date(rawDate);
if (Number.isNaN(parsed.getTime())) {
throw new Error(`Invalid --since date: "${rawDate}". Use ISO8601 or YYYY-MM-DD.`);
}
return parsed;
}
function parseCliArgs(args: string[]): CliOptions {
let workspaceId: string | null = null;
let agentId: string | undefined;
let since: Date | undefined;
let sessionsDir: string | undefined;
let endpoint = DEFAULT_ENDPOINT;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--help" || arg === "-h") {
printUsage();
process.exit(0);
}
if (arg.startsWith("--workspace-id=")) {
workspaceId = arg.slice("--workspace-id=".length);
continue;
}
if (arg === "--workspace-id") {
const value = args[index + 1];
if (!value) {
throw new Error("Missing value for --workspace-id");
}
workspaceId = value;
index += 1;
continue;
}
if (arg.startsWith("--agent-id=")) {
agentId = arg.slice("--agent-id=".length);
continue;
}
if (arg === "--agent-id") {
const value = args[index + 1];
if (!value) {
throw new Error("Missing value for --agent-id");
}
agentId = value;
index += 1;
continue;
}
if (arg.startsWith("--since=")) {
since = parseSinceDate(arg.slice("--since=".length));
continue;
}
if (arg === "--since") {
const value = args[index + 1];
if (!value) {
throw new Error("Missing value for --since");
}
since = parseSinceDate(value);
index += 1;
continue;
}
if (arg.startsWith("--sessions-dir=")) {
sessionsDir = arg.slice("--sessions-dir=".length);
continue;
}
if (arg === "--sessions-dir") {
const value = args[index + 1];
if (!value) {
throw new Error("Missing value for --sessions-dir");
}
sessionsDir = value;
index += 1;
continue;
}
if (arg.startsWith("--endpoint=")) {
endpoint = arg.slice("--endpoint=".length);
continue;
}
if (arg === "--endpoint") {
const value = args[index + 1];
if (!value) {
throw new Error("Missing value for --endpoint");
}
endpoint = value;
index += 1;
continue;
}
throw new Error(`Unknown flag: ${arg}`);
}
if (!workspaceId || workspaceId.trim().length === 0) {
throw new Error("--workspace-id is required");
}
return {
workspaceId: workspaceId.trim(),
agentId: agentId?.trim(),
since,
sessionsDir: sessionsDir ? path.resolve(expandHomePath(sessionsDir)) : undefined,
endpoint,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function asString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function normalizeIsoTimestamp(value: unknown): string | null {
if (typeof value === "string") {
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString();
}
return null;
}
if (typeof value === "number" && Number.isFinite(value)) {
const millis = value >= 1_000_000_000_000 ? value : value * 1000;
const parsed = new Date(millis);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString();
}
}
return null;
}
function truncate(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 3)}...`;
}
function deriveTitle(content: string, fallbackSessionId: string): string {
const firstLine = content
.split(/\r?\n/u)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstLine) {
return `OpenClaw session ${fallbackSessionId}`;
}
const normalized = firstLine.replace(/\s+/gu, " ").trim();
return truncate(normalized, 140);
}
function extractTextContent(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
const parts: string[] = [];
for (const item of content) {
if (typeof item === "string") {
const trimmed = item.trim();
if (trimmed.length > 0) {
parts.push(trimmed);
}
continue;
}
if (!isRecord(item)) {
continue;
}
const itemType = asString(item.type);
if (itemType !== null && itemType !== "text") {
continue;
}
const textValue = asString(item.text);
if (textValue && textValue.trim().length > 0) {
parts.push(textValue.trim());
}
}
return parts.join("\n\n").trim();
}
if (isRecord(content)) {
const textValue = asString(content.text);
if (textValue) {
return textValue.trim();
}
}
return "";
}
function inferAgentIdFromPath(filePath: string): string | null {
const pathParts = filePath.split(path.sep);
const agentsIndex = pathParts.lastIndexOf("agents");
if (agentsIndex < 0) {
return null;
}
const candidate = pathParts[agentsIndex + 1];
return candidate && candidate.trim().length > 0 ? candidate : null;
}
async function parseSessionFile(filePath: string): Promise<ParsedSession> {
const fallbackSessionId = path.basename(filePath, path.extname(filePath));
const inferredAgentId = inferAgentIdFromPath(filePath) ?? undefined;
let sessionId = fallbackSessionId;
let title: string | null = null;
let startedAt: string | undefined;
let endedAt: string | undefined;
let parseErrors = 0;
const messages: IngestMessage[] = [];
const readStream = createReadStream(filePath, { encoding: "utf8" });
const lineReader = createInterface({
input: readStream,
crlfDelay: Number.POSITIVE_INFINITY,
});
for await (const rawLine of lineReader) {
const line = rawLine.trim();
if (line.length === 0) {
continue;
}
let parsedLine: unknown;
try {
parsedLine = JSON.parse(line) as unknown;
} catch {
parseErrors += 1;
continue;
}
if (!isRecord(parsedLine)) {
parseErrors += 1;
continue;
}
const eventType = asString(parsedLine.type);
if (eventType === "session") {
const rawSessionId = asString(parsedLine.id);
if (rawSessionId && rawSessionId.trim().length > 0) {
sessionId = rawSessionId;
}
const sessionTimestamp = normalizeIsoTimestamp(parsedLine.timestamp);
if (sessionTimestamp) {
startedAt ??= sessionTimestamp;
}
continue;
}
if (eventType !== "message") {
continue;
}
const messageRecord = parsedLine.message;
if (!isRecord(messageRecord)) {
continue;
}
const role = asString(messageRecord.role);
if (role !== "user" && role !== "assistant") {
continue;
}
const content = extractTextContent(messageRecord.content);
if (content.length === 0) {
continue;
}
const timestamp =
normalizeIsoTimestamp(messageRecord.timestamp) ?? normalizeIsoTimestamp(parsedLine.timestamp);
const message: IngestMessage = {
role,
content,
timestamp: timestamp ?? undefined,
};
messages.push(message);
if (!title && role === "user") {
title = deriveTitle(content, sessionId);
}
if (!startedAt && timestamp) {
startedAt = timestamp;
}
if (timestamp) {
endedAt = timestamp;
}
}
return {
sessionId,
title: title ?? `OpenClaw session ${sessionId}`,
messages,
startedAt,
endedAt,
parseErrors,
inferredAgentId,
};
}
async function pathExists(candidatePath: string): Promise<boolean> {
try {
await access(candidatePath, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
async function discoverSessionDirectories(overrideDir?: string): Promise<string[]> {
if (overrideDir) {
if (!(await pathExists(overrideDir))) {
throw new Error(`Provided --sessions-dir does not exist: ${overrideDir}`);
}
return [overrideDir];
}
const defaultDir = path.join(homedir(), ".openclaw", "sessions");
if (await pathExists(defaultDir)) {
return [defaultDir];
}
const agentsRoot = path.join(homedir(), ".openclaw", "agents");
if (!(await pathExists(agentsRoot))) {
return [];
}
const agentEntries = await readdir(agentsRoot, { withFileTypes: true });
const directories: string[] = [];
for (const entry of agentEntries) {
if (!entry.isDirectory()) {
continue;
}
const sessionsDir = path.join(agentsRoot, entry.name, "sessions");
if (await pathExists(sessionsDir)) {
directories.push(sessionsDir);
}
}
return directories.sort((left, right) => left.localeCompare(right));
}
async function discoverSessionFiles(overrideDir?: string): Promise<string[]> {
const directories = await discoverSessionDirectories(overrideDir);
const files: string[] = [];
for (const directory of directories) {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
continue;
}
files.push(path.join(directory, entry.name));
}
}
return files.sort((left, right) => left.localeCompare(right));
}
async function resolveSessionTimestamp(session: ParsedSession, filePath: string): Promise<Date> {
const sessionTimestamp = session.startedAt ?? session.endedAt;
if (sessionTimestamp) {
const parsed = new Date(sessionTimestamp);
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
}
const fileStat = await stat(filePath);
return fileStat.mtime;
}
function buildPayload(
options: CliOptions,
session: ParsedSession,
fallbackAgentId: string | undefined
): IngestPayload {
const payload: IngestPayload = {
sessionId: session.sessionId,
workspaceId: options.workspaceId,
title: session.title,
messages: session.messages,
};
const selectedAgentId = options.agentId ?? fallbackAgentId;
if (selectedAgentId && selectedAgentId.trim().length > 0) {
payload.agentId = selectedAgentId.trim();
}
return payload;
}
async function sendIngestRequest(
endpoint: string,
token: string,
payload: IngestPayload
): Promise<SendResult> {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const body = await response.text();
return {
ok: response.ok,
status: response.status,
body,
};
}
function summarizeFailureBody(body: string): string {
const compact = body.replace(/\s+/gu, " ").trim();
if (compact.length === 0) {
return "(empty response body)";
}
return truncate(compact, 220);
}
async function main(): Promise<void> {
const options = parseCliArgs(process.argv.slice(2));
const token = process.env.MOSAIC_API_TOKEN;
if (!token || token.trim().length === 0) {
throw new Error("MOSAIC_API_TOKEN environment variable is required.");
}
const sessionFiles = await discoverSessionFiles(options.sessionsDir);
if (sessionFiles.length === 0) {
console.log("No OpenClaw session files found.");
return;
}
console.log(`Discovered ${sessionFiles.length} session file(s).`);
if (options.since) {
console.log(`Applying --since filter at ${options.since.toISOString()}.`);
}
const summary: IngestSummary = {
discovered: sessionFiles.length,
processed: 0,
ingested: 0,
skippedSince: 0,
skippedEmpty: 0,
skippedDuplicate: 0,
failed: 0,
};
for (const [index, filePath] of sessionFiles.entries()) {
const position = `${index + 1}/${sessionFiles.length}`;
const parsedSession = await parseSessionFile(filePath);
summary.processed += 1;
if (parsedSession.messages.length === 0) {
summary.skippedEmpty += 1;
console.log(
`[${position}] Skipped ${parsedSession.sessionId}: no user/assistant text messages.`
);
continue;
}
const sessionDate = await resolveSessionTimestamp(parsedSession, filePath);
if (options.since && sessionDate.getTime() < options.since.getTime()) {
summary.skippedSince += 1;
console.log(
`[${position}] Skipped ${parsedSession.sessionId}: session is before --since (${sessionDate.toISOString()}).`
);
continue;
}
const payload = buildPayload(options, parsedSession, parsedSession.inferredAgentId);
let result: SendResult;
try {
result = await sendIngestRequest(options.endpoint, token, payload);
} catch (error) {
summary.failed += 1;
const message = error instanceof Error ? error.message : String(error);
console.error(`[${position}] Failed ${parsedSession.sessionId}: request error: ${message}`);
continue;
}
if (result.ok) {
summary.ingested += 1;
const note =
parsedSession.parseErrors > 0 ? ` (parse warnings: ${parsedSession.parseErrors})` : "";
console.log(
`[${position}] Ingested ${parsedSession.sessionId} (${parsedSession.messages.length} messages)${note}.`
);
continue;
}
if (result.status === 409) {
summary.skippedDuplicate += 1;
console.log(`[${position}] Skipped ${parsedSession.sessionId}: already exists (409).`);
continue;
}
summary.failed += 1;
console.error(
`[${position}] Failed ${parsedSession.sessionId}: HTTP ${result.status} ${summarizeFailureBody(result.body)}`
);
}
console.log("\nIngestion summary:");
console.log(` Discovered: ${summary.discovered}`);
console.log(` Processed: ${summary.processed}`);
console.log(` Ingested: ${summary.ingested}`);
console.log(` Skipped (--since): ${summary.skippedSince}`);
console.log(` Skipped (empty): ${summary.skippedEmpty}`);
console.log(` Skipped (duplicate): ${summary.skippedDuplicate}`);
console.log(` Failed: ${summary.failed}`);
if (summary.failed > 0) {
process.exit(1);
}
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`Fatal error: ${message}`);
process.exit(1);
});