9 Commits

Author SHA1 Message Date
Mondo Diaz
832e4b27a8 Add teams migration to runtime migrations
Add teams table, team_memberships table, and team_id column to projects
in the runtime migrations. This fixes startup failures on existing databases
that don't have the teams schema from the migration file.
2026-01-28 20:25:57 +00:00
Mondo Diaz
0a69910e8b Merge branch 'feature/multi-tenancy-teams' into 'main'
Add multi-tenancy with Teams feature

Closes #88, #89, #90, #91, #92, #93, #94, #95, #96, #97, #98, #99, #100, #101, #102, #103, and #104

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!48
2026-01-28 12:50:58 -06:00
Mondo Diaz
576791d19e Add multi-tenancy with Teams feature 2026-01-28 12:50:58 -06:00
Mondo Diaz
a5796f5437 Merge branch 'feature/stage-password-ci-var' into 'main'
Use CI variable for stage admin password

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!47
2026-01-27 15:44:35 -06:00
Mondo Diaz
284945ba33 Use CI variable for stage admin password 2026-01-27 15:44:34 -06:00
Mondo Diaz
fe07638485 Merge branch 'feature/admin-password-env-var' into 'main'
Add configurable admin password via environment variable

Closes #87

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!46
2026-01-27 14:23:41 -06:00
Mondo Diaz
7120cf64f1 Add configurable admin password via environment variable 2026-01-27 14:23:40 -06:00
Mondo Diaz
718e6e7193 Merge branch 'feature/package-dependencies' into 'main'
Add package dependencies system and project settings page

Closes #76, #77, #78, #79, #80, and #81

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!45
2026-01-27 10:11:04 -06:00
Mondo Diaz
abba90ebac Add package dependencies system and project settings page 2026-01-27 10:11:04 -06:00
49 changed files with 5926 additions and 175 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
# Orchard Local Development Environment
# Copy this file to .env and customize as needed
# Note: .env is gitignored and will not be committed
# Admin account password (required for local development)
# This sets the initial admin password when the database is first created
ORCHARD_ADMIN_PASSWORD=changeme123

View File

