Files
stack/apps/web/src/components/workspace/MemberList.tsx
Jason Woltje 6d92251fc1
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(SEC-WEB-27+28): Robust email validation + role cast validation
SEC-WEB-27: Replace weak email.includes('@') check with RFC 5322-aligned
programmatic validation (isValidEmail). Uses character-level domain label
validation to avoid ReDoS vulnerabilities from complex regex patterns.

SEC-WEB-28: Replace unsafe 'as WorkspaceMemberRole' type casts with
runtime validation (toWorkspaceMemberRole) that checks against known enum
values and falls back to MEMBER for invalid inputs. Applied in both
InviteMember.tsx and MemberList.tsx.

Adds 43 tests covering validation logic, InviteMember component, and
MemberList component behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:40:05 -06:00

137 lines
5.3 KiB
TypeScript

"use client";
import type { User, WorkspaceMember } from "@mosaic/shared";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { toWorkspaceMemberRole } from "./validation";
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): React.JSX.Element {
const canManageMembers =
currentUserRole === WorkspaceMemberRole.OWNER || currentUserRole === WorkspaceMemberRole.ADMIN;
const handleRoleChange = async (userId: string, newRole: WorkspaceMemberRole): Promise<void> => {
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): Promise<void> => {
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, toWorkspaceMemberRole(e.target.value))
}
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>
);
}