312 lines
10 KiB
TypeScript
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">×</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;
|