@@ -15,6 +15,7 @@ variables:
STAGE_RDS_HOST: orchard-stage.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com
STAGE_RDS_DBNAME: postgres
STAGE_SECRET_ARN: "arn:aws-us-gov:secretsmanager:us-gov-west-1:052673043337:secret:rds!cluster-a573672b-1a38-4665-a654-1b7df37b5297-IaeFQL"
STAGE_AUTH_SECRET_ARN: "arn:aws-us-gov:secretsmanager:us-gov-west-1:052673043337:secret:orchard-stage-creds-SMqvQx"
STAGE_S3_BUCKET: orchard-artifacts-stage
AWS_REGION: us-gov-west-1
# Shared pip cache directory
@@ -117,6 +118,9 @@ release:
- pip install --index-url "$PIP_INDEX_URL" pytest pytest-asyncio httpx
script:
- cd backend
# Debug: Print environment variables for test configuration
- echo "ORCHARD_TEST_URL=$ORCHARD_TEST_URL"
- echo "ORCHARD_TEST_PASSWORD is set to '${ORCHARD_TEST_PASSWORD:-NOT SET}'"
# Run full integration test suite, excluding:
# - large/slow tests
# - requires_direct_s3 tests (can't access MinIO from outside K8s cluster)
@@ -196,14 +200,13 @@ release:
sys.exit(0)
PYTEST_SCRIPT
# Integration tests for stage deployment (full suite)
# Reset stage template - shared by pre and post test reset jobs
# Reset stage template - runs from CI runner, uses CI variable for auth
# Calls the /api/v1/admin/factory-reset endpoint which handles DB and S3 cleanup
.reset_stage_template: &reset_stage_template
stage: deploy
image: deps.global.bsf.tools/docker/python:3.12-slim
timeout: 5m
retry: 1 # Retry once on transient failures
retry: 1
before_script:
- pip install --index-url "$PIP_INDEX_URL" httpx
script:
@@ -216,19 +219,22 @@ release:
BASE_URL = os.environ.get("STAGE_URL", "")
ADMIN_USER = "admin"
ADMIN_PASS = "changeme123" # Default admin password
ADMIN_PASS = os.environ.get("STAGE_ADMIN_PASSWORD", "")
MAX_RETRIES = 3
RETRY_DELAY = 5 # seconds
RETRY_DELAY = 5
if not BASE_URL:
print("ERROR: STAGE_URL environment variable not set")
print("ERROR: STAGE_URL not set")
sys.exit(1)
if not ADMIN_PASS:
print("ERROR: STAGE_ADMIN_PASSWORD not set")
sys.exit(1)
print(f"=== Resetting stage environment at {BASE_URL} ===")
def do_reset():
with httpx.Client(base_url=BASE_URL, timeout=120.0) as client:
# Login as admin
print("Logging in as admin...")
login_response = client.post(
"/api/v1/auth/login",
@@ -238,7 +244,6 @@ release:
raise Exception(f"Login failed: {login_response.status_code} - {login_response.text}")
print("Login successful")
# Call factory reset endpoint
print("Calling factory reset endpoint...")
reset_response = client.post(
"/api/v1/admin/factory-reset",
@@ -256,7 +261,6 @@ release:
else:
raise Exception(f"Factory reset failed: {reset_response.status_code} - {reset_response.text}")
# Retry loop
for attempt in range(1, MAX_RETRIES + 1):
try:
print(f"Attempt {attempt}/{MAX_RETRIES}")
@@ -280,12 +284,14 @@ reset_stage_pre:
<<: *reset_stage_template
needs: [deploy_stage]
# Integration tests for stage deployment (full suite)
# Integration tests for stage deployment
# Uses CI variable STAGE_ADMIN_PASSWORD (set in GitLab CI/CD settings)
integration_test_stage:
<<: *integration_test_template
needs: [reset_stage_pre]
variables:
ORCHARD_TEST_URL: $STAGE_URL
ORCHARD_TEST_PASSWORD: $STAGE_ADMIN_PASSWORD
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
@@ -297,11 +303,13 @@ reset_stage:
allow_failure: true # Don't fail pipeline if reset has issues
# Integration tests for feature deployment (full suite)
# Uses DEV_ADMIN_PASSWORD CI variable (same as deploy_feature)
integration_test_feature:
<<: *integration_test_template
needs: [deploy_feature]
variables:
ORCHARD_TEST_URL: https://orchard-$CI_COMMIT_REF_SLUG.common.global.bsf.tools
ORCHARD_TEST_PASSWORD: $DEV_ADMIN_PASSWORD
rules:
- if: '$CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != "main"'
when: on_success
@@ -422,6 +430,7 @@ deploy_stage:
--namespace $NAMESPACE \
-f $VALUES_FILE \
--set image.tag=git.linux-amd64-$CI_COMMIT_SHA \
--set orchard.auth.adminPassword=$STAGE_ADMIN_PASSWORD \
--wait \
--atomic \
--timeout 10m
@@ -453,6 +462,7 @@ deploy_feature:
--namespace $NAMESPACE \
-f $VALUES_FILE \
--set image.tag=git.linux-amd64-$CI_COMMIT_SHA \
--set orchard.auth.adminPassword=$DEV_ADMIN_PASSWORD \
--set ingress.hosts[0].host=orchard-$CI_COMMIT_REF_SLUG.common.global.bsf.tools \
--set ingress.tls[0].hosts[0]=orchard-$CI_COMMIT_REF_SLUG.common.global.bsf.tools \
--set ingress.tls[0].secretName=orchard-$CI_COMMIT_REF_SLUG-tls \

View File

@@ -7,6 +7,67 @@ 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
- Added Helm chart support for admin password via multiple sources (#87):
- `orchard.auth.adminPassword` - plain value (creates K8s secret)
- `orchard.auth.existingSecret` - reference existing K8s secret
- `orchard.auth.secretsManager` - AWS Secrets Manager integration
- Added `.env.example` template for local development (#87)
- Added `.env` file support in docker-compose.local.yml (#87)
- Added Project Settings page accessible to project admins (#65)
- General settings section for editing description and visibility
- Access Management section (moved from project page)
@@ -44,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

View File

@@ -360,21 +360,36 @@ def create_default_admin(db: Session) -> Optional[User]:
"""Create the default admin user if no users exist.
Returns the created user, or None if users already exist.
The admin password can be set via ORCHARD_ADMIN_PASSWORD environment variable.
If not set, defaults to 'changeme123' and requires password change on first login.
"""
# Check if any users exist
user_count = db.query(User).count()
if user_count > 0:
return None
settings = get_settings()
# Use configured password or default
password = settings.admin_password if settings.admin_password else "changeme123"
# Only require password change if using the default password
must_change = not settings.admin_password
# Create default admin
auth_service = AuthService(db)
admin = auth_service.create_user(
username="admin",
password="changeme123",
password=password,
is_admin=True,
must_change_password=True,
must_change_password=must_change,
)
if settings.admin_password:
logger.info("Created default admin user with configured password")
else:
logger.info("Created default admin user with default password (changeme123)")
return admin
@@ -648,9 +663,11 @@ class AuthorizationService:
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()
@@ -669,6 +686,23 @@ class AuthorizationService:
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)
@@ -682,8 +716,22 @@ class AuthorizationService:
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
@@ -869,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 ---

View File

@@ -53,6 +53,9 @@ class Settings(BaseSettings):
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
log_format: str = "auto" # "json", "standard", or "auto" (json in production)
# Initial admin user settings
admin_password: str = "" # Initial admin password (if empty, uses 'changeme123')
# JWT Authentication settings (optional, for external identity providers)
jwt_enabled: bool = False # Enable JWT token validation
jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS

View File

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

View File

@@ -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",
),
)

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -56,6 +56,26 @@ os.environ.setdefault("ORCHARD_S3_BUCKET", "test-bucket")
os.environ.setdefault("ORCHARD_S3_ACCESS_KEY_ID", "test")
os.environ.setdefault("ORCHARD_S3_SECRET_ACCESS_KEY", "test")
# =============================================================================
# Admin Credentials Helper
# =============================================================================
def get_admin_password() -> str:
"""Get the admin password for test authentication.
Returns the password from ORCHARD_TEST_PASSWORD environment variable,
or 'changeme123' as the default for local development.
"""
return os.environ.get("ORCHARD_TEST_PASSWORD", "changeme123")
def get_admin_username() -> str:
"""Get the admin username for test authentication."""
return os.environ.get("ORCHARD_TEST_USERNAME", "admin")
# Re-export factory functions for backward compatibility
from tests.factories import (
create_test_file,

View File

@@ -8,6 +8,8 @@ allow these tests to run. Production uses strict rate limits (5/minute).
import pytest
from uuid import uuid4
from tests.conftest import get_admin_password, get_admin_username
# Mark all tests in this module as auth_intensive (informational, not excluded from CI)
pytestmark = pytest.mark.auth_intensive
@@ -21,11 +23,11 @@ class TestAuthLogin:
"""Test successful login with default admin credentials."""
response = auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
assert response.status_code == 200
data = response.json()
assert data["username"] == "admin"
assert data["username"] == get_admin_username()
assert data["is_admin"] is True
assert "orchard_session" in response.cookies
@@ -34,7 +36,7 @@ class TestAuthLogin:
"""Test login with wrong password."""
response = auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "wrongpassword"},
json={"username": get_admin_username(), "password": "wrongpassword"},
)
assert response.status_code == 401
assert "Invalid username or password" in response.json()["detail"]
@@ -58,7 +60,7 @@ class TestAuthLogout:
# First login
login_response = auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
assert login_response.status_code == 200
@@ -84,13 +86,13 @@ class TestAuthMe:
# Login first
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
response = auth_client.get("/api/v1/auth/me")
assert response.status_code == 200
data = response.json()
assert data["username"] == "admin"
assert data["username"] == get_admin_username()
assert data["is_admin"] is True
assert "id" in data
assert "created_at" in data
@@ -119,7 +121,7 @@ class TestAuthChangePassword:
# Login as admin to create a test user
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
test_username = f"pwchange_{uuid4().hex[:8]}"
auth_client.post(
@@ -162,7 +164,7 @@ class TestAuthChangePassword:
# Login as admin to create a test user
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
test_username = f"pwwrong_{uuid4().hex[:8]}"
auth_client.post(
@@ -194,7 +196,7 @@ class TestAPIKeys:
# Login first
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
# Create API key
@@ -226,7 +228,7 @@ class TestAPIKeys:
# Login and create API key
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
create_response = auth_client.post(
"/api/v1/auth/keys",
@@ -242,12 +244,12 @@ class TestAPIKeys:
headers={"Authorization": f"Bearer {api_key}"},
)
assert response.status_code == 200
assert response.json()["username"] == "admin"
assert response.json()["username"] == get_admin_username()
# Clean up
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
auth_client.delete(f"/api/v1/auth/keys/{key_id}")
@@ -257,7 +259,7 @@ class TestAPIKeys:
# Login and create API key
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
create_response = auth_client.post(
"/api/v1/auth/keys",
@@ -288,14 +290,14 @@ class TestAdminUserManagement:
# Login as admin
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
response = auth_client.get("/api/v1/admin/users")
assert response.status_code == 200
users = response.json()
assert len(users) >= 1
assert any(u["username"] == "admin" for u in users)
assert any(u["username"] == get_admin_username() for u in users)
@pytest.mark.integration
def test_create_user(self, auth_client):
@@ -303,7 +305,7 @@ class TestAdminUserManagement:
# Login as admin
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
# Create new user
@@ -336,7 +338,7 @@ class TestAdminUserManagement:
# Login as admin
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
# Create a test user
@@ -362,7 +364,7 @@ class TestAdminUserManagement:
# Login as admin
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
# Create a test user
@@ -393,7 +395,7 @@ class TestAdminUserManagement:
# Login as admin and create non-admin user
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
test_username = f"nonadmin_{uuid4().hex[:8]}"
auth_client.post(
@@ -423,7 +425,7 @@ class TestSecurityEdgeCases:
# Login as admin and create a user
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
test_username = f"inactive_{uuid4().hex[:8]}"
auth_client.post(
@@ -451,7 +453,7 @@ class TestSecurityEdgeCases:
"""Test that short passwords are rejected when creating users."""
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
response = auth_client.post(
@@ -467,7 +469,7 @@ class TestSecurityEdgeCases:
# Create test user
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
test_username = f"shortchange_{uuid4().hex[:8]}"
auth_client.post(
@@ -494,7 +496,7 @@ class TestSecurityEdgeCases:
"""Test that short passwords are rejected when resetting password."""
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
# Create a test user first
@@ -516,7 +518,7 @@ class TestSecurityEdgeCases:
"""Test that duplicate usernames are rejected."""
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
test_username = f"duplicate_{uuid4().hex[:8]}"
@@ -541,7 +543,7 @@ class TestSecurityEdgeCases:
# Login as admin and create an API key
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
create_response = auth_client.post(
"/api/v1/auth/keys",
@@ -572,7 +574,7 @@ class TestSecurityEdgeCases:
auth_client.cookies.clear()
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
auth_client.delete(f"/api/v1/auth/keys/{admin_key_id}")
@@ -582,7 +584,7 @@ class TestSecurityEdgeCases:
# Create a test user
auth_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
json={"username": get_admin_username(), "password": get_admin_password()},
)
test_username = f"sessiontest_{uuid4().hex[:8]}"
auth_client.post(

View File

@@ -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}")

View File

@@ -0,0 +1,95 @@
"""Unit tests for authentication module."""
import pytest
from unittest.mock import patch, MagicMock
class TestCreateDefaultAdmin:
"""Tests for the create_default_admin function."""
def test_create_default_admin_with_env_password(self):
"""Test that ORCHARD_ADMIN_PASSWORD env var sets admin password."""
from app.auth import create_default_admin, verify_password
# Create mock settings with custom password
mock_settings = MagicMock()
mock_settings.admin_password = "my-custom-password-123"
# Mock database session
mock_db = MagicMock()
mock_db.query.return_value.count.return_value = 0 # No existing users
# Track the user that gets created
created_user = None
def capture_user(user):
nonlocal created_user
created_user = user
mock_db.add.side_effect = capture_user
with patch("app.auth.get_settings", return_value=mock_settings):
admin = create_default_admin(mock_db)
# Verify the user was created
assert mock_db.add.called
assert created_user is not None
assert created_user.username == "admin"
assert created_user.is_admin is True
# Password should NOT require change when set via env var
assert created_user.must_change_password is False
# Verify password was hashed correctly
assert verify_password("my-custom-password-123", created_user.password_hash)
def test_create_default_admin_with_default_password(self):
"""Test that default password 'changeme123' is used when env var not set."""
from app.auth import create_default_admin, verify_password
# Create mock settings with empty password (default)
mock_settings = MagicMock()
mock_settings.admin_password = ""
# Mock database session
mock_db = MagicMock()
mock_db.query.return_value.count.return_value = 0 # No existing users
# Track the user that gets created
created_user = None
def capture_user(user):
nonlocal created_user
created_user = user
mock_db.add.side_effect = capture_user
with patch("app.auth.get_settings", return_value=mock_settings):
admin = create_default_admin(mock_db)
# Verify the user was created
assert mock_db.add.called
assert created_user is not None
assert created_user.username == "admin"
assert created_user.is_admin is True
# Password SHOULD require change when using default
assert created_user.must_change_password is True
# Verify default password was used
assert verify_password("changeme123", created_user.password_hash)
def test_create_default_admin_skips_when_users_exist(self):
"""Test that no admin is created when users already exist."""
from app.auth import create_default_admin
# Create mock settings
mock_settings = MagicMock()
mock_settings.admin_password = "some-password"
# Mock database session with existing users
mock_db = MagicMock()
mock_db.query.return_value.count.return_value = 1 # Users exist
with patch("app.auth.get_settings", return_value=mock_settings):
result = create_default_admin(mock_db)
# Should return None and not create any user
assert result is None
assert not mock_db.add.called

View File

@@ -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

View File

@@ -26,6 +26,8 @@ services:
- ORCHARD_REDIS_PORT=6379
# Higher rate limit for local development/testing
- ORCHARD_LOGIN_RATE_LIMIT=1000/minute
# Admin password - set in .env file or environment (see .env.example)
- ORCHARD_ADMIN_PASSWORD=${ORCHARD_ADMIN_PASSWORD:-}
depends_on:
postgres:
condition: service_healthy

View File

@@ -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() {
<Route path="/settings/api-keys" element={<APIKeysPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/oidc" element={<AdminOIDCPage />} />
<Route path="/teams" element={<TeamsPage />} />
<Route path="/teams/:slug" element={<TeamDashboardPage />} />
<Route path="/teams/:slug/settings" element={<TeamSettingsPage />} />
<Route path="/teams/:slug/members" element={<TeamMembersPage />} />
<Route path="/project/:projectName" element={<ProjectPage />} />
<Route path="/project/:projectName/settings" element={<ProjectSettingsPage />} />
<Route path="/project/:projectName/:packageName" element={<PackagePage />} />
@@ -60,7 +69,9 @@ function AppRoutes() {
function App() {
return (
<AuthProvider>
<AppRoutes />
<TeamProvider>
<AppRoutes />
</TeamProvider>
</AuthProvider>
);
}

View File

@@ -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<Proje
return data.items;
}
export async function createProject(data: { name: string; description?: string; is_public?: boolean }): Promise<Project> {
export async function createProject(data: { name: string; description?: string; is_public?: boolean; team_id?: string }): Promise<Project> {
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<PaginatedResponse<TeamDetail>> {
const query = buildQueryString(params as Record<string, unknown>);
const response = await fetch(`${API_BASE}/teams${query}`, {
credentials: 'include',
});
return handleResponse<PaginatedResponse<TeamDetail>>(response);
}
export async function createTeam(data: TeamCreate): Promise<TeamDetail> {
const response = await fetch(`${API_BASE}/teams`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include',
});
return handleResponse<TeamDetail>(response);
}
export async function getTeam(slug: string): Promise<TeamDetail> {
const response = await fetch(`${API_BASE}/teams/${slug}`, {
credentials: 'include',
});
return handleResponse<TeamDetail>(response);
}
export async function updateTeam(slug: string, data: TeamUpdate): Promise<TeamDetail> {
const response = await fetch(`${API_BASE}/teams/${slug}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include',
});
return handleResponse<TeamDetail>(response);
}
export async function deleteTeam(slug: string): Promise<void> {
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<TeamMember[]> {
const response = await fetch(`${API_BASE}/teams/${slug}/members`, {
credentials: 'include',
});
return handleResponse<TeamMember[]>(response);
}
export async function addTeamMember(slug: string, data: TeamMemberCreate): Promise<TeamMember> {
const response = await fetch(`${API_BASE}/teams/${slug}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include',
});
return handleResponse<TeamMember>(response);
}
export async function updateTeamMember(
slug: string,
username: string,
data: TeamMemberUpdate
): Promise<TeamMember> {
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<TeamMember>(response);
}
export async function removeTeamMember(slug: string, username: string): Promise<void> {
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<PaginatedResponse<Project>> {
const query = buildQueryString(params as Record<string, unknown>);
const response = await fetch(`${API_BASE}/teams/${slug}/projects${query}`, {
credentials: 'include',
});
return handleResponse<PaginatedResponse<Project>>(response);
}
// User search (for autocomplete)
export interface UserSearchResult {
id: string;
username: string;
is_admin: boolean;
}
export async function searchUsers(query: string, limit: number = 10): Promise<UserSearchResult[]> {
const response = await fetch(`${API_BASE}/users/search?q=${encodeURIComponent(query)}&limit=${limit}`, {
credentials: 'include',
});
return handleResponse<UserSearchResult[]>(response);
}

View File

@@ -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;
}

View File

@@ -208,85 +208,104 @@ export function AccessManagement({ projectName }: AccessManagementProps) {
<tr>
<th>User</th>
<th>Access Level</th>
<th>Source</th>
<th>Granted</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{permissions.map((p) => (
<tr key={p.id}>
<td>{p.user_id}</td>
<td>
{editingUser === p.user_id ? (
<select
value={editLevel}
onChange={(e) => setEditLevel(e.target.value as AccessLevel)}
disabled={submitting}
>
<option value="read">Read</option>
<option value="write">Write</option>
<option value="admin">Admin</option>
</select>
) : (
<span className={`access-badge access-badge--${p.level}`}>
{p.level}
</span>
)}
</td>
<td>{new Date(p.created_at).toLocaleDateString()}</td>
<td>
{editingUser === p.user_id ? (
<input
type="date"
value={editExpiresAt}
onChange={(e) => setEditExpiresAt(e.target.value)}
disabled={submitting}
min={new Date().toISOString().split('T')[0]}
/>
) : (
formatExpiration(p.expires_at)
)}
</td>
<td className="actions">
{editingUser === p.user_id ? (
<>
<button
className="btn btn-sm btn-primary"
onClick={() => handleUpdate(p.user_id)}
{permissions.map((p) => {
const isTeamBased = p.source === 'team';
return (
<tr key={p.id} className={isTeamBased ? 'team-access-row' : ''}>
<td>{p.user_id}</td>
<td>
{editingUser === p.user_id && !isTeamBased ? (
<select
value={editLevel}
onChange={(e) => setEditLevel(e.target.value as AccessLevel)}
disabled={submitting}
>
Save
</button>
<button
className="btn btn-sm"
onClick={cancelEdit}
<option value="read">Read</option>
<option value="write">Write</option>
<option value="admin">Admin</option>
</select>
) : (
<span className={`access-badge access-badge--${p.level}`}>
{p.level}
</span>
)}
</td>
<td>
{isTeamBased ? (
<span className="access-source access-source--team" title={`Team role: ${p.team_role}`}>
Team: {p.team_slug}
</span>
) : (
<span className="access-source access-source--explicit">
Explicit
</span>
)}
</td>
<td>{new Date(p.created_at).toLocaleDateString()}</td>
<td>
{editingUser === p.user_id && !isTeamBased ? (
<input
type="date"
value={editExpiresAt}
onChange={(e) => setEditExpiresAt(e.target.value)}
disabled={submitting}
>
Cancel
</button>
</>
) : (
<>
<button
className="btn btn-sm"
onClick={() => startEdit(p)}
disabled={submitting}
>
Edit
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => handleRevoke(p.user_id)}
disabled={submitting}
>
Revoke
</button>
</>
)}
</td>
</tr>
))}
min={new Date().toISOString().split('T')[0]}
/>
) : (
formatExpiration(p.expires_at)
)}
</td>
<td className="actions">
{isTeamBased ? (
<span className="text-muted" title="Manage access via team settings">
Via team
</span>
) : editingUser === p.user_id ? (
<>
<button
className="btn btn-sm btn-primary"
onClick={() => handleUpdate(p.user_id)}
disabled={submitting}
>
Save
</button>
<button
className="btn btn-sm"
onClick={cancelEdit}
disabled={submitting}
>
Cancel
</button>
</>
) : (
<>
<button
className="btn btn-sm"
onClick={() => startEdit(p)}
disabled={submitting}
>
Edit
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => handleRevoke(p.user_id)}
disabled={submitting}
>
Revoke
</button>
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}

View File

@@ -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;

View File

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

View File

@@ -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);
}

View File

@@ -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<HTMLDivElement>(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<string, string> = {
owner: 'var(--color-success)',
admin: 'var(--color-primary)',
member: 'var(--color-text-muted)',
};
return (
<div className="team-selector" ref={dropdownRef}>
<button
className="team-selector-trigger"
onClick={() => setIsOpen(!isOpen)}
disabled={loading}
aria-expanded={isOpen}
aria-haspopup="listbox"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span className="team-selector-name">
{loading ? 'Loading...' : currentTeam?.name || 'Select Team'}
</span>
<svg
className={`team-selector-chevron ${isOpen ? 'open' : ''}`}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{isOpen && (
<div className="team-selector-dropdown" role="listbox">
{teams.length === 0 ? (
<div className="team-selector-empty">
<p>You're not a member of any teams yet.</p>
<Link
to="/teams/new"
className="team-selector-create-link"
onClick={() => setIsOpen(false)}
>
Create your first team
</Link>
</div>
) : (
<>
<ul className="team-selector-list">
{teams.map(team => (
<li key={team.id}>
<button
className={`team-selector-item ${currentTeam?.id === team.id ? 'selected' : ''}`}
onClick={() => handleTeamSelect(team)}
role="option"
aria-selected={currentTeam?.id === team.id}
>
<div className="team-selector-item-info">
<span className="team-selector-item-name">{team.name}</span>
<span className="team-selector-item-meta">
{team.project_count} project{team.project_count !== 1 ? 's' : ''}
</span>
</div>
{team.user_role && (
<span
className="team-selector-item-role"
style={{ color: roleColors[team.user_role] || roleColors.member }}
>
{team.user_role}
</span>
)}
{currentTeam?.id === team.id && (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</button>
</li>
))}
</ul>
<div className="team-selector-footer">
<Link
to="/teams"
className="team-selector-link"
onClick={() => setIsOpen(false)}
>
View all teams
</Link>
<Link
to="/teams/new"
className="team-selector-link team-selector-link-primary"
onClick={() => setIsOpen(false)}
>
+ New Team
</Link>
</div>
</>
)}
</div>
)}
</div>
);
}

View File

@@ -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;
}

View File

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

View File

@@ -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<void>;
clearError: () => void;
}
const TeamContext = createContext<TeamContextType | undefined>(undefined);
interface TeamProviderProps {
children: ReactNode;
}
export function TeamProvider({ children }: TeamProviderProps) {
const { user } = useAuth();
const [teams, setTeams] = useState<TeamDetail[]>([]);
const [currentTeam, setCurrentTeamState] = useState<TeamDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<TeamContext.Provider value={{
teams,
currentTeam,
loading,
error,
setCurrentTeam,
refreshTeams,
clearError,
}}>
{children}
</TeamContext.Provider>
);
}
export function useTeam() {
const context = useContext(TeamContext);
if (context === undefined) {
throw new Error('useTeam must be used within a TeamProvider');
}
return context;
}

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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<TeamDetail | null>(null);
const [projects, setProjects] = useState<PaginatedResponse<Project> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="team-dashboard">
<div className="loading-state">Loading team...</div>
</div>
);
}
if (error || !team) {
return (
<div className="team-dashboard">
<div className="error-state">
<h2>Error loading team</h2>
<p>{error || 'Team not found'}</p>
<Link to="/teams" className="btn btn-primary">Back to Teams</Link>
</div>
</div>
);
}
const isAdminOrOwner = team.user_role === 'owner' || team.user_role === 'admin' || user?.is_admin;
const roleVariants: Record<string, 'success' | 'info' | 'default'> = {
owner: 'success',
admin: 'info',
member: 'default',
};
return (
<div className="team-dashboard">
<Breadcrumb
items={[
{ label: 'Teams', href: '/teams' },
{ label: team.name },
]}
/>
<div className="team-header">
<div className="team-header-left">
<div className="team-header-title">
<h1>{team.name}</h1>
{team.user_role && (
<Badge variant={roleVariants[team.user_role] || 'default'}>
{team.user_role}
</Badge>
)}
<span className="team-slug">@{team.slug}</span>
</div>
{team.description && (
<p className="team-description">{team.description}</p>
)}
</div>
{isAdminOrOwner && (
<div className="team-header-actions">
<Link to={`/teams/${slug}/members`} className="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Members
</Link>
<Link to={`/teams/${slug}/settings`} className="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Settings
</Link>
</div>
)}
</div>
{showProjectForm && (
<div className="modal-overlay" onClick={() => setShowProjectForm(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<h2>Create New Project</h2>
<form onSubmit={handleCreateProject}>
<div className="form-group">
<label htmlFor="project-name">Project Name</label>
<input
id="project-name"
type="text"
value={newProject.name}
onChange={e => setNewProject({ ...newProject, name: e.target.value })}
placeholder="my-project"
required
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="project-description">Description (optional)</label>
<textarea
id="project-description"
value={newProject.description}
onChange={e => setNewProject({ ...newProject, description: e.target.value })}
placeholder="What is this project for?"
rows={3}
/>
</div>
<div className="form-group checkbox-group">
<label>
<input
type="checkbox"
checked={newProject.is_public}
onChange={e => setNewProject({ ...newProject, is_public: e.target.checked })}
/>
Public project
</label>
<span className="form-hint">Public projects are visible to everyone</span>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowProjectForm(false)}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={creating}>
{creating ? 'Creating...' : 'Create Project'}
</button>
</div>
</form>
</div>
</div>
)}
<div className="team-section">
<div className="section-header">
<h2>Projects</h2>
{isAdminOrOwner && (
<button className="btn btn-primary btn-sm" onClick={() => setShowProjectForm(true)}>
+ New Project
</button>
)}
</div>
{projects?.items.length === 0 ? (
<div className="empty-state">
<p>No projects in this team yet.</p>
{isAdminOrOwner && (
<button className="btn btn-primary" onClick={() => setShowProjectForm(true)}>
Create Project
</button>
)}
</div>
) : (
<DataTable
data={projects?.items || []}
keyExtractor={(project) => project.id}
onRowClick={(project) => navigate(`/project/${project.name}`)}
columns={[
{
key: 'name',
header: 'Name',
render: (project) => (
<Link
to={`/project/${project.name}`}
className="cell-name"
onClick={(e) => e.stopPropagation()}
>
{project.name}
</Link>
),
},
{
key: 'description',
header: 'Description',
className: 'cell-description',
render: (project) => project.description || <span className="text-muted"></span>,
},
{
key: 'visibility',
header: 'Visibility',
render: (project) => (
<Badge variant={project.is_public ? 'public' : 'private'}>
{project.is_public ? 'Public' : 'Private'}
</Badge>
),
},
{
key: 'created_by',
header: 'Created By',
render: (project) => <span className="text-muted">{project.created_by}</span>,
},
...(isAdminOrOwner ? [{
key: 'actions',
header: '',
render: (project: Project) => (
<button
className="btn btn-sm btn-ghost"
onClick={(e) => {
e.stopPropagation();
navigate(`/project/${project.name}/settings`);
}}
title="Settings"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
),
}] : []),
]}
/>
)}
{projects && projects.pagination.total > 10 && (
<div className="section-footer">
<Link to={`/teams/${slug}/projects`} className="view-all-link">
View all {projects.pagination.total} projects
</Link>
</div>
)}
</div>
</div>
);
}
export default TeamDashboardPage;

View File

@@ -0,0 +1,247 @@
.team-members {
padding: 1.5rem 0;
max-width: 800px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
}
.page-header h1 {
margin: 0;
font-size: 1.75rem;
}
/* Member cell in table */
.member-cell {
display: flex;
align-items: center;
gap: 0.75rem;
}
.member-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 1rem;
flex-shrink: 0;
}
.member-details {
display: flex;
flex-direction: column;
min-width: 0;
}
.member-username {
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
.you-badge {
font-size: 0.75rem;
font-weight: normal;
color: var(--text-muted);
}
.member-email {
font-size: 0.8125rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-muted {
color: var(--text-muted);
}
.role-select {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
}
.role-select:focus {
outline: none;
border-color: var(--accent-primary);
}
/* Messages */
.error-message {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: var(--error-bg);
border: 1px solid var(--error);
border-radius: var(--radius-md);
color: var(--error);
font-size: 0.875rem;
}
.error-dismiss {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: inherit;
padding: 0;
line-height: 1;
}
/* 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);
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 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: 400px;
box-shadow: var(--shadow-lg);
}
.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.375rem;
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary);
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.9375rem;
background: var(--bg-tertiary);
color: var(--text-primary);
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
/* 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:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-primary-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
}
.btn-icon {
padding: 0.375rem;
}
.btn-danger-ghost {
background: transparent;
color: var(--text-muted);
}
.btn-danger-ghost:hover:not(:disabled) {
background: var(--error-bg);
color: var(--error);
}

View File

@@ -0,0 +1,311 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import { TeamDetail, TeamMember, TeamMemberCreate, TeamRole } from '../types';
import {
getTeam,
listTeamMembers,
addTeamMember,
updateTeamMember,
removeTeamMember,
} from '../api';
import { useAuth } from '../contexts/AuthContext';
import { Badge } from '../components/Badge';
import { Breadcrumb } from '../components/Breadcrumb';
import { DataTable } from '../components/DataTable';
import { UserAutocomplete } from '../components/UserAutocomplete';
import './TeamMembersPage.css';
function TeamMembersPage() {
const { slug } = useParams<{ slug: string }>();
const { user } = useAuth();
const [team, setTeam] = useState<TeamDetail | null>(null);
const [members, setMembers] = useState<TeamMember[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [adding, setAdding] = useState(false);
const [newMember, setNewMember] = useState<TeamMemberCreate>({ username: '', role: 'member' });
const [editingMember, setEditingMember] = useState<string | null>(null);
const [removingMember, setRemovingMember] = useState<string | null>(null);
const loadData = useCallback(async () => {
if (!slug) return;
try {
setLoading(true);
const [teamData, membersData] = await Promise.all([
getTeam(slug),
listTeamMembers(slug),
]);
setTeam(teamData);
setMembers(membersData);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load team');
} finally {
setLoading(false);
}
}, [slug]);
useEffect(() => {
loadData();
}, [loadData]);
async function handleAddMember(e: React.FormEvent) {
e.preventDefault();
if (!slug) return;
try {
setAdding(true);
setError(null);
await addTeamMember(slug, newMember);
setNewMember({ username: '', role: 'member' });
setShowAddForm(false);
loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add member');
} finally {
setAdding(false);
}
}
async function handleRoleChange(username: string, newRole: TeamRole) {
if (!slug) return;
try {
setEditingMember(username);
setError(null);
await updateTeamMember(slug, username, { role: newRole });
loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update member');
} finally {
setEditingMember(null);
}
}
async function handleRemoveMember(username: string) {
if (!slug) return;
if (!confirm(`Remove ${username} from the team?`)) return;
try {
setRemovingMember(username);
setError(null);
await removeTeamMember(slug, username);
loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove member');
} finally {
setRemovingMember(null);
}
}
if (loading) {
return (
<div className="team-members">
<div className="loading-state">Loading team members...</div>
</div>
);
}
if (error && !team) {
return (
<div className="team-members">
<div className="error-state">
<h2>Error loading team</h2>
<p>{error}</p>
<Link to="/teams" className="btn btn-primary">Back to Teams</Link>
</div>
</div>
);
}
if (!team) return null;
const isOwner = team.user_role === 'owner' || user?.is_admin;
const isAdmin = team.user_role === 'admin' || isOwner;
const roleVariants: Record<string, 'success' | 'info' | 'default'> = {
owner: 'success',
admin: 'info',
member: 'default',
};
const roles: TeamRole[] = ['owner', 'admin', 'member'];
return (
<div className="team-members">
<Breadcrumb
items={[
{ label: 'Teams', href: '/teams' },
{ label: team.name, href: `/teams/${slug}` },
{ label: 'Members' },
]}
/>
<div className="page-header">
<h1>Team Members</h1>
{isAdmin && (
<button className="btn btn-primary" onClick={() => setShowAddForm(true)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
Invite Member
</button>
)}
</div>
{error && (
<div className="error-message">
{error}
<button onClick={() => setError(null)} className="error-dismiss">&times;</button>
</div>
)}
{showAddForm && (
<div className="modal-overlay" onClick={() => setShowAddForm(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<h2>Invite Member</h2>
<form onSubmit={handleAddMember}>
<div className="form-group">
<label htmlFor="username">Username</label>
<UserAutocomplete
value={newMember.username}
onChange={(username) => setNewMember({ ...newMember, username })}
placeholder="Search for a user..."
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="role">Role</label>
<select
id="role"
value={newMember.role}
onChange={e => setNewMember({ ...newMember, role: e.target.value as TeamRole })}
>
<option value="member">Member - Can view team projects</option>
<option value="admin">Admin - Can manage team settings and members</option>
{isOwner && (
<option value="owner">Owner - Full control, can delete team</option>
)}
</select>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowAddForm(false)}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={adding}>
{adding ? 'Adding...' : 'Add Member'}
</button>
</div>
</form>
</div>
</div>
)}
<DataTable
data={members}
keyExtractor={(member) => member.id}
emptyMessage="No members in this team yet."
columns={[
{
key: 'member',
header: 'Member',
render: (member) => {
const isCurrentUser = user?.username === member.username;
return (
<div className="member-cell">
<div className="member-avatar">
{member.username.charAt(0).toUpperCase()}
</div>
<div className="member-details">
<span className="member-username">
{member.username}
{isCurrentUser && <span className="you-badge">(you)</span>}
</span>
{member.email && (
<span className="member-email">{member.email}</span>
)}
</div>
</div>
);
},
},
{
key: 'role',
header: 'Role',
render: (member) => {
const isCurrentUser = user?.username === member.username;
const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner');
if (canModify) {
return (
<select
value={member.role}
onChange={e => handleRoleChange(member.username, e.target.value as TeamRole)}
disabled={editingMember === member.username}
className="role-select"
onClick={e => e.stopPropagation()}
>
{roles.map(role => (
<option
key={role}
value={role}
disabled={role === 'owner' && !isOwner}
>
{role.charAt(0).toUpperCase() + role.slice(1)}
</option>
))}
</select>
);
}
return (
<Badge variant={roleVariants[member.role] || 'default'}>
{member.role}
</Badge>
);
},
},
{
key: 'joined',
header: 'Joined',
render: (member) => (
<span className="text-muted">
{new Date(member.created_at).toLocaleDateString()}
</span>
),
},
...(isAdmin ? [{
key: 'actions',
header: '',
render: (member: TeamMember) => {
const isCurrentUser = user?.username === member.username;
const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner');
if (!canModify) return null;
return (
<button
className="btn btn-icon btn-danger-ghost"
onClick={(e) => {
e.stopPropagation();
handleRemoveMember(member.username);
}}
disabled={removingMember === member.username}
title="Remove member"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
);
},
}] : []),
]}
/>
</div>
);
}
export default TeamMembersPage;

View File

@@ -0,0 +1,239 @@
.team-settings {
padding: 1.5rem 0;
max-width: 640px;
margin: 0 auto;
}
.team-settings h1 {
margin: 0 0 1.5rem;
font-size: 1.75rem;
}
.settings-form {
margin-bottom: 2rem;
}
.form-section {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.form-section h2 {
margin: 0 0 1rem;
font-size: 1.125rem;
color: var(--text-primary);
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.9375rem;
background: var(--bg-tertiary);
color: var(--text-primary);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
}
.input-disabled {
background: var(--bg-elevated) !important;
color: var(--text-muted) !important;
cursor: not-allowed;
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8125rem;
color: var(--text-muted);
}
/* Danger zone */
.danger-zone {
border-color: var(--error);
background: var(--error-bg);
}
.danger-zone h2 {
color: var(--error);
}
.danger-warning {
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Messages */
.error-message {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: var(--error-bg);
border: 1px solid var(--error);
border-radius: var(--radius-md);
color: var(--error);
font-size: 0.875rem;
}
.error-dismiss {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: inherit;
padding: 0;
line-height: 1;
}
.success-message {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: var(--success-bg);
border: 1px solid var(--success);
border-radius: var(--radius-md);
color: var(--success);
font-size: 0.875rem;
}
/* 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);
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 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: 400px;
box-shadow: var(--shadow-lg);
}
.modal-content h2 {
margin: 0 0 1rem;
font-size: 1.25rem;
color: var(--error);
}
.modal-content p {
margin: 0 0 1rem;
font-size: 0.9375rem;
color: var(--text-secondary);
}
.delete-confirm-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.9375rem;
margin-bottom: 1rem;
background: var(--bg-tertiary);
color: var(--text-primary);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* 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:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-primary-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
}
.btn-danger {
background: var(--error);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}

View File

@@ -0,0 +1,251 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { TeamDetail, TeamUpdate } from '../types';
import { getTeam, updateTeam, deleteTeam } from '../api';
import { useAuth } from '../contexts/AuthContext';
import { Breadcrumb } from '../components/Breadcrumb';
import './TeamSettingsPage.css';
function TeamSettingsPage() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const [team, setTeam] = useState<TeamDetail | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [formData, setFormData] = useState<TeamUpdate>({
name: '',
description: '',
});
const loadTeam = useCallback(async () => {
if (!slug) return;
try {
setLoading(true);
const teamData = await getTeam(slug);
setTeam(teamData);
setFormData({
name: teamData.name,
description: teamData.description || '',
});
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load team');
} finally {
setLoading(false);
}
}, [slug]);
useEffect(() => {
loadTeam();
}, [loadTeam]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!slug || !team) return;
try {
setSaving(true);
setError(null);
const updatedTeam = await updateTeam(slug, formData);
setTeam(updatedTeam);
setSuccessMessage('Settings saved successfully');
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save settings');
} finally {
setSaving(false);
}
}
async function handleDelete() {
if (!slug || !team) return;
if (deleteConfirmText !== team.slug) return;
try {
setDeleting(true);
await deleteTeam(slug);
navigate('/teams');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete team');
setShowDeleteConfirm(false);
} finally {
setDeleting(false);
}
}
if (loading) {
return (
<div className="team-settings">
<div className="loading-state">Loading team settings...</div>
</div>
);
}
if (error && !team) {
return (
<div className="team-settings">
<div className="error-state">
<h2>Error loading team</h2>
<p>{error}</p>
<Link to="/teams" className="btn btn-primary">Back to Teams</Link>
</div>
</div>
);
}
if (!team) return null;
const isOwner = team.user_role === 'owner' || user?.is_admin;
const isAdmin = team.user_role === 'admin' || isOwner;
if (!isAdmin) {
return (
<div className="team-settings">
<div className="error-state">
<h2>Access Denied</h2>
<p>You need admin privileges to access team settings.</p>
<Link to={`/teams/${slug}`} className="btn btn-primary">Back to Team</Link>
</div>
</div>
);
}
return (
<div className="team-settings">
<Breadcrumb
items={[
{ label: 'Teams', href: '/teams' },
{ label: team.name, href: `/teams/${slug}` },
{ label: 'Settings' },
]}
/>
<h1>Team Settings</h1>
{error && (
<div className="error-message">
{error}
<button onClick={() => setError(null)} className="error-dismiss">&times;</button>
</div>
)}
{successMessage && (
<div className="success-message">
{successMessage}
</div>
)}
<form onSubmit={handleSubmit} className="settings-form">
<div className="form-section">
<h2>General</h2>
<div className="form-group">
<label htmlFor="team-name">Team Name</label>
<input
id="team-name"
type="text"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="form-group">
<label htmlFor="team-slug">Slug</label>
<input
id="team-slug"
type="text"
value={team.slug}
disabled
className="input-disabled"
/>
<span className="form-hint">Team slug cannot be changed</span>
</div>
<div className="form-group">
<label htmlFor="team-description">Description</label>
<textarea
id="team-description"
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
rows={3}
placeholder="What is this team for?"
/>
</div>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
{isOwner && (
<div className="form-section danger-zone">
<h2>Danger Zone</h2>
<p className="danger-warning">
Deleting a team is permanent and cannot be undone.
You must move or delete all projects in this team first.
</p>
<button
type="button"
className="btn btn-danger"
onClick={() => setShowDeleteConfirm(true)}
>
Delete Team
</button>
</div>
)}
{showDeleteConfirm && (
<div className="modal-overlay" onClick={() => setShowDeleteConfirm(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<h2>Delete Team</h2>
<p>
This will permanently delete the team <strong>{team.name}</strong>.
This action cannot be undone.
</p>
<p>
To confirm, type <strong>{team.slug}</strong> below:
</p>
<input
type="text"
value={deleteConfirmText}
onChange={e => setDeleteConfirmText(e.target.value)}
placeholder={team.slug}
className="delete-confirm-input"
/>
<div className="form-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setShowDeleteConfirm(false);
setDeleteConfirmText('');
}}
>
Cancel
</button>
<button
type="button"
className="btn btn-danger"
disabled={deleteConfirmText !== team.slug || deleting}
onClick={handleDelete}
>
{deleting ? 'Deleting...' : 'Delete Team'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default TeamSettingsPage;

View File

@@ -0,0 +1,376 @@
.teams-page {
padding: 1.5rem 0;
max-width: 1200px;
margin: 0 auto;
}
/* Header */
.teams-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
}
.teams-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
/* Search */
.teams-search {
position: relative;
margin-bottom: 1.5rem;
}
.teams-search__icon {
position: absolute;
left: 0.875rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}
.teams-search__input {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 2.75rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.875rem;
}
.teams-search__input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
.teams-search__input::placeholder {
color: var(--text-muted);
}
.teams-search__clear {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0.375rem;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
}
.teams-search__clear:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
/* Error */
.teams-error {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: var(--error-bg);
border: 1px solid var(--error);
border-radius: var(--radius-md);
color: var(--error);
font-size: 0.875rem;
}
.teams-error__dismiss {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: inherit;
padding: 0;
line-height: 1;
}
/* Loading */
.teams-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 4rem 2rem;
color: var(--text-muted);
}
.teams-loading__spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-primary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: teams-spin 0.8s linear infinite;
}
@keyframes teams-spin {
to { transform: rotate(360deg); }
}
/* Empty State */
.teams-empty-state {
text-align: center;
padding: 4rem 2rem;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-primary);
}
.teams-empty-icon {
color: var(--text-muted);
margin-bottom: 1rem;
}
.teams-empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.teams-empty-state p {
margin: 0 0 1.5rem;
color: var(--text-muted);
}
/* Table cell styles */
.team-name-cell {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.team-name-link {
font-weight: 500;
color: var(--text-primary);
text-decoration: none;
}
.team-name-link:hover {
color: var(--accent-primary);
}
.team-slug {
font-size: 0.8125rem;
color: var(--text-muted);
}
.team-description-cell {
color: var(--text-secondary);
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-muted {
color: var(--text-muted);
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 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);
width: 100%;
max-width: 480px;
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-primary);
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.modal-close {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: var(--text-muted);
display: flex;
border-radius: var(--radius-sm);
}
.modal-close:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.modal-content form {
padding: 1.5rem;
}
/* Form */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary);
}
.form-group .optional {
font-weight: 400;
color: var(--text-muted);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--bg-tertiary);
color: var(--text-primary);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
.input-with-prefix {
display: flex;
align-items: stretch;
}
.input-prefix {
display: flex;
align-items: center;
padding: 0 0.75rem;
background: var(--bg-elevated);
border: 1px solid var(--border-primary);
border-right: none;
border-radius: var(--radius-md) 0 0 var(--radius-md);
color: var(--text-muted);
font-size: 0.875rem;
}
.input-with-prefix input {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-primary);
}
/* 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;
transition: all 0.15s ease;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-primary-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
}
/* Responsive */
@media (max-width: 640px) {
.teams-header {
flex-direction: column;
align-items: stretch;
}
.teams-header .btn {
justify-content: center;
}
.teams-stats {
justify-content: space-around;
}
.teams-table-container {
overflow-x: auto;
}
.teams-table {
min-width: 600px;
}
}

View File

@@ -0,0 +1,310 @@
import { useState, useEffect, useCallback } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { TeamDetail, TeamCreate, PaginatedResponse } from '../types';
import { listTeams, createTeam } from '../api';
import { useAuth } from '../contexts/AuthContext';
import { Badge } from '../components/Badge';
import { DataTable } from '../components/DataTable';
import './TeamsPage.css';
function TeamsPage() {
const navigate = useNavigate();
const { user } = useAuth();
const [teamsData, setTeamsData] = useState<PaginatedResponse<TeamDetail> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [newTeam, setNewTeam] = useState<TeamCreate>({ name: '', slug: '', description: '' });
const [creating, setCreating] = useState(false);
const [slugManuallySet, setSlugManuallySet] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const loadTeams = useCallback(async () => {
try {
setLoading(true);
const data = await listTeams({ limit: 100 });
setTeamsData(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load teams');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadTeams();
}, [loadTeams]);
// Auto-generate slug from name
const handleNameChange = (name: string) => {
setNewTeam(prev => ({
...prev,
name,
slug: slugManuallySet ? prev.slug : name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''),
}));
};
const handleSlugChange = (slug: string) => {
setSlugManuallySet(true);
setNewTeam(prev => ({ ...prev, slug }));
};
async function handleCreateTeam(e: React.FormEvent) {
e.preventDefault();
try {
setCreating(true);
const team = await createTeam(newTeam);
setNewTeam({ name: '', slug: '', description: '' });
setSlugManuallySet(false);
setShowForm(false);
navigate(`/teams/${team.slug}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create team');
} finally {
setCreating(false);
}
}
const closeModal = () => {
setShowForm(false);
setNewTeam({ name: '', slug: '', description: '' });
setSlugManuallySet(false);
};
// Filter teams by search
const filteredTeams = teamsData?.items.filter(team =>
team.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
team.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
(team.description?.toLowerCase().includes(searchQuery.toLowerCase()))
) || [];
const totalTeams = teamsData?.items.length || 0;
const roleConfig: Record<string, { variant: 'success' | 'info' | 'default'; label: string }> = {
owner: { variant: 'success', label: 'Owner' },
admin: { variant: 'info', label: 'Admin' },
member: { variant: 'default', label: 'Member' },
};
if (!user) {
return (
<div className="teams-page">
<div className="teams-empty-state">
<div className="teams-empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<h2>Sign in to view your teams</h2>
<p>Teams help you organize projects and collaborate with others.</p>
<Link to="/login" className="btn btn-primary">Sign In</Link>
</div>
</div>
);
}
return (
<div className="teams-page">
{/* Header */}
<div className="teams-header">
<h1>Teams</h1>
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Team
</button>
</div>
{/* Search */}
{!loading && totalTeams > 3 && (
<div className="teams-search">
<svg className="teams-search__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
type="text"
placeholder="Search teams..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="teams-search__input"
/>
{searchQuery && (
<button className="teams-search__clear" onClick={() => setSearchQuery('')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
)}
</div>
)}
{error && (
<div className="teams-error">
{error}
<button onClick={() => setError(null)} className="teams-error__dismiss">&times;</button>
</div>
)}
{/* Create Team Modal */}
{showForm && (
<div className="modal-overlay" onClick={closeModal}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>Create New Team</h2>
<button className="modal-close" onClick={closeModal}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<form onSubmit={handleCreateTeam}>
<div className="form-group">
<label htmlFor="team-name">Team Name</label>
<input
id="team-name"
type="text"
value={newTeam.name}
onChange={e => handleNameChange(e.target.value)}
placeholder="Engineering"
required
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="team-slug">URL Slug</label>
<div className="input-with-prefix">
<span className="input-prefix">@</span>
<input
id="team-slug"
type="text"
value={newTeam.slug}
onChange={e => handleSlugChange(e.target.value)}
placeholder="engineering"
pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
title="Lowercase letters, numbers, and hyphens only"
required
/>
</div>
<span className="form-hint">Used in URLs. Lowercase letters, numbers, and hyphens.</span>
</div>
<div className="form-group">
<label htmlFor="team-description">Description <span className="optional">(optional)</span></label>
<textarea
id="team-description"
value={newTeam.description}
onChange={e => setNewTeam({ ...newTeam, description: e.target.value })}
placeholder="What is this team for?"
rows={3}
/>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={closeModal}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={creating}>
{creating ? 'Creating...' : 'Create Team'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Content */}
{loading ? (
<div className="teams-loading">
<div className="teams-loading__spinner" />
<span>Loading teams...</span>
</div>
) : filteredTeams.length === 0 ? (
<div className="teams-empty-state">
<div className="teams-empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
{searchQuery ? (
<>
<h2>No teams found</h2>
<p>No teams match "{searchQuery}"</p>
<button className="btn btn-secondary" onClick={() => setSearchQuery('')}>
Clear search
</button>
</>
) : (
<>
<h2>No teams yet</h2>
<p>Create your first team to start organizing your projects.</p>
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
Create Team
</button>
</>
)}
</div>
) : (
<DataTable
data={filteredTeams}
keyExtractor={(team) => team.id}
onRowClick={(team) => navigate(`/teams/${team.slug}`)}
columns={[
{
key: 'name',
header: 'Name',
render: (team) => (
<div className="team-name-cell">
<Link
to={`/teams/${team.slug}`}
className="cell-name"
onClick={(e) => e.stopPropagation()}
>
{team.name}
</Link>
<span className="team-slug">@{team.slug}</span>
</div>
),
},
{
key: 'description',
header: 'Description',
className: 'cell-description',
render: (team) => team.description || <span className="text-muted"></span>,
},
{
key: 'role',
header: 'Role',
render: (team) => team.user_role ? (
<Badge variant={roleConfig[team.user_role]?.variant || 'default'}>
{roleConfig[team.user_role]?.label || team.user_role}
</Badge>
) : null,
},
{
key: 'members',
header: 'Members',
render: (team) => <span className="text-muted">{team.member_count}</span>,
},
{
key: 'projects',
header: 'Projects',
render: (team) => <span className="text-muted">{team.project_count}</span>,
},
]}
/>
)}
</div>
);
}
export default TeamsPage;

View File

@@ -12,6 +12,10 @@ export interface Project {
// Access level info (populated when listing projects)
access_level?: AccessLevel | null;
is_owner?: boolean;
// Team info
team_id?: string | null;
team_slug?: string | null;
team_name?: string | null;
}
export interface TagSummary {
@@ -316,6 +320,8 @@ export interface UserUpdate {
}
// Access Permission types
export type AccessSource = 'explicit' | 'team';
export interface AccessPermission {
id: string;
project_id: string;
@@ -323,6 +329,9 @@ export interface AccessPermission {
level: AccessLevel;
created_at: string;
expires_at: string | null;
source?: AccessSource; // "explicit" or "team"
team_slug?: string; // Team slug if source is "team"
team_role?: string; // Team role if source is "team"
}
export interface AccessPermissionCreate {
@@ -447,3 +456,50 @@ export interface DependencyResolutionError {
}>;
}>;
}
// Team types
export type TeamRole = 'owner' | 'admin' | 'member';
export interface Team {
id: string;
name: string;
slug: string;
description: string | null;
created_at: string;
updated_at: string;
member_count: number;
project_count: number;
}
export interface TeamDetail extends Team {
user_role: TeamRole | null;
}
export interface TeamMember {
id: string;
user_id: string;
username: string;
email: string | null;
role: TeamRole;
created_at: string;
}
export interface TeamCreate {
name: string;
slug: string;
description?: string;
}
export interface TeamUpdate {
name?: string;
description?: string;
}
export interface TeamMemberCreate {
username: string;
role: TeamRole;
}
export interface TeamMemberUpdate {
role: TeamRole;
}

View File

@@ -141,3 +141,16 @@ MinIO secret name
{{- printf "%s-s3-secret" (include "orchard.fullname" .) }}
{{- end }}
{{- end }}
{{/*
Auth secret name (for admin password)
*/}}
{{- define "orchard.auth.secretName" -}}
{{- if and .Values.orchard.auth .Values.orchard.auth.existingSecret }}
{{- .Values.orchard.auth.existingSecret }}
{{- else if and .Values.orchard.auth .Values.orchard.auth.secretsManager .Values.orchard.auth.secretsManager.enabled }}
{{- printf "%s-auth-credentials" (include "orchard.fullname" .) }}
{{- else }}
{{- printf "%s-auth-secret" (include "orchard.fullname" .) }}
{{- end }}
{{- end }}

View File

@@ -128,11 +128,39 @@ spec:
value: {{ .Values.orchard.rateLimit.login | quote }}
{{- end }}
{{- end }}
{{- if and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled }}
{{- if .Values.orchard.database.poolSize }}
- name: ORCHARD_DATABASE_POOL_SIZE
value: {{ .Values.orchard.database.poolSize | quote }}
{{- end }}
{{- if .Values.orchard.database.maxOverflow }}
- name: ORCHARD_DATABASE_MAX_OVERFLOW
value: {{ .Values.orchard.database.maxOverflow | quote }}
{{- end }}
{{- if .Values.orchard.database.poolTimeout }}
- name: ORCHARD_DATABASE_POOL_TIMEOUT
value: {{ .Values.orchard.database.poolTimeout | quote }}
{{- end }}
{{- if .Values.orchard.auth }}
{{- if or .Values.orchard.auth.secretsManager .Values.orchard.auth.existingSecret .Values.orchard.auth.adminPassword }}
- name: ORCHARD_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "orchard.auth.secretName" . }}
key: admin-password
{{- end }}
{{- end }}
{{- if or (and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled) (and .Values.orchard.auth .Values.orchard.auth.secretsManager .Values.orchard.auth.secretsManager.enabled) }}
volumeMounts:
{{- if and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled }}
- name: db-secrets
mountPath: /mnt/secrets-store
mountPath: /mnt/secrets-store/db
readOnly: true
{{- end }}
{{- if and .Values.orchard.auth .Values.orchard.auth.secretsManager .Values.orchard.auth.secretsManager.enabled }}
- name: auth-secrets
mountPath: /mnt/secrets-store/auth
readOnly: true
{{- end }}
{{- end }}
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
@@ -140,14 +168,24 @@ spec:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- if and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled }}
{{- if or (and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled) (and .Values.orchard.auth .Values.orchard.auth.secretsManager .Values.orchard.auth.secretsManager.enabled) }}
volumes:
{{- if and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled }}
- name: db-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: {{ include "orchard.fullname" . }}-db-secret
{{- end }}
{{- if and .Values.orchard.auth .Values.orchard.auth.secretsManager .Values.orchard.auth.secretsManager.enabled }}
- name: auth-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: {{ include "orchard.fullname" . }}-auth-secret
{{- end }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:

View File

@@ -25,3 +25,27 @@ spec:
- objectName: db-password
key: password
{{- end }}
---
{{- if and .Values.orchard.auth .Values.orchard.auth.secretsManager .Values.orchard.auth.secretsManager.enabled }}
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: {{ include "orchard.fullname" . }}-auth-secret
labels:
{{- include "orchard.labels" . | nindent 4 }}
spec:
provider: aws
parameters:
objects: |
- objectName: "{{ .Values.orchard.auth.secretsManager.secretArn }}"
objectType: "secretsmanager"
jmesPath:
- path: admin_password
objectAlias: admin-password
secretObjects:
- secretName: {{ include "orchard.fullname" . }}-auth-credentials
type: Opaque
data:
- objectName: admin-password
key: admin-password
{{- end }}

View File

@@ -22,3 +22,15 @@ data:
access-key-id: {{ .Values.orchard.s3.accessKeyId | b64enc | quote }}
secret-access-key: {{ .Values.orchard.s3.secretAccessKey | b64enc | quote }}
{{- end }}
---
{{- if and .Values.orchard.auth .Values.orchard.auth.adminPassword (not .Values.orchard.auth.existingSecret) (not (and .Values.orchard.auth.secretsManager .Values.orchard.auth.secretsManager.enabled)) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "orchard.fullname" . }}-auth-secret
labels:
{{- include "orchard.labels" . | nindent 4 }}
type: Opaque
data:
admin-password: {{ .Values.orchard.auth.adminPassword | b64enc | quote }}
{{- end }}

View File

@@ -53,15 +53,16 @@ ingress:
hosts:
- orchard-dev.common.global.bsf.tools # Overridden by CI
# Lighter resources for ephemeral environments
# Resources for dev/feature environments
# Bumped to handle concurrent integration tests
# Note: memory requests must equal limits per cluster policy
resources:
limits:
cpu: 250m
memory: 256Mi
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 256Mi
cpu: 200m
memory: 512Mi
livenessProbe:
httpGet:
@@ -90,6 +91,10 @@ orchard:
host: "0.0.0.0"
port: 8080
# Authentication settings
# Admin password is set via CI variable (DEV_ADMIN_PASSWORD) passed as --set flag
# This keeps the password out of version control
database:
host: ""
port: 5432
@@ -99,6 +104,10 @@ orchard:
sslmode: disable
existingSecret: ""
existingSecretPasswordKey: "password"
# Increased pool settings for concurrent integration tests
poolSize: 10
maxOverflow: 20
poolTimeout: 60
s3:
endpoint: ""
@@ -134,15 +143,16 @@ postgresql:
primary:
persistence:
enabled: false
# Resources with memory requests = limits per cluster policy
# Bumped resources for concurrent integration tests
# Note: memory requests must equal limits per cluster policy
resourcesPreset: "none"
resources:
limits:
cpu: 250m
memory: 256Mi
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 256Mi
cpu: 200m
memory: 512Mi
# Volume permissions init container
volumePermissions:
resourcesPreset: "none"
@@ -168,15 +178,16 @@ minio:
defaultBuckets: "orchard-artifacts"
persistence:
enabled: false
# Resources with memory requests = limits per cluster policy
# Bumped resources for concurrent integration tests
# Note: memory requests must equal limits per cluster policy
resourcesPreset: "none" # Disable preset to use explicit resources
resources:
limits:
cpu: 250m
memory: 256Mi
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 256Mi
cpu: 200m
memory: 512Mi
# Init container resources
defaultInitContainers:
volumePermissions:

View File

@@ -93,6 +93,13 @@ orchard:
host: "0.0.0.0"
port: 8080
# Authentication settings
auth:
# Admin password from AWS Secrets Manager
secretsManager:
enabled: true
secretArn: "arn:aws-us-gov:secretsmanager:us-gov-west-1:052673043337:secret:orch-prod-creds-0nhqkY"
# Database configuration - uses AWS Secrets Manager via CSI driver
database:
host: "orchard-prd.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com"

View File

@@ -95,6 +95,10 @@ orchard:
host: "0.0.0.0"
port: 8080
# Authentication settings
# Admin password is set via CI variable (STAGE_ADMIN_PASSWORD) passed as --set flag
# This keeps the password out of version control
# Database configuration - uses AWS Secrets Manager via CSI driver
database:
host: "orchard-stage.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com"

View File

@@ -120,6 +120,17 @@ orchard:
mode: "presigned" # presigned, redirect, or proxy
presignedUrlExpiry: 3600 # Presigned URL expiry in seconds
# Authentication settings
auth:
# Option 1: Plain admin password (creates K8s secret)
adminPassword: ""
# Option 2: Use existing K8s secret (must have 'admin-password' key)
existingSecret: ""
# Option 3: AWS Secrets Manager
# secretsManager:
# enabled: false
# secretArn: "" # Secret must have 'admin_password' field
# PostgreSQL subchart configuration
postgresql:
enabled: true

62
migrations/009_teams.sql Normal file
View File

@@ -0,0 +1,62 @@
-- Migration 009: Teams and Multi-Tenancy
-- Adds support for team-based multi-tenancy
-- Part of Multi-Tenancy with Teams feature
-- Create teams table
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(255) NOT NULL,
settings JSONB DEFAULT '{}'::jsonb,
-- Slug must be lowercase alphanumeric with hyphens
CONSTRAINT check_team_slug_format CHECK (slug ~ '^[a-z0-9][a-z0-9-]*[a-z0-9]$' OR slug ~ '^[a-z0-9]$')
);
-- Create team_memberships table
CREATE TABLE IF NOT EXISTS team_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
invited_by VARCHAR(255),
-- Each user can only be a member of a team once
CONSTRAINT unique_team_membership UNIQUE (team_id, user_id),
-- Role must be one of: owner, admin, member
CONSTRAINT check_team_role CHECK (role IN ('owner', 'admin', 'member'))
);
-- Add team_id column to projects table (nullable for migration compatibility)
ALTER TABLE projects ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
-- Indexes for teams table
CREATE INDEX IF NOT EXISTS idx_teams_slug ON teams(slug);
CREATE INDEX IF NOT EXISTS idx_teams_created_by ON teams(created_by);
CREATE INDEX IF NOT EXISTS idx_teams_created_at ON teams(created_at);
-- Indexes for team_memberships table
CREATE INDEX IF NOT EXISTS idx_team_memberships_team_id ON team_memberships(team_id);
CREATE INDEX IF NOT EXISTS idx_team_memberships_user_id ON team_memberships(user_id);
CREATE INDEX IF NOT EXISTS idx_team_memberships_role ON team_memberships(role);
CREATE INDEX IF NOT EXISTS idx_team_memberships_team_role ON team_memberships(team_id, role);
-- Index for projects team_id
CREATE INDEX IF NOT EXISTS idx_projects_team_id ON projects(team_id);
-- Comments
COMMENT ON TABLE teams IS 'Teams serve as organizational containers for projects';
COMMENT ON COLUMN teams.slug IS 'URL-friendly unique identifier (lowercase alphanumeric with hyphens)';
COMMENT ON COLUMN teams.settings IS 'JSON object for team-specific settings';
COMMENT ON TABLE team_memberships IS 'Maps users to teams with their roles';
COMMENT ON COLUMN team_memberships.role IS 'User role in the team: owner, admin, or member';
COMMENT ON COLUMN team_memberships.invited_by IS 'Username of the user who invited this member';
COMMENT ON COLUMN projects.team_id IS 'Optional team that owns this project';

View File

@@ -0,0 +1,99 @@
-- Migration 009b: Migrate Existing Projects to Personal Teams
-- Creates personal teams for existing users and assigns their projects to those teams.
-- This migration is idempotent and can be run multiple times safely.
-- Create personal teams for users who own projects but don't have a personal team yet
INSERT INTO teams (name, slug, description, created_by, settings)
SELECT DISTINCT
u.username || '''s Team' AS name,
LOWER(u.username) || '-personal' AS slug,
'Personal team for ' || u.username AS description,
u.username AS created_by,
'{"personal": true}'::jsonb AS settings
FROM users u
JOIN projects p ON p.created_by = u.username
WHERE NOT EXISTS (
SELECT 1 FROM teams t
WHERE t.slug = LOWER(u.username) || '-personal'
)
AND p.team_id IS NULL
ON CONFLICT (slug) DO NOTHING;
-- Add users as owners of their personal teams
INSERT INTO team_memberships (team_id, user_id, role, invited_by)
SELECT
t.id AS team_id,
u.id AS user_id,
'owner' AS role,
u.username AS invited_by
FROM teams t
JOIN users u ON t.created_by = u.username
WHERE t.slug LIKE '%-personal'
AND NOT EXISTS (
SELECT 1 FROM team_memberships tm
WHERE tm.team_id = t.id
AND tm.user_id = u.id
)
ON CONFLICT DO NOTHING;
-- Assign projects without a team to their creator's personal team
UPDATE projects p
SET team_id = t.id
FROM teams t
WHERE t.slug = LOWER(p.created_by) || '-personal'
AND p.team_id IS NULL;
-- Handle orphaned projects (created_by doesn't match any user)
-- Create a special orphaned projects team if there are any
DO $$
DECLARE
orphan_count INTEGER;
orphan_team_id UUID;
BEGIN
-- Count orphaned projects
SELECT COUNT(*) INTO orphan_count
FROM projects p
WHERE p.team_id IS NULL
AND NOT EXISTS (
SELECT 1 FROM users u WHERE u.username = p.created_by
);
IF orphan_count > 0 THEN
-- Create or get the orphaned projects team
INSERT INTO teams (name, slug, description, created_by, settings)
VALUES (
'Orphaned Projects',
'orphaned-projects',
'Projects whose original creators no longer exist',
'system',
'{"system": true}'::jsonb
)
ON CONFLICT (slug) DO UPDATE SET name = teams.name
RETURNING id INTO orphan_team_id;
-- Assign orphaned projects to this team
UPDATE projects
SET team_id = orphan_team_id
WHERE team_id IS NULL
AND NOT EXISTS (
SELECT 1 FROM users u WHERE u.username = projects.created_by
);
RAISE NOTICE 'Migrated % orphaned project(s) to orphaned-projects team', orphan_count;
END IF;
END $$;
-- Log migration results
DO $$
DECLARE
teams_created INTEGER;
memberships_created INTEGER;
projects_migrated INTEGER;
BEGIN
SELECT COUNT(*) INTO teams_created FROM teams WHERE slug LIKE '%-personal';
SELECT COUNT(*) INTO memberships_created FROM team_memberships;
SELECT COUNT(*) INTO projects_migrated FROM projects WHERE team_id IS NOT NULL;
RAISE NOTICE 'Migration complete: % personal teams, % memberships, % projects with teams',
teams_created, memberships_created, projects_migrated;
END $$;