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-based access displayed as read-only with "Source" column indicating origin
- Team members with access show team slug and role - Team members with access show team slug and role
- Added integration tests for team CRUD, membership, and project operations - 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 unit tests for TeamAuthorizationService
- Added `ORCHARD_ADMIN_PASSWORD` environment variable to configure initial admin password (#87) - 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) - 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) - 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 - Upload endpoint now accepts optional `ensure` file parameter for declaring dependencies
- Updated upload API documentation with ensure file format and examples - 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 ## [0.5.1] - 2026-01-23
### Changed ### Changed

View File

@@ -285,60 +285,6 @@ def _run_migrations():
END IF; END IF;
END $$; 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: with engine.connect() as conn:
@@ -347,7 +293,6 @@ def _run_migrations():
conn.execute(text(migration)) conn.execute(text(migration))
conn.commit() conn.commit()
except Exception as e: except Exception as e:
conn.rollback()
logger.warning(f"Migration failed (may already be applied): {e}") logger.warning(f"Migration failed (may already be applied): {e}")

View File

@@ -1093,43 +1093,6 @@ def oidc_callback(
return response 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 --- # --- 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 .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__)
@@ -177,53 +176,6 @@ 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

@@ -668,17 +668,3 @@ export async function listTeamProjects(
}); });
return handleResponse<PaginatedResponse<Project>>(response); 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 { .footer-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 12px;
}
.footer-icon {
color: var(--accent-primary);
} }
.footer-logo { .footer-logo {
@@ -296,10 +292,6 @@
color: var(--text-primary); color: var(--text-primary);
} }
.footer-separator {
color: var(--text-muted);
}
.footer-tagline { .footer-tagline {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.875rem; 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 { Link, NavLink, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { GlobalSearch } from './GlobalSearch'; import { GlobalSearch } from './GlobalSearch';
import { listTeams } from '../api';
import { TeamDetail } from '../types';
import './Layout.css'; import './Layout.css';
interface LayoutProps { interface LayoutProps {
@@ -15,22 +13,8 @@ function Layout({ children }: LayoutProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, loading, logout } = useAuth(); const { user, loading, logout } = useAuth();
const [showUserMenu, setShowUserMenu] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false);
const [userTeams, setUserTeams] = useState<TeamDetail[]>([]);
const menuRef = useRef<HTMLDivElement>(null); 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 // Close menu when clicking outside
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
@@ -93,18 +77,15 @@ function Layout({ children }: LayoutProps) {
</svg> </svg>
Dashboard Dashboard
</Link> </Link>
{user && userTeams.length > 0 && ( {user && (
<Link <Link to="/teams" className={location.pathname.startsWith('/teams') ? 'active' : ''}>
to={userTeams.length === 1 ? `/teams/${userTeams[0].slug}` : '/teams'}
className={location.pathname.startsWith('/teams') ? 'active' : ''}
>
<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">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/> <circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/> <path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/> <path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg> </svg>
{userTeams.length === 1 ? 'Team' : 'Teams'} Teams
</Link> </Link>
)} )}
<a href="/docs" className="nav-link-muted"> <a href="/docs" className="nav-link-muted">
@@ -218,17 +199,7 @@ function Layout({ children }: LayoutProps) {
<footer className="footer"> <footer className="footer">
<div className="container footer-content"> <div className="container footer-content">
<div className="footer-brand"> <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-logo">Orchard</span>
<span className="footer-separator">·</span>
<span className="footer-tagline">Content-Addressable Storage</span> <span className="footer-tagline">Content-Addressable Storage</span>
</div> </div>
<div className="footer-links"> <div className="footer-links">

View File

@@ -7,10 +7,10 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
background: var(--bg-secondary); background: var(--color-bg-secondary);
border: 1px solid var(--border-primary); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--text-primary); color: var(--color-text);
font-size: 0.875rem; font-size: 0.875rem;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
@@ -18,8 +18,8 @@
} }
.team-selector-trigger:hover:not(:disabled) { .team-selector-trigger:hover:not(:disabled) {
background: var(--bg-tertiary); background: var(--color-bg-tertiary);
border-color: var(--border-secondary); border-color: var(--color-border-hover);
} }
.team-selector-trigger:disabled { .team-selector-trigger:disabled {
@@ -51,8 +51,8 @@
right: 0; right: 0;
min-width: 240px; min-width: 240px;
margin-top: 0.25rem; margin-top: 0.25rem;
background: var(--bg-secondary); background: var(--color-bg);
border: 1px solid var(--border-primary); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
z-index: 100; z-index: 100;
@@ -62,7 +62,7 @@
.team-selector-empty { .team-selector-empty {
padding: 1rem; padding: 1rem;
text-align: center; text-align: center;
color: var(--text-muted); color: var(--color-text-muted);
} }
.team-selector-empty p { .team-selector-empty p {
@@ -71,7 +71,7 @@
} }
.team-selector-create-link { .team-selector-create-link {
color: var(--accent-primary); color: var(--color-primary);
font-size: 0.875rem; font-size: 0.875rem;
text-decoration: none; text-decoration: none;
} }
@@ -96,7 +96,7 @@
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: none; background: none;
border: none; border: none;
color: var(--text-primary); color: var(--color-text);
font-size: 0.875rem; font-size: 0.875rem;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
@@ -104,11 +104,11 @@
} }
.team-selector-item:hover { .team-selector-item:hover {
background: var(--bg-hover); background: var(--color-bg-secondary);
} }
.team-selector-item.selected { .team-selector-item.selected {
background: rgba(16, 185, 129, 0.1); background: var(--color-primary-bg);
} }
.team-selector-item-info { .team-selector-item-info {
@@ -127,7 +127,7 @@
.team-selector-item-meta { .team-selector-item-meta {
display: block; display: block;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--color-text-muted);
} }
.team-selector-item-role { .team-selector-item-role {
@@ -140,24 +140,24 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border-primary); border-top: 1px solid var(--color-border);
background: var(--bg-tertiary); background: var(--color-bg-secondary);
} }
.team-selector-link { .team-selector-link {
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--text-muted); color: var(--color-text-muted);
text-decoration: none; text-decoration: none;
} }
.team-selector-link:hover { .team-selector-link:hover {
color: var(--text-primary); color: var(--color-text);
} }
.team-selector-link-primary { .team-selector-link-primary {
color: var(--accent-primary); color: var(--color-primary);
} }
.team-selector-link-primary:hover { .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,7 +179,6 @@ function Home() {
</form> </form>
)} )}
{user && (
<div className="list-controls"> <div className="list-controls">
<FilterDropdown <FilterDropdown
label="Visibility" label="Visibility"
@@ -188,9 +187,8 @@ function Home() {
onChange={handleVisibilityChange} onChange={handleVisibilityChange}
/> />
</div> </div>
)}
{user && hasActiveFilters && ( {hasActiveFilters && (
<FilterChipGroup onClearAll={clearFilters}> <FilterChipGroup onClearAll={clearFilters}>
{visibility && ( {visibility && (
<FilterChip <FilterChip

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import { getTeam, listTeamProjects, createProject } from '../api';
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 './TeamDashboardPage.css'; import './TeamDashboardPage.css';
function TeamDashboardPage() { function TeamDashboardPage() {
@@ -96,22 +95,42 @@ function TeamDashboardPage() {
/> />
<div className="team-header"> <div className="team-header">
<div className="team-header-left"> <div className="team-header-info">
<div className="team-header-title">
<h1>{team.name}</h1> <h1>{team.name}</h1>
{team.user_role && ( {team.user_role && (
<Badge variant={roleVariants[team.user_role] || 'default'}> <Badge variant={roleVariants[team.user_role] || 'default'}>
{team.user_role} {team.user_role}
</Badge> </Badge>
)} )}
<span className="team-slug">@{team.slug}</span>
</div> </div>
{team.description && ( {team.description && (
<p className="team-description">{team.description}</p> <p className="team-description">{team.description}</p>
)} )}
<div className="team-meta">
<span className="team-slug">@{team.slug}</span>
</div> </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 && ( {isAdminOrOwner && (
<div className="team-header-actions"> <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"> <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"> <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"/> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
@@ -121,16 +140,8 @@ function TeamDashboardPage() {
</svg> </svg>
Members Members
</Link> </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> </div>
)} )}
</div>
{showProjectForm && ( {showProjectForm && (
<div className="modal-overlay" onClick={() => setShowProjectForm(false)}> <div className="modal-overlay" onClick={() => setShowProjectForm(false)}>
@@ -203,65 +214,28 @@ function TeamDashboardPage() {
)} )}
</div> </div>
) : ( ) : (
<DataTable <div className="projects-grid">
data={projects?.items || []} {projects?.items.map(project => (
keyExtractor={(project) => project.id} <div
onRowClick={(project) => navigate(`/project/${project.name}`)} key={project.id}
columns={[ className="project-card"
{ onClick={() => navigate(`/project/${project.name}`)}
key: 'name',
header: 'Name',
render: (project) => (
<Link
to={`/project/${project.name}`}
className="cell-name"
onClick={(e) => e.stopPropagation()}
> >
{project.name} <div className="project-card-header">
</Link> <h3>{project.name}</h3>
),
},
{
key: 'description',
header: 'Description',
className: 'cell-description',
render: (project) => project.description || <span className="text-muted"></span>,
},
{
key: 'visibility',
header: 'Visibility',
render: (project) => (
<Badge variant={project.is_public ? 'public' : 'private'}> <Badge variant={project.is_public ? 'public' : 'private'}>
{project.is_public ? 'Public' : 'Private'} {project.is_public ? 'Public' : 'Private'}
</Badge> </Badge>
), </div>
}, {project.description && (
{ <p className="project-card-description">{project.description}</p>
key: 'created_by', )}
header: 'Created By', <div className="project-card-meta">
render: (project) => <span className="text-muted">{project.created_by}</span>, <span>Created by {project.created_by}</span>
}, </div>
...(isAdminOrOwner ? [{ </div>
key: 'actions', ))}
header: '', </div>
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>
),
}] : []),
]}
/>
)} )}
{projects && projects.pagination.total > 10 && ( {projects && projects.pagination.total > 10 && (

View File

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

View File

@@ -11,8 +11,6 @@ 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 './TeamMembersPage.css'; import './TeamMembersPage.css';
function TeamMembersPage() { function TeamMembersPage() {
@@ -168,10 +166,13 @@ function TeamMembersPage() {
<form onSubmit={handleAddMember}> <form onSubmit={handleAddMember}>
<div className="form-group"> <div className="form-group">
<label htmlFor="username">Username</label> <label htmlFor="username">Username</label>
<UserAutocomplete <input
id="username"
type="text"
value={newMember.username} value={newMember.username}
onChange={(username) => setNewMember({ ...newMember, username })} onChange={e => setNewMember({ ...newMember, username: e.target.value })}
placeholder="Search for a user..." placeholder="Enter username"
required
autoFocus autoFocus
/> />
</div> </div>
@@ -202,18 +203,14 @@ function TeamMembersPage() {
</div> </div>
)} )}
<DataTable <div className="members-list">
data={members} {members.map(member => {
keyExtractor={(member) => member.id}
emptyMessage="No members in this team yet."
columns={[
{
key: 'member',
header: 'Member',
render: (member) => {
const isCurrentUser = user?.username === member.username; const isCurrentUser = user?.username === member.username;
const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner');
return ( return (
<div className="member-cell"> <div key={member.id} className={`member-card ${isCurrentUser ? 'current-user' : ''}`}>
<div className="member-info">
<div className="member-avatar"> <div className="member-avatar">
{member.username.charAt(0).toUpperCase()} {member.username.charAt(0).toUpperCase()}
</div> </div>
@@ -227,24 +224,13 @@ function TeamMembersPage() {
)} )}
</div> </div>
</div> </div>
); <div className="member-actions">
}, {canModify ? (
},
{
key: 'role',
header: 'Role',
render: (member) => {
const isCurrentUser = user?.username === member.username;
const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner');
if (canModify) {
return (
<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
@@ -256,40 +242,15 @@ function TeamMembersPage() {
</option> </option>
))} ))}
</select> </select>
); ) : (
}
return (
<Badge variant={roleVariants[member.role] || 'default'}> <Badge variant={roleVariants[member.role] || 'default'}>
{member.role} {member.role}
</Badge> </Badge>
); )}
}, {canModify && (
},
{
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 <button
className="btn btn-icon btn-danger-ghost" className="btn btn-icon btn-danger-ghost"
onClick={(e) => { onClick={() => handleRemoveMember(member.username)}
e.stopPropagation();
handleRemoveMember(member.username);
}}
disabled={removingMember === member.username} disabled={removingMember === member.username}
title="Remove member" title="Remove member"
> >
@@ -299,11 +260,12 @@ function TeamMembersPage() {
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg> </svg>
</button> </button>
)}
</div>
</div>
); );
}, })}
}] : []), </div>
]}
/>
</div> </div>
); );
} }

