Files
stack/apps/web/src/components/team/TeamMemberList.tsx
Jason Woltje e8a9a3087a
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
fix(ci): fix pipeline #366 — web @mosaic/ui build, Dockerfile find bug, event handler types
Three root causes resolved:

1. .woodpecker/web.yml: build-shared step was missing @mosaic/ui build,
   causing 10 test suite failures + 20 typecheck errors (TS2307)

2. apps/orchestrator/Dockerfile: find -o without parentheses only deleted
   last pattern's matches, leaving spec files with test fixture secrets
   that triggered 5 Trivy false positives (3 CRITICAL, 2 HIGH)

3. 9 web files had untyped event handler parameters (e) causing 49 lint
   errors and 19 typecheck errors — added React.ChangeEvent<T> types

Verification: lint 0 errors, typecheck 0 errors, tests 73/73 suites pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 17:50:41 -06:00

172 lines
5.9 KiB
TypeScript

"use client";
import { useState } from "react";
import type { TeamMember, User } from "@mosaic/shared";
import { TeamMemberRole } from "@mosaic/shared";
import { Card, CardHeader, CardContent, Button, Select, Avatar } from "@mosaic/ui";
interface TeamMemberWithUser extends TeamMember {
user: User;
}
interface TeamMemberListProps {
members: TeamMemberWithUser[];
onAddMember: (userId: string, role?: TeamMemberRole) => Promise<void>;
onRemoveMember: (userId: string) => Promise<void>;
availableUsers?: User[];
}
const roleOptions = [
{ value: TeamMemberRole.MEMBER, label: "Member" },
{ value: TeamMemberRole.ADMIN, label: "Admin" },
{ value: TeamMemberRole.OWNER, label: "Owner" },
];
export function TeamMemberList({
members,
onAddMember,
onRemoveMember,
availableUsers = [],
}: TeamMemberListProps): React.JSX.Element {
const [isAdding, setIsAdding] = useState(false);
const [selectedUserId, setSelectedUserId] = useState("");
const [selectedRole, setSelectedRole] = useState(TeamMemberRole.MEMBER);
const [removingUserId, setRemovingUserId] = useState<string | null>(null);
const handleAddMember = async (): Promise<void> => {
if (!selectedUserId) return;
setIsAdding(true);
try {
await onAddMember(selectedUserId, selectedRole);
setSelectedUserId("");
setSelectedRole(TeamMemberRole.MEMBER);
} catch (error) {
console.error("Failed to add member:", error);
alert("Failed to add member. Please try again.");
} finally {
setIsAdding(false);
}
};
const handleRemoveMember = async (userId: string): Promise<void> => {
setRemovingUserId(userId);
try {
await onRemoveMember(userId);
} catch (error) {
console.error("Failed to remove member:", error);
alert("Failed to remove member. Please try again.");
} finally {
setRemovingUserId(null);
}
};
const memberUserIds = new Set(members.map((m) => m.userId));
const usersToAdd = availableUsers.filter((user) => !memberUserIds.has(user.id));
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">Team Members</h2>
<span className="text-sm text-gray-500">
{members.length} member{members.length !== 1 ? "s" : ""}
</span>
</div>
</CardHeader>
<CardContent>
{/* Member list */}
<div className="space-y-3 mb-6">
{members.length === 0 ? (
<p className="text-center text-gray-500 py-4">No members yet</p>
) : (
members.map((member) => (
<div
key={member.userId}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-3">
<Avatar
src={member.user.image ?? ""}
alt={member.user.name}
fallback={member.user.name.charAt(0).toUpperCase()}
/>
<div>
<p className="font-medium text-gray-900">{member.user.name}</p>
<p className="text-sm text-gray-500">{member.user.email}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span
className={`px-3 py-1 text-xs font-medium rounded-full ${
member.role === TeamMemberRole.OWNER
? "bg-purple-100 text-purple-700"
: member.role === TeamMemberRole.ADMIN
? "bg-blue-100 text-blue-700"
: "bg-gray-100 text-gray-700"
}`}
>
{member.role}
</span>
{member.role !== TeamMemberRole.OWNER && (
<Button
variant="danger"
size="sm"
onClick={() => handleRemoveMember(member.userId)}
disabled={removingUserId === member.userId}
>
{removingUserId === member.userId ? "Removing..." : "Remove"}
</Button>
)}
</div>
</div>
))
)}
</div>
{/* Add member form */}
{usersToAdd.length > 0 && (
<div className="pt-4 border-t border-gray-200">
<h3 className="text-sm font-medium text-gray-700 mb-3">Add Member</h3>
<div className="flex gap-2">
<div className="flex-1">
<Select
options={usersToAdd.map((user) => ({
value: user.id,
label: `${user.name} (${user.email})`,
}))}
value={selectedUserId}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedUserId(e.target.value);
}}
placeholder="Select a user..."
fullWidth
disabled={isAdding}
/>
</div>
<div className="w-32">
<Select
options={roleOptions}
value={selectedRole}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedRole(e.target.value as TeamMemberRole);
}}
fullWidth
disabled={isAdding}
/>
</div>
<Button
variant="primary"
onClick={handleAddMember}
disabled={!selectedUserId || isAdding}
>
{isAdding ? "Adding..." : "Add"}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}