Files
stack/apps/web/src/app/(authenticated)/settings/workspaces/page.tsx
Jason Woltje 7106512fa9
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
feat(web): add user edit/invite dialogs and workspace member management (MS21-UI-002, MS21-UI-004) (#592)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 03:54:32 +00:00

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>
);
}