Add multi-tenancy with Teams feature
Implement team-based organization for projects with role-based access control: Backend: - Add teams and team_memberships database tables (migrations 009, 009b) - Add Team and TeamMembership ORM models with relationships - Implement TeamAuthorizationService for team-level access control - Add team CRUD, membership, and projects API endpoints - Update project creation to support team assignment Frontend: - Add TeamContext for managing team state with localStorage persistence - Add TeamSelector component for switching between teams - Add TeamsPage, TeamDashboardPage, TeamSettingsPage, TeamMembersPage - Add team API client functions - Update navigation with Teams link Security: - Team role hierarchy: owner > admin > member - Membership checked before system admin fallback - Self-modification prevention for role changes - Email visibility restricted to team admins/owners - Slug validation rejects consecutive hyphens Tests: - Unit tests for TeamAuthorizationService - Integration tests for all team API endpoints
This commit is contained in:
@@ -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
|
||||
@@ -1053,3 +1057,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user