All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
570 lines
20 KiB
TypeScript
570 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import type { ReactElement, SyntheticEvent } from "react";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import { UserPlus, UserX } from "lucide-react";
|
|
import { WorkspaceMemberRole } from "@mosaic/shared";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
addWorkspaceMember,
|
|
createWorkspace,
|
|
fetchUserWorkspaces,
|
|
fetchWorkspaceMembers,
|
|
removeWorkspaceMember,
|
|
type UserWorkspace,
|
|
type WorkspaceMemberEntry,
|
|
} from "@/lib/api/workspaces";
|
|
import { fetchAdminUsers, type AdminUser } from "@/lib/api/admin";
|
|
|
|
function getErrorMessage(error: unknown, fallback: string): string {
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
function toRoleLabel(role: WorkspaceMemberRole): string {
|
|
return `${role.charAt(0)}${role.slice(1).toLowerCase()}`;
|
|
}
|
|
|
|
interface RemoveMemberTarget {
|
|
userId: string;
|
|
email: string;
|
|
}
|
|
|
|
const ROLE_BADGE_CLASS: Record<WorkspaceMemberRole, 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 {
|
|
const [workspaces, setWorkspaces] = useState<UserWorkspace[]>([]);
|
|
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(null);
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [newWorkspaceName, setNewWorkspaceName] = useState("");
|
|
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> => {
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const data = await fetchUserWorkspaces();
|
|
setWorkspaces(data);
|
|
setLoadError(null);
|
|
setSelectedWorkspaceId((current) => {
|
|
if (current && data.some((workspace) => workspace.id === current)) {
|
|
return current;
|
|
}
|
|
|
|
return data[0]?.id ?? null;
|
|
});
|
|
} catch (error) {
|
|
setLoadError(getErrorMessage(error, "Failed to load workspaces"));
|
|
setSelectedWorkspaceId(null);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const loadMembers = useCallback(async (workspaceId: string): Promise<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(() => {
|
|
void loadWorkspaces();
|
|
}, [loadWorkspaces]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedWorkspaceId) {
|
|
setMembers([]);
|
|
setMembersError(null);
|
|
return;
|
|
}
|
|
|
|
void loadMembers(selectedWorkspaceId);
|
|
}, [loadMembers, selectedWorkspaceId]);
|
|
|
|
const handleCreateWorkspace = async (event: SyntheticEvent<HTMLFormElement>): Promise<void> => {
|
|
event.preventDefault();
|
|
|
|
const workspaceName = newWorkspaceName.trim();
|
|
if (!workspaceName) {
|
|
return;
|
|
}
|
|
|
|
setIsCreating(true);
|
|
setCreateError(null);
|
|
|
|
try {
|
|
await createWorkspace({ name: workspaceName });
|
|
setNewWorkspaceName("");
|
|
await loadWorkspaces();
|
|
} catch (error) {
|
|
setCreateError(getErrorMessage(error, "Failed to create workspace"));
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<main className="max-w-6xl mx-auto p-6 space-y-6">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Workspaces</h1>
|
|
<p className="text-muted-foreground mt-1">Manage workspaces and workspace members</p>
|
|
</div>
|
|
<Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700">
|
|
← Back to Settings
|
|
</Link>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Create New Workspace</CardTitle>
|
|
<CardDescription>Create a workspace for a new team or project.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleCreateWorkspace} className="flex gap-3">
|
|
<Input
|
|
type="text"
|
|
value={newWorkspaceName}
|
|
onChange={(event) => {
|
|
setNewWorkspaceName(event.target.value);
|
|
}}
|
|
placeholder="Enter workspace name..."
|
|
disabled={isCreating}
|
|
/>
|
|
<Button type="submit" disabled={isCreating || !newWorkspaceName.trim()}>
|
|
{isCreating ? "Creating..." : "Create Workspace"}
|
|
</Button>
|
|
</form>
|
|
{createError !== null ? (
|
|
<p className="mt-3 text-sm text-destructive" role="alert">
|
|
{createError}
|
|
</p>
|
|
) : null}
|
|
</CardContent>
|
|
</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>
|
|
|
|
<AlertDialog
|
|
open={removeTarget !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open && !isRemovingMember) {
|
|
setRemoveTarget(null);
|
|
}
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Remove Workspace Member</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Remove {removeTarget?.email} from {selectedWorkspace?.name}? They will lose access to
|
|
this workspace.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isRemovingMember}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
disabled={isRemovingMember}
|
|
onClick={() => {
|
|
void handleRemoveMember();
|
|
}}
|
|
>
|
|
{isRemovingMember ? "Removing..." : "Remove"}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</main>
|
|
);
|
|
}
|