feat(web): add workspace management UI (M2 #12)

- Create workspace listing page at /settings/workspaces
  - List all user workspaces with role badges
  - Create new workspace functionality
  - Display member count per workspace

- Create workspace detail page at /settings/workspaces/[id]
  - Workspace settings (name, ID, created date)
  - Member management with role editing
  - Invite member functionality
  - Delete workspace (owner only)

- Add workspace components:
  - WorkspaceCard: Display workspace info with role badge
  - WorkspaceSettings: Edit workspace settings and delete
  - MemberList: Display and manage workspace members
  - InviteMember: Send invitations with role selection

- Add WorkspaceMemberWithUser type to shared package
- Follow existing app patterns for styling and structure
- Use mock data (ready for API integration)
This commit is contained in:
Jason Woltje
2026-01-29 16:59:26 -06:00
parent 287a0e2556
commit 5291fece26
43 changed files with 4152 additions and 99 deletions

View File

@@ -0,0 +1,121 @@
"use client";
import { useState } from "react";
import { WorkspaceMemberRole } from "@mosaic/shared";
interface InviteMemberProps {
onInvite: (email: string, role: WorkspaceMemberRole) => Promise<void>;
}
export function InviteMember({ onInvite }: InviteMemberProps) {
const [email, setEmail] = useState("");
const [role, setRole] = useState<WorkspaceMemberRole>(WorkspaceMemberRole.MEMBER);
const [isInviting, setIsInviting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!email.trim()) {
setError("Email is required");
return;
}
if (!email.includes("@")) {
setError("Please enter a valid email address");
return;
}
setIsInviting(true);
try {
await onInvite(email.trim(), role);
setEmail("");
setRole(WorkspaceMemberRole.MEMBER);
alert("Invitation sent successfully!");
} catch (error) {
console.error("Failed to invite member:", error);
setError(
error instanceof Error
? error.message
: "Failed to send invitation. Please try again."
);
} finally {
setIsInviting(false);
}
};
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Invite Member
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-2"
>
Email Address
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="colleague@example.com"
disabled={isInviting}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100"
/>
</div>
<div>
<label
htmlFor="role"
className="block text-sm font-medium text-gray-700 mb-2"
>
Role
</label>
<select
id="role"
value={role}
onChange={(e) => setRole(e.target.value as WorkspaceMemberRole)}
disabled={isInviting}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100"
>
<option value={WorkspaceMemberRole.ADMIN}>
Admin - Can manage workspace and members
</option>
<option value={WorkspaceMemberRole.MEMBER}>
Member - Can create and edit content
</option>
<option value={WorkspaceMemberRole.GUEST}>
Guest - View-only access
</option>
</select>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-700">{error}</p>
</div>
)}
<button
type="submit"
disabled={isInviting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isInviting ? "Sending Invitation..." : "Send Invitation"}
</button>
</form>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
💡 The invited user will receive an email with instructions to join
this workspace.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import type { User, WorkspaceMember } from "@mosaic/shared";
import { WorkspaceMemberRole } from "@mosaic/shared";
export interface WorkspaceMemberWithUser extends WorkspaceMember {
user: User;
}
interface MemberListProps {
members: WorkspaceMemberWithUser[];
currentUserId: string;
currentUserRole: WorkspaceMemberRole;
workspaceOwnerId: string;
onRoleChange: (userId: string, newRole: WorkspaceMemberRole) => Promise<void>;
onRemove: (userId: string) => Promise<void>;
}
const roleColors: Record<WorkspaceMemberRole, string> = {
[WorkspaceMemberRole.OWNER]: "bg-purple-100 text-purple-700",
[WorkspaceMemberRole.ADMIN]: "bg-blue-100 text-blue-700",
[WorkspaceMemberRole.MEMBER]: "bg-green-100 text-green-700",
[WorkspaceMemberRole.GUEST]: "bg-gray-100 text-gray-700",
};
export function MemberList({
members,
currentUserId,
currentUserRole,
workspaceOwnerId,
onRoleChange,
onRemove,
}: MemberListProps) {
const canManageMembers =
currentUserRole === WorkspaceMemberRole.OWNER ||
currentUserRole === WorkspaceMemberRole.ADMIN;
const handleRoleChange = async (userId: string, newRole: WorkspaceMemberRole) => {
try {
await onRoleChange(userId, newRole);
} catch (error) {
console.error("Failed to change role:", error);
alert("Failed to change member role");
}
};
const handleRemove = async (userId: string) => {
if (!confirm("Are you sure you want to remove this member?")) {
return;
}
try {
await onRemove(userId);
} catch (error) {
console.error("Failed to remove member:", error);
alert("Failed to remove member");
}
};
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Members ({members.length})
</h2>
<ul className="divide-y divide-gray-200">
{members.map((member) => {
const isCurrentUser = member.userId === currentUserId;
const isOwner = member.userId === workspaceOwnerId;
const canModify = canManageMembers && !isOwner && !isCurrentUser;
return (
<li key={member.userId} className="py-4 first:pt-0 last:pb-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
{member.user.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-gray-900">
{member.user.name}
</p>
{isCurrentUser && (
<span className="text-xs text-gray-500">(you)</span>
)}
</div>
<p className="text-sm text-gray-600">{member.user.email}</p>
<p className="text-xs text-gray-500 mt-1">
Joined {new Date(member.joinedAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{canModify ? (
<select
value={member.role}
onChange={(e) =>
handleRoleChange(
member.userId,
e.target.value as WorkspaceMemberRole
)
}
className="px-3 py-1 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value={WorkspaceMemberRole.ADMIN}>Admin</option>
<option value={WorkspaceMemberRole.MEMBER}>Member</option>
<option value={WorkspaceMemberRole.GUEST}>Guest</option>
</select>
) : (
<span
className={`px-3 py-1 rounded-full text-xs font-medium ${roleColors[member.role]}`}
>
{member.role}
</span>
)}
{canModify && (
<button
onClick={() => handleRemove(member.userId)}
className="text-red-600 hover:text-red-700 text-sm"
aria-label="Remove member"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)}
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import type { Workspace } from "@mosaic/shared";
import { WorkspaceMemberRole } from "@mosaic/shared";
import Link from "next/link";
interface WorkspaceCardProps {
workspace: Workspace;
userRole: WorkspaceMemberRole;
memberCount: number;
}
const roleColors: Record<WorkspaceMemberRole, string> = {
[WorkspaceMemberRole.OWNER]: "bg-purple-100 text-purple-700",
[WorkspaceMemberRole.ADMIN]: "bg-blue-100 text-blue-700",
[WorkspaceMemberRole.MEMBER]: "bg-green-100 text-green-700",
[WorkspaceMemberRole.GUEST]: "bg-gray-100 text-gray-700",
};
const roleLabels: Record<WorkspaceMemberRole, string> = {
[WorkspaceMemberRole.OWNER]: "Owner",
[WorkspaceMemberRole.ADMIN]: "Admin",
[WorkspaceMemberRole.MEMBER]: "Member",
[WorkspaceMemberRole.GUEST]: "Guest",
};
export function WorkspaceCard({ workspace, userRole, memberCount }: WorkspaceCardProps) {
return (
<Link
href={`/settings/workspaces/${workspace.id}`}
className="block bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{workspace.name}
</h3>
<div className="flex items-center gap-3 text-sm text-gray-600">
<span className="flex items-center gap-1">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
{memberCount} {memberCount === 1 ? "member" : "members"}
</span>
</div>
</div>
<span
className={`px-3 py-1 rounded-full text-xs font-medium ${roleColors[userRole]}`}
>
{roleLabels[userRole]}
</span>
</div>
<div className="mt-4 text-sm text-gray-500">
Created {new Date(workspace.createdAt).toLocaleDateString()}
</div>
</Link>
);
}

View File

@@ -0,0 +1,183 @@
"use client";
import { useState } from "react";
import type { Workspace } from "@mosaic/shared";
import { WorkspaceMemberRole } from "@mosaic/shared";
interface WorkspaceSettingsProps {
workspace: Workspace;
userRole: WorkspaceMemberRole;
onUpdate: (name: string) => Promise<void>;
onDelete: () => Promise<void>;
}
export function WorkspaceSettings({
workspace,
userRole,
onUpdate,
onDelete,
}: WorkspaceSettingsProps) {
const [name, setName] = useState(workspace.name);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const canEdit = userRole === WorkspaceMemberRole.OWNER || userRole === WorkspaceMemberRole.ADMIN;
const canDelete = userRole === WorkspaceMemberRole.OWNER;
const handleSave = async () => {
if (name.trim() === "" || name === workspace.name) {
setIsEditing(false);
setName(workspace.name);
return;
}
setIsSaving(true);
try {
await onUpdate(name);
setIsEditing(false);
} catch (error) {
console.error("Failed to update workspace:", error);
alert("Failed to update workspace");
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
setIsDeleting(true);
try {
await onDelete();
} catch (error) {
console.error("Failed to delete workspace:", error);
alert("Failed to delete workspace");
setIsDeleting(false);
}
};
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-6">
Workspace Settings
</h2>
<div className="space-y-6">
{/* Workspace Name */}
<div>
<label
htmlFor="workspace-name"
className="block text-sm font-medium text-gray-700 mb-2"
>
Workspace Name
</label>
{isEditing ? (
<div className="flex gap-2">
<input
id="workspace-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isSaving}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100"
/>
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isSaving ? "Saving..." : "Save"}
</button>
<button
onClick={() => {
setIsEditing(false);
setName(workspace.name);
}}
disabled={isSaving}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
Cancel
</button>
</div>
) : (
<div className="flex items-center justify-between">
<p className="text-gray-900">{workspace.name}</p>
{canEdit && (
<button
onClick={() => setIsEditing(true)}
className="px-3 py-1 text-sm text-blue-600 hover:text-blue-700"
>
Edit
</button>
)}
</div>
)}
</div>
{/* Workspace ID */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Workspace ID
</label>
<code className="block px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-600">
{workspace.id}
</code>
</div>
{/* Created Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Created
</label>
<p className="text-gray-600">
{new Date(workspace.createdAt).toLocaleString()}
</p>
</div>
{/* Delete Workspace */}
{canDelete && (
<div className="pt-6 border-t border-gray-200">
<h3 className="text-sm font-medium text-red-700 mb-2">
Danger Zone
</h3>
<p className="text-sm text-gray-600 mb-4">
Deleting this workspace will permanently remove all associated
data, including tasks, events, and projects. This action cannot
be undone.
</p>
{showDeleteConfirm ? (
<div className="space-y-3">
<p className="text-sm font-medium text-gray-900">
Are you sure you want to delete this workspace?
</p>
<div className="flex gap-2">
<button
onClick={handleDelete}
disabled={isDeleting}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
>
{isDeleting ? "Deleting..." : "Yes, Delete Workspace"}
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={isDeleting}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Delete Workspace
</button>
)}
</div>
)}
</div>
</div>
);
}