View File

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

View File

@@ -1,153 +1,26 @@
.teams-page { .teams-page {
padding: 1.5rem 0; padding: 1.5rem 0;
max-width: 1200px;
margin: 0 auto;
} }
/* Header */ .page-header {
.teams-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
gap: 1rem; gap: 1rem;
} }
.teams-header h1 { .page-header h1 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.75rem;
font-weight: 600;
} }
/* Search */ .page-subtitle {
.teams-search { margin: 0.25rem 0 0;
position: relative; color: var(--color-text-muted);
margin-bottom: 1.5rem; 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 { .team-name-cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -156,29 +29,81 @@
.team-name-link { .team-name-link {
font-weight: 500; font-weight: 500;
color: var(--text-primary); color: var(--color-text);
text-decoration: none; text-decoration: none;
} }
.team-name-link:hover { .team-name-link:hover {
color: var(--accent-primary); color: var(--color-primary);
} }
.team-slug { .team-slug {
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--text-muted); color: var(--color-text-muted);
} }
.team-description-cell { .team-description {
color: var(--text-secondary); color: var(--color-text-secondary);
font-size: 0.875rem;
max-width: 300px; max-width: 300px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.text-muted { /* Empty state */
color: var(--text-muted); .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 */ /* Modal */
@@ -188,7 +113,7 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.7); background: rgba(0, 0, 0, 0.5);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -197,47 +122,17 @@
} }
.modal-content { .modal-content {
background: var(--bg-secondary); background: var(--color-bg);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem;
width: 100%; width: 100%;
max-width: 480px; max-width: 480px;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-xl);
overflow: hidden;
} }
.modal-header { .modal-content h2 {
display: flex; margin: 0 0 1.5rem;
justify-content: space-between; font-size: 1.25rem;
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;
} }
/* Form */ /* Form */
@@ -250,58 +145,31 @@
margin-bottom: 0.375rem; margin-bottom: 0.375rem;
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary);
}
.form-group .optional {
font-weight: 400;
color: var(--text-muted);
} }
.form-group input, .form-group input,
.form-group textarea { .form-group textarea {
width: 100%; width: 100%;
padding: 0.625rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--border-primary); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 0.875rem; font-size: 0.9375rem;
background: var(--bg-tertiary); background: var(--color-bg);
color: var(--text-primary); color: var(--color-text);
} }
.form-group input:focus, .form-group input:focus,
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: var(--accent-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2); box-shadow: 0 0 0 2px var(--color-primary-bg);
}
.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;
} }
.form-hint { .form-hint {
display: block; display: block;
margin-top: 0.25rem; margin-top: 0.25rem;
font-size: 0.75rem; font-size: 0.8125rem;
color: var(--text-muted); color: var(--color-text-muted);
} }
.form-actions { .form-actions {
@@ -309,8 +177,6 @@
justify-content: flex-end; justify-content: flex-end;
gap: 0.75rem; gap: 0.75rem;
margin-top: 1.5rem; margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-primary);
} }
/* Buttons */ /* Buttons */
@@ -333,44 +199,20 @@
} }
.btn-primary { .btn-primary {
background: var(--accent-primary); background: var(--color-primary);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: var(--accent-primary-hover); background: var(--color-primary-hover);
} }
.btn-secondary { .btn-secondary {
background: var(--bg-tertiary); background: var(--color-bg-secondary);
color: var(--text-primary); color: var(--color-text);
border: 1px solid var(--border-primary); border: 1px solid var(--color-border);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: var(--bg-hover); background: var(--color-bg-tertiary);
}
/* 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;
}
} }

View File

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