5 Commits

Author SHA1 Message Date
Mondo Diaz
6901880a2f Update CHANGELOG with access management team display feature 2026-01-28 00:57:30 +00:00
Mondo Diaz
89186a0d61 Show team-based access in project access management
- Add source, team_slug, team_role fields to AccessPermissionResponse schema
- Update list_project_permissions endpoint to include team members with source="team"
- Display team-based access in AccessManagement component with read-only styling
- Add "Source" column to differentiate explicit vs team-based permissions
- Team-based access shows "Via team" in actions column (not editable)
2026-01-28 00:57:16 +00:00
Mondo Diaz
da6af4ae71 Fix team members not seeing private projects in listings
The list_projects endpoint was only showing projects that were public or
created by the user. Updated to also include projects belonging to teams
where the user is a member.

This allows team members to see private projects in the main project
listing, not just on the team dashboard.
2026-01-28 00:14:16 +00:00
Mondo Diaz
053d45add1 Add project creation from team dashboard and update seed data
- Add project creation modal to TeamDashboardPage with team_id assignment
- Update createProject API function to accept optional team_id
- Update seed data to create a "Demo Team" and assign all projects to it
- Admin user is added as team owner when present
2026-01-28 00:02:53 +00:00
Mondo Diaz
a1bf38de04 Add multi-tenancy with Teams feature
Implement team-based organization for projects with role-based access control:

Backend:
- Add teams and team_memberships database tables (migrations 009, 009b)
- Add Team and TeamMembership ORM models with relationships
- Implement TeamAuthorizationService for team-level access control
- Add team CRUD, membership, and projects API endpoints
- Update project creation to support team assignment

Frontend:
- Add TeamContext for managing team state with localStorage persistence
- Add TeamSelector component for switching between teams
- Add TeamsPage, TeamDashboardPage, TeamSettingsPage, TeamMembersPage
- Add team API client functions
- Update navigation with Teams link

Security:
- Team role hierarchy: owner > admin > member
- Membership checked before system admin fallback
- Self-modification prevention for role changes
- Email visibility restricted to team admins/owners
- Slug validation rejects consecutive hyphens

Tests:
- Unit tests for TeamAuthorizationService
- Integration tests for all team API endpoints
2026-01-27 23:28:31 +00:00
20 changed files with 530 additions and 1244 deletions

View File

@@ -48,16 +48,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Team-based access displayed as read-only with "Source" column indicating origin
- Team members with access show team slug and role
- Added integration tests for team CRUD, membership, and project operations
- Redesigned teams portal with modern card-based layout
- Card grid view with team avatar, name, slug, role badge, and stats
- Stats bar showing total teams, owned teams, and total projects
- Search functionality for filtering teams (appears when >3 teams)
- Empty states for no teams and no search results
- Added user autocomplete component for team member invitations
- `GET /api/v1/users/search` endpoint for username prefix search
- Dropdown shows matching users as you type
- Keyboard navigation support (arrow keys, enter, escape)
- Debounced search to reduce API calls
- Added unit tests for TeamAuthorizationService
- Added `ORCHARD_ADMIN_PASSWORD` environment variable to configure initial admin password (#87)
- When set, admin user is created with the specified password (no password change required)
@@ -105,13 +95,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added pre-test stage reset to ensure known environment state before integration tests (#54)
- Upload endpoint now accepts optional `ensure` file parameter for declaring dependencies
- Updated upload API documentation with ensure file format and examples
- Converted teams list and team projects to use DataTable component for consistent styling
- Centered team members and team settings page content
- Added orchard logo icon and dot separator to footer
### Fixed
- Fixed dark theme styling for team pages - modals, forms, and dropdowns now use correct theme variables
- Fixed UserAutocomplete and TeamSelector dropdown backgrounds for dark theme
## [0.5.1] - 2026-01-23
### Changed

View File

@@ -285,60 +285,6 @@ def _run_migrations():
END IF;
END $$;
""",
# Teams and multi-tenancy migration (009_teams.sql)
"""
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(255) NOT NULL,
settings JSONB DEFAULT '{}'
);
""",
"""
CREATE TABLE IF NOT EXISTS team_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL DEFAULT 'member',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
invited_by VARCHAR(255),
CONSTRAINT team_memberships_unique UNIQUE (team_id, user_id),
CONSTRAINT team_memberships_role_check CHECK (role IN ('owner', 'admin', 'member'))
);
""",
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'projects' AND column_name = 'team_id'
) THEN
ALTER TABLE projects ADD COLUMN team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_projects_team_id ON projects(team_id);
END IF;
END $$;
""",
"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_slug') THEN
CREATE INDEX idx_teams_slug ON teams(slug);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_created_by') THEN
CREATE INDEX idx_teams_created_by ON teams(created_by);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_team_id') THEN
CREATE INDEX idx_team_memberships_team_id ON team_memberships(team_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_user_id') THEN
CREATE INDEX idx_team_memberships_user_id ON team_memberships(user_id);
END IF;
END $$;
""",
]
with engine.connect() as conn:
@@ -347,7 +293,6 @@ def _run_migrations():
conn.execute(text(migration))
conn.commit()
except Exception as e:
conn.rollback()
logger.warning(f"Migration failed (may already be applied): {e}")

View File

@@ -1093,43 +1093,6 @@ def oidc_callback(
return response
# --- User Search Routes (for autocomplete) ---
@router.get("/api/v1/users/search")
def search_users(
q: str = Query(..., min_length=1, description="Search query for username"),
limit: int = Query(default=10, ge=1, le=50, description="Maximum results"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Search for users by username prefix.
Returns basic user info for autocomplete (no email for privacy).
Any authenticated user can search.
"""
search_pattern = f"{q.lower()}%"
users = (
db.query(User)
.filter(
func.lower(User.username).like(search_pattern),
User.is_active == True,
)
.order_by(User.username)
.limit(limit)
.all()
)
return [
{
"id": str(u.id),
"username": u.username,
"is_admin": u.is_admin,
}
for u in users
]
# --- Admin User Management Routes ---

View File

@@ -7,7 +7,6 @@ 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__)
@@ -177,53 +176,6 @@ 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

