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 .storage import get_storage
from .auth import hash_password
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})")
# 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
project_map = {}
package_map = {}

View File

@@ -16,34 +16,11 @@
font-size: 1.75rem;
}
/* Members list */
.members-list {
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 {
/* Member cell in table */
.member-cell {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.member-avatar {
@@ -87,11 +64,8 @@
white-space: nowrap;
}
.member-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
.text-muted {
color: var(--color-text-muted);
}
.role-select {

View File

@@ -11,6 +11,7 @@ import {
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';
@@ -201,34 +202,49 @@ function TeamMembersPage() {
</div>
)}
<div className="members-list">
{members.map(member => {
const isCurrentUser = user?.username === member.username;
const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner');
<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');
return (
<div key={member.id} className={`member-card ${isCurrentUser ? 'current-user' : ''}`}>
<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 ? (
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
@@ -240,30 +256,54 @@ function TeamMembersPage() {
</option>
))}
</select>
) : (
<Badge variant={roleVariants[member.role] || 'default'}>
{member.role}
</Badge>
)}
{canModify && (
<button
className="btn btn-icon btn-danger-ghost"
onClick={() => 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>
);
})}
</div>
);
}
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>
);
}

View File

@@ -13,37 +13,12 @@
gap: 1rem;
}
.teams-header__content {
display: flex;
align-items: center;
gap: 1.5rem;
}
.teams-header__content h1 {
.teams-header h1 {
margin: 0;
font-size: 1.5rem;
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 */
.teams-search {
position: relative;

View File

@@ -79,10 +79,7 @@ function TeamsPage() {
(team.description?.toLowerCase().includes(searchQuery.toLowerCase()))
) || [];
// Stats
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 }> = {
owner: { variant: 'success', label: 'Owner' },
@@ -114,16 +111,7 @@ function TeamsPage() {
<div className="teams-page">
{/* Header */}
<div className="teams-header">
<div className="teams-header__content">
<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>
<h1>Teams</h1>
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
<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" />