Use DataTable for members, add seed users, remove teams stats

- Update TeamMembersPage to use DataTable component for consistency
- Add test users (alice, bob, charlie, diana, eve, frank) with various roles
- Remove stats from teams list header
- Passwords for test users are same as their usernames
This commit is contained in:
Mondo Diaz
2026-01-28 16:20:23 +00:00
parent 2b9c039157
commit 1b2bc33aba
5 changed files with 140 additions and 115 deletions

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency, Team, TeamMembership, User from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency, Team, TeamMembership, User
from .storage import get_storage from .storage import get_storage
from .auth import hash_password
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -176,6 +177,53 @@ def seed_database(db: Session) -> None:
logger.info(f"Created team: {demo_team.name} ({demo_team.slug})") logger.info(f"Created team: {demo_team.name} ({demo_team.slug})")
# Create test users with various roles
test_users = [
{"username": "alice", "email": "alice@example.com", "role": "admin"},
{"username": "bob", "email": "bob@example.com", "role": "admin"},
{"username": "charlie", "email": "charlie@example.com", "role": "member"},
{"username": "diana", "email": "diana@example.com", "role": "member"},
{"username": "eve", "email": "eve@example.com", "role": "member"},
{"username": "frank", "email": None, "role": "member"},
]
for user_data in test_users:
# Check if user already exists
existing_user = db.query(User).filter(User.username == user_data["username"]).first()
if existing_user:
test_user = existing_user
else:
# Create the user with password same as username
test_user = User(
username=user_data["username"],
email=user_data["email"],
password_hash=hash_password(user_data["username"]),
is_admin=False,
is_active=True,
must_change_password=False,
)
db.add(test_user)
db.flush()
logger.info(f"Created test user: {user_data['username']}")
# Add to demo team with specified role
existing_membership = db.query(TeamMembership).filter(
TeamMembership.team_id == demo_team.id,
TeamMembership.user_id == test_user.id,
).first()
if not existing_membership:
membership = TeamMembership(
team_id=demo_team.id,
user_id=test_user.id,
role=user_data["role"],
invited_by=team_owner_username,
)
db.add(membership)
logger.info(f"Added {user_data['username']} to {demo_team.slug} as {user_data['role']}")
db.flush()
# Create projects and packages # Create projects and packages
project_map = {} project_map = {}
package_map = {} package_map = {}

View File

@@ -16,34 +16,11 @@
font-size: 1.75rem; font-size: 1.75rem;
} }
/* Members list */ /* Member cell in table */
.members-list { .member-cell {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.member-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
gap: 1rem;
}
.member-card.current-user {
background: var(--color-primary-bg);
border-color: var(--color-primary-border, var(--color-border));
}
.member-info {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
min-width: 0;
} }
.member-avatar { .member-avatar {
@@ -87,11 +64,8 @@
white-space: nowrap; white-space: nowrap;
} }
.member-actions { .text-muted {
display: flex; color: var(--color-text-muted);
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
} }
.role-select { .role-select {

View File

@@ -11,6 +11,7 @@ import {
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { Breadcrumb } from '../components/Breadcrumb'; import { Breadcrumb } from '../components/Breadcrumb';
import { DataTable } from '../components/DataTable';
import { UserAutocomplete } from '../components/UserAutocomplete'; import { UserAutocomplete } from '../components/UserAutocomplete';
import './TeamMembersPage.css'; import './TeamMembersPage.css';
@@ -201,34 +202,49 @@ function TeamMembersPage() {
</div> </div>
)} )}
<div className="members-list"> <DataTable
{members.map(member => { data={members}
const isCurrentUser = user?.username === member.username; keyExtractor={(member) => member.id}
const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner'); 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');
return ( if (canModify) {
<div key={member.id} className={`member-card ${isCurrentUser ? 'current-user' : ''}`}> return (
<div className="member-info">
<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>
<div className="member-actions">
{canModify ? (
<select <select
value={member.role} value={member.role}
onChange={e => handleRoleChange(member.username, e.target.value as TeamRole)} onChange={e => handleRoleChange(member.username, e.target.value as TeamRole)}
disabled={editingMember === member.username} disabled={editingMember === member.username}
className="role-select" className="role-select"
onClick={e => e.stopPropagation()}
> >
{roles.map(role => ( {roles.map(role => (
<option <option
@@ -240,30 +256,54 @@ function TeamMembersPage() {
</option> </option>
))} ))}
</select> </select>
) : ( );
<Badge variant={roleVariants[member.role] || 'default'}> }
{member.role} return (
</Badge> <Badge variant={roleVariants[member.role] || 'default'}>
)} {member.role}
{canModify && ( </Badge>
<button );
className="btn btn-icon btn-danger-ghost" },
onClick={() => handleRemoveMember(member.username)} },
disabled={removingMember === member.username} {
title="Remove member" key: 'joined',
> header: 'Joined',
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> render: (member) => (
<path d="M3 6h18"/> <span className="text-muted">
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/> {new Date(member.created_at).toLocaleDateString()}
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/> </span>
</svg> ),
</button> },
)} ...(isAdmin ? [{
</div> key: 'actions',
</div> header: '',
); render: (member: TeamMember) => {
})} const isCurrentUser = user?.username === member.username;
</div> 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> </div>
); );
} }

View File

@@ -13,37 +13,12 @@
gap: 1rem; gap: 1rem;
} }
.teams-header__content { .teams-header h1 {
display: flex;
align-items: center;
gap: 1.5rem;
}
.teams-header__content h1 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
} }
.teams-header__stats {
display: flex;
align-items: center;
gap: 1rem;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.teams-header__stats span {
display: flex;
align-items: center;
gap: 0.375rem;
}
.teams-header__stats .stat-value {
font-weight: 600;
color: var(--color-text);
}
/* Search */ /* Search */
.teams-search { .teams-search {
position: relative; position: relative;

View File

@@ -79,10 +79,7 @@ function TeamsPage() {
(team.description?.toLowerCase().includes(searchQuery.toLowerCase())) (team.description?.toLowerCase().includes(searchQuery.toLowerCase()))
) || []; ) || [];
// Stats
const totalTeams = teamsData?.items.length || 0; const totalTeams = teamsData?.items.length || 0;
const totalProjects = teamsData?.items.reduce((sum, t) => sum + t.project_count, 0) || 0;
const ownedTeams = teamsData?.items.filter(t => t.user_role === 'owner').length || 0;
const roleConfig: Record<string, { variant: 'success' | 'info' | 'default'; label: string }> = { const roleConfig: Record<string, { variant: 'success' | 'info' | 'default'; label: string }> = {
owner: { variant: 'success', label: 'Owner' }, owner: { variant: 'success', label: 'Owner' },
@@ -114,16 +111,7 @@ function TeamsPage() {
<div className="teams-page"> <div className="teams-page">
{/* Header */} {/* Header */}
<div className="teams-header"> <div className="teams-header">
<div className="teams-header__content"> <h1>Teams</h1>
<h1>Teams</h1>
{!loading && totalTeams > 0 && (
<div className="teams-header__stats">
<span><span className="stat-value">{totalTeams}</span> teams</span>
<span><span className="stat-value">{ownedTeams}</span> owned</span>
<span><span className="stat-value">{totalProjects}</span> projects</span>
</div>
)}
</div>
<button className="btn btn-primary" onClick={() => setShowForm(true)}> <button className="btn btn-primary" onClick={() => setShowForm(true)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" /> <line x1="12" y1="5" x2="12" y2="19" />