Files
orchard/frontend/src/pages/TeamMembersPage.tsx
2026-01-28 12:50:58 -06:00

312 lines
10 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import { TeamDetail, TeamMember, TeamMemberCreate, TeamRole } from '../types';
import {
getTeam,
listTeamMembers,
addTeamMember,
updateTeamMember,
removeTeamMember,
} from '../api';
import { useAuth } from '../contexts/AuthContext';
import { Badge } from '../components/Badge';
import { Breadcrumb } from '../components/Breadcrumb';
import { DataTable } from '../components/DataTable';
import { UserAutocomplete } from '../components/UserAutocomplete';
import './TeamMembersPage.css';
function TeamMembersPage() {
const { slug } = useParams<{ slug: string }>();
const { user } = useAuth();
const [team, setTeam] = useState<TeamDetail | null>(null);
const [members, setMembers] = useState<TeamMember[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [adding, setAdding] = useState(false);
const [newMember, setNewMember] = useState<TeamMemberCreate>({ username: '', role: 'member' });
const [editingMember, setEditingMember] = useState<string | null>(null);
const [removingMember, setRemovingMember] = useState<string | null>(null);
const loadData = useCallback(async () => {
if (!slug) return;
try {
setLoading(true);
const [teamData, membersData] = await Promise.all([
getTeam(slug),
listTeamMembers(slug),
]);
setTeam(teamData);
setMembers(membersData);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load team');
} finally {
setLoading(false);
}
}, [slug]);
useEffect(() => {
loadData();
}, [loadData]);
async function handleAddMember(e: React.FormEvent) {
e.preventDefault();
if (!slug) return;
try {
setAdding(true);
setError(null);
await addTeamMember(slug, newMember);
setNewMember({ username: '', role: 'member' });
setShowAddForm(false);
loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add member');
} finally {
setAdding(false);
}
}
async function handleRoleChange(username: string, newRole: TeamRole) {
if (!slug) return;
try {
setEditingMember(username);
setError(null);
await updateTeamMember(slug, username, { role: newRole });
loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update member');
} finally {
setEditingMember(null);
}
}
async function handleRemoveMember(username: string) {
if (!slug) return;
if (!confirm(`Remove ${username} from the team?`)) return;
try {
setRemovingMember(username);
setError(null);
await removeTeamMember(slug, username);
loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove member');
} finally {
setRemovingMember(null);
}
}
if (loading) {
return (
<div className="team-members">
<div className="loading-state">Loading team members...</div>
</div>
);
}
if (error && !team) {
return (
<div className="team-members">
<div className="error-state">
<h2>Error loading team</h2>
<p>{error}</p>
<Link to="/teams" className="btn btn-primary">Back to Teams</Link>
</div>
</div>
);
}
if (!team) return null;
const isOwner = team.user_role === 'owner' || user?.is_admin;
const isAdmin = team.user_role === 'admin' || isOwner;
const roleVariants: Record<string, 'success' | 'info' | 'default'> = {
owner: 'success',
admin: 'info',
member: 'default',
};
const roles: TeamRole[] = ['owner', 'admin', 'member'];
return (
<div className="team-members">
<Breadcrumb
items={[
{ label: 'Teams', href: '/teams' },
{ label: team.name, href: `/teams/${slug}` },
{ label: 'Members' },
]}
/>
<div className="page-header">
<h1>Team Members</h1>
{isAdmin && (
<button className="btn btn-primary" onClick={() => setShowAddForm(true)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
Invite Member
</button>
)}
</div>
{error && (
<div className="error-message">
{error}
<button onClick={() => setError(null)} className="error-dismiss">&times;</button>
</div>
)}
{showAddForm && (
<div className="modal-overlay" onClick={() => setShowAddForm(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<h2>Invite Member</h2>
<form onSubmit={handleAddMember}>
<div className="form-group">
<label htmlFor="username">Username</label>
<UserAutocomplete
value={newMember.username}
onChange={(username) => setNewMember({ ...newMember, username })}
placeholder="Search for a user..."
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="role">Role</label>
<select
id="role"
value={newMember.role}
onChange={e => setNewMember({ ...newMember, role: e.target.value as TeamRole })}
>
<option value="member">Member - Can view team projects</option>
<option value="admin">Admin - Can manage team settings and members</option>
{isOwner && (
<option value="owner">Owner - Full control, can delete team</option>
)}
</select>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowAddForm(false)}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={adding}>
{adding ? 'Adding...' : 'Add Member'}
</button>
</div>
</form>
</div>
</div>
)}
<DataTable
data={members}
keyExtractor={(member) => member.id}
emptyMessage="No members in this team yet."
columns={[
{
key: 'member',
header: 'Member',
render: (member) => {
const isCurrentUser = user?.username === member.username;
return (
<div className="member-cell">
<div className="member-avatar">
{member.username.charAt(0).toUpperCase()}
</div>
<div className="member-details">
<span className="member-username">
{member.username}
{isCurrentUser && <span className="you-badge">(you)</span>}
</span>
{member.email && (
<span className="member-email">{member.email}</span>
)}
</div>
</div>
);
},
},
{
key: 'role',
header: 'Role',
render: (member) => {
const isCurrentUser = user?.username === member.username;
const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner');
if (canModify) {
return (
<select
value={member.role}
onChange={e => handleRoleChange(member.username, e.target.value as TeamRole)}
disabled={editingMember === member.username}
className="role-select"
onClick={e => e.stopPropagation()}
>
{roles.map(role => (
<option
key={role}
value={role}
disabled={role === 'owner' && !isOwner}
>
{role.charAt(0).toUpperCase() + role.slice(1)}
</option>
))}
</select>
);
}
return (
<Badge variant={roleVariants[member.role] || 'default'}>
{member.role}
</Badge>
);
},
},
{
key: 'joined',
header: 'Joined',
render: (member) => (
<span className="text-muted">
{new Date(member.created_at).toLocaleDateString()}
</span>
),
},
...(isAdmin ? [{
key: 'actions',
header: '',
render: (member: TeamMember) => {
const isCurrentUser = user?.username === member.username;
const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner');
if (!canModify) return null;
return (
<button
className="btn btn-icon btn-danger-ghost"
onClick={(e) => {
e.stopPropagation();
handleRemoveMember(member.username);
}}
disabled={removingMember === member.username}
title="Remove member"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
);
},
}] : []),
]}
/>
</div>
);
}
export default TeamMembersPage;