@@ -668,17 +668,3 @@ export async function listTeamProjects(
});
return handleResponse<PaginatedResponse<Project>>(response);
}
// User search (for autocomplete)
export interface UserSearchResult {
id: string;
username: string;
is_admin: boolean;
}
export async function searchUsers(query: string, limit: number = 10): Promise<UserSearchResult[]> {
const response = await fetch(`${API_BASE}/users/search?q=${encodeURIComponent(query)}&limit=${limit}`, {
credentials: 'include',
});
return handleResponse<UserSearchResult[]>(response);
}

View File

@@ -284,11 +284,7 @@
.footer-brand {
display: flex;
align-items: center;
gap: 8px;
}
.footer-icon {
color: var(--accent-primary);
gap: 12px;
}
.footer-logo {
@@ -296,10 +292,6 @@
color: var(--text-primary);
}
.footer-separator {
color: var(--text-muted);
}
.footer-tagline {
color: var(--text-secondary);
font-size: 0.875rem;

View File

@@ -2,8 +2,6 @@ import { ReactNode, useState, useRef, useEffect } from 'react';
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { GlobalSearch } from './GlobalSearch';
import { listTeams } from '../api';
import { TeamDetail } from '../types';
import './Layout.css';
interface LayoutProps {
@@ -15,22 +13,8 @@ function Layout({ children }: LayoutProps) {
const navigate = useNavigate();
const { user, loading, logout } = useAuth();
const [showUserMenu, setShowUserMenu] = useState(false);
const [userTeams, setUserTeams] = useState<TeamDetail[]>([]);
const menuRef = useRef<HTMLDivElement>(null);
// Fetch user's teams
useEffect(() => {
if (user) {
listTeams({ limit: 10 }).then(data => {
setUserTeams(data.items);
}).catch(() => {
setUserTeams([]);
});
} else {
setUserTeams([]);
}
}, [user]);
// Close menu when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
@@ -93,18 +77,15 @@ function Layout({ children }: LayoutProps) {
</svg>
Dashboard
</Link>
{user && userTeams.length > 0 && (
<Link
to={userTeams.length === 1 ? `/teams/${userTeams[0].slug}` : '/teams'}
className={location.pathname.startsWith('/teams') ? 'active' : ''}
>
{user && (
<Link to="/teams" className={location.pathname.startsWith('/teams') ? 'active' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
{userTeams.length === 1 ? 'Team' : 'Teams'}
Teams
</Link>
)}
<a href="/docs" className="nav-link-muted">
@@ -218,17 +199,7 @@ function Layout({ children }: LayoutProps) {
<footer className="footer">
<div className="container footer-content">
<div className="footer-brand">
<svg className="footer-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 14 Q6 8 3 8 Q6 4 6 4 Q6 4 9 8 Q6 8 6 14" fill="currentColor" opacity="0.6"/>
<rect x="5.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
<path d="M12 12 Q12 5 8 5 Q12 1 12 1 Q12 1 16 5 Q12 5 12 12" fill="currentColor"/>
<rect x="11.25" y="11" width="1.5" height="5" fill="currentColor"/>
<path d="M18 14 Q18 8 15 8 Q18 4 18 4 Q18 4 21 8 Q18 8 18 14" fill="currentColor" opacity="0.6"/>
<rect x="17.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
<ellipse cx="12" cy="19" rx="9" ry="1.5" fill="currentColor" opacity="0.3"/>
</svg>
<span className="footer-logo">Orchard</span>
<span className="footer-separator">·</span>
<span className="footer-tagline">Content-Addressable Storage</span>
</div>
<div className="footer-links">

View File

@@ -7,10 +7,10 @@
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--text-primary);
color: var(--color-text);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease;
@@ -18,8 +18,8 @@
}
.team-selector-trigger:hover:not(:disabled) {
background: var(--bg-tertiary);
border-color: var(--border-secondary);
background: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
}
.team-selector-trigger:disabled {
@@ -51,8 +51,8 @@
right: 0;
min-width: 240px;
margin-top: 0.25rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
@@ -62,7 +62,7 @@
.team-selector-empty {
padding: 1rem;
text-align: center;
color: var(--text-muted);
color: var(--color-text-muted);
}
.team-selector-empty p {
@@ -71,7 +71,7 @@
}
.team-selector-create-link {
color: var(--accent-primary);
color: var(--color-primary);
font-size: 0.875rem;
text-decoration: none;
}
@@ -96,7 +96,7 @@
padding: 0.5rem 0.75rem;
background: none;
border: none;
color: var(--text-primary);
color: var(--color-text);
font-size: 0.875rem;
cursor: pointer;
text-align: left;
@@ -104,11 +104,11 @@
}
.team-selector-item:hover {
background: var(--bg-hover);
background: var(--color-bg-secondary);
}
.team-selector-item.selected {
background: rgba(16, 185, 129, 0.1);
background: var(--color-primary-bg);
}
.team-selector-item-info {
@@ -127,7 +127,7 @@
.team-selector-item-meta {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
color: var(--color-text-muted);
}
.team-selector-item-role {
@@ -140,24 +140,24 @@
display: flex;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border-primary);
background: var(--bg-tertiary);
border-top: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.team-selector-link {
font-size: 0.8125rem;
color: var(--text-muted);
color: var(--color-text-muted);
text-decoration: none;
}
.team-selector-link:hover {
color: var(--text-primary);
color: var(--color-text);
}
.team-selector-link-primary {
color: var(--accent-primary);
color: var(--color-primary);
}
.team-selector-link-primary:hover {
color: var(--accent-primary-hover);
color: var(--color-primary-hover);
}

View File

@@ -1,105 +0,0 @@
.user-autocomplete {
position: relative;
width: 100%;
}
.user-autocomplete__input-wrapper {
position: relative;
}
.user-autocomplete__input {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 0.75rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 0.875rem;
}
.user-autocomplete__input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
.user-autocomplete__spinner {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
border: 2px solid var(--border-primary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: translateY(-50%) rotate(360deg); }
}
.user-autocomplete__dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
padding: 0.25rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
max-height: 240px;
overflow-y: auto;
list-style: none;
}
.user-autocomplete__option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.1s;
}
.user-autocomplete__option:hover,
.user-autocomplete__option.selected {
background: var(--bg-hover);
}
.user-autocomplete__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
}
.user-autocomplete__user-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-autocomplete__username {
font-weight: 500;
color: var(--text-primary);
}
.user-autocomplete__admin-badge {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.025em;
}

View File

@@ -1,171 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { searchUsers, UserSearchResult } from '../api';
import './UserAutocomplete.css';
interface UserAutocompleteProps {
value: string;
onChange: (username: string) => void;
placeholder?: string;
disabled?: boolean;
autoFocus?: boolean;
}
export function UserAutocomplete({
value,
onChange,
placeholder = 'Search users...',
disabled = false,
autoFocus = false,
}: UserAutocompleteProps) {
const [query, setQuery] = useState(value);
const [results, setResults] = useState<UserSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Search for users with debounce
const doSearch = useCallback(async (searchQuery: string) => {
if (searchQuery.length < 1) {
setResults([]);
setIsOpen(false);
return;
}
setLoading(true);
try {
const users = await searchUsers(searchQuery);
setResults(users);
setIsOpen(users.length > 0);
setSelectedIndex(-1);
} catch {
setResults([]);
setIsOpen(false);
} finally {
setLoading(false);
}
}, []);
// Handle input change with debounce
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setQuery(newValue);
onChange(newValue); // Update parent immediately for form validation
// Debounce the search
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
doSearch(newValue);
}, 200);
};
// Handle selecting a user
const handleSelect = (user: UserSearchResult) => {
setQuery(user.username);
onChange(user.username);
setIsOpen(false);
setResults([]);
inputRef.current?.focus();
};
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => (prev < results.length - 1 ? prev + 1 : prev));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => (prev > 0 ? prev - 1 : -1));
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0 && results[selectedIndex]) {
handleSelect(results[selectedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
break;
}
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Sync external value changes
useEffect(() => {
setQuery(value);
}, [value]);
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
return (
<div className="user-autocomplete" ref={containerRef}>
<div className="user-autocomplete__input-wrapper">
<input
ref={inputRef}
type="text"
value={query}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => query.length >= 1 && results.length > 0 && setIsOpen(true)}
placeholder={placeholder}
disabled={disabled}
autoFocus={autoFocus}
autoComplete="off"
className="user-autocomplete__input"
/>
{loading && (
<div className="user-autocomplete__spinner" />
)}
</div>
{isOpen && results.length > 0 && (
<ul className="user-autocomplete__dropdown">
{results.map((user, index) => (
<li
key={user.id}
className={`user-autocomplete__option ${index === selectedIndex ? 'selected' : ''}`}
onClick={() => handleSelect(user)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="user-autocomplete__avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<div className="user-autocomplete__user-info">
<span className="user-autocomplete__username">{user.username}</span>
{user.is_admin && (
<span className="user-autocomplete__admin-badge">Admin</span>
)}
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -179,18 +179,16 @@ function Home() {
</form>
)}
{user && (
<div className="list-controls">
<FilterDropdown
label="Visibility"
options={VISIBILITY_OPTIONS}
value={visibility}
onChange={handleVisibilityChange}
/>
</div>
)}
<div className="list-controls">
<FilterDropdown
label="Visibility"
options={VISIBILITY_OPTIONS}
value={visibility}
onChange={handleVisibilityChange}
/>
</div>
{user && hasActiveFilters && (
{hasActiveFilters && (
<FilterChipGroup onClearAll={clearFilters}>
{visibility && (
<FilterChip

View File

@@ -211,7 +211,7 @@ function ProjectPage() {
</div>
</div>
<div className="page-header__actions">
{canAdmin && !project.team_id && (
{canAdmin && (
<button
className="btn btn-secondary"
onClick={() => navigate(`/project/${projectName}/settings`)}

View File

@@ -10,6 +10,7 @@ import {
ForbiddenError,
} from '../api';
import { Breadcrumb } from '../components/Breadcrumb';
import { AccessManagement } from '../components/AccessManagement';
import { useAuth } from '../contexts/AuthContext';
import './ProjectSettingsPage.css';
@@ -235,6 +236,9 @@ function ProjectSettingsPage() {
</form>
</div>
{/* Access Management Section */}
<AccessManagement projectName={projectName!} />
{/* Danger Zone Section */}
<div className="project-settings-danger-zone">
<h2>Danger Zone</h2>

View File

@@ -3,18 +3,10 @@
}
.team-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1.5rem;
margin-bottom: 2rem;
margin-bottom: 1.5rem;
}
.team-header-left {
flex: 1;
}
.team-header-title {
.team-header-info {
display: flex;
align-items: center;
gap: 0.75rem;
@@ -23,26 +15,57 @@
.team-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.team-slug {
font-size: 0.875rem;
color: var(--text-muted);
font-size: 1.75rem;
}
.team-description {
margin: 0 0 0.5rem;
color: var(--text-secondary);
font-size: 0.9375rem;
color: var(--color-text-secondary);
font-size: 1rem;
max-width: 600px;
}
.team-header-actions {
.team-meta {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
align-items: center;
gap: 1rem;
}
.team-slug {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.team-stats {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem 1.5rem;
min-width: 120px;
}
.stat-value {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text);
}
.stat-label {
font-size: 0.8125rem;
color: var(--color-text-muted);
margin-top: 0.25rem;
}
.team-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 2rem;
}
.team-section {
@@ -61,23 +84,54 @@
font-size: 1.25rem;
}
/* Table utility classes */
.text-muted {
color: var(--text-muted);
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
border: none;
padding: 0.375rem;
.project-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem;
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.15s ease;
}
.btn-ghost:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
.project-card:hover {
border-color: var(--color-border-hover);
box-shadow: var(--shadow-sm);
}
.project-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.project-card-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.project-card-description {
margin: 0 0 0.75rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.project-card-meta {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.section-footer {
@@ -87,7 +141,7 @@
.view-all-link {
font-size: 0.875rem;
color: var(--accent-primary);
color: var(--color-primary);
text-decoration: none;
}
@@ -108,16 +162,16 @@
.error-state p {
margin: 0 0 1.5rem;
color: var(--text-muted);
color: var(--color-text-muted);
}
.empty-state {
text-align: center;
padding: 2rem;
background: var(--bg-secondary);
border: 1px dashed var(--border-primary);
background: var(--color-bg-secondary);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
color: var(--text-muted);
color: var(--color-text-muted);
}
.empty-state p {
@@ -150,29 +204,29 @@
}
.btn-primary {
background: var(--accent-primary);
background: var(--color-primary);
color: white;
}
.btn-primary:hover {
background: var(--accent-primary-hover);
background: var(--color-primary-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-primary);
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover {
background: var(--bg-hover);
background: var(--color-bg-tertiary);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
@@ -181,21 +235,18 @@
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
background: var(--color-bg);
border-radius: var(--radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 480px;
max-height: 90vh;
box-shadow: var(--shadow-lg);
overflow-y: auto;
}
.modal-content h2 {
margin: 0 0 1.5rem;
font-size: 1.25rem;
color: var(--text-primary);
}
/* Form */
@@ -208,25 +259,24 @@
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary);
}
.form-group input[type="text"],
.form-group textarea {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid var(--border-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--bg-tertiary);
color: var(--text-primary);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-alpha);
}
.form-group textarea {
@@ -249,7 +299,7 @@
.form-hint {
display: block;
font-size: 0.8125rem;
color: var(--text-muted);
color: var(--color-text-muted);
margin-top: 0.375rem;
}

View File

@@ -5,7 +5,6 @@ import { getTeam, listTeamProjects, createProject } from '../api';
import { useAuth } from '../contexts/AuthContext';
import { Badge } from '../components/Badge';
import { Breadcrumb } from '../components/Breadcrumb';
import { DataTable } from '../components/DataTable';
import './TeamDashboardPage.css';
function TeamDashboardPage() {
@@ -96,42 +95,54 @@ function TeamDashboardPage() {
/>
<div className="team-header">
<div className="team-header-left">
<div className="team-header-title">
<h1>{team.name}</h1>
{team.user_role && (
<Badge variant={roleVariants[team.user_role] || 'default'}>
{team.user_role}
</Badge>
)}
<span className="team-slug">@{team.slug}</span>
</div>
{team.description && (
<p className="team-description">{team.description}</p>
<div className="team-header-info">
<h1>{team.name}</h1>
{team.user_role && (
<Badge variant={roleVariants[team.user_role] || 'default'}>
{team.user_role}
</Badge>
)}
</div>
{isAdminOrOwner && (
<div className="team-header-actions">
<Link to={`/teams/${slug}/members`} className="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Members
</Link>
<Link to={`/teams/${slug}/settings`} className="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Settings
</Link>
</div>
{team.description && (
<p className="team-description">{team.description}</p>
)}
<div className="team-meta">
<span className="team-slug">@{team.slug}</span>
</div>
</div>
<div className="team-stats">
<div className="stat-card">
<div className="stat-value">{team.project_count}</div>
<div className="stat-label">Projects</div>
</div>
<div className="stat-card">
<div className="stat-value">{team.member_count}</div>
<div className="stat-label">Members</div>
</div>
</div>
{isAdminOrOwner && (
<div className="team-actions">
<Link to={`/teams/${slug}/settings`} className="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Settings
</Link>
<Link to={`/teams/${slug}/members`} className="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Members
</Link>
</div>
)}
{showProjectForm && (
<div className="modal-overlay" onClick={() => setShowProjectForm(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
@@ -203,65 +214,28 @@ function TeamDashboardPage() {
)}
</div>
) : (
<DataTable
data={projects?.items || []}
keyExtractor={(project) => project.id}
onRowClick={(project) => navigate(`/project/${project.name}`)}
columns={[
{
key: 'name',
header: 'Name',
render: (project) => (
<Link
to={`/project/${project.name}`}
className="cell-name"
onClick={(e) => e.stopPropagation()}
>
{project.name}
</Link>
),
},
{
key: 'description',
header: 'Description',
className: 'cell-description',
render: (project) => project.description || <span className="text-muted"></span>,
},
{
key: 'visibility',
header: 'Visibility',
render: (project) => (
<div className="projects-grid">
{projects?.items.map(project => (
<div
key={project.id}
className="project-card"
onClick={() => navigate(`/project/${project.name}`)}
>
<div className="project-card-header">
<h3>{project.name}</h3>
<Badge variant={project.is_public ? 'public' : 'private'}>
{project.is_public ? 'Public' : 'Private'}
</Badge>
),
},
{
key: 'created_by',
header: 'Created By',
render: (project) => <span className="text-muted">{project.created_by}</span>,
},
...(isAdminOrOwner ? [{
key: 'actions',
header: '',
render: (project: Project) => (
<button
className="btn btn-sm btn-ghost"
onClick={(e) => {
e.stopPropagation();
navigate(`/project/${project.name}/settings`);
}}
title="Settings"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
),
}] : []),
]}
/>
</div>
{project.description && (
<p className="project-card-description">{project.description}</p>
)}
<div className="project-card-meta">
<span>Created by {project.created_by}</span>
</div>
</div>
))}
</div>
)}
{projects && projects.pagination.total > 10 && (

View File

@@ -1,7 +1,6 @@
.team-members {
padding: 1.5rem 0;
max-width: 800px;
margin: 0 auto;
}
.page-header {
@@ -17,18 +16,41 @@
font-size: 1.75rem;
}
/* Member cell in table */
.member-cell {
/* 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 {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.member-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent-primary);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
@@ -54,34 +76,37 @@
.you-badge {
font-size: 0.75rem;
font-weight: normal;
color: var(--text-muted);
color: var(--color-text-muted);
}
.member-email {
font-size: 0.8125rem;
color: var(--text-muted);
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-muted {
color: var(--text-muted);
.member-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.role-select {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--bg-tertiary);
color: var(--text-primary);
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
}
.role-select:focus {
outline: none;
border-color: var(--accent-primary);
border-color: var(--color-primary);
}
/* Messages */
@@ -91,10 +116,10 @@
justify-content: space-between;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: var(--error-bg);
border: 1px solid var(--error);
background: var(--color-error-bg, #fef2f2);
border: 1px solid var(--color-error-border, #fecaca);
border-radius: var(--radius-md);
color: var(--error);
color: var(--color-error, #dc2626);
font-size: 0.875rem;
}
@@ -121,7 +146,7 @@
.error-state p {
margin: 0 0 1.5rem;
color: var(--text-muted);
color: var(--color-text-muted);
}
/* Modal */
@@ -131,7 +156,7 @@
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
@@ -140,19 +165,17 @@
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
background: var(--color-bg);
border-radius: var(--radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: var(--shadow-lg);
box-shadow: var(--shadow-xl);
}
.modal-content h2 {
margin: 0 0 1.5rem;
font-size: 1.25rem;
color: var(--text-primary);
}
/* Form */
@@ -165,25 +188,24 @@
margin-bottom: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary);
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 0.9375rem;
background: var(--bg-tertiary);
color: var(--text-primary);
background: var(--color-bg);
color: var(--color-text);
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-bg);
}
.form-actions {
@@ -214,22 +236,22 @@
}
.btn-primary {
background: var(--accent-primary);
background: var(--color-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-primary-hover);
background: var(--color-primary-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-primary);
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
background: var(--color-bg-tertiary);
}
.btn-icon {
@@ -238,10 +260,10 @@
.btn-danger-ghost {
background: transparent;
color: var(--text-muted);
color: var(--color-text-muted);
}
.btn-danger-ghost:hover:not(:disabled) {
background: var(--error-bg);
color: var(--error);
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
}

View File

@@ -11,8 +11,6 @@ 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';
function TeamMembersPage() {
@@ -168,10 +166,13 @@ function TeamMembersPage() {
<form onSubmit={handleAddMember}>
<div className="form-group">
<label htmlFor="username">Username</label>
<UserAutocomplete
<input
id="username"
type="text"
value={newMember.username}
onChange={(username) => setNewMember({ ...newMember, username })}
placeholder="Search for a user..."
onChange={e => setNewMember({ ...newMember, username: e.target.value })}
placeholder="Enter username"
required
autoFocus
/>
</div>
@@ -202,49 +203,34 @@ function TeamMembersPage() {
</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');
<div className="members-list">
{members.map(member => {
const isCurrentUser = user?.username === member.username;
const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner');
if (canModify) {
return (
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 ? (
<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
@@ -256,54 +242,30 @@ function TeamMembersPage() {
</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>
);
},
}] : []),
]}
/>
) : (
<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>
</div>
);
}

View File

@@ -1,7 +1,6 @@
.team-settings {
padding: 1.5rem 0;
max-width: 640px;
margin: 0 auto;
}
.team-settings h1 {
@@ -14,8 +13,8 @@
}
.form-section {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 1.5rem;
margin-bottom: 1.5rem;
@@ -24,7 +23,6 @@
.form-section h2 {
margin: 0 0 1rem;
font-size: 1.125rem;
color: var(--text-primary);
}
.form-group {
@@ -36,30 +34,29 @@
margin-bottom: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 0.9375rem;
background: var(--bg-tertiary);
color: var(--text-primary);
background: var(--color-bg);
color: var(--color-text);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-bg);
}
.input-disabled {
background: var(--bg-elevated) !important;
color: var(--text-muted) !important;
background: var(--color-bg-tertiary) !important;
color: var(--color-text-muted) !important;
cursor: not-allowed;
}
@@ -67,23 +64,23 @@
display: block;
margin-top: 0.25rem;
font-size: 0.8125rem;
color: var(--text-muted);
color: var(--color-text-muted);
}
/* Danger zone */
.danger-zone {
border-color: var(--error);
background: var(--error-bg);
border-color: var(--color-error-border, #fecaca);
background: var(--color-error-bg, #fef2f2);
}
.danger-zone h2 {
color: var(--error);
color: var(--color-error, #dc2626);
}
.danger-warning {
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--text-secondary);
color: var(--color-text-secondary);
}
/* Messages */
@@ -93,10 +90,10 @@
justify-content: space-between;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: var(--error-bg);
border: 1px solid var(--error);
background: var(--color-error-bg, #fef2f2);
border: 1px solid var(--color-error-border, #fecaca);
border-radius: var(--radius-md);
color: var(--error);
color: var(--color-error, #dc2626);
font-size: 0.875rem;
}
@@ -113,10 +110,10 @@
.success-message {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: var(--success-bg);
border: 1px solid var(--success);
background: var(--color-success-bg, #f0fdf4);
border: 1px solid var(--color-success-border, #86efac);
border-radius: var(--radius-md);
color: var(--success);
color: var(--color-success, #16a34a);
font-size: 0.875rem;
}
@@ -133,7 +130,7 @@
.error-state p {
margin: 0 0 1.5rem;
color: var(--text-muted);
color: var(--color-text-muted);
}
/* Modal */
@@ -143,7 +140,7 @@
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
@@ -152,36 +149,33 @@
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
background: var(--color-bg);
border-radius: var(--radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: var(--shadow-lg);
box-shadow: var(--shadow-xl);
}
.modal-content h2 {
margin: 0 0 1rem;
font-size: 1.25rem;
color: var(--error);
color: var(--color-error, #dc2626);
}
.modal-content p {
margin: 0 0 1rem;
font-size: 0.9375rem;
color: var(--text-secondary);
color: var(--color-text-secondary);
}
.delete-confirm-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 0.9375rem;
margin-bottom: 1rem;
background: var(--bg-tertiary);
color: var(--text-primary);
}
.form-actions {
@@ -211,26 +205,26 @@
}
.btn-primary {
background: var(--accent-primary);
background: var(--color-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-primary-hover);
background: var(--color-primary-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-primary);
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
background: var(--color-bg-tertiary);
}
.btn-danger {
background: var(--error);
background: var(--color-error, #dc2626);
color: white;
}

View File

@@ -1,153 +1,26 @@
.teams-page {
padding: 1.5rem 0;
max-width: 1200px;
margin: 0 auto;
}
/* Header */
.teams-header {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
margin-bottom: 1.5rem;
gap: 1rem;
}
.teams-header h1 {
.page-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
font-size: 1.75rem;
}
/* Search */
.teams-search {
position: relative;
margin-bottom: 1.5rem;
.page-subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
font-size: 0.9375rem;
}
.teams-search__icon {
position: absolute;
left: 0.875rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}
.teams-search__input {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 2.75rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.875rem;
}
.teams-search__input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
.teams-search__input::placeholder {
color: var(--text-muted);
}
.teams-search__clear {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0.375rem;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
}
.teams-search__clear:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
/* Error */
.teams-error {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: var(--error-bg);
border: 1px solid var(--error);
border-radius: var(--radius-md);
color: var(--error);
font-size: 0.875rem;
}
.teams-error__dismiss {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: inherit;
padding: 0;
line-height: 1;
}
/* Loading */
.teams-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 4rem 2rem;
color: var(--text-muted);
}
.teams-loading__spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-primary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: teams-spin 0.8s linear infinite;
}
@keyframes teams-spin {
to { transform: rotate(360deg); }
}
/* Empty State */
.teams-empty-state {
text-align: center;
padding: 4rem 2rem;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-primary);
}
.teams-empty-icon {
color: var(--text-muted);
margin-bottom: 1rem;
}
.teams-empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.teams-empty-state p {
margin: 0 0 1.5rem;
color: var(--text-muted);
}
/* Table cell styles */
.team-name-cell {
display: flex;
flex-direction: column;
@@ -156,29 +29,81 @@
.team-name-link {
font-weight: 500;
color: var(--text-primary);
color: var(--color-text);
text-decoration: none;
}
.team-name-link:hover {
color: var(--accent-primary);
color: var(--color-primary);
}
.team-slug {
font-size: 0.8125rem;
color: var(--text-muted);
color: var(--color-text-muted);
}
.team-description-cell {
color: var(--text-secondary);
.team-description {
color: var(--color-text-secondary);
font-size: 0.875rem;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-muted {
color: var(--text-muted);
/* Empty state */
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
}
.empty-state svg {
color: var(--color-text-muted);
margin-bottom: 1rem;
}
.empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.empty-state p {
margin: 0 0 1.5rem;
color: var(--color-text-muted);
}
/* Loading state */
.loading-state {
text-align: center;
padding: 4rem 2rem;
color: var(--color-text-muted);
}
/* Error message */
.error-message {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: var(--color-error-bg, #fef2f2);
border: 1px solid var(--color-error-border, #fecaca);
border-radius: var(--radius-md);
color: var(--color-error, #dc2626);
font-size: 0.875rem;
}
.error-dismiss {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: inherit;
padding: 0;
line-height: 1;
}
/* Modal */
@@ -188,7 +113,7 @@
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
@@ -197,47 +122,17 @@
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
background: var(--color-bg);
border-radius: var(--radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 480px;
box-shadow: var(--shadow-lg);
overflow: hidden;
box-shadow: var(--shadow-xl);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-primary);
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.modal-close {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: var(--text-muted);
display: flex;
border-radius: var(--radius-sm);
}
.modal-close:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.modal-content form {
padding: 1.5rem;
.modal-content h2 {
margin: 0 0 1.5rem;
font-size: 1.25rem;
}
/* Form */
@@ -250,58 +145,31 @@
margin-bottom: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary);
}
.form-group .optional {
font-weight: 400;
color: var(--text-muted);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid var(--border-primary);
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 0.9375rem;
background: var(--color-bg);
color: var(--color-text);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
.input-with-prefix {
display: flex;
align-items: stretch;
}
.input-prefix {
display: flex;
align-items: center;
padding: 0 0.75rem;
background: var(--bg-elevated);
border: 1px solid var(--border-primary);
border-right: none;
border-radius: var(--radius-md) 0 0 var(--radius-md);
color: var(--text-muted);
font-size: 0.875rem;
}
.input-with-prefix input {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-bg);
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--text-muted);
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.form-actions {
@@ -309,8 +177,6 @@
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-primary);
}
/* Buttons */
@@ -333,44 +199,20 @@
}
.btn-primary {
background: var(--accent-primary);
background: var(--color-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-primary-hover);
background: var(--color-primary-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-primary);
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
}
/* Responsive */
@media (max-width: 640px) {
.teams-header {
flex-direction: column;
align-items: stretch;
}
.teams-header .btn {
justify-content: center;
}
.teams-stats {
justify-content: space-around;
}
.teams-table-container {
overflow-x: auto;
}
.teams-table {
min-width: 600px;
}
background: var(--color-bg-tertiary);
}

View File

@@ -17,7 +17,6 @@ function TeamsPage() {
const [newTeam, setNewTeam] = useState<TeamCreate>({ name: '', slug: '', description: '' });
const [creating, setCreating] = useState(false);
const [slugManuallySet, setSlugManuallySet] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const loadTeams = useCallback(async () => {
try {
@@ -66,39 +65,16 @@ function TeamsPage() {
}
}
const closeModal = () => {
setShowForm(false);
setNewTeam({ name: '', slug: '', description: '' });
setSlugManuallySet(false);
};
// Filter teams by search
const filteredTeams = teamsData?.items.filter(team =>
team.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
team.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
(team.description?.toLowerCase().includes(searchQuery.toLowerCase()))
) || [];
const totalTeams = teamsData?.items.length || 0;
const roleConfig: Record<string, { variant: 'success' | 'info' | 'default'; label: string }> = {
owner: { variant: 'success', label: 'Owner' },
admin: { variant: 'info', label: 'Admin' },
member: { variant: 'default', label: 'Member' },
const roleVariants: Record<string, 'success' | 'info' | 'default'> = {
owner: 'success',
admin: 'info',
member: 'default',
};
if (!user) {
return (
<div className="teams-page">
<div className="teams-empty-state">
<div className="teams-empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<div className="empty-state">
<h2>Sign in to view your teams</h2>
<p>Teams help you organize projects and collaborate with others.</p>
<Link to="/login" className="btn btn-primary">Sign In</Link>
@@ -107,65 +83,76 @@ function TeamsPage() {
);
}
const columns = [
{
key: 'name',
header: 'Team',
render: (team: TeamDetail) => (
<div className="team-name-cell">
<Link to={`/teams/${team.slug}`} className="team-name-link">
{team.name}
</Link>
<span className="team-slug">@{team.slug}</span>
</div>
),
},
{
key: 'description',
header: 'Description',
render: (team: TeamDetail) => (
<span className="team-description">{team.description || '-'}</span>
),
},
{
key: 'role',
header: 'Your Role',
render: (team: TeamDetail) => (
team.user_role ? (
<Badge variant={roleVariants[team.user_role] || 'default'}>
{team.user_role}
</Badge>
) : null
),
},
{
key: 'members',
header: 'Members',
render: (team: TeamDetail) => team.member_count,
},
{
key: 'projects',
header: 'Projects',
render: (team: TeamDetail) => team.project_count,
},
];
return (
<div className="teams-page">
{/* Header */}
<div className="teams-header">
<h1>Teams</h1>
<div className="page-header">
<div>
<h1>Teams</h1>
<p className="page-subtitle">Organize projects and collaborate with others</p>
</div>
<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" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Team
New Team
</button>
</div>
{/* Search */}
{!loading && totalTeams > 3 && (
<div className="teams-search">
<svg className="teams-search__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
type="text"
placeholder="Search teams..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="teams-search__input"
/>
{searchQuery && (
<button className="teams-search__clear" onClick={() => setSearchQuery('')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
)}
</div>
)}
{error && (
<div className="teams-error">
<div className="error-message">
{error}
<button onClick={() => setError(null)} className="teams-error__dismiss">&times;</button>
<button onClick={() => setError(null)} className="error-dismiss">&times;</button>
</div>
)}
{/* Create Team Modal */}
{showForm && (
<div className="modal-overlay" onClick={closeModal}>
<div className="modal-overlay" onClick={() => setShowForm(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>Create New Team</h2>
<button className="modal-close" onClick={closeModal}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<h2>Create New Team</h2>
<form onSubmit={handleCreateTeam}>
<div className="form-group">
<label htmlFor="team-name">Team Name</label>
@@ -174,30 +161,27 @@ function TeamsPage() {
type="text"
value={newTeam.name}
onChange={e => handleNameChange(e.target.value)}
placeholder="Engineering"
placeholder="My Team"
required
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="team-slug">URL Slug</label>
<div className="input-with-prefix">
<span className="input-prefix">@</span>
<input
id="team-slug"
type="text"
value={newTeam.slug}
onChange={e => handleSlugChange(e.target.value)}
placeholder="engineering"
pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
title="Lowercase letters, numbers, and hyphens only"
required
/>
</div>
<span className="form-hint">Used in URLs. Lowercase letters, numbers, and hyphens.</span>
<label htmlFor="team-slug">Slug</label>
<input
id="team-slug"
type="text"
value={newTeam.slug}
onChange={e => handleSlugChange(e.target.value)}
placeholder="my-team"
pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
title="Lowercase letters, numbers, and hyphens only"
required
/>
<span className="form-hint">Lowercase letters, numbers, and hyphens only</span>
</div>
<div className="form-group">
<label htmlFor="team-description">Description <span className="optional">(optional)</span></label>
<label htmlFor="team-description">Description (optional)</label>
<textarea
id="team-description"
value={newTeam.description}
@@ -207,7 +191,7 @@ function TeamsPage() {
/>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={closeModal}>
<button type="button" className="btn btn-secondary" onClick={() => setShowForm(false)}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={creating}>
@@ -219,88 +203,28 @@ function TeamsPage() {
</div>
)}
{/* Content */}
{loading ? (
<div className="teams-loading">
<div className="teams-loading__spinner" />
<span>Loading teams...</span>
</div>
) : filteredTeams.length === 0 ? (
<div className="teams-empty-state">
<div className="teams-empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
{searchQuery ? (
<>
<h2>No teams found</h2>
<p>No teams match "{searchQuery}"</p>
<button className="btn btn-secondary" onClick={() => setSearchQuery('')}>
Clear search
</button>
</>
) : (
<>
<h2>No teams yet</h2>
<p>Create your first team to start organizing your projects.</p>
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
Create Team
</button>
</>
)}
<div className="loading-state">Loading teams...</div>
) : teamsData?.items.length === 0 ? (
<div className="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<h2>No teams yet</h2>
<p>Create your first team to start organizing your projects.</p>
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
Create Team
</button>
</div>
) : (
<DataTable
data={filteredTeams}
keyExtractor={(team) => team.id}
onRowClick={(team) => navigate(`/teams/${team.slug}`)}
columns={[
{
key: 'name',
header: 'Name',
render: (team) => (
<div className="team-name-cell">
<Link
to={`/teams/${team.slug}`}
className="cell-name"
onClick={(e) => e.stopPropagation()}
>
{team.name}
</Link>
<span className="team-slug">@{team.slug}</span>
</div>
),
},
{
key: 'description',
header: 'Description',
className: 'cell-description',
render: (team) => team.description || <span className="text-muted"></span>,
},
{
key: 'role',
header: 'Role',
render: (team) => team.user_role ? (
<Badge variant={roleConfig[team.user_role]?.variant || 'default'}>
{roleConfig[team.user_role]?.label || team.user_role}
</Badge>
) : null,
},
{
key: 'members',
header: 'Members',
render: (team) => <span className="text-muted">{team.member_count}</span>,
},
{
key: 'projects',
header: 'Projects',
render: (team) => <span className="text-muted">{team.project_count}</span>,
},
]}
columns={columns}
data={teamsData?.items || []}
keyExtractor={team => team.id}
onRowClick={team => navigate(`/teams/${team.slug}`)}
/>
)}
</div>