1 Commits

Author SHA1 Message Date
Mondo Diaz
ba7cd96107 Add package dependencies and project settings page
Package Dependencies:
- Add artifact dependency management system
- Add dependency API endpoints (get, resolve, reverse)
- Add ensure file parsing for declaring dependencies
- Add circular dependency and conflict detection
- Add frontend dependency visualization with graph modal
- Add migration for artifact_dependencies table

Project Settings Page (#65):
- Add dedicated settings page for project admins
- General settings section (description, visibility)
- Access management section (moved from project page)
- Danger zone with inline delete confirmation
- Add Settings button to project page header
2026-01-27 15:29:51 +00:00
49 changed files with 175 additions and 5926 deletions

View File

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

View File

@@ -7,67 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### 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) - Added Project Settings page accessible to project admins (#65)
- General settings section for editing description and visibility - General settings section for editing description and visibility
- Access Management section (moved from project page) - Access Management section (moved from project page)
@@ -105,13 +44,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added pre-test stage reset to ensure known environment state before integration tests (#54) - Added pre-test stage reset to ensure known environment state before integration tests (#54)
- Upload endpoint now accepts optional `ensure` file parameter for declaring dependencies - Upload endpoint now accepts optional `ensure` file parameter for declaring dependencies
- Updated upload API documentation with ensure file format and examples - Updated upload API documentation with ensure file format and examples
- Converted teams list and team projects to use DataTable component for consistent styling
- Centered team members and team settings page content
- Added orchard logo icon and dot separator to footer
### Fixed
- Fixed dark theme styling for team pages - modals, forms, and dropdowns now use correct theme variables
- Fixed UserAutocomplete and TeamSelector dropdown backgrounds for dark theme
## [0.5.1] - 2026-01-23 ## [0.5.1] - 2026-01-23
### Changed ### Changed

View File

@@ -360,36 +360,21 @@ def create_default_admin(db: Session) -> Optional[User]:
"""Create the default admin user if no users exist. """Create the default admin user if no users exist.
Returns the created user, or None if users already 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 # Check if any users exist
user_count = db.query(User).count() user_count = db.query(User).count()
if user_count > 0: if user_count > 0:
return None 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 # Create default admin
auth_service = AuthService(db) auth_service = AuthService(db)
admin = auth_service.create_user( admin = auth_service.create_user(
username="admin", username="admin",
password=password, password="changeme123",
is_admin=True, is_admin=True,
must_change_password=must_change, must_change_password=True,
) )
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 return admin
@@ -658,51 +643,32 @@ class AuthorizationService:
self, project_id: str, user: Optional[User] self, project_id: str, user: Optional[User]
) -> Optional[str]: ) -> Optional[str]:
"""Get the user's access level for a project. """Get the user's access level for a project.
Returns the highest access level the user has, or None if no access. Returns the highest access level the user has, or None if no access.
Checks in order: Checks in order:
1. System admin - gets admin access to all projects 1. System admin - gets admin access to all projects
2. Project owner (created_by) - gets admin access 2. Project owner (created_by) - gets admin access
3. Team-based access (owner/admin gets admin, member gets read) 3. Explicit permission in access_permissions table
4. Explicit permission in access_permissions table
5. Public access
""" """
from .models import Project, AccessPermission, TeamMembership from .models import Project, AccessPermission
# Get the project # Get the project
project = self.db.query(Project).filter(Project.id == project_id).first() project = self.db.query(Project).filter(Project.id == project_id).first()
if not project: if not project:
return None return None
# Anonymous users only get access to public projects # Anonymous users only get access to public projects
if not user: if not user:
return "read" if project.is_public else None return "read" if project.is_public else None
# System admins get admin access everywhere # System admins get admin access everywhere
if user.is_admin: if user.is_admin:
return "admin" return "admin"
# Project owner gets admin access # Project owner gets admin access
if project.created_by == user.username: if project.created_by == user.username:
return "admin" 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 # Check explicit permissions
permission = ( permission = (
self.db.query(AccessPermission) self.db.query(AccessPermission)
@@ -712,27 +678,13 @@ class AuthorizationService:
) )
.first() .first()
) )
if permission: if permission:
# Check expiration # Check expiration
if permission.expires_at and permission.expires_at < datetime.now(timezone.utc): if permission.expires_at and permission.expires_at < datetime.now(timezone.utc):
pass # Permission expired, fall through return "read" if project.is_public else None
else: return permission.level
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 # Fall back to public access
return "read" if project.is_public else None return "read" if project.is_public else None
@@ -917,226 +869,6 @@ def check_project_access(
return project 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 --- # --- OIDC Configuration Service ---

View File

@@ -53,9 +53,6 @@ class Settings(BaseSettings):
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
log_format: str = "auto" # "json", "standard", or "auto" (json in production) 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 Authentication settings (optional, for external identity providers)
jwt_enabled: bool = False # Enable JWT token validation jwt_enabled: bool = False # Enable JWT token validation
jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS

View File

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

View File

@@ -32,7 +32,6 @@ class Project(Base):
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow
) )
created_by = Column(String(255), nullable=False) created_by = Column(String(255), nullable=False)
team_id = Column(UUID(as_uuid=True), ForeignKey("teams.id", ondelete="SET NULL"))
packages = relationship( packages = relationship(
"Package", back_populates="project", cascade="all, delete-orphan" "Package", back_populates="project", cascade="all, delete-orphan"
@@ -40,12 +39,10 @@ class Project(Base):
permissions = relationship( permissions = relationship(
"AccessPermission", back_populates="project", cascade="all, delete-orphan" "AccessPermission", back_populates="project", cascade="all, delete-orphan"
) )
team = relationship("Team", back_populates="projects")
__table_args__ = ( __table_args__ = (
Index("idx_projects_name", "name"), Index("idx_projects_name", "name"),
Index("idx_projects_created_by", "created_by"), Index("idx_projects_created_by", "created_by"),
Index("idx_projects_team_id", "team_id"),
) )
@@ -372,9 +369,6 @@ class User(Base):
sessions = relationship( sessions = relationship(
"Session", back_populates="user", cascade="all, delete-orphan" "Session", back_populates="user", cascade="all, delete-orphan"
) )
team_memberships = relationship(
"TeamMembership", back_populates="user", cascade="all, delete-orphan"
)
__table_args__ = ( __table_args__ = (
Index("idx_users_username", "username"), Index("idx_users_username", "username"),
@@ -567,73 +561,3 @@ class ArtifactDependency(Base):
unique=True, 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,7 +25,6 @@ class ProjectCreate(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
is_public: bool = True is_public: bool = True
team_id: Optional[UUID] = None
class ProjectResponse(BaseModel): class ProjectResponse(BaseModel):
@@ -36,9 +35,6 @@ class ProjectResponse(BaseModel):
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
created_by: str created_by: str
team_id: Optional[UUID] = None
team_slug: Optional[str] = None
team_name: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -911,9 +907,6 @@ class AccessPermissionResponse(BaseModel):
level: str level: str
created_at: datetime created_at: datetime
expires_at: Optional[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: class Config:
from_attributes = True from_attributes = True
@@ -1060,139 +1053,3 @@ class CircularDependencyError(BaseModel):
message: str message: str
cycle: List[str] # List of "project/package" strings showing the cycle 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,9 +5,8 @@ import hashlib
import logging import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency, Team, TeamMembership, User from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency
from .storage import get_storage from .storage import get_storage
from .auth import hash_password
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -150,80 +149,6 @@ def seed_database(db: Session) -> None:
logger.info("Seeding database with test data...") logger.info("Seeding database with test data...")
storage = get_storage() 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 # Create projects and packages
project_map = {} project_map = {}
package_map = {} package_map = {}
@@ -233,8 +158,7 @@ def seed_database(db: Session) -> None:
name=project_data["name"], name=project_data["name"],
description=project_data["description"], description=project_data["description"],
is_public=project_data["is_public"], is_public=project_data["is_public"],
created_by=team_owner_username, created_by="seed-user",
team_id=demo_team.id, # Assign to demo team
) )
db.add(project) db.add(project)
db.flush() # Get the ID db.flush() # Get the ID
@@ -250,7 +174,7 @@ def seed_database(db: Session) -> None:
db.flush() db.flush()
package_map[(project_data["name"], package_data["name"])] = package package_map[(project_data["name"], package_data["name"])] = package
logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages (assigned to {demo_team.slug})") logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages")
# Create artifacts, tags, and versions # Create artifacts, tags, and versions
artifact_count = 0 artifact_count = 0
@@ -288,7 +212,7 @@ def seed_database(db: Session) -> None:
size=size, size=size,
content_type=artifact_data["content_type"], content_type=artifact_data["content_type"],
original_name=artifact_data["filename"], original_name=artifact_data["filename"],
created_by=team_owner_username, created_by="seed-user",
s3_key=s3_key, s3_key=s3_key,
ref_count=ref_count, ref_count=ref_count,
) )
@@ -311,7 +235,7 @@ def seed_database(db: Session) -> None:
artifact_id=sha256_hash, artifact_id=sha256_hash,
version=artifact_data["version"], version=artifact_data["version"],
version_source="explicit", version_source="explicit",
created_by=team_owner_username, created_by="seed-user",
) )
db.add(version) db.add(version)
version_count += 1 version_count += 1
@@ -322,7 +246,7 @@ def seed_database(db: Session) -> None:
package_id=package.id, package_id=package.id,
name=tag_name, name=tag_name,
artifact_id=sha256_hash, artifact_id=sha256_hash,
created_by=team_owner_username, created_by="seed-user",
) )
db.add(tag) db.add(tag)
tag_count += 1 tag_count += 1

View File

@@ -56,26 +56,6 @@ os.environ.setdefault("ORCHARD_S3_BUCKET", "test-bucket")
os.environ.setdefault("ORCHARD_S3_ACCESS_KEY_ID", "test") os.environ.setdefault("ORCHARD_S3_ACCESS_KEY_ID", "test")
os.environ.setdefault("ORCHARD_S3_SECRET_ACCESS_KEY", "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 # Re-export factory functions for backward compatibility
from tests.factories import ( from tests.factories import (
create_test_file, create_test_file,

View File

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

View File

@@ -1,316 +0,0 @@
"""
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

@@ -1,95 +0,0 @@
"""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

@@ -1,213 +0,0 @@
"""
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,8 +26,6 @@ services:
- ORCHARD_REDIS_PORT=6379 - ORCHARD_REDIS_PORT=6379
# Higher rate limit for local development/testing # Higher rate limit for local development/testing
- ORCHARD_LOGIN_RATE_LIMIT=1000/minute - 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: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View File

@@ -1,6 +1,5 @@
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext'; import { AuthProvider, useAuth } from './contexts/AuthContext';
import { TeamProvider } from './contexts/TeamContext';
import Layout from './components/Layout'; import Layout from './components/Layout';
import Home from './pages/Home'; import Home from './pages/Home';
import ProjectPage from './pages/ProjectPage'; import ProjectPage from './pages/ProjectPage';
@@ -12,10 +11,6 @@ import APIKeysPage from './pages/APIKeysPage';
import AdminUsersPage from './pages/AdminUsersPage'; import AdminUsersPage from './pages/AdminUsersPage';
import AdminOIDCPage from './pages/AdminOIDCPage'; import AdminOIDCPage from './pages/AdminOIDCPage';
import ProjectSettingsPage from './pages/ProjectSettingsPage'; 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 // Component that checks if user must change password
function RequirePasswordChange({ children }: { children: React.ReactNode }) { function RequirePasswordChange({ children }: { children: React.ReactNode }) {
@@ -50,10 +45,6 @@ function AppRoutes() {
<Route path="/settings/api-keys" element={<APIKeysPage />} /> <Route path="/settings/api-keys" element={<APIKeysPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} /> <Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/oidc" element={<AdminOIDCPage />} /> <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" element={<ProjectPage />} />
<Route path="/project/:projectName/settings" element={<ProjectSettingsPage />} /> <Route path="/project/:projectName/settings" element={<ProjectSettingsPage />} />
<Route path="/project/:projectName/:packageName" element={<PackagePage />} /> <Route path="/project/:projectName/:packageName" element={<PackagePage />} />
@@ -69,9 +60,7 @@ function AppRoutes() {
function App() { function App() {
return ( return (
<AuthProvider> <AuthProvider>
<TeamProvider> <AppRoutes />
<AppRoutes />
</TeamProvider>
</AuthProvider> </AuthProvider>
); );
} }

View File

@@ -36,12 +36,6 @@ import {
ArtifactDependenciesResponse, ArtifactDependenciesResponse,
ReverseDependenciesResponse, ReverseDependenciesResponse,
DependencyResolutionResponse, DependencyResolutionResponse,
TeamDetail,
TeamMember,
TeamCreate,
TeamUpdate,
TeamMemberCreate,
TeamMemberUpdate,
} from './types'; } from './types';
const API_BASE = '/api/v1'; const API_BASE = '/api/v1';
@@ -166,7 +160,7 @@ export async function listProjectsSimple(params: ListParams = {}): Promise<Proje
return data.items; return data.items;
} }
export async function createProject(data: { name: string; description?: string; is_public?: boolean; team_id?: string }): Promise<Project> { export async function createProject(data: { name: string; description?: string; is_public?: boolean }): Promise<Project> {
const response = await fetch(`${API_BASE}/projects`, { const response = await fetch(`${API_BASE}/projects`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -568,117 +562,3 @@ export async function getEnsureFile(
} }
return response.text(); 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,32 +114,3 @@
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary); 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,104 +208,85 @@ export function AccessManagement({ projectName }: AccessManagementProps) {
<tr> <tr>
<th>User</th> <th>User</th>
<th>Access Level</th> <th>Access Level</th>
<th>Source</th>
<th>Granted</th> <th>Granted</th>
<th>Expires</th> <th>Expires</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{permissions.map((p) => { {permissions.map((p) => (
const isTeamBased = p.source === 'team'; <tr key={p.id}>
return ( <td>{p.user_id}</td>
<tr key={p.id} className={isTeamBased ? 'team-access-row' : ''}> <td>
<td>{p.user_id}</td> {editingUser === p.user_id ? (
<td> <select
{editingUser === p.user_id && !isTeamBased ? ( value={editLevel}
<select onChange={(e) => setEditLevel(e.target.value as AccessLevel)}
value={editLevel} disabled={submitting}
onChange={(e) => setEditLevel(e.target.value as AccessLevel)} >
<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)}
disabled={submitting} disabled={submitting}
> >
<option value="read">Read</option> Save
<option value="write">Write</option> </button>
<option value="admin">Admin</option> <button
</select> className="btn btn-sm"
) : ( onClick={cancelEdit}
<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} disabled={submitting}
min={new Date().toISOString().split('T')[0]} >
/> Cancel
) : ( </button>
formatExpiration(p.expires_at) </>
)} ) : (
</td> <>
<td className="actions"> <button
{isTeamBased ? ( className="btn btn-sm"
<span className="text-muted" title="Manage access via team settings"> onClick={() => startEdit(p)}
Via team disabled={submitting}
</span> >
) : editingUser === p.user_id ? ( Edit
<> </button>
<button <button
className="btn btn-sm btn-primary" className="btn btn-sm btn-danger"
onClick={() => handleUpdate(p.user_id)} onClick={() => handleRevoke(p.user_id)}
disabled={submitting} disabled={submitting}
> >
Save Revoke
</button> </button>
<button </>
className="btn btn-sm" )}
onClick={cancelEdit} </td>
disabled={submitting} </tr>
> ))}
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> </tbody>
</table> </table>
)} )}

View File

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

View File

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

View File

@@ -1,163 +0,0 @@
.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

@@ -1,141 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,270 +0,0 @@
.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

@@ -1,279 +0,0 @@
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

@@ -1,247 +0,0 @@
.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

@@ -1,311 +0,0 @@
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

@@ -1,239 +0,0 @@
.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

@@ -1,251 +0,0 @@
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

@@ -1,376 +0,0 @@
.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

@@ -1,310 +0,0 @@
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,10 +12,6 @@ export interface Project {
// Access level info (populated when listing projects) // Access level info (populated when listing projects)
access_level?: AccessLevel | null; access_level?: AccessLevel | null;
is_owner?: boolean; is_owner?: boolean;
// Team info
team_id?: string | null;
team_slug?: string | null;
team_name?: string | null;
} }
export interface TagSummary { export interface TagSummary {
@@ -320,8 +316,6 @@ export interface UserUpdate {
} }
// Access Permission types // Access Permission types
export type AccessSource = 'explicit' | 'team';
export interface AccessPermission { export interface AccessPermission {
id: string; id: string;
project_id: string; project_id: string;
@@ -329,9 +323,6 @@ export interface AccessPermission {
level: AccessLevel; level: AccessLevel;
created_at: string; created_at: string;
expires_at: string | null; 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 { export interface AccessPermissionCreate {
@@ -456,50 +447,3 @@ 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,16 +141,3 @@ MinIO secret name
{{- printf "%s-s3-secret" (include "orchard.fullname" .) }} {{- printf "%s-s3-secret" (include "orchard.fullname" .) }}
{{- end }} {{- end }}
{{- 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,39 +128,11 @@ spec:
value: {{ .Values.orchard.rateLimit.login | quote }} value: {{ .Values.orchard.rateLimit.login | quote }}
{{- end }} {{- end }}
{{- end }} {{- end }}
{{- if .Values.orchard.database.poolSize }} {{- if and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled }}
- 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: volumeMounts:
{{- if and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled }}
- name: db-secrets - name: db-secrets
mountPath: /mnt/secrets-store/db mountPath: /mnt/secrets-store
readOnly: true 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 }} {{- end }}
livenessProbe: livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }} {{- toYaml .Values.livenessProbe | nindent 12 }}
@@ -168,24 +140,14 @@ spec:
{{- toYaml .Values.readinessProbe | nindent 12 }} {{- toYaml .Values.readinessProbe | nindent 12 }}
resources: resources:
{{- toYaml .Values.resources | nindent 12 }} {{- toYaml .Values.resources | nindent 12 }}
{{- 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) }} {{- if and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled }}
volumes: volumes:
{{- if and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled }}
- name: db-secrets - name: db-secrets
csi: csi:
driver: secrets-store.csi.k8s.io driver: secrets-store.csi.k8s.io
readOnly: true readOnly: true
volumeAttributes: volumeAttributes:
secretProviderClass: {{ include "orchard.fullname" . }}-db-secret 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 }} {{- end }}
{{- with .Values.nodeSelector }} {{- with .Values.nodeSelector }}
nodeSelector: nodeSelector:

View File

@@ -25,27 +25,3 @@ spec:
- objectName: db-password - objectName: db-password
key: password key: password
{{- end }} {{- 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,15 +22,3 @@ data:
access-key-id: {{ .Values.orchard.s3.accessKeyId | b64enc | quote }} access-key-id: {{ .Values.orchard.s3.accessKeyId | b64enc | quote }}
secret-access-key: {{ .Values.orchard.s3.secretAccessKey | b64enc | quote }} secret-access-key: {{ .Values.orchard.s3.secretAccessKey | b64enc | quote }}
{{- end }} {{- 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,16 +53,15 @@ ingress:
hosts: hosts:
- orchard-dev.common.global.bsf.tools # Overridden by CI - orchard-dev.common.global.bsf.tools # Overridden by CI
# Resources for dev/feature environments # Lighter resources for ephemeral environments
# Bumped to handle concurrent integration tests
# Note: memory requests must equal limits per cluster policy # Note: memory requests must equal limits per cluster policy
resources: resources:
limits: limits:
cpu: 500m cpu: 250m
memory: 512Mi memory: 256Mi
requests: requests:
cpu: 200m cpu: 100m
memory: 512Mi memory: 256Mi
livenessProbe: livenessProbe:
httpGet: httpGet:
@@ -91,10 +90,6 @@ orchard:
host: "0.0.0.0" host: "0.0.0.0"
port: 8080 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: database:
host: "" host: ""
port: 5432 port: 5432
@@ -104,10 +99,6 @@ orchard:
sslmode: disable sslmode: disable
existingSecret: "" existingSecret: ""
existingSecretPasswordKey: "password" existingSecretPasswordKey: "password"
# Increased pool settings for concurrent integration tests
poolSize: 10
maxOverflow: 20
poolTimeout: 60
s3: s3:
endpoint: "" endpoint: ""
@@ -143,16 +134,15 @@ postgresql:
primary: primary:
persistence: persistence:
enabled: false enabled: false
# Bumped resources for concurrent integration tests # Resources with memory requests = limits per cluster policy
# Note: memory requests must equal limits per cluster policy
resourcesPreset: "none" resourcesPreset: "none"
resources: resources:
limits: limits:
cpu: 500m cpu: 250m
memory: 512Mi memory: 256Mi
requests: requests:
cpu: 200m cpu: 100m
memory: 512Mi memory: 256Mi
# Volume permissions init container # Volume permissions init container
volumePermissions: volumePermissions:
resourcesPreset: "none" resourcesPreset: "none"
@@ -178,16 +168,15 @@ minio:
defaultBuckets: "orchard-artifacts" defaultBuckets: "orchard-artifacts"
persistence: persistence:
enabled: false enabled: false
# Bumped resources for concurrent integration tests # Resources with memory requests = limits per cluster policy
# Note: memory requests must equal limits per cluster policy
resourcesPreset: "none" # Disable preset to use explicit resources resourcesPreset: "none" # Disable preset to use explicit resources
resources: resources:
limits: limits:
cpu: 500m cpu: 250m
memory: 512Mi memory: 256Mi
requests: requests:
cpu: 200m cpu: 100m
memory: 512Mi memory: 256Mi
# Init container resources # Init container resources
defaultInitContainers: defaultInitContainers:
volumePermissions: volumePermissions:

View File

@@ -93,13 +93,6 @@ orchard:
host: "0.0.0.0" host: "0.0.0.0"
port: 8080 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 configuration - uses AWS Secrets Manager via CSI driver
database: database:
host: "orchard-prd.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com" host: "orchard-prd.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com"

View File

@@ -95,10 +95,6 @@ orchard:
host: "0.0.0.0" host: "0.0.0.0"
port: 8080 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 configuration - uses AWS Secrets Manager via CSI driver
database: database:
host: "orchard-stage.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com" host: "orchard-stage.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com"

View File

@@ -120,17 +120,6 @@ orchard:
mode: "presigned" # presigned, redirect, or proxy mode: "presigned" # presigned, redirect, or proxy
presignedUrlExpiry: 3600 # Presigned URL expiry in seconds 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 subchart configuration
postgresql: postgresql:
enabled: true enabled: true

View File

@@ -1,62 +0,0 @@
-- 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

@@ -1,99 +0,0 @@
-- 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 $$;