From 576791d19e14a497d5e8571452cc61df192402db Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Wed, 28 Jan 2026 12:50:58 -0600 Subject: [PATCH] Add multi-tenancy with Teams feature --- CHANGELOG.md | 59 ++ backend/app/auth.py | 277 +++++- backend/app/models.py | 76 ++ backend/app/routes.py | 847 ++++++++++++++++++- backend/app/schemas.py | 143 ++++ backend/app/seed.py | 88 +- backend/tests/integration/test_teams_api.py | 316 +++++++ backend/tests/unit/test_team_auth.py | 213 +++++ frontend/src/App.tsx | 13 +- frontend/src/api.ts | 122 ++- frontend/src/components/AccessManagement.css | 29 + frontend/src/components/AccessManagement.tsx | 159 ++-- frontend/src/components/Layout.css | 10 +- frontend/src/components/Layout.tsx | 40 + frontend/src/components/TeamSelector.css | 163 ++++ frontend/src/components/TeamSelector.tsx | 141 +++ frontend/src/components/UserAutocomplete.css | 105 +++ frontend/src/components/UserAutocomplete.tsx | 171 ++++ frontend/src/contexts/TeamContext.tsx | 110 +++ frontend/src/pages/Home.tsx | 20 +- frontend/src/pages/ProjectPage.tsx | 2 +- frontend/src/pages/ProjectSettingsPage.tsx | 4 - frontend/src/pages/TeamDashboardPage.css | 270 ++++++ frontend/src/pages/TeamDashboardPage.tsx | 279 ++++++ frontend/src/pages/TeamMembersPage.css | 247 ++++++ frontend/src/pages/TeamMembersPage.tsx | 311 +++++++ frontend/src/pages/TeamSettingsPage.css | 239 ++++++ frontend/src/pages/TeamSettingsPage.tsx | 251 ++++++ frontend/src/pages/TeamsPage.css | 376 ++++++++ frontend/src/pages/TeamsPage.tsx | 310 +++++++ frontend/src/types.ts | 56 ++ migrations/009_teams.sql | 62 ++ migrations/009b_migrate_projects.sql | 99 +++ 33 files changed, 5493 insertions(+), 115 deletions(-) create mode 100644 backend/tests/integration/test_teams_api.py create mode 100644 backend/tests/unit/test_team_auth.py create mode 100644 frontend/src/components/TeamSelector.css create mode 100644 frontend/src/components/TeamSelector.tsx create mode 100644 frontend/src/components/UserAutocomplete.css create mode 100644 frontend/src/components/UserAutocomplete.tsx create mode 100644 frontend/src/contexts/TeamContext.tsx create mode 100644 frontend/src/pages/TeamDashboardPage.css create mode 100644 frontend/src/pages/TeamDashboardPage.tsx create mode 100644 frontend/src/pages/TeamMembersPage.css create mode 100644 frontend/src/pages/TeamMembersPage.tsx create mode 100644 frontend/src/pages/TeamSettingsPage.css create mode 100644 frontend/src/pages/TeamSettingsPage.tsx create mode 100644 frontend/src/pages/TeamsPage.css create mode 100644 frontend/src/pages/TeamsPage.tsx create mode 100644 migrations/009_teams.sql create mode 100644 migrations/009b_migrate_projects.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f8eaa8..5a1ce10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Added team-based multi-tenancy for organizing projects and collaboration (#88-#104) + - Teams serve as organizational containers for projects + - Users can belong to multiple teams with different roles (owner, admin, member) + - Projects can optionally belong to a team +- Added database schema for teams (#88): + - `teams` table with id, name, slug, description, settings, timestamps + - `team_memberships` table mapping users to teams with roles + - `team_id` column on projects table for team association + - Migrations `009_teams.sql` and `009b_migrate_projects.sql` +- Added Team and TeamMembership ORM models with relationships (#89) +- Added TeamAuthorizationService for team-level access control (#90): + - Team owner/admin gets admin access to all team projects + - Team member gets read access to team projects (upgradeable by explicit permission) + - Role hierarchy: owner > admin > member +- Added Team API endpoints (#92, #93, #94, #95): + - `GET /api/v1/teams` - List teams user belongs to (paginated) + - `POST /api/v1/teams` - Create team (creator becomes owner) + - `GET /api/v1/teams/{slug}` - Get team details + - `PUT /api/v1/teams/{slug}` - Update team (requires admin) + - `DELETE /api/v1/teams/{slug}` - Delete team (requires owner) + - `GET /api/v1/teams/{slug}/members` - List team members + - `POST /api/v1/teams/{slug}/members` - Add member (requires admin) + - `PUT /api/v1/teams/{slug}/members/{username}` - Update member role + - `DELETE /api/v1/teams/{slug}/members/{username}` - Remove member + - `GET /api/v1/teams/{slug}/projects` - List team projects (paginated) +- Updated project creation to support optional team assignment (#95) +- Updated project responses to include team info (team_id, team_slug, team_name) +- Added frontend team management (#97-#104): + - TeamContext provider for managing current team selection + - TeamSelector dropdown component (persists selection in localStorage) + - Teams list page at `/teams` + - Team dashboard page at `/teams/{slug}` with inline project creation + - Team settings page at `/teams/{slug}/settings` + - Team members page at `/teams/{slug}/members` + - Teams navigation link in header (authenticated users only) +- Updated seed data to create a "Demo Team" and assign all seed projects to it +- Added TypeScript types and API client functions for teams +- Access management now shows team-based permissions alongside explicit permissions + - 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) - When not set, defaults to `changeme123` and requires password change on first login @@ -53,6 +105,13 @@ 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 diff --git a/backend/app/auth.py b/backend/app/auth.py index 1cf41cf..3a5c7a0 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -658,32 +658,51 @@ class AuthorizationService: self, project_id: str, user: Optional[User] ) -> Optional[str]: """Get the user's access level for a project. - + Returns the highest access level the user has, or None if no access. Checks in order: 1. System admin - gets admin access to all projects 2. Project owner (created_by) - gets admin access - 3. Explicit permission in access_permissions table + 3. Team-based access (owner/admin gets admin, member gets read) + 4. Explicit permission in access_permissions table + 5. Public access """ - from .models import Project, AccessPermission - + from .models import Project, AccessPermission, TeamMembership + # Get the project project = self.db.query(Project).filter(Project.id == project_id).first() if not project: return None - + # Anonymous users only get access to public projects if not user: return "read" if project.is_public else None - + # System admins get admin access everywhere if user.is_admin: return "admin" - + # Project owner gets admin access if project.created_by == user.username: return "admin" - + + # Check team-based access if project belongs to a team + if project.team_id: + membership = ( + self.db.query(TeamMembership) + .filter( + TeamMembership.team_id == project.team_id, + TeamMembership.user_id == user.id, + ) + .first() + ) + if membership: + # Team owner/admin gets admin on all team projects + if membership.role in ("owner", "admin"): + return "admin" + # Team member gets read access (upgradeable by explicit permission) + # Continue checking explicit permissions for potential upgrade + # Check explicit permissions permission = ( self.db.query(AccessPermission) @@ -693,13 +712,27 @@ class AuthorizationService: ) .first() ) - + if permission: # Check expiration if permission.expires_at and permission.expires_at < datetime.now(timezone.utc): - return "read" if project.is_public else None - return permission.level - + pass # Permission expired, fall through + else: + return permission.level + + # Team member gets read access if no explicit permission + if project.team_id: + membership = ( + self.db.query(TeamMembership) + .filter( + TeamMembership.team_id == project.team_id, + TeamMembership.user_id == user.id, + ) + .first() + ) + if membership: + return "read" + # Fall back to public access return "read" if project.is_public else None @@ -884,6 +917,226 @@ def check_project_access( return project +# --- Team Authorization --- + +# Team roles in order of increasing privilege +TEAM_ROLES = ["member", "admin", "owner"] + + +def get_team_role_rank(role: str) -> int: + """Get numeric rank for team role comparison.""" + try: + return TEAM_ROLES.index(role) + except ValueError: + return -1 + + +def has_sufficient_team_role(user_role: str, required_role: str) -> bool: + """Check if user_role is sufficient for required_role. + + Role hierarchy: owner > admin > member + """ + return get_team_role_rank(user_role) >= get_team_role_rank(required_role) + + +class TeamAuthorizationService: + """Service for checking team-level authorization.""" + + def __init__(self, db: Session): + self.db = db + + def get_user_team_role( + self, team_id: str, user: Optional[User] + ) -> Optional[str]: + """Get the user's role in a team. + + Returns the role ('owner', 'admin', 'member') or None if not a member. + System admins who are not team members are treated as team admins. + """ + from .models import Team, TeamMembership + + if not user: + return None + + # Check actual membership first + membership = ( + self.db.query(TeamMembership) + .filter( + TeamMembership.team_id == team_id, + TeamMembership.user_id == user.id, + ) + .first() + ) + + if membership: + return membership.role + + # System admins who are not members get admin access + if user.is_admin: + return "admin" + + return None + + def check_team_access( + self, + team_id: str, + user: Optional[User], + required_role: str = "member", + ) -> bool: + """Check if user has required role in team. + + Args: + team_id: Team ID to check + user: User to check (None means no access) + required_role: Minimum required role ('member', 'admin', 'owner') + + Returns: + True if user has sufficient role, False otherwise + """ + user_role = self.get_user_team_role(team_id, user) + if not user_role: + return False + return has_sufficient_team_role(user_role, required_role) + + def can_create_project(self, team_id: str, user: Optional[User]) -> bool: + """Check if user can create projects in team (requires admin+).""" + return self.check_team_access(team_id, user, "admin") + + def can_manage_members(self, team_id: str, user: Optional[User]) -> bool: + """Check if user can manage team members (requires admin+).""" + return self.check_team_access(team_id, user, "admin") + + def can_delete_team(self, team_id: str, user: Optional[User]) -> bool: + """Check if user can delete the team (requires owner).""" + return self.check_team_access(team_id, user, "owner") + + def get_team_by_slug(self, slug: str) -> Optional["Team"]: + """Get a team by its slug.""" + from .models import Team + + return self.db.query(Team).filter(Team.slug == slug).first() + + def get_user_teams(self, user: User) -> list: + """Get all teams a user is a member of.""" + from .models import Team, TeamMembership + + return ( + self.db.query(Team) + .join(TeamMembership) + .filter(TeamMembership.user_id == user.id) + .order_by(Team.name) + .all() + ) + + +def get_team_authorization_service(db: Session = Depends(get_db)) -> TeamAuthorizationService: + """Get a TeamAuthorizationService instance.""" + return TeamAuthorizationService(db) + + +class TeamAccessChecker: + """Dependency for checking team access in route handlers.""" + + def __init__(self, required_role: str = "member"): + self.required_role = required_role + + def __call__( + self, + slug: str, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional), + ) -> User: + """Check if user has required role in team. + + Raises 404 if team not found, 401 if not authenticated, 403 if insufficient role. + Returns the current user. + """ + from .models import Team + + # Find team by slug + team = db.query(Team).filter(Team.slug == slug).first() + if not team: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Team '{slug}' not found", + ) + + if not current_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + auth_service = TeamAuthorizationService(db) + + if not auth_service.check_team_access(str(team.id), current_user, self.required_role): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Insufficient team permissions. Required role: {self.required_role}", + ) + + return current_user + + +# Pre-configured team access checkers +require_team_member = TeamAccessChecker("member") +require_team_admin = TeamAccessChecker("admin") +require_team_owner = TeamAccessChecker("owner") + + +def check_team_access( + db: Session, + team_slug: str, + user: Optional[User], + required_role: str = "member", +) -> "Team": + """Check if user has required role in team. + + This is a helper function for use in route handlers. + + Args: + db: Database session + team_slug: Slug of the team + user: Current user (can be None for no access) + required_role: Required team role (member, admin, owner) + + Returns: + The Team object if access is granted + + Raises: + HTTPException 404: Team not found + HTTPException 401: Authentication required + HTTPException 403: Insufficient permissions + """ + from .models import Team + + # Find team by slug + team = db.query(Team).filter(Team.slug == team_slug).first() + if not team: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Team '{team_slug}' not found", + ) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + auth_service = TeamAuthorizationService(db) + + if not auth_service.check_team_access(str(team.id), user, required_role): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Insufficient team permissions. Required role: {required_role}", + ) + + return team + + # --- OIDC Configuration Service --- diff --git a/backend/app/models.py b/backend/app/models.py index 92aa6c0..67c6a3c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -32,6 +32,7 @@ class Project(Base): DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow ) created_by = Column(String(255), nullable=False) + team_id = Column(UUID(as_uuid=True), ForeignKey("teams.id", ondelete="SET NULL")) packages = relationship( "Package", back_populates="project", cascade="all, delete-orphan" @@ -39,10 +40,12 @@ class Project(Base): permissions = relationship( "AccessPermission", back_populates="project", cascade="all, delete-orphan" ) + team = relationship("Team", back_populates="projects") __table_args__ = ( Index("idx_projects_name", "name"), Index("idx_projects_created_by", "created_by"), + Index("idx_projects_team_id", "team_id"), ) @@ -369,6 +372,9 @@ class User(Base): sessions = relationship( "Session", back_populates="user", cascade="all, delete-orphan" ) + team_memberships = relationship( + "TeamMembership", back_populates="user", cascade="all, delete-orphan" + ) __table_args__ = ( Index("idx_users_username", "username"), @@ -561,3 +567,73 @@ class ArtifactDependency(Base): unique=True, ), ) + + +class Team(Base): + """Team for organizing projects and users.""" + + __tablename__ = "teams" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String(255), nullable=False) + slug = Column(String(255), unique=True, nullable=False) + description = Column(Text) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + updated_at = Column( + DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow + ) + created_by = Column(String(255), nullable=False) + settings = Column(JSON, default=dict) + + # Relationships + memberships = relationship( + "TeamMembership", back_populates="team", cascade="all, delete-orphan" + ) + projects = relationship("Project", back_populates="team") + + __table_args__ = ( + Index("idx_teams_slug", "slug"), + Index("idx_teams_created_by", "created_by"), + Index("idx_teams_created_at", "created_at"), + CheckConstraint( + "slug ~ '^[a-z0-9][a-z0-9-]*[a-z0-9]$' OR slug ~ '^[a-z0-9]$'", + name="check_team_slug_format", + ), + ) + + +class TeamMembership(Base): + """Maps users to teams with their roles.""" + + __tablename__ = "team_memberships" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + team_id = Column( + UUID(as_uuid=True), + ForeignKey("teams.id", ondelete="CASCADE"), + nullable=False, + ) + user_id = Column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + role = Column(String(20), nullable=False, default="member") + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + invited_by = Column(String(255)) + + # Relationships + team = relationship("Team", back_populates="memberships") + user = relationship("User", back_populates="team_memberships") + + __table_args__ = ( + Index("idx_team_memberships_team_id", "team_id"), + Index("idx_team_memberships_user_id", "user_id"), + Index("idx_team_memberships_role", "role"), + Index("idx_team_memberships_team_role", "team_id", "role"), + Index("idx_team_memberships_unique", "team_id", "user_id", unique=True), + CheckConstraint( + "role IN ('owner', 'admin', 'member')", + name="check_team_role", + ), + ) diff --git a/backend/app/routes.py b/backend/app/routes.py index afc65b9..337f5a5 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -16,7 +16,7 @@ from fastapi import ( ) from fastapi.responses import StreamingResponse, RedirectResponse, PlainTextResponse from sqlalchemy.orm import Session -from sqlalchemy import or_, and_, func, text +from sqlalchemy import or_, and_, func, text, case from typing import List, Optional, Literal import math import io @@ -48,6 +48,8 @@ from .models import ( AccessPermission, PackageVersion, ArtifactDependency, + Team, + TeamMembership, ) from .schemas import ( ProjectCreate, @@ -127,6 +129,13 @@ from .schemas import ( DependencyResolutionResponse, CircularDependencyError as CircularDependencyErrorSchema, DependencyConflictError as DependencyConflictErrorSchema, + TeamCreate, + TeamUpdate, + TeamResponse, + TeamDetailResponse, + TeamMemberCreate, + TeamMemberUpdate, + TeamMemberResponse, ) from .metadata import extract_metadata from .dependencies import ( @@ -558,6 +567,9 @@ from .auth import ( MIN_PASSWORD_LENGTH, check_project_access, AuthorizationService, + TeamAuthorizationService, + check_team_access, + get_team_authorization_service, ) from .rate_limit import limiter, LOGIN_RATE_LIMIT @@ -1081,6 +1093,43 @@ 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 --- @@ -1438,15 +1487,46 @@ def list_projects( ) # Base query - filter by access - query = db.query(Project).filter( - or_(Project.is_public == True, Project.created_by == user_id) - ) + # Users can see projects that are: + # 1. Public + # 2. Created by them + # 3. Belong to a team they're a member of + if current_user: + # Get team IDs where user is a member + user_team_ids = db.query(TeamMembership.team_id).filter( + TeamMembership.user_id == current_user.id + ).subquery() + + query = db.query(Project).filter( + or_( + Project.is_public == True, + Project.created_by == user_id, + Project.team_id.in_(user_team_ids) + ) + ) + else: + # Anonymous users only see public projects + query = db.query(Project).filter(Project.is_public == True) # Apply visibility filter if visibility == "public": query = query.filter(Project.is_public == True) elif visibility == "private": - query = query.filter(Project.is_public == False, Project.created_by == user_id) + if current_user: + # Get team IDs where user is a member (for private filter) + user_team_ids_for_private = db.query(TeamMembership.team_id).filter( + TeamMembership.user_id == current_user.id + ).subquery() + query = query.filter( + Project.is_public == False, + or_( + Project.created_by == user_id, + Project.team_id.in_(user_team_ids_for_private) + ) + ) + else: + # Anonymous users can't see private projects + query = query.filter(False) # Apply search filter (case-insensitive on name and description) if search: @@ -1543,11 +1623,33 @@ def create_project( if existing: raise HTTPException(status_code=400, detail="Project already exists") + # If team_id is provided, verify user has admin access to the team + team = None + if project.team_id: + team = db.query(Team).filter(Team.id == project.team_id).first() + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + # Check if user has admin role in team + if current_user: + team_auth = TeamAuthorizationService(db) + if not team_auth.can_create_project(str(team.id), current_user): + raise HTTPException( + status_code=403, + detail="Requires admin role in team to create projects", + ) + else: + raise HTTPException( + status_code=401, + detail="Authentication required to create projects in a team", + ) + db_project = Project( name=project.name, description=project.description, is_public=project.is_public, created_by=user_id, + team_id=project.team_id, ) db.add(db_project) @@ -1558,12 +1660,28 @@ def create_project( resource=f"project/{project.name}", user_id=user_id, source_ip=request.client.host if request.client else None, - details={"is_public": project.is_public}, + details={ + "is_public": project.is_public, + "team_id": str(project.team_id) if project.team_id else None, + }, ) db.commit() db.refresh(db_project) - return db_project + + # Build response with team info + return ProjectResponse( + id=db_project.id, + name=db_project.name, + description=db_project.description, + is_public=db_project.is_public, + created_at=db_project.created_at, + updated_at=db_project.updated_at, + created_by=db_project.created_by, + team_id=team.id if team else None, + team_slug=team.slug if team else None, + team_name=team.name if team else None, + ) @router.get("/api/v1/projects/{project_name}", response_model=ProjectResponse) @@ -1574,7 +1692,20 @@ def get_project( ): """Get a single project by name. Requires read access for private projects.""" project = check_project_access(db, project_name, current_user, "read") - return project + + # Build response with team info + return ProjectResponse( + id=project.id, + name=project.name, + description=project.description, + is_public=project.is_public, + created_at=project.created_at, + updated_at=project.updated_at, + created_by=project.created_by, + team_id=project.team.id if project.team else None, + team_slug=project.team.slug if project.team else None, + team_name=project.team.name if project.team else None, + ) @router.put("/api/v1/projects/{project_name}", response_model=ProjectResponse) @@ -1701,14 +1832,63 @@ def list_project_permissions( ): """ List all access permissions for a project. + Includes both explicit permissions and team-based access. Requires admin access to the project. """ project = check_project_access(db, project_name, current_user, "admin") auth_service = AuthorizationService(db) - permissions = auth_service.list_project_permissions(str(project.id)) + explicit_permissions = auth_service.list_project_permissions(str(project.id)) - return permissions + # Convert to response format with source field + result = [] + for perm in explicit_permissions: + result.append(AccessPermissionResponse( + id=perm.id, + project_id=perm.project_id, + user_id=perm.user_id, + level=perm.level, + created_at=perm.created_at, + expires_at=perm.expires_at, + source="explicit", + )) + + # Add team-based access if project belongs to a team + if project.team_id: + team = db.query(Team).filter(Team.id == project.team_id).first() + if team: + memberships = ( + db.query(TeamMembership) + .join(User, TeamMembership.user_id == User.id) + .filter(TeamMembership.team_id == project.team_id) + .all() + ) + + # Track users who already have explicit permissions + explicit_users = {p.user_id for p in result} + + for membership in memberships: + user = db.query(User).filter(User.id == membership.user_id).first() + if user and user.username not in explicit_users: + # Map team role to project access level + if membership.role in ("owner", "admin"): + level = "admin" + else: + level = "read" + + result.append(AccessPermissionResponse( + id=membership.id, # Use membership ID + project_id=project.id, + user_id=user.username, + level=level, + created_at=membership.created_at, + expires_at=None, + source="team", + team_slug=team.slug, + team_role=membership.role, + )) + + return result @router.post( @@ -1842,6 +2022,653 @@ def get_my_project_access( } +# Team routes +@router.get("/api/v1/teams", response_model=PaginatedResponse[TeamDetailResponse]) +def list_teams( + page: int = Query(default=1, ge=1, description="Page number"), + limit: int = Query(default=20, ge=1, le=100, description="Items per page"), + search: Optional[str] = Query(default=None, description="Search by name or slug"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List all teams the current user belongs to.""" + # Base query - teams user is a member of + query = ( + db.query(Team) + .join(TeamMembership) + .filter(TeamMembership.user_id == current_user.id) + ) + + # Apply search filter + if search: + search_lower = search.lower() + query = query.filter( + or_( + func.lower(Team.name).contains(search_lower), + func.lower(Team.slug).contains(search_lower), + ) + ) + + # Get total count + total = query.count() + + # Apply sorting and pagination + query = query.order_by(Team.name) + offset = (page - 1) * limit + teams = query.offset(offset).limit(limit).all() + + # Calculate total pages + total_pages = math.ceil(total / limit) if total > 0 else 1 + + # Build response with member counts and user roles + items = [] + for team in teams: + member_count = db.query(TeamMembership).filter(TeamMembership.team_id == team.id).count() + project_count = db.query(Project).filter(Project.team_id == team.id).count() + + # Get user's role in this team + membership = ( + db.query(TeamMembership) + .filter( + TeamMembership.team_id == team.id, + TeamMembership.user_id == current_user.id, + ) + .first() + ) + + items.append( + TeamDetailResponse( + id=team.id, + name=team.name, + slug=team.slug, + description=team.description, + created_at=team.created_at, + updated_at=team.updated_at, + member_count=member_count, + project_count=project_count, + user_role=membership.role if membership else None, + ) + ) + + return PaginatedResponse( + items=items, + pagination=PaginationMeta( + page=page, + limit=limit, + total=total, + total_pages=total_pages, + has_more=page < total_pages, + ), + ) + + +@router.post("/api/v1/teams", response_model=TeamDetailResponse, status_code=201) +def create_team( + team_data: TeamCreate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create a new team. The creator becomes the owner.""" + # Check if slug already exists + existing = db.query(Team).filter(Team.slug == team_data.slug).first() + if existing: + raise HTTPException(status_code=400, detail="Team slug already exists") + + # Create the team + team = Team( + name=team_data.name, + slug=team_data.slug, + description=team_data.description, + created_by=current_user.username, + ) + db.add(team) + db.flush() # Get the team ID + + # Add creator as owner + membership = TeamMembership( + team_id=team.id, + user_id=current_user.id, + role="owner", + invited_by=current_user.username, + ) + db.add(membership) + + # Audit log + _log_audit( + db=db, + action="team.create", + resource=f"team/{team.slug}", + user_id=current_user.username, + source_ip=request.client.host if request.client else None, + details={"team_name": team.name}, + ) + + db.commit() + db.refresh(team) + + return TeamDetailResponse( + id=team.id, + name=team.name, + slug=team.slug, + description=team.description, + created_at=team.created_at, + updated_at=team.updated_at, + member_count=1, + project_count=0, + user_role="owner", + ) + + +@router.get("/api/v1/teams/{slug}", response_model=TeamDetailResponse) +def get_team( + slug: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get team details. Requires team membership.""" + team = check_team_access(db, slug, current_user, "member") + + member_count = db.query(TeamMembership).filter(TeamMembership.team_id == team.id).count() + project_count = db.query(Project).filter(Project.team_id == team.id).count() + + # Get user's role + membership = ( + db.query(TeamMembership) + .filter( + TeamMembership.team_id == team.id, + TeamMembership.user_id == current_user.id, + ) + .first() + ) + user_role = membership.role if membership else ("admin" if current_user.is_admin else None) + + return TeamDetailResponse( + id=team.id, + name=team.name, + slug=team.slug, + description=team.description, + created_at=team.created_at, + updated_at=team.updated_at, + member_count=member_count, + project_count=project_count, + user_role=user_role, + ) + + +@router.put("/api/v1/teams/{slug}", response_model=TeamDetailResponse) +def update_team( + slug: str, + team_update: TeamUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update team details. Requires admin role.""" + team = check_team_access(db, slug, current_user, "admin") + + # Track changes for audit + changes = {} + if team_update.name is not None and team_update.name != team.name: + changes["name"] = {"old": team.name, "new": team_update.name} + team.name = team_update.name + if team_update.description is not None and team_update.description != team.description: + changes["description"] = {"old": team.description, "new": team_update.description} + team.description = team_update.description + + if changes: + _log_audit( + db=db, + action="team.update", + resource=f"team/{slug}", + user_id=current_user.username, + source_ip=request.client.host if request.client else None, + details=changes, + ) + db.commit() + db.refresh(team) + + member_count = db.query(TeamMembership).filter(TeamMembership.team_id == team.id).count() + project_count = db.query(Project).filter(Project.team_id == team.id).count() + + membership = ( + db.query(TeamMembership) + .filter( + TeamMembership.team_id == team.id, + TeamMembership.user_id == current_user.id, + ) + .first() + ) + user_role = membership.role if membership else ("admin" if current_user.is_admin else None) + + return TeamDetailResponse( + id=team.id, + name=team.name, + slug=team.slug, + description=team.description, + created_at=team.created_at, + updated_at=team.updated_at, + member_count=member_count, + project_count=project_count, + user_role=user_role, + ) + + +@router.delete("/api/v1/teams/{slug}", status_code=204) +def delete_team( + slug: str, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete a team. Requires owner role.""" + team = check_team_access(db, slug, current_user, "owner") + + # Check if team has any projects + project_count = db.query(Project).filter(Project.team_id == team.id).count() + if project_count > 0: + raise HTTPException( + status_code=400, + detail=f"Cannot delete team with {project_count} project(s). Move or delete projects first.", + ) + + # Audit log + _log_audit( + db=db, + action="team.delete", + resource=f"team/{slug}", + user_id=current_user.username, + source_ip=request.client.host if request.client else None, + details={"team_name": team.name}, + ) + + db.delete(team) + db.commit() + return Response(status_code=204) + + +# Team membership routes +@router.get("/api/v1/teams/{slug}/members", response_model=List[TeamMemberResponse]) +def list_team_members( + slug: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List all members of a team. Requires team membership. + + Email addresses are only visible to team admins/owners. + """ + team = check_team_access(db, slug, current_user, "member") + + # Check if current user is admin/owner to determine email visibility + current_membership = ( + db.query(TeamMembership) + .filter( + TeamMembership.team_id == team.id, + TeamMembership.user_id == current_user.id, + ) + .first() + ) + can_see_emails = ( + current_user.is_admin or + (current_membership and current_membership.role in ("owner", "admin")) + ) + + memberships = ( + db.query(TeamMembership) + .join(User) + .filter(TeamMembership.team_id == team.id) + .order_by( + # Sort by role (owner first, then admin, then member) + case( + (TeamMembership.role == "owner", 0), + (TeamMembership.role == "admin", 1), + else_=2, + ), + User.username, + ) + .all() + ) + + return [ + TeamMemberResponse( + id=m.id, + user_id=m.user_id, + username=m.user.username, + email=m.user.email if can_see_emails else None, + role=m.role, + created_at=m.created_at, + ) + for m in memberships + ] + + +@router.post("/api/v1/teams/{slug}/members", response_model=TeamMemberResponse, status_code=201) +def add_team_member( + slug: str, + member_data: TeamMemberCreate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Add a member to a team. Requires admin role.""" + team = check_team_access(db, slug, current_user, "admin") + + # Find the user by username + user = db.query(User).filter(User.username == member_data.username).first() + if not user: + raise HTTPException(status_code=404, detail=f"User '{member_data.username}' not found") + + # Check if already a member + existing = ( + db.query(TeamMembership) + .filter( + TeamMembership.team_id == team.id, + TeamMembership.user_id == user.id, + ) + .first() + ) + if existing: + raise HTTPException(status_code=400, detail="User is already a member of this team") + + # Only owners can add other owners + if member_data.role == "owner": + current_membership = ( + db.query(TeamMembership) + .filter( + TeamMembership.team_id == team.id, + TeamMembership.user_id == current_user.id, + ) + .first() + ) + if not current_membership or current_membership.role != "owner": + raise HTTPException(status_code=403, detail="Only owners can add other owners") + + membership = TeamMembership( + team_id=team.id, + user_id=user.id, + role=member_data.role, + invited_by=current_user.username, + ) + db.add(membership) + + _log_audit( + db=db, + action="team.member.add", + resource=f"team/{slug}/members/{member_data.username}", + user_id=current_user.username, + source_ip=request.client.host if request.client else None, + details={"role": member_data.role}, + ) + + db.commit() + db.refresh(membership) + + return TeamMemberResponse( + id=membership.id, + user_id=membership.user_id, + username=user.username, + email=user.email, + role=membership.role, + created_at=membership.created_at, + ) + + +@router.put("/api/v1/teams/{slug}/members/{username}", response_model=TeamMemberResponse) +def update_team_member( + slug: str, + username: str, + member_update: TeamMemberUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update a member's role. Requires admin role.""" + team = check_team_access(db, slug, current_user, "admin") + + # Find the user + user = db.query(User).filter(User.username == username).first() + if not user: + raise HTTPException(status_code=404, detail=f"User '{username}' not found") + + # Find the membership + membership = ( + db.query(TeamMembership) + .filter( + TeamMembership.team_id == team.id, + TeamMembership.user_id == user.id, + ) + .first() + ) + if not membership: + raise HTTPException(status_code=404, detail=f"User '{username}' is not a member of this team") + + # Prevent self-role modification + if user.id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot modify your own role") + + # Get current user's membership to check permissions + current_membership = ( + db.query(TeamMembership) + .filter( + TeamMembership.team_id == team.id, + TeamMembership.user_id == current_user.id, + ) + .first() + ) + current_role = current_membership.role if current_membership else None + + # Prevent demoting the last owner + if membership.role == "owner" and member_update.role != "owner": + owner_count = ( + db.query(TeamMembership) + .filter( + TeamMembership.team_id == team.id, + TeamMembership.role == "owner", + ) + .count() + ) + if owner_count <= 1: + raise HTTPException(status_code=400, detail="Cannot demote the last owner") + + # Only team owners can modify other owners or promote to owner (system admins cannot) + if membership.role == "owner" or member_update.role == "owner": + if current_role != "owner": + raise HTTPException(status_code=403, detail="Only team owners can modify owner roles") + + old_role = membership.role + membership.role = member_update.role + + _log_audit( + db=db, + action="team.member.update", + resource=f"team/{slug}/members/{username}", + user_id=current_user.username, + source_ip=request.client.host if request.client else None, + details={"old_role": old_role, "new_role": member_update.role}, + ) + + db.commit() + db.refresh(membership) + + return TeamMemberResponse( + id=membership.id, + user_id=membership.user_id, + username=user.username, + email=user.email, + role=membership.role, + created_at=membership.created_at, + ) + + +@router.delete("/api/v1/teams/{slug}/members/{username}", status_code=204) +def remove_team_member( + slug: str, + username: str, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Remove a member from a team. Requires admin role.""" + team = check_team_access(db, slug, current_user, "admin") + + # Find the user + user = db.query(User).filter(User.username == username).first() + if not user: + raise HTTPException(status_code=404, detail=f"User '{username}' not found") + + # Find the membership + membership = ( + db.query(TeamMembership) + .filter( + TeamMembership.team_id == team.id, + TeamMembership.user_id == user.id, + ) + .first() + ) + if not membership: + raise HTTPException(status_code=404, detail=f"User '{username}' is not a member of this team") + + # Prevent self-removal (use a "leave team" action instead if needed) + if user.id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot remove yourself. Transfer ownership first if you are an owner.") + + # Prevent removing the last owner + if membership.role == "owner": + owner_count = ( + db.query(TeamMembership) + .filter( + TeamMembership.team_id == team.id, + TeamMembership.role == "owner", + ) + .count() + ) + if owner_count <= 1: + raise HTTPException(status_code=400, detail="Cannot remove the last owner") + + # Only team owners can remove other owners (system admins cannot) + current_membership = ( + db.query(TeamMembership) + .filter( + TeamMembership.team_id == team.id, + TeamMembership.user_id == current_user.id, + ) + .first() + ) + if not current_membership or current_membership.role != "owner": + raise HTTPException(status_code=403, detail="Only team owners can remove other owners") + + _log_audit( + db=db, + action="team.member.remove", + resource=f"team/{slug}/members/{username}", + user_id=current_user.username, + source_ip=request.client.host if request.client else None, + details={"role": membership.role}, + ) + + db.delete(membership) + db.commit() + return Response(status_code=204) + + +# Team projects route +@router.get("/api/v1/teams/{slug}/projects", response_model=PaginatedResponse[ProjectResponse]) +def list_team_projects( + slug: str, + page: int = Query(default=1, ge=1, description="Page number"), + limit: int = Query(default=20, ge=1, le=100, description="Items per page"), + search: Optional[str] = Query(default=None, description="Search by name or description"), + visibility: Optional[str] = Query(default=None, description="Filter by visibility (public, private)"), + sort: str = Query(default="name", description="Sort field (name, created_at, updated_at)"), + order: str = Query(default="asc", description="Sort order (asc, desc)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List all projects in a team. Requires team membership.""" + team = check_team_access(db, slug, current_user, "member") + + # Validate sort field + valid_sort_fields = { + "name": Project.name, + "created_at": Project.created_at, + "updated_at": Project.updated_at, + } + if sort not in valid_sort_fields: + raise HTTPException( + status_code=400, + detail=f"Invalid sort field. Must be one of: {', '.join(valid_sort_fields.keys())}", + ) + + if order not in ("asc", "desc"): + raise HTTPException(status_code=400, detail="Invalid order. Must be 'asc' or 'desc'") + + # Base query - projects in this team + query = db.query(Project).filter(Project.team_id == team.id) + + # Apply visibility filter + if visibility == "public": + query = query.filter(Project.is_public == True) + elif visibility == "private": + query = query.filter(Project.is_public == False) + + # Apply search filter + if search: + search_lower = search.lower() + query = query.filter( + or_( + func.lower(Project.name).contains(search_lower), + func.lower(Project.description).contains(search_lower), + ) + ) + + # Get total count + total = query.count() + + # Apply sorting + sort_column = valid_sort_fields[sort] + if order == "desc": + query = query.order_by(sort_column.desc()) + else: + query = query.order_by(sort_column.asc()) + + # Apply pagination + offset = (page - 1) * limit + projects = query.offset(offset).limit(limit).all() + + # Calculate total pages + total_pages = math.ceil(total / limit) if total > 0 else 1 + + # Build response with team info + items = [] + for p in projects: + items.append( + ProjectResponse( + id=p.id, + name=p.name, + description=p.description, + is_public=p.is_public, + created_at=p.created_at, + updated_at=p.updated_at, + created_by=p.created_by, + team_id=team.id, + team_slug=team.slug, + team_name=team.name, + ) + ) + + return PaginatedResponse( + items=items, + pagination=PaginationMeta( + page=page, + limit=limit, + total=total, + total_pages=total_pages, + has_more=page < total_pages, + ), + ) + + # Package routes @router.get( "/api/v1/project/{project_name}/packages", diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f893187..d378a8c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -25,6 +25,7 @@ class ProjectCreate(BaseModel): name: str description: Optional[str] = None is_public: bool = True + team_id: Optional[UUID] = None class ProjectResponse(BaseModel): @@ -35,6 +36,9 @@ class ProjectResponse(BaseModel): created_at: datetime updated_at: datetime created_by: str + team_id: Optional[UUID] = None + team_slug: Optional[str] = None + team_name: Optional[str] = None class Config: from_attributes = True @@ -907,6 +911,9 @@ class AccessPermissionResponse(BaseModel): level: str created_at: datetime expires_at: Optional[datetime] + source: Optional[str] = "explicit" # "explicit" or "team" + team_slug: Optional[str] = None # Team slug if source is "team" + team_role: Optional[str] = None # Team role if source is "team" class Config: from_attributes = True @@ -1053,3 +1060,139 @@ class CircularDependencyError(BaseModel): message: str cycle: List[str] # List of "project/package" strings showing the cycle + +# Team schemas +TEAM_ROLES = ["owner", "admin", "member"] +RESERVED_TEAM_SLUGS = {"new", "api", "admin", "settings", "members", "projects", "search"} + + +class TeamCreate(BaseModel): + """Create a new team""" + name: str + slug: str + description: Optional[str] = None + + @field_validator('name') + @classmethod + def validate_name(cls, v: str) -> str: + """Validate team name.""" + if not v or not v.strip(): + raise ValueError("Name cannot be empty") + if len(v) > 255: + raise ValueError("Name must be 255 characters or less") + return v.strip() + + @field_validator('slug') + @classmethod + def validate_slug(cls, v: str) -> str: + """Validate team slug format (lowercase alphanumeric with hyphens).""" + import re + if not v: + raise ValueError("Slug cannot be empty") + if len(v) < 2: + raise ValueError("Slug must be at least 2 characters") + if len(v) > 255: + raise ValueError("Slug must be 255 characters or less") + if not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', v) and not re.match(r'^[a-z0-9]$', v): + raise ValueError( + "Slug must be lowercase alphanumeric with hyphens, " + "starting and ending with alphanumeric characters" + ) + if '--' in v: + raise ValueError("Slug cannot contain consecutive hyphens") + if v in RESERVED_TEAM_SLUGS: + raise ValueError(f"Slug '{v}' is reserved and cannot be used") + return v + + @field_validator('description') + @classmethod + def validate_description(cls, v: Optional[str]) -> Optional[str]: + """Validate team description.""" + if v is not None and len(v) > 2000: + raise ValueError("Description must be 2000 characters or less") + return v + + +class TeamUpdate(BaseModel): + """Update team details""" + name: Optional[str] = None + description: Optional[str] = None + + @field_validator('name') + @classmethod + def validate_name(cls, v: Optional[str]) -> Optional[str]: + """Validate team name.""" + if v is not None: + if not v.strip(): + raise ValueError("Name cannot be empty") + if len(v) > 255: + raise ValueError("Name must be 255 characters or less") + return v.strip() + return v + + @field_validator('description') + @classmethod + def validate_description(cls, v: Optional[str]) -> Optional[str]: + """Validate team description.""" + if v is not None and len(v) > 2000: + raise ValueError("Description must be 2000 characters or less") + return v + + +class TeamResponse(BaseModel): + """Team response with basic info""" + id: UUID + name: str + slug: str + description: Optional[str] + created_at: datetime + updated_at: datetime + member_count: int = 0 + project_count: int = 0 + + class Config: + from_attributes = True + + +class TeamDetailResponse(TeamResponse): + """Team response with user's role""" + user_role: Optional[str] = None # 'owner', 'admin', 'member', or None + + +class TeamMemberCreate(BaseModel): + """Add a member to a team""" + username: str + role: str = "member" + + @field_validator('role') + @classmethod + def validate_role(cls, v: str) -> str: + if v not in TEAM_ROLES: + raise ValueError(f"Role must be one of: {', '.join(TEAM_ROLES)}") + return v + + +class TeamMemberUpdate(BaseModel): + """Update a team member's role""" + role: str + + @field_validator('role') + @classmethod + def validate_role(cls, v: str) -> str: + if v not in TEAM_ROLES: + raise ValueError(f"Role must be one of: {', '.join(TEAM_ROLES)}") + return v + + +class TeamMemberResponse(BaseModel): + """Team member response""" + id: UUID + user_id: UUID + username: str + email: Optional[str] + role: str + created_at: datetime + + class Config: + from_attributes = True + diff --git a/backend/app/seed.py b/backend/app/seed.py index ed1a29d..9a18e66 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -5,8 +5,9 @@ import hashlib import logging from sqlalchemy.orm import Session -from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency +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__) @@ -149,6 +150,80 @@ def seed_database(db: Session) -> None: logger.info("Seeding database with test data...") storage = get_storage() + # Find or use admin user for team ownership + admin_user = db.query(User).filter(User.username == "admin").first() + team_owner_username = admin_user.username if admin_user else "seed-user" + + # Create a demo team + demo_team = Team( + name="Demo Team", + slug="demo-team", + description="A demonstration team with sample projects", + created_by=team_owner_username, + ) + db.add(demo_team) + db.flush() + + # Add admin user as team owner if they exist + if admin_user: + membership = TeamMembership( + team_id=demo_team.id, + user_id=admin_user.id, + role="owner", + invited_by=team_owner_username, + ) + db.add(membership) + db.flush() + + 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 = {} @@ -158,7 +233,8 @@ def seed_database(db: Session) -> None: name=project_data["name"], description=project_data["description"], is_public=project_data["is_public"], - created_by="seed-user", + created_by=team_owner_username, + team_id=demo_team.id, # Assign to demo team ) db.add(project) db.flush() # Get the ID @@ -174,7 +250,7 @@ def seed_database(db: Session) -> None: db.flush() package_map[(project_data["name"], package_data["name"])] = package - logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages") + logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages (assigned to {demo_team.slug})") # Create artifacts, tags, and versions artifact_count = 0 @@ -212,7 +288,7 @@ def seed_database(db: Session) -> None: size=size, content_type=artifact_data["content_type"], original_name=artifact_data["filename"], - created_by="seed-user", + created_by=team_owner_username, s3_key=s3_key, ref_count=ref_count, ) @@ -235,7 +311,7 @@ def seed_database(db: Session) -> None: artifact_id=sha256_hash, version=artifact_data["version"], version_source="explicit", - created_by="seed-user", + created_by=team_owner_username, ) db.add(version) version_count += 1 @@ -246,7 +322,7 @@ def seed_database(db: Session) -> None: package_id=package.id, name=tag_name, artifact_id=sha256_hash, - created_by="seed-user", + created_by=team_owner_username, ) db.add(tag) tag_count += 1 diff --git a/backend/tests/integration/test_teams_api.py b/backend/tests/integration/test_teams_api.py new file mode 100644 index 0000000..1d57cdb --- /dev/null +++ b/backend/tests/integration/test_teams_api.py @@ -0,0 +1,316 @@ +""" +Integration tests for Teams API endpoints. +""" + +import pytest + + +@pytest.mark.integration +class TestTeamsCRUD: + """Tests for team creation, listing, updating, and deletion.""" + + def test_create_team(self, integration_client, unique_test_id): + """Test creating a new team.""" + team_name = f"Test Team {unique_test_id}" + team_slug = f"test-team-{unique_test_id}" + + response = integration_client.post( + "/api/v1/teams", + json={ + "name": team_name, + "slug": team_slug, + "description": "A test team", + }, + ) + assert response.status_code == 201, f"Failed to create team: {response.text}" + + data = response.json() + assert data["name"] == team_name + assert data["slug"] == team_slug + assert data["description"] == "A test team" + assert data["user_role"] == "owner" + assert data["member_count"] == 1 + assert data["project_count"] == 0 + + # Cleanup + integration_client.delete(f"/api/v1/teams/{team_slug}") + + def test_create_team_duplicate_slug(self, integration_client, unique_test_id): + """Test that duplicate team slugs are rejected.""" + team_slug = f"dup-team-{unique_test_id}" + + # Create first team + response = integration_client.post( + "/api/v1/teams", + json={"name": "First Team", "slug": team_slug}, + ) + assert response.status_code == 201 + + # Try to create second team with same slug + response = integration_client.post( + "/api/v1/teams", + json={"name": "Second Team", "slug": team_slug}, + ) + assert response.status_code == 400 + assert "already exists" in response.json()["detail"].lower() + + # Cleanup + integration_client.delete(f"/api/v1/teams/{team_slug}") + + def test_create_team_invalid_slug(self, integration_client): + """Test that invalid team slugs are rejected.""" + invalid_slugs = [ + "UPPERCASE", + "with spaces", + "-starts-with-hyphen", + "ends-with-hyphen-", + "has--double--hyphen", + ] + + for invalid_slug in invalid_slugs: + response = integration_client.post( + "/api/v1/teams", + json={"name": "Test", "slug": invalid_slug}, + ) + assert response.status_code == 422, f"Slug '{invalid_slug}' should be invalid" + + def test_list_teams(self, integration_client, unique_test_id): + """Test listing teams the user belongs to.""" + # Create a team + team_slug = f"list-team-{unique_test_id}" + integration_client.post( + "/api/v1/teams", + json={"name": "List Test Team", "slug": team_slug}, + ) + + # List teams + response = integration_client.get("/api/v1/teams") + assert response.status_code == 200 + + data = response.json() + assert "items" in data + assert "pagination" in data + + # Find our team + team = next((t for t in data["items"] if t["slug"] == team_slug), None) + assert team is not None + assert team["name"] == "List Test Team" + + # Cleanup + integration_client.delete(f"/api/v1/teams/{team_slug}") + + def test_get_team(self, integration_client, unique_test_id): + """Test getting team details.""" + team_slug = f"get-team-{unique_test_id}" + integration_client.post( + "/api/v1/teams", + json={"name": "Get Test Team", "slug": team_slug, "description": "Test"}, + ) + + response = integration_client.get(f"/api/v1/teams/{team_slug}") + assert response.status_code == 200 + + data = response.json() + assert data["slug"] == team_slug + assert data["name"] == "Get Test Team" + assert data["user_role"] == "owner" + + # Cleanup + integration_client.delete(f"/api/v1/teams/{team_slug}") + + def test_get_nonexistent_team(self, integration_client): + """Test getting a team that doesn't exist.""" + response = integration_client.get("/api/v1/teams/nonexistent-team-12345") + assert response.status_code == 404 + + def test_update_team(self, integration_client, unique_test_id): + """Test updating team details.""" + team_slug = f"update-team-{unique_test_id}" + integration_client.post( + "/api/v1/teams", + json={"name": "Original Name", "slug": team_slug}, + ) + + response = integration_client.put( + f"/api/v1/teams/{team_slug}", + json={"name": "Updated Name", "description": "New description"}, + ) + assert response.status_code == 200 + + data = response.json() + assert data["name"] == "Updated Name" + assert data["description"] == "New description" + assert data["slug"] == team_slug # Slug should not change + + # Cleanup + integration_client.delete(f"/api/v1/teams/{team_slug}") + + def test_delete_team(self, integration_client, unique_test_id): + """Test deleting a team.""" + team_slug = f"delete-team-{unique_test_id}" + integration_client.post( + "/api/v1/teams", + json={"name": "Delete Test Team", "slug": team_slug}, + ) + + response = integration_client.delete(f"/api/v1/teams/{team_slug}") + assert response.status_code == 204 + + # Verify team is gone + response = integration_client.get(f"/api/v1/teams/{team_slug}") + assert response.status_code == 404 + + +@pytest.mark.integration +class TestTeamMembers: + """Tests for team membership management.""" + + @pytest.fixture + def test_team(self, integration_client, unique_test_id): + """Create a test team for member tests.""" + team_slug = f"member-team-{unique_test_id}" + response = integration_client.post( + "/api/v1/teams", + json={"name": "Member Test Team", "slug": team_slug}, + ) + assert response.status_code == 201 + + yield team_slug + + # Cleanup + try: + integration_client.delete(f"/api/v1/teams/{team_slug}") + except Exception: + pass + + def test_list_members(self, integration_client, test_team): + """Test listing team members.""" + response = integration_client.get(f"/api/v1/teams/{test_team}/members") + assert response.status_code == 200 + + members = response.json() + assert len(members) == 1 + assert members[0]["role"] == "owner" + + def test_owner_is_first_member(self, integration_client, test_team): + """Test that the team creator is automatically the owner.""" + response = integration_client.get(f"/api/v1/teams/{test_team}/members") + members = response.json() + + assert len(members) >= 1 + owner = next((m for m in members if m["role"] == "owner"), None) + assert owner is not None + + +@pytest.mark.integration +class TestTeamProjects: + """Tests for team project management.""" + + @pytest.fixture + def test_team(self, integration_client, unique_test_id): + """Create a test team for project tests.""" + team_slug = f"proj-team-{unique_test_id}" + response = integration_client.post( + "/api/v1/teams", + json={"name": "Project Test Team", "slug": team_slug}, + ) + assert response.status_code == 201 + + data = response.json() + yield {"slug": team_slug, "id": data["id"]} + + # Cleanup + try: + integration_client.delete(f"/api/v1/teams/{team_slug}") + except Exception: + pass + + def test_list_team_projects_empty(self, integration_client, test_team): + """Test listing projects in an empty team.""" + response = integration_client.get(f"/api/v1/teams/{test_team['slug']}/projects") + assert response.status_code == 200 + + data = response.json() + assert data["items"] == [] + assert data["pagination"]["total"] == 0 + + def test_create_project_in_team(self, integration_client, test_team, unique_test_id): + """Test creating a project within a team.""" + project_name = f"team-project-{unique_test_id}" + + response = integration_client.post( + "/api/v1/projects", + json={ + "name": project_name, + "description": "A team project", + "team_id": test_team["id"], + }, + ) + assert response.status_code == 200, f"Failed to create project: {response.text}" + + data = response.json() + assert data["team_id"] == test_team["id"] + assert data["team_slug"] == test_team["slug"] + + # Verify project appears in team projects list + response = integration_client.get(f"/api/v1/teams/{test_team['slug']}/projects") + assert response.status_code == 200 + projects = response.json()["items"] + assert any(p["name"] == project_name for p in projects) + + # Cleanup + integration_client.delete(f"/api/v1/projects/{project_name}") + + def test_project_team_info_in_response(self, integration_client, test_team, unique_test_id): + """Test that project responses include team info.""" + project_name = f"team-info-project-{unique_test_id}" + + # Create project in team + integration_client.post( + "/api/v1/projects", + json={"name": project_name, "team_id": test_team["id"]}, + ) + + # Get project and verify team info + response = integration_client.get(f"/api/v1/projects/{project_name}") + assert response.status_code == 200 + + data = response.json() + assert data["team_id"] == test_team["id"] + assert data["team_slug"] == test_team["slug"] + assert data["team_name"] == "Project Test Team" + + # Cleanup + integration_client.delete(f"/api/v1/projects/{project_name}") + + +@pytest.mark.integration +class TestTeamAuthorization: + """Tests for team-based authorization.""" + + def test_cannot_delete_team_with_projects(self, integration_client, unique_test_id): + """Test that teams with projects cannot be deleted.""" + team_slug = f"nodelete-team-{unique_test_id}" + project_name = f"nodelete-project-{unique_test_id}" + + # Create team + response = integration_client.post( + "/api/v1/teams", + json={"name": "No Delete Team", "slug": team_slug}, + ) + team_id = response.json()["id"] + + # Create project in team + integration_client.post( + "/api/v1/projects", + json={"name": project_name, "team_id": team_id}, + ) + + # Try to delete team - should fail + response = integration_client.delete(f"/api/v1/teams/{team_slug}") + assert response.status_code == 400 + assert "project" in response.json()["detail"].lower() + + # Cleanup - delete project first, then team + integration_client.delete(f"/api/v1/projects/{project_name}") + integration_client.delete(f"/api/v1/teams/{team_slug}") diff --git a/backend/tests/unit/test_team_auth.py b/backend/tests/unit/test_team_auth.py new file mode 100644 index 0000000..5fb8647 --- /dev/null +++ b/backend/tests/unit/test_team_auth.py @@ -0,0 +1,213 @@ +""" +Unit tests for TeamAuthorizationService. +""" + +import pytest +from unittest.mock import MagicMock, patch +import uuid + + +class TestTeamRoleHierarchy: + """Tests for team role hierarchy functions.""" + + def test_get_team_role_rank(self): + """Test role ranking.""" + from app.auth import get_team_role_rank + + assert get_team_role_rank("member") == 0 + assert get_team_role_rank("admin") == 1 + assert get_team_role_rank("owner") == 2 + assert get_team_role_rank("invalid") == -1 + + def test_has_sufficient_team_role(self): + """Test role sufficiency checks.""" + from app.auth import has_sufficient_team_role + + # Same role should be sufficient + assert has_sufficient_team_role("member", "member") is True + assert has_sufficient_team_role("admin", "admin") is True + assert has_sufficient_team_role("owner", "owner") is True + + # Higher role should be sufficient for lower requirements + assert has_sufficient_team_role("admin", "member") is True + assert has_sufficient_team_role("owner", "member") is True + assert has_sufficient_team_role("owner", "admin") is True + + # Lower role should NOT be sufficient for higher requirements + assert has_sufficient_team_role("member", "admin") is False + assert has_sufficient_team_role("member", "owner") is False + assert has_sufficient_team_role("admin", "owner") is False + + +class TestTeamAuthorizationService: + """Tests for TeamAuthorizationService class.""" + + @pytest.fixture + def mock_db(self): + """Create a mock database session.""" + return MagicMock() + + @pytest.fixture + def mock_user(self): + """Create a mock user.""" + user = MagicMock() + user.id = uuid.uuid4() + user.username = "testuser" + user.is_admin = False + return user + + @pytest.fixture + def mock_admin_user(self): + """Create a mock admin user.""" + user = MagicMock() + user.id = uuid.uuid4() + user.username = "adminuser" + user.is_admin = True + return user + + def test_get_user_team_role_no_user(self, mock_db): + """Test that None is returned for anonymous users.""" + from app.auth import TeamAuthorizationService + + service = TeamAuthorizationService(mock_db) + result = service.get_user_team_role("team-id", None) + assert result is None + + def test_get_user_team_role_admin_user(self, mock_db, mock_admin_user): + """Test that system admins who are not members get admin role.""" + from app.auth import TeamAuthorizationService + + # Mock no membership found + mock_db.query.return_value.filter.return_value.first.return_value = None + + service = TeamAuthorizationService(mock_db) + result = service.get_user_team_role("team-id", mock_admin_user) + assert result == "admin" + + def test_get_user_team_role_member(self, mock_db, mock_user): + """Test getting role for a team member.""" + from app.auth import TeamAuthorizationService + + # Mock the membership query + mock_membership = MagicMock() + mock_membership.role = "member" + mock_db.query.return_value.filter.return_value.first.return_value = mock_membership + + service = TeamAuthorizationService(mock_db) + result = service.get_user_team_role("team-id", mock_user) + assert result == "member" + + def test_get_user_team_role_not_member(self, mock_db, mock_user): + """Test getting role for a non-member.""" + from app.auth import TeamAuthorizationService + + # Mock no membership found + mock_db.query.return_value.filter.return_value.first.return_value = None + + service = TeamAuthorizationService(mock_db) + result = service.get_user_team_role("team-id", mock_user) + assert result is None + + def test_check_team_access_member(self, mock_db, mock_user): + """Test access check for member requiring member role.""" + from app.auth import TeamAuthorizationService + + # Mock the membership query + mock_membership = MagicMock() + mock_membership.role = "member" + mock_db.query.return_value.filter.return_value.first.return_value = mock_membership + + service = TeamAuthorizationService(mock_db) + + # Member should have member access + assert service.check_team_access("team-id", mock_user, "member") is True + # Member should not have admin access + assert service.check_team_access("team-id", mock_user, "admin") is False + # Member should not have owner access + assert service.check_team_access("team-id", mock_user, "owner") is False + + def test_check_team_access_admin(self, mock_db, mock_user): + """Test access check for admin role.""" + from app.auth import TeamAuthorizationService + + # Mock admin membership + mock_membership = MagicMock() + mock_membership.role = "admin" + mock_db.query.return_value.filter.return_value.first.return_value = mock_membership + + service = TeamAuthorizationService(mock_db) + + assert service.check_team_access("team-id", mock_user, "member") is True + assert service.check_team_access("team-id", mock_user, "admin") is True + assert service.check_team_access("team-id", mock_user, "owner") is False + + def test_check_team_access_owner(self, mock_db, mock_user): + """Test access check for owner role.""" + from app.auth import TeamAuthorizationService + + # Mock owner membership + mock_membership = MagicMock() + mock_membership.role = "owner" + mock_db.query.return_value.filter.return_value.first.return_value = mock_membership + + service = TeamAuthorizationService(mock_db) + + assert service.check_team_access("team-id", mock_user, "member") is True + assert service.check_team_access("team-id", mock_user, "admin") is True + assert service.check_team_access("team-id", mock_user, "owner") is True + + def test_can_create_project(self, mock_db, mock_user): + """Test can_create_project requires admin role.""" + from app.auth import TeamAuthorizationService + + service = TeamAuthorizationService(mock_db) + + # Member cannot create projects + mock_membership = MagicMock() + mock_membership.role = "member" + mock_db.query.return_value.filter.return_value.first.return_value = mock_membership + assert service.can_create_project("team-id", mock_user) is False + + # Admin can create projects + mock_membership.role = "admin" + assert service.can_create_project("team-id", mock_user) is True + + # Owner can create projects + mock_membership.role = "owner" + assert service.can_create_project("team-id", mock_user) is True + + def test_can_manage_members(self, mock_db, mock_user): + """Test can_manage_members requires admin role.""" + from app.auth import TeamAuthorizationService + + service = TeamAuthorizationService(mock_db) + + # Member cannot manage members + mock_membership = MagicMock() + mock_membership.role = "member" + mock_db.query.return_value.filter.return_value.first.return_value = mock_membership + assert service.can_manage_members("team-id", mock_user) is False + + # Admin can manage members + mock_membership.role = "admin" + assert service.can_manage_members("team-id", mock_user) is True + + def test_can_delete_team(self, mock_db, mock_user): + """Test can_delete_team requires owner role.""" + from app.auth import TeamAuthorizationService + + service = TeamAuthorizationService(mock_db) + + # Member cannot delete team + mock_membership = MagicMock() + mock_membership.role = "member" + mock_db.query.return_value.filter.return_value.first.return_value = mock_membership + assert service.can_delete_team("team-id", mock_user) is False + + # Admin cannot delete team + mock_membership.role = "admin" + assert service.can_delete_team("team-id", mock_user) is False + + # Only owner can delete team + mock_membership.role = "owner" + assert service.can_delete_team("team-id", mock_user) is True diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8e77d92..0b66fdf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { AuthProvider, useAuth } from './contexts/AuthContext'; +import { TeamProvider } from './contexts/TeamContext'; import Layout from './components/Layout'; import Home from './pages/Home'; import ProjectPage from './pages/ProjectPage'; @@ -11,6 +12,10 @@ import APIKeysPage from './pages/APIKeysPage'; import AdminUsersPage from './pages/AdminUsersPage'; import AdminOIDCPage from './pages/AdminOIDCPage'; import ProjectSettingsPage from './pages/ProjectSettingsPage'; +import TeamsPage from './pages/TeamsPage'; +import TeamDashboardPage from './pages/TeamDashboardPage'; +import TeamSettingsPage from './pages/TeamSettingsPage'; +import TeamMembersPage from './pages/TeamMembersPage'; // Component that checks if user must change password function RequirePasswordChange({ children }: { children: React.ReactNode }) { @@ -45,6 +50,10 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -60,7 +69,9 @@ function AppRoutes() { function App() { return ( - + + + ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 56df640..d8a0141 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -36,6 +36,12 @@ import { ArtifactDependenciesResponse, ReverseDependenciesResponse, DependencyResolutionResponse, + TeamDetail, + TeamMember, + TeamCreate, + TeamUpdate, + TeamMemberCreate, + TeamMemberUpdate, } from './types'; const API_BASE = '/api/v1'; @@ -160,7 +166,7 @@ export async function listProjectsSimple(params: ListParams = {}): Promise { +export async function createProject(data: { name: string; description?: string; is_public?: boolean; team_id?: string }): Promise { const response = await fetch(`${API_BASE}/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -562,3 +568,117 @@ export async function getEnsureFile( } return response.text(); } + +// Team API +export async function listTeams(params: ListParams = {}): Promise> { + const query = buildQueryString(params as Record); + const response = await fetch(`${API_BASE}/teams${query}`, { + credentials: 'include', + }); + return handleResponse>(response); +} + +export async function createTeam(data: TeamCreate): Promise { + const response = await fetch(`${API_BASE}/teams`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function getTeam(slug: string): Promise { + const response = await fetch(`${API_BASE}/teams/${slug}`, { + credentials: 'include', + }); + return handleResponse(response); +} + +export async function updateTeam(slug: string, data: TeamUpdate): Promise { + const response = await fetch(`${API_BASE}/teams/${slug}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function deleteTeam(slug: string): Promise { + const response = await fetch(`${API_BASE}/teams/${slug}`, { + method: 'DELETE', + credentials: 'include', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new ApiError(error.detail || `HTTP ${response.status}`, response.status); + } +} + +export async function listTeamMembers(slug: string): Promise { + const response = await fetch(`${API_BASE}/teams/${slug}/members`, { + credentials: 'include', + }); + return handleResponse(response); +} + +export async function addTeamMember(slug: string, data: TeamMemberCreate): Promise { + const response = await fetch(`${API_BASE}/teams/${slug}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function updateTeamMember( + slug: string, + username: string, + data: TeamMemberUpdate +): Promise { + const response = await fetch(`${API_BASE}/teams/${slug}/members/${username}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function removeTeamMember(slug: string, username: string): Promise { + const response = await fetch(`${API_BASE}/teams/${slug}/members/${username}`, { + method: 'DELETE', + credentials: 'include', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new ApiError(error.detail || `HTTP ${response.status}`, response.status); + } +} + +export async function listTeamProjects( + slug: string, + params: ProjectListParams = {} +): Promise> { + const query = buildQueryString(params as Record); + const response = await fetch(`${API_BASE}/teams/${slug}/projects${query}`, { + credentials: 'include', + }); + return handleResponse>(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 { + const response = await fetch(`${API_BASE}/users/search?q=${encodeURIComponent(query)}&limit=${limit}`, { + credentials: 'include', + }); + return handleResponse(response); +} diff --git a/frontend/src/components/AccessManagement.css b/frontend/src/components/AccessManagement.css index 21c8d5d..3fdf3c0 100644 --- a/frontend/src/components/AccessManagement.css +++ b/frontend/src/components/AccessManagement.css @@ -114,3 +114,32 @@ font-size: 0.875rem; color: var(--text-primary); } + +/* Access source styling */ +.access-source { + display: inline-block; + padding: 0.2rem 0.4rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.access-source--explicit { + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +.access-source--team { + background: var(--color-info-bg, #e3f2fd); + color: var(--color-info, #1976d2); +} + +/* Team access row styling */ +.team-access-row { + background: var(--bg-secondary, #fafafa); +} + +.team-access-row td.actions .text-muted { + font-size: 0.8125rem; + font-style: italic; +} diff --git a/frontend/src/components/AccessManagement.tsx b/frontend/src/components/AccessManagement.tsx index 6201661..bb903a9 100644 --- a/frontend/src/components/AccessManagement.tsx +++ b/frontend/src/components/AccessManagement.tsx @@ -208,85 +208,104 @@ export function AccessManagement({ projectName }: AccessManagementProps) { User Access Level + Source Granted Expires Actions - {permissions.map((p) => ( - - {p.user_id} - - {editingUser === p.user_id ? ( - - ) : ( - - {p.level} - - )} - - {new Date(p.created_at).toLocaleDateString()} - - {editingUser === p.user_id ? ( - setEditExpiresAt(e.target.value)} - disabled={submitting} - min={new Date().toISOString().split('T')[0]} - /> - ) : ( - formatExpiration(p.expires_at) - )} - - - {editingUser === p.user_id ? ( - <> - - - ) : ( - <> - - - - )} - - - ))} + min={new Date().toISOString().split('T')[0]} + /> + ) : ( + formatExpiration(p.expires_at) + )} + + + {isTeamBased ? ( + + Via team + + ) : editingUser === p.user_id ? ( + <> + + + + ) : ( + <> + + + + )} + + + ); + })} )} diff --git a/frontend/src/components/Layout.css b/frontend/src/components/Layout.css index 2333c0f..584719f 100644 --- a/frontend/src/components/Layout.css +++ b/frontend/src/components/Layout.css @@ -284,7 +284,11 @@ .footer-brand { display: flex; align-items: center; - gap: 12px; + gap: 8px; +} + +.footer-icon { + color: var(--accent-primary); } .footer-logo { @@ -292,6 +296,10 @@ color: var(--text-primary); } +.footer-separator { + color: var(--text-muted); +} + .footer-tagline { color: var(--text-secondary); font-size: 0.875rem; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c27470c..9e2e559 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -2,6 +2,8 @@ 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 { @@ -13,8 +15,22 @@ function Layout({ children }: LayoutProps) { const navigate = useNavigate(); const { user, loading, logout } = useAuth(); const [showUserMenu, setShowUserMenu] = useState(false); + const [userTeams, setUserTeams] = useState([]); const menuRef = useRef(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) { @@ -77,6 +93,20 @@ function Layout({ children }: LayoutProps) { Dashboard + {user && userTeams.length > 0 && ( + + + + + + + + {userTeams.length === 1 ? 'Team' : 'Teams'} + + )} @@ -188,7 +218,17 @@ function Layout({ children }: LayoutProps) {
+ + + + + + + + + Orchard + · Content-Addressable Storage
diff --git a/frontend/src/components/TeamSelector.css b/frontend/src/components/TeamSelector.css new file mode 100644 index 0000000..05a8dc2 --- /dev/null +++ b/frontend/src/components/TeamSelector.css @@ -0,0 +1,163 @@ +.team-selector { + position: relative; +} + +.team-selector-trigger { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 0.875rem; + cursor: pointer; + transition: all 0.15s ease; + min-width: 160px; +} + +.team-selector-trigger:hover:not(:disabled) { + background: var(--bg-tertiary); + border-color: var(--border-secondary); +} + +.team-selector-trigger:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.team-selector-name { + flex: 1; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.team-selector-chevron { + transition: transform 0.15s ease; + flex-shrink: 0; +} + +.team-selector-chevron.open { + transform: rotate(180deg); +} + +.team-selector-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + min-width: 240px; + margin-top: 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; + overflow: hidden; +} + +.team-selector-empty { + padding: 1rem; + text-align: center; + color: var(--text-muted); +} + +.team-selector-empty p { + margin: 0 0 0.75rem; + font-size: 0.875rem; +} + +.team-selector-create-link { + color: var(--accent-primary); + font-size: 0.875rem; + text-decoration: none; +} + +.team-selector-create-link:hover { + text-decoration: underline; +} + +.team-selector-list { + list-style: none; + margin: 0; + padding: 0.25rem 0; + max-height: 280px; + overflow-y: auto; +} + +.team-selector-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.75rem; + background: none; + border: none; + color: var(--text-primary); + font-size: 0.875rem; + cursor: pointer; + text-align: left; + transition: background 0.1s ease; +} + +.team-selector-item:hover { + background: var(--bg-hover); +} + +.team-selector-item.selected { + background: rgba(16, 185, 129, 0.1); +} + +.team-selector-item-info { + flex: 1; + min-width: 0; +} + +.team-selector-item-name { + display: block; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.team-selector-item-meta { + display: block; + font-size: 0.75rem; + color: var(--text-muted); +} + +.team-selector-item-role { + font-size: 0.75rem; + text-transform: capitalize; + flex-shrink: 0; +} + +.team-selector-footer { + display: flex; + justify-content: space-between; + padding: 0.5rem 0.75rem; + border-top: 1px solid var(--border-primary); + background: var(--bg-tertiary); +} + +.team-selector-link { + font-size: 0.8125rem; + color: var(--text-muted); + text-decoration: none; +} + +.team-selector-link:hover { + color: var(--text-primary); +} + +.team-selector-link-primary { + color: var(--accent-primary); +} + +.team-selector-link-primary:hover { + color: var(--accent-primary-hover); +} diff --git a/frontend/src/components/TeamSelector.tsx b/frontend/src/components/TeamSelector.tsx new file mode 100644 index 0000000..1ff641a --- /dev/null +++ b/frontend/src/components/TeamSelector.tsx @@ -0,0 +1,141 @@ +import { useState, useRef, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { useTeam } from '../contexts/TeamContext'; +import { useAuth } from '../contexts/AuthContext'; +import { TeamDetail } from '../types'; +import './TeamSelector.css'; + +export function TeamSelector() { + const { user } = useAuth(); + const { teams, currentTeam, loading, setCurrentTeam } = useTeam(); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Don't show if not authenticated + if (!user) { + return null; + } + + const handleTeamSelect = (team: TeamDetail) => { + setCurrentTeam(team); + setIsOpen(false); + }; + + const roleColors: Record = { + owner: 'var(--color-success)', + admin: 'var(--color-primary)', + member: 'var(--color-text-muted)', + }; + + return ( +
+ + + {isOpen && ( +
+ {teams.length === 0 ? ( +
+

You're not a member of any teams yet.

+ setIsOpen(false)} + > + Create your first team + +
+ ) : ( + <> +
    + {teams.map(team => ( +
  • + +
  • + ))} +
+
+ setIsOpen(false)} + > + View all teams + + setIsOpen(false)} + > + + New Team + +
+ + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/UserAutocomplete.css b/frontend/src/components/UserAutocomplete.css new file mode 100644 index 0000000..e334794 --- /dev/null +++ b/frontend/src/components/UserAutocomplete.css @@ -0,0 +1,105 @@ +.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; +} diff --git a/frontend/src/components/UserAutocomplete.tsx b/frontend/src/components/UserAutocomplete.tsx new file mode 100644 index 0000000..0b249d9 --- /dev/null +++ b/frontend/src/components/UserAutocomplete.tsx @@ -0,0 +1,171 @@ +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([]); + const [loading, setLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const containerRef = useRef(null); + const inputRef = useRef(null); + const debounceRef = useRef>(); + + // 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) => { + 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 ( +
+
+ query.length >= 1 && results.length > 0 && setIsOpen(true)} + placeholder={placeholder} + disabled={disabled} + autoFocus={autoFocus} + autoComplete="off" + className="user-autocomplete__input" + /> + {loading && ( +
+ )} +
+ + {isOpen && results.length > 0 && ( +
    + {results.map((user, index) => ( +
  • handleSelect(user)} + onMouseEnter={() => setSelectedIndex(index)} + > +
    + {user.username.charAt(0).toUpperCase()} +
    +
    + {user.username} + {user.is_admin && ( + Admin + )} +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/contexts/TeamContext.tsx b/frontend/src/contexts/TeamContext.tsx new file mode 100644 index 0000000..ac6297b --- /dev/null +++ b/frontend/src/contexts/TeamContext.tsx @@ -0,0 +1,110 @@ +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; +import { TeamDetail } from '../types'; +import { listTeams } from '../api'; +import { useAuth } from './AuthContext'; + +const SELECTED_TEAM_KEY = 'orchard_selected_team'; + +interface TeamContextType { + teams: TeamDetail[]; + currentTeam: TeamDetail | null; + loading: boolean; + error: string | null; + setCurrentTeam: (team: TeamDetail | null) => void; + refreshTeams: () => Promise; + clearError: () => void; +} + +const TeamContext = createContext(undefined); + +interface TeamProviderProps { + children: ReactNode; +} + +export function TeamProvider({ children }: TeamProviderProps) { + const { user } = useAuth(); + const [teams, setTeams] = useState([]); + const [currentTeam, setCurrentTeamState] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadTeams = useCallback(async () => { + if (!user) { + setTeams([]); + setCurrentTeamState(null); + return; + } + + setLoading(true); + setError(null); + try { + const response = await listTeams({ limit: 100 }); + setTeams(response.items); + + // Try to restore previously selected team + const savedSlug = localStorage.getItem(SELECTED_TEAM_KEY); + if (savedSlug) { + const savedTeam = response.items.find(t => t.slug === savedSlug); + if (savedTeam) { + setCurrentTeamState(savedTeam); + return; + } + } + + // Auto-select first team if none selected + if (response.items.length > 0 && !currentTeam) { + setCurrentTeamState(response.items[0]); + localStorage.setItem(SELECTED_TEAM_KEY, response.items[0].slug); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load teams'; + setError(message); + } finally { + setLoading(false); + } + }, [user, currentTeam]); + + // Load teams when user changes + useEffect(() => { + loadTeams(); + }, [user]); // eslint-disable-line react-hooks/exhaustive-deps + + const setCurrentTeam = useCallback((team: TeamDetail | null) => { + setCurrentTeamState(team); + if (team) { + localStorage.setItem(SELECTED_TEAM_KEY, team.slug); + } else { + localStorage.removeItem(SELECTED_TEAM_KEY); + } + }, []); + + const refreshTeams = useCallback(async () => { + await loadTeams(); + }, [loadTeams]); + + const clearError = useCallback(() => { + setError(null); + }, []); + + return ( + + {children} + + ); +} + +export function useTeam() { + const context = useContext(TeamContext); + if (context === undefined) { + throw new Error('useTeam must be used within a TeamProvider'); + } + return context; +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index fb6aeab..7b3792b 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -179,16 +179,18 @@ function Home() { )} -
- -
+ {user && ( +
+ +
+ )} - {hasActiveFilters && ( + {user && hasActiveFilters && ( {visibility && (
- {canAdmin && ( + {canAdmin && !project.team_id && (
- {/* Access Management Section */} - - {/* Danger Zone Section */}

Danger Zone

diff --git a/frontend/src/pages/TeamDashboardPage.css b/frontend/src/pages/TeamDashboardPage.css new file mode 100644 index 0000000..84115e1 --- /dev/null +++ b/frontend/src/pages/TeamDashboardPage.css @@ -0,0 +1,270 @@ +.team-dashboard { + padding: 1.5rem 0; +} + +.team-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1.5rem; + margin-bottom: 2rem; +} + +.team-header-left { + flex: 1; +} + +.team-header-title { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.team-header h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; +} + +.team-slug { + font-size: 0.875rem; + color: var(--text-muted); +} + +.team-description { + margin: 0 0 0.5rem; + color: var(--text-secondary); + font-size: 0.9375rem; + max-width: 600px; +} + +.team-header-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.team-section { + margin-top: 2rem; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.section-header h2 { + margin: 0; + font-size: 1.25rem; +} + +/* Table utility classes */ +.text-muted { + color: var(--text-muted); +} + +.btn-ghost { + background: transparent; + color: var(--text-muted); + border: none; + padding: 0.375rem; + cursor: pointer; + border-radius: var(--radius-sm); +} + +.btn-ghost:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.section-footer { + margin-top: 1rem; + text-align: center; +} + +.view-all-link { + font-size: 0.875rem; + color: var(--accent-primary); + text-decoration: none; +} + +.view-all-link:hover { + text-decoration: underline; +} + +/* States */ +.loading-state, +.error-state { + text-align: center; + padding: 4rem 2rem; +} + +.error-state h2 { + margin: 0 0 0.5rem; +} + +.error-state p { + margin: 0 0 1.5rem; + color: var(--text-muted); +} + +.empty-state { + text-align: center; + padding: 2rem; + background: var(--bg-secondary); + border: 1px dashed var(--border-primary); + border-radius: var(--radius-md); + color: var(--text-muted); +} + +.empty-state p { + margin: 0; +} + +.empty-hint { + margin-top: 0.5rem !important; + font-size: 0.875rem; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border: none; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: all 0.15s ease; +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; +} + +.btn-primary { + background: var(--accent-primary); + color: white; +} + +.btn-primary:hover { + background: var(--accent-primary-hover); +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-primary); +} + +.btn-secondary:hover { + background: var(--bg-hover); +} + +/* Modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal-content { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + 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 */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + 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-radius: var(--radius-md); + background: var(--bg-tertiary); + color: var(--text-primary); + 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); +} + +.form-group textarea { + resize: vertical; + min-height: 80px; +} + +.checkbox-group label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.checkbox-group input[type="checkbox"] { + width: 1rem; + height: 1rem; +} + +.form-hint { + display: block; + font-size: 0.8125rem; + color: var(--text-muted); + margin-top: 0.375rem; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.empty-state .btn { + margin-top: 1rem; +} diff --git a/frontend/src/pages/TeamDashboardPage.tsx b/frontend/src/pages/TeamDashboardPage.tsx new file mode 100644 index 0000000..00acbce --- /dev/null +++ b/frontend/src/pages/TeamDashboardPage.tsx @@ -0,0 +1,279 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Link, useParams, useNavigate } from 'react-router-dom'; +import { TeamDetail, Project, PaginatedResponse } from '../types'; +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() { + const { slug } = useParams<{ slug: string }>(); + const navigate = useNavigate(); + const { user } = useAuth(); + const [team, setTeam] = useState(null); + const [projects, setProjects] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showProjectForm, setShowProjectForm] = useState(false); + const [newProject, setNewProject] = useState({ name: '', description: '', is_public: true }); + const [creating, setCreating] = useState(false); + + const loadTeamData = useCallback(async () => { + if (!slug) return; + try { + setLoading(true); + const [teamData, projectsData] = await Promise.all([ + getTeam(slug), + listTeamProjects(slug, { limit: 10 }), + ]); + setTeam(teamData); + setProjects(projectsData); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load team'); + } finally { + setLoading(false); + } + }, [slug]); + + useEffect(() => { + loadTeamData(); + }, [loadTeamData]); + + async function handleCreateProject(e: React.FormEvent) { + e.preventDefault(); + if (!team) return; + try { + setCreating(true); + const project = await createProject({ ...newProject, team_id: team.id }); + setNewProject({ name: '', description: '', is_public: true }); + setShowProjectForm(false); + navigate(`/project/${project.name}`); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create project'); + } finally { + setCreating(false); + } + } + + if (loading) { + return ( +
+
Loading team...
+
+ ); + } + + if (error || !team) { + return ( +
+
+

Error loading team

+

{error || 'Team not found'}

+ Back to Teams +
+
+ ); + } + + const isAdminOrOwner = team.user_role === 'owner' || team.user_role === 'admin' || user?.is_admin; + + const roleVariants: Record = { + owner: 'success', + admin: 'info', + member: 'default', + }; + + return ( +
+ + +
+
+
+

{team.name}

+ {team.user_role && ( + + {team.user_role} + + )} + @{team.slug} +
+ {team.description && ( +

{team.description}

+ )} +
+ {isAdminOrOwner && ( +
+ + + + + + + + Members + + + + + + + Settings + +
+ )} +
+ + {showProjectForm && ( +
setShowProjectForm(false)}> +
e.stopPropagation()}> +

Create New Project

+
+
+ + setNewProject({ ...newProject, name: e.target.value })} + placeholder="my-project" + required + autoFocus + /> +
+
+ +