Add multi-tenancy with Teams feature
This commit is contained in:
311
frontend/src/pages/TeamMembersPage.tsx
Normal file
311
frontend/src/pages/TeamMembersPage.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user