7 Commits

Author SHA1 Message Date
Mondo Diaz
3ebdf51105 Add password change flow and auth error handling
- Add ChangePasswordPage component for forced password changes
- Add RequirePasswordChange wrapper in App.tsx to redirect users
- Add custom error classes (UnauthorizedError, ForbiddenError) in api.ts
- Add 401/403 error handling in ProjectPage and PackagePage
- Add refreshUser function to AuthContext
- Add must_change_password field to User type
- Add access denied UI for forbidden resources
2026-01-09 13:14:05 -06:00
Mondo Diaz
6b9f63a30e Add frontend access control enhancements and JWT support
- Hide New Project button for unauthenticated users, show login link
- Add lock icon for private projects on home page
- Show access level badges on project cards (Owner, Admin, Write, Read)
- Add permission expiration date field to AccessManagement component
- Add query timeout configuration for database (ORCHARD_DATABASE_QUERY_TIMEOUT)
- Add JWT token validation support for external identity providers
  - Configurable via ORCHARD_JWT_* environment variables
  - Supports HS256 with secret or RS256 with JWKS
  - Auto-provisions users from JWT claims
2026-01-08 18:52:57 -06:00
Mondo Diaz
f7c91e94f6 Add access management UI for project admins
Components:
- AccessManagement component for managing project permissions
- Display list of users with access to project
- Add user form with username and access level selection
- Edit access level inline
- Revoke access with confirmation

Integration:
- Show AccessManagement on ProjectPage for admin users
- Uses listProjectPermissions, grantProjectAccess, etc. APIs

Styling:
- Access level badges with color coding
- Responsive form layout
- Action buttons for edit/revoke
2026-01-08 18:31:55 -06:00
Mondo Diaz
ac625fa55f Add conditional UI based on user access level
ProjectPage:
- Display user's access level badge (Owner/Admin/Write/Read)
- Hide "New Package" button for read-only users
- Show "Read-only access" text for authenticated read-only users

PackagePage:
- Hide upload form for read-only users
- Show message explaining read-only access
- Fetch access level along with package data
2026-01-08 18:29:03 -06:00
Mondo Diaz
0bef44a292 Add access permission management API
Backend:
- Add AccessPermission schemas (Create, Update, Response)
- Add ProjectWithAccessResponse schema
- Add permission endpoints:
  - GET /project/{name}/permissions - list permissions (admin only)
  - POST /project/{name}/permissions - grant access (admin only)
  - PUT /project/{name}/permissions/{username} - update access
  - DELETE /project/{name}/permissions/{username} - revoke access
  - GET /project/{name}/my-access - get current user's access level

Frontend:
- Add AccessLevel, AccessPermission types
- Add API functions for access management:
  - getMyProjectAccess()
  - listProjectPermissions()
  - grantProjectAccess()
  - updateProjectAccess()
  - revokeProjectAccess()
2026-01-08 18:26:22 -06:00
Mondo Diaz
6aa199b80b Add rate limiting to login endpoint
Security:
- Add slowapi dependency for rate limiting
- Create rate_limit.py module with configurable limits
- Apply 5 requests/minute limit to login endpoint
- Make rate limit configurable via ORCHARD_LOGIN_RATE_LIMIT env var

Testing:
- Set high rate limit (1000/min) in docker-compose.local.yml for tests
- All 265 tests pass
2026-01-08 18:18:29 -06:00
Mondo Diaz
d61c7a71fb Add project-level authorization checks
Authorization:
- Add AuthorizationService for checking project access
- Implement get_user_access_level() with admin, owner, and permission checks
- Add check_project_access() helper for route handlers
- Add grant_access() and revoke_access() methods
- Add ProjectAccessChecker dependency class

Routes:
- Add authorization checks to project CRUD (read, update, delete)
- Add authorization checks to package create
- Add authorization checks to upload endpoint (requires write)
- Add authorization checks to download endpoint (requires read)
- Add authorization checks to tag create

Tests:
- Fix pagination flakiness in test_list_projects
- Fix pagination flakiness in test_projects_search
- Add API key authentication to concurrent upload test
2026-01-08 16:20:42 -06:00
24 changed files with 1762 additions and 102 deletions

View File

@@ -1,16 +1,20 @@
"""Authentication service for Orchard. """Authentication service for Orchard.
Handles password hashing, session management, and API key operations. Handles password hashing, session management, API key operations, and JWT validation.
""" """
import hashlib import hashlib
import secrets import secrets
import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import User, Session as UserSession, APIKey from .models import User, Session as UserSession, APIKey
from .config import get_settings
logger = logging.getLogger(__name__)
# Password hashing context (bcrypt with cost factor 12) # Password hashing context (bcrypt with cost factor 12)
@@ -374,6 +378,147 @@ def create_default_admin(db: Session) -> Optional[User]:
return admin return admin
# --- JWT Validation ---
def validate_jwt_token(token: str) -> Optional[dict]:
"""Validate a JWT token and return the decoded payload.
Returns None if validation fails or JWT is not configured.
Uses python-jose for JWT operations.
"""
settings = get_settings()
if not settings.jwt_enabled:
return None
try:
from jose import jwt, JWTError, ExpiredSignatureError
from jose.exceptions import JWTClaimsError
except ImportError:
logger.warning("python-jose not installed, JWT authentication disabled")
return None
try:
# Build decode options
decode_options = {}
# Set up key for validation
if settings.jwt_algorithm.startswith("RS"):
# RS256/RS384/RS512 - use JWKS
if not settings.jwt_jwks_url:
logger.error("JWT JWKS URL not configured for RSA algorithm")
return None
try:
import httpx
# Fetch JWKS from the URL
response = httpx.get(settings.jwt_jwks_url, timeout=10.0)
response.raise_for_status()
jwks = response.json()
# Get the key ID from the token header
unverified_header = jwt.get_unverified_header(token)
kid = unverified_header.get("kid")
# Find the matching key
rsa_key = None
for key in jwks.get("keys", []):
if key.get("kid") == kid:
rsa_key = key
break
if not rsa_key:
logger.error(f"No matching key found in JWKS for kid: {kid}")
return None
key = rsa_key
except Exception as e:
logger.error(f"Failed to get signing key from JWKS: {e}")
return None
else:
# HS256/HS384/HS512 - use secret
if not settings.jwt_secret:
logger.error("JWT secret not configured for HMAC algorithm")
return None
key = settings.jwt_secret
# Build decode kwargs
decode_kwargs = {
"algorithms": [settings.jwt_algorithm],
"options": decode_options,
}
# Add issuer validation if configured
if settings.jwt_issuer:
decode_kwargs["issuer"] = settings.jwt_issuer
# Add audience validation if configured
if settings.jwt_audience:
decode_kwargs["audience"] = settings.jwt_audience
# Decode and validate the token
payload = jwt.decode(token, key, **decode_kwargs)
return payload
except ExpiredSignatureError:
logger.debug("JWT token expired")
return None
except JWTClaimsError as e:
logger.debug(f"JWT claims error: {e}")
return None
except JWTError as e:
logger.debug(f"Invalid JWT token: {e}")
return None
except Exception as e:
logger.error(f"JWT validation error: {e}")
return None
def get_or_create_user_from_jwt(db: Session, payload: dict) -> Optional[User]:
"""Get or create a user from JWT payload.
Uses the configured username claim to extract the username.
Creates a new user if one doesn't exist (for SSO auto-provisioning).
"""
settings = get_settings()
username = payload.get(settings.jwt_username_claim)
if not username:
logger.warning(f"JWT missing username claim: {settings.jwt_username_claim}")
return None
# Sanitize username (remove domain from email if needed)
if "@" in username and settings.jwt_username_claim == "email":
# Keep full email as username for email-based auth
pass
auth_service = AuthService(db)
user = auth_service.get_user_by_username(username)
if user:
if not user.is_active:
logger.debug(f"JWT user {username} is inactive")
return None
return user
# Auto-provision user from JWT
logger.info(f"Auto-provisioning user from JWT: {username}")
try:
user = auth_service.create_user(
username=username,
password=None, # No password for SSO users
email=payload.get("email"),
is_admin=False,
must_change_password=False,
)
return user
except Exception as e:
logger.error(f"Failed to auto-provision JWT user: {e}")
return None
# --- FastAPI Dependencies --- # --- FastAPI Dependencies ---
from fastapi import Depends, HTTPException, status, Cookie, Header from fastapi import Depends, HTTPException, status, Cookie, Header
@@ -388,10 +533,15 @@ def get_current_user_optional(
session_token: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME), session_token: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME),
authorization: Optional[str] = Header(None), authorization: Optional[str] = Header(None),
) -> Optional[User]: ) -> Optional[User]:
"""Get the current user from session cookie or API key. """Get the current user from session cookie, API key, or JWT token.
Returns None if no valid authentication is provided. Returns None if no valid authentication is provided.
Does not raise an exception for unauthenticated requests. Does not raise an exception for unauthenticated requests.
Authentication methods are tried in order:
1. Session cookie (web UI)
2. API key (Bearer token starting with 'orch_')
3. JWT token (Bearer token that's a valid JWT)
""" """
auth_service = AuthService(db) auth_service = AuthService(db)
@@ -403,11 +553,22 @@ def get_current_user_optional(
if user and user.is_active: if user and user.is_active:
return user return user
# Then try API key (CLI/programmatic access) # Then try Bearer token (API key or JWT)
if authorization: if authorization and authorization.startswith("Bearer "):
if authorization.startswith("Bearer "): token = authorization[7:] # Remove "Bearer " prefix
api_key = authorization[7:] # Remove "Bearer " prefix
user = auth_service.get_user_from_api_key(api_key) # Check if it's an API key (starts with orch_)
if token.startswith(API_KEY_PREFIX):
user = auth_service.get_user_from_api_key(token)
if user:
return user
else:
# Try JWT validation
settings = get_settings()
if settings.jwt_enabled:
payload = validate_jwt_token(token)
if payload:
user = get_or_create_user_from_jwt(db, payload)
if user: if user:
return user return user
@@ -448,3 +609,261 @@ def require_admin(
def get_auth_service(db: Session = Depends(get_db)) -> AuthService: def get_auth_service(db: Session = Depends(get_db)) -> AuthService:
"""Get an AuthService instance.""" """Get an AuthService instance."""
return AuthService(db) return AuthService(db)
# --- Authorization ---
# Access levels in order of increasing privilege
ACCESS_LEVELS = ["read", "write", "admin"]
def get_access_level_rank(level: str) -> int:
"""Get numeric rank for access level comparison."""
try:
return ACCESS_LEVELS.index(level)
except ValueError:
return -1
def has_sufficient_access(user_level: str, required_level: str) -> bool:
"""Check if user_level is sufficient for required_level.
Access levels are hierarchical: admin > write > read
"""
return get_access_level_rank(user_level) >= get_access_level_rank(required_level)
class AuthorizationService:
"""Service for checking project-level authorization."""
def __init__(self, db: Session):
self.db = db
def get_user_access_level(
self, project_id: str, user: Optional[User]
) -> Optional[str]:
"""Get the user's access level for a project.
Returns the highest access level the user has, or None if no access.
Checks in order:
1. System admin - gets admin access to all projects
2. Project owner (created_by) - gets admin access
3. Explicit permission in access_permissions table
"""
from .models import Project, AccessPermission
# Get the project
project = self.db.query(Project).filter(Project.id == project_id).first()
if not project:
return None
# Anonymous users only get access to public projects
if not user:
return "read" if project.is_public else None
# System admins get admin access everywhere
if user.is_admin:
return "admin"
# Project owner gets admin access
if project.created_by == user.username:
return "admin"
# Check explicit permissions
permission = (
self.db.query(AccessPermission)
.filter(
AccessPermission.project_id == project_id,
AccessPermission.user_id == user.username,
)
.first()
)
if permission:
# Check expiration
if permission.expires_at and permission.expires_at < datetime.now(timezone.utc):
return "read" if project.is_public else None
return permission.level
# Fall back to public access
return "read" if project.is_public else None
def check_access(
self,
project_id: str,
user: Optional[User],
required_level: str,
) -> bool:
"""Check if user has required access level for project."""
user_level = self.get_user_access_level(project_id, user)
if not user_level:
return False
return has_sufficient_access(user_level, required_level)
def grant_access(
self,
project_id: str,
username: str,
level: str,
expires_at: Optional[datetime] = None,
) -> "AccessPermission":
"""Grant access to a user for a project."""
from .models import AccessPermission
# Check if permission already exists
existing = (
self.db.query(AccessPermission)
.filter(
AccessPermission.project_id == project_id,
AccessPermission.user_id == username,
)
.first()
)
if existing:
existing.level = level
existing.expires_at = expires_at
self.db.commit()
return existing
permission = AccessPermission(
project_id=project_id,
user_id=username,
level=level,
expires_at=expires_at,
)
self.db.add(permission)
self.db.commit()
self.db.refresh(permission)
return permission
def revoke_access(self, project_id: str, username: str) -> bool:
"""Revoke a user's access to a project. Returns True if deleted."""
from .models import AccessPermission
count = (
self.db.query(AccessPermission)
.filter(
AccessPermission.project_id == project_id,
AccessPermission.user_id == username,
)
.delete()
)
self.db.commit()
return count > 0
def list_project_permissions(self, project_id: str) -> list:
"""List all permissions for a project."""
from .models import AccessPermission
return (
self.db.query(AccessPermission)
.filter(AccessPermission.project_id == project_id)
.all()
)
def get_authorization_service(db: Session = Depends(get_db)) -> AuthorizationService:
"""Get an AuthorizationService instance."""
return AuthorizationService(db)
class ProjectAccessChecker:
"""Dependency for checking project access in route handlers."""
def __init__(self, required_level: str = "read"):
self.required_level = required_level
def __call__(
self,
project: str,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
) -> User:
"""Check if user has required access to project.
Raises 404 if project not found, 403 if insufficient access.
Returns the current user (or None for public read access).
"""
from .models import Project
# Find project by name
proj = db.query(Project).filter(Project.name == project).first()
if not proj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Project '{project}' not found",
)
auth_service = AuthorizationService(db)
if not auth_service.check_access(str(proj.id), current_user, self.required_level):
if not current_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required for private project",
headers={"WWW-Authenticate": "Bearer"},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient permissions. Required: {self.required_level}",
)
return current_user
# Pre-configured access checkers for common use cases
require_project_read = ProjectAccessChecker("read")
require_project_write = ProjectAccessChecker("write")
require_project_admin = ProjectAccessChecker("admin")
def check_project_access(
db: Session,
project_name: str,
user: Optional[User],
required_level: str = "read",
) -> "Project":
"""Check if user has required access to project.
This is a helper function for use in route handlers.
Args:
db: Database session
project_name: Name of the project
user: Current user (can be None for anonymous)
required_level: Required access level (read, write, admin)
Returns:
The Project object if access is granted
Raises:
HTTPException 404: Project not found
HTTPException 401: Authentication required for private project
HTTPException 403: Insufficient permissions
"""
from .models import Project
# Find project by name
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Project '{project_name}' not found",
)
auth_service = AuthorizationService(db)
if not auth_service.check_access(str(project.id), user, required_level):
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required for private project",
headers={"WWW-Authenticate": "Bearer"},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient permissions. Required: {required_level}",
)
return project

View File

@@ -25,6 +25,7 @@ class Settings(BaseSettings):
database_pool_recycle: int = ( database_pool_recycle: int = (
1800 # Recycle connections after this many seconds (30 min) 1800 # Recycle connections after this many seconds (30 min)
) )
database_query_timeout: int = 30 # Query timeout in seconds (0 = no timeout)
# S3 # S3
s3_endpoint: str = "" s3_endpoint: str = ""
@@ -52,6 +53,17 @@ 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)
# JWT Authentication settings (optional, for external identity providers)
jwt_enabled: bool = False # Enable JWT token validation
jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS
jwt_algorithm: str = "HS256" # HS256 or RS256
jwt_issuer: str = "" # Expected issuer (iss claim), leave empty to skip validation
jwt_audience: str = "" # Expected audience (aud claim), leave empty to skip validation
jwt_jwks_url: str = "" # JWKS URL for RS256 (e.g., https://auth.example.com/.well-known/jwks.json)
jwt_username_claim: str = (
"sub" # JWT claim to use as username (sub, email, preferred_username, etc.)
)
@property @property
def database_url(self) -> str: def database_url(self) -> str:
sslmode = f"?sslmode={self.database_sslmode}" if self.database_sslmode else "" sslmode = f"?sslmode={self.database_sslmode}" if self.database_sslmode else ""

View File

@@ -12,6 +12,12 @@ from .models import Base
settings = get_settings() settings = get_settings()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Build connect_args with query timeout if configured
connect_args = {}
if settings.database_query_timeout > 0:
# PostgreSQL statement_timeout is in milliseconds
connect_args["options"] = f"-c statement_timeout={settings.database_query_timeout * 1000}"
# Create engine with connection pool configuration # Create engine with connection pool configuration
engine = create_engine( engine = create_engine(
settings.database_url, settings.database_url,
@@ -21,6 +27,7 @@ engine = create_engine(
max_overflow=settings.database_max_overflow, max_overflow=settings.database_max_overflow,
pool_timeout=settings.database_pool_timeout, pool_timeout=settings.database_pool_timeout,
pool_recycle=settings.database_pool_recycle, pool_recycle=settings.database_pool_recycle,
connect_args=connect_args,
) )
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

View File

@@ -1,15 +1,19 @@
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import logging import logging
import os import os
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from .config import get_settings from .config import get_settings
from .database import init_db, SessionLocal from .database import init_db, SessionLocal
from .routes import router from .routes import router
from .seed import seed_database from .seed import seed_database
from .auth import create_default_admin from .auth import create_default_admin
from .rate_limit import limiter
settings = get_settings() settings = get_settings()
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -55,6 +59,10 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# Set up rate limiting
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Include API routes # Include API routes
app.include_router(router) app.include_router(router)

16
backend/app/rate_limit.py Normal file
View File

@@ -0,0 +1,16 @@
"""Rate limiting configuration for Orchard API.
Uses slowapi for rate limiting with IP-based keys.
"""
import os
from slowapi import Limiter
from slowapi.util import get_remote_address
# Rate limiter - uses IP address as key
limiter = Limiter(key_func=get_remote_address)
# Rate limit strings - configurable via environment for testing
# Default: 5 login attempts per minute per IP
# In tests: set ORCHARD_LOGIN_RATE_LIMIT to a high value like "1000/minute"
LOGIN_RATE_LIMIT = os.environ.get("ORCHARD_LOGIN_RATE_LIMIT", "5/minute")

View File

@@ -45,11 +45,13 @@ from .models import (
Consumer, Consumer,
AuditLog, AuditLog,
User, User,
AccessPermission,
) )
from .schemas import ( from .schemas import (
ProjectCreate, ProjectCreate,
ProjectUpdate, ProjectUpdate,
ProjectResponse, ProjectResponse,
ProjectWithAccessResponse,
PackageCreate, PackageCreate,
PackageUpdate, PackageUpdate,
PackageResponse, PackageResponse,
@@ -107,6 +109,9 @@ from .schemas import (
APIKeyCreate, APIKeyCreate,
APIKeyResponse, APIKeyResponse,
APIKeyCreateResponse, APIKeyCreateResponse,
AccessPermissionCreate,
AccessPermissionUpdate,
AccessPermissionResponse,
) )
from .metadata import extract_metadata from .metadata import extract_metadata
from .config import get_settings from .config import get_settings
@@ -371,10 +376,14 @@ from .auth import (
validate_password_strength, validate_password_strength,
PasswordTooShortError, PasswordTooShortError,
MIN_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH,
check_project_access,
AuthorizationService,
) )
from .rate_limit import limiter, LOGIN_RATE_LIMIT
@router.post("/api/v1/auth/login", response_model=LoginResponse) @router.post("/api/v1/auth/login", response_model=LoginResponse)
@limiter.limit(LOGIN_RATE_LIMIT)
def login( def login(
login_request: LoginRequest, login_request: LoginRequest,
request: Request, request: Request,
@@ -940,7 +949,7 @@ def global_search(
# Project routes # Project routes
@router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectResponse]) @router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectWithAccessResponse])
def list_projects( def list_projects(
request: Request, request: Request,
page: int = Query(default=1, ge=1, description="Page number"), page: int = Query(default=1, ge=1, description="Page number"),
@@ -956,8 +965,9 @@ def list_projects(
), ),
order: str = Query(default="asc", description="Sort order (asc, desc)"), order: str = Query(default="asc", description="Sort order (asc, desc)"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
): ):
user_id = get_user_id(request) user_id = current_user.username if current_user else get_user_id(request)
# Validate sort field # Validate sort field
valid_sort_fields = { valid_sort_fields = {
@@ -1015,8 +1025,51 @@ def list_projects(
# Calculate total pages # Calculate total pages
total_pages = math.ceil(total / limit) if total > 0 else 1 total_pages = math.ceil(total / limit) if total > 0 else 1
# Build access level info for each project
project_ids = [p.id for p in projects]
access_map = {}
if current_user and project_ids:
# Get access permissions for this user across these projects
permissions = (
db.query(AccessPermission)
.filter(
AccessPermission.project_id.in_(project_ids),
AccessPermission.user_id == current_user.username,
)
.all()
)
access_map = {p.project_id: p.level for p in permissions}
# Build response with access levels
items = []
for p in projects:
is_owner = p.created_by == user_id
access_level = None
if is_owner:
access_level = "admin"
elif p.id in access_map:
access_level = access_map[p.id]
elif p.is_public:
access_level = "read"
items.append(
ProjectWithAccessResponse(
id=p.id,
name=p.name,
description=p.description,
is_public=p.is_public,
created_at=p.created_at,
updated_at=p.updated_at,
created_by=p.created_by,
access_level=access_level,
is_owner=is_owner,
)
)
return PaginatedResponse( return PaginatedResponse(
items=projects, items=items,
pagination=PaginationMeta( pagination=PaginationMeta(
page=page, page=page,
limit=limit, limit=limit,
@@ -1064,10 +1117,13 @@ def create_project(
@router.get("/api/v1/projects/{project_name}", response_model=ProjectResponse) @router.get("/api/v1/projects/{project_name}", response_model=ProjectResponse)
def get_project(project_name: str, db: Session = Depends(get_db)): def get_project(
project = db.query(Project).filter(Project.name == project_name).first() project_name: str,
if not project: db: Session = Depends(get_db),
raise HTTPException(status_code=404, detail="Project not found") current_user: Optional[User] = Depends(get_current_user_optional),
):
"""Get a single project by name. Requires read access for private projects."""
project = check_project_access(db, project_name, current_user, "read")
return project return project
@@ -1077,13 +1133,11 @@ def update_project(
project_update: ProjectUpdate, project_update: ProjectUpdate,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
): ):
"""Update a project's metadata.""" """Update a project's metadata. Requires admin access."""
user_id = get_user_id(request) project = check_project_access(db, project_name, current_user, "admin")
user_id = current_user.username if current_user else get_user_id(request)
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Track changes for audit log # Track changes for audit log
changes = {} changes = {}
@@ -1130,14 +1184,16 @@ def delete_project(
project_name: str, project_name: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
): ):
""" """
Delete a project and all its packages. Delete a project and all its packages. Requires admin access.
Decrements ref_count for all artifacts referenced by tags in all packages Decrements ref_count for all artifacts referenced by tags in all packages
within this project. within this project.
""" """
user_id = get_user_id(request) check_project_access(db, project_name, current_user, "admin")
user_id = current_user.username if current_user else get_user_id(request)
project = db.query(Project).filter(Project.name == project_name).first() project = db.query(Project).filter(Project.name == project_name).first()
if not project: if not project:
@@ -1183,6 +1239,159 @@ def delete_project(
return None return None
# Access Permission routes
@router.get(
"/api/v1/project/{project_name}/permissions",
response_model=List[AccessPermissionResponse],
)
def list_project_permissions(
project_name: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List all access permissions for a project.
Requires admin access to the project.
"""
project = check_project_access(db, project_name, current_user, "admin")
auth_service = AuthorizationService(db)
permissions = auth_service.list_project_permissions(str(project.id))
return permissions
@router.post(
"/api/v1/project/{project_name}/permissions",
response_model=AccessPermissionResponse,
)
def grant_project_access(
project_name: str,
permission: AccessPermissionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Grant access to a user for a project.
Requires admin access to the project.
"""
project = check_project_access(db, project_name, current_user, "admin")
auth_service = AuthorizationService(db)
new_permission = auth_service.grant_access(
str(project.id),
permission.username,
permission.level,
permission.expires_at,
)
return new_permission
@router.put(
"/api/v1/project/{project_name}/permissions/{username}",
response_model=AccessPermissionResponse,
)
def update_project_access(
project_name: str,
username: str,
permission: AccessPermissionUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Update a user's access level for a project.
Requires admin access to the project.
"""
project = check_project_access(db, project_name, current_user, "admin")
auth_service = AuthorizationService(db)
# Get existing permission
from .models import AccessPermission
existing = (
db.query(AccessPermission)
.filter(
AccessPermission.project_id == project.id,
AccessPermission.user_id == username,
)
.first()
)
if not existing:
raise HTTPException(
status_code=404,
detail=f"No access permission found for user '{username}'",
)
# Update fields
if permission.level is not None:
existing.level = permission.level
if permission.expires_at is not None:
existing.expires_at = permission.expires_at
db.commit()
db.refresh(existing)
return existing
@router.delete(
"/api/v1/project/{project_name}/permissions/{username}",
status_code=204,
)
def revoke_project_access(
project_name: str,
username: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Revoke a user's access to a project.
Requires admin access to the project.
"""
project = check_project_access(db, project_name, current_user, "admin")
auth_service = AuthorizationService(db)
deleted = auth_service.revoke_access(str(project.id), username)
if not deleted:
raise HTTPException(
status_code=404,
detail=f"No access permission found for user '{username}'",
)
return None
@router.get(
"/api/v1/project/{project_name}/my-access",
)
def get_my_project_access(
project_name: str,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
):
"""
Get the current user's access level for a project.
Returns null for anonymous users on private projects.
"""
from .models import Project
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
auth_service = AuthorizationService(db)
access_level = auth_service.get_user_access_level(str(project.id), current_user)
return {
"project": project_name,
"access_level": access_level,
"is_owner": current_user and project.created_by == current_user.username,
}
# Package routes # Package routes
@router.get( @router.get(
"/api/v1/project/{project_name}/packages", "/api/v1/project/{project_name}/packages",
@@ -1453,10 +1662,10 @@ def create_package(
package: PackageCreate, package: PackageCreate,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
): ):
project = db.query(Project).filter(Project.name == project_name).first() """Create a new package in a project. Requires write access."""
if not project: project = check_project_access(db, project_name, current_user, "write")
raise HTTPException(status_code=404, detail="Project not found")
# Validate format # Validate format
if package.format not in PACKAGE_FORMATS: if package.format not in PACKAGE_FORMATS:
@@ -1680,14 +1889,12 @@ def upload_artifact(
- Authorization: Bearer <api-key> for authentication - Authorization: Bearer <api-key> for authentication
""" """
start_time = time.time() start_time = time.time()
user_id = get_user_id_from_request(request, db, current_user)
settings = get_settings() settings = get_settings()
storage_result = None storage_result = None
# Get project and package # Check authorization (write access required for uploads)
project = db.query(Project).filter(Project.name == project_name).first() project = check_project_access(db, project_name, current_user, "write")
if not project: user_id = current_user.username if current_user else get_user_id_from_request(request, db, current_user)
raise HTTPException(status_code=404, detail="Project not found")
package = ( package = (
db.query(Package) db.query(Package)
@@ -2312,6 +2519,7 @@ def download_artifact(
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
storage: S3Storage = Depends(get_storage), storage: S3Storage = Depends(get_storage),
current_user: Optional[User] = Depends(get_current_user_optional),
range: Optional[str] = Header(None), range: Optional[str] = Header(None),
mode: Optional[Literal["proxy", "redirect", "presigned"]] = Query( mode: Optional[Literal["proxy", "redirect", "presigned"]] = Query(
default=None, default=None,
@@ -2347,10 +2555,8 @@ def download_artifact(
""" """
settings = get_settings() settings = get_settings()
# Get project and package # Check authorization (read access required for downloads)
project = db.query(Project).filter(Project.name == project_name).first() project = check_project_access(db, project_name, current_user, "read")
if not project:
raise HTTPException(status_code=404, detail="Project not found")
package = ( package = (
db.query(Package) db.query(Package)
@@ -2568,10 +2774,8 @@ def get_artifact_url(
""" """
settings = get_settings() settings = get_settings()
# Get project and package # Check authorization (read access required for downloads)
project = db.query(Project).filter(Project.name == project_name).first() project = check_project_access(db, project_name, current_user, "read")
if not project:
raise HTTPException(status_code=404, detail="Project not found")
package = ( package = (
db.query(Package) db.query(Package)
@@ -2826,12 +3030,11 @@ def create_tag(
tag: TagCreate, tag: TagCreate,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
): ):
user_id = get_user_id(request) """Create or update a tag. Requires write access."""
project = check_project_access(db, project_name, current_user, "write")
project = db.query(Project).filter(Project.name == project_name).first() user_id = current_user.username if current_user else get_user_id(request)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
package = ( package = (
db.query(Package) db.query(Package)

View File

@@ -47,6 +47,13 @@ class ProjectUpdate(BaseModel):
is_public: Optional[bool] = None is_public: Optional[bool] = None
class ProjectWithAccessResponse(ProjectResponse):
"""Project response with user's access level included"""
access_level: Optional[str] = None # 'read', 'write', 'admin', or None
is_owner: bool = False
# Package format and platform enums # Package format and platform enums
PACKAGE_FORMATS = [ PACKAGE_FORMATS = [
"generic", "generic",
@@ -776,3 +783,49 @@ class APIKeyCreateResponse(BaseModel):
created_at: datetime created_at: datetime
expires_at: Optional[datetime] expires_at: Optional[datetime]
# Access Permission schemas
class AccessPermissionCreate(BaseModel):
"""Grant access to a user for a project"""
username: str
level: str # 'read', 'write', or 'admin'
expires_at: Optional[datetime] = None
@field_validator('level')
@classmethod
def validate_level(cls, v):
if v not in ('read', 'write', 'admin'):
raise ValueError("level must be 'read', 'write', or 'admin'")
return v
class AccessPermissionUpdate(BaseModel):
"""Update access permission"""
level: Optional[str] = None
expires_at: Optional[datetime] = None
@field_validator('level')
@classmethod
def validate_level(cls, v):
if v is not None and v not in ('read', 'write', 'admin'):
raise ValueError("level must be 'read', 'write', or 'admin'")
return v
class AccessPermissionResponse(BaseModel):
"""Access permission response"""
id: UUID
project_id: UUID
user_id: str
level: str
created_at: datetime
expires_at: Optional[datetime]
class Config:
from_attributes = True
class ProjectWithAccessResponse(ProjectResponse):
"""Project response with user's access level"""
user_access_level: Optional[str] = None

View File

@@ -10,6 +10,7 @@ pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
bcrypt==4.0.1 bcrypt==4.0.1
slowapi==0.1.9
# Test dependencies # Test dependencies
pytest>=7.4.0 pytest>=7.4.0

View File

@@ -182,9 +182,10 @@ def test_app():
@pytest.fixture @pytest.fixture
def integration_client(): def integration_client():
""" """
Create a test client for integration tests. Create an authenticated test client for integration tests.
Uses the real database and MinIO from docker-compose.local.yml. Uses the real database and MinIO from docker-compose.local.yml.
Authenticates as admin for write operations.
""" """
from httpx import Client from httpx import Client
@@ -192,6 +193,15 @@ def integration_client():
base_url = os.environ.get("ORCHARD_TEST_URL", "http://localhost:8080") base_url = os.environ.get("ORCHARD_TEST_URL", "http://localhost:8080")
with Client(base_url=base_url, timeout=30.0) as client: with Client(base_url=base_url, timeout=30.0) as client:
# Login as admin to enable write operations
login_response = client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
# If login fails, tests will fail - that's expected if auth is broken
if login_response.status_code != 200:
# Try to continue without auth for backward compatibility
pass
yield client yield client

View File

@@ -59,7 +59,8 @@ class TestProjectCRUD:
@pytest.mark.integration @pytest.mark.integration
def test_list_projects(self, integration_client, test_project): def test_list_projects(self, integration_client, test_project):
"""Test listing projects includes created project.""" """Test listing projects includes created project."""
response = integration_client.get("/api/v1/projects") # Search specifically for our test project to avoid pagination issues
response = integration_client.get(f"/api/v1/projects?search={test_project}")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -107,9 +108,11 @@ class TestProjectListingFilters:
@pytest.mark.integration @pytest.mark.integration
def test_projects_search(self, integration_client, test_project): def test_projects_search(self, integration_client, test_project):
"""Test project search by name.""" """Test project search by name."""
# Search for our test project # Search using the unique portion of our test project name
# test_project format is "test-project-test-{uuid[:8]}"
unique_part = test_project.split("-")[-1] # Get the UUID portion
response = integration_client.get( response = integration_client.get(
f"/api/v1/projects?search={test_project[:10]}" f"/api/v1/projects?search={unique_part}"
) )
assert response.status_code == 200 assert response.status_code == 200

View File

@@ -286,6 +286,14 @@ class TestConcurrentUploads:
expected_hash = compute_sha256(content) expected_hash = compute_sha256(content)
num_concurrent = 5 num_concurrent = 5
# Create an API key for worker threads
api_key_response = integration_client.post(
"/api/v1/auth/keys",
json={"name": "concurrent-test-key"},
)
assert api_key_response.status_code == 200, f"Failed to create API key: {api_key_response.text}"
api_key = api_key_response.json()["key"]
results = [] results = []
errors = [] errors = []
@@ -306,6 +314,7 @@ class TestConcurrentUploads:
f"/api/v1/project/{project}/{package}/upload", f"/api/v1/project/{project}/{package}/upload",
files=files, files=files,
data={"tag": f"concurrent-{tag_suffix}"}, data={"tag": f"concurrent-{tag_suffix}"},
headers={"Authorization": f"Bearer {api_key}"},
) )
if response.status_code == 200: if response.status_code == 200:
results.append(response.json()) results.append(response.json())

View File

@@ -24,6 +24,8 @@ services:
- ORCHARD_S3_USE_PATH_STYLE=true - ORCHARD_S3_USE_PATH_STYLE=true
- ORCHARD_REDIS_HOST=redis - ORCHARD_REDIS_HOST=redis
- ORCHARD_REDIS_PORT=6379 - ORCHARD_REDIS_PORT=6379
# Higher rate limit for local development/testing
- ORCHARD_LOGIN_RATE_LIMIT=1000/minute
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View File

@@ -1,22 +1,41 @@
import { Routes, Route } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider, useAuth } from './contexts/AuthContext';
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';
import PackagePage from './pages/PackagePage'; import PackagePage from './pages/PackagePage';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import ChangePasswordPage from './pages/ChangePasswordPage';
import APIKeysPage from './pages/APIKeysPage'; import APIKeysPage from './pages/APIKeysPage';
import AdminUsersPage from './pages/AdminUsersPage'; import AdminUsersPage from './pages/AdminUsersPage';
function App() { // Component that checks if user must change password
function RequirePasswordChange({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return null;
}
// If user is logged in and must change password, redirect to change password page
if (user?.must_change_password && location.pathname !== '/change-password') {
return <Navigate to="/change-password" replace />;
}
return <>{children}</>;
}
function AppRoutes() {
return ( return (
<AuthProvider>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/change-password" element={<ChangePasswordPage />} />
<Route <Route
path="*" path="*"
element={ element={
<RequirePasswordChange>
<Layout> <Layout>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
@@ -27,9 +46,17 @@ function App() {
<Route path="/project/:projectName/:packageName" element={<PackagePage />} /> <Route path="/project/:projectName/:packageName" element={<PackagePage />} />
</Routes> </Routes>
</Layout> </Layout>
</RequirePasswordChange>
} }
/> />
</Routes> </Routes>
);
}
function App() {
return (
<AuthProvider>
<AppRoutes />
</AuthProvider> </AuthProvider>
); );
} }

View File

@@ -25,14 +25,51 @@ import {
AdminUser, AdminUser,
UserCreate, UserCreate,
UserUpdate, UserUpdate,
AccessPermission,
AccessPermissionCreate,
AccessPermissionUpdate,
AccessLevel,
} from './types'; } from './types';
const API_BASE = '/api/v1'; const API_BASE = '/api/v1';
// Custom error classes for better error handling
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
export class UnauthorizedError extends ApiError {
constructor(message: string = 'Not authenticated') {
super(message, 401);
this.name = 'UnauthorizedError';
}
}
export class ForbiddenError extends ApiError {
constructor(message: string = 'Access denied') {
super(message, 403);
this.name = 'ForbiddenError';
}
}
async function handleResponse<T>(response: Response): Promise<T> { async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' })); const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `HTTP ${response.status}`); const message = error.detail || `HTTP ${response.status}`;
if (response.status === 401) {
throw new UnauthorizedError(message);
}
if (response.status === 403) {
throw new ForbiddenError(message);
}
throw new ApiError(message, response.status);
} }
return response.json(); return response.json();
} }
@@ -70,6 +107,19 @@ export async function logout(): Promise<void> {
} }
} }
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
const response = await fetch(`${API_BASE}/auth/change-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
credentials: 'include',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
}
export async function getCurrentUser(): Promise<User | null> { export async function getCurrentUser(): Promise<User | null> {
try { try {
const response = await fetch(`${API_BASE}/auth/me`, { const response = await fetch(`${API_BASE}/auth/me`, {
@@ -299,3 +349,62 @@ export async function resetUserPassword(username: string, newPassword: string):
throw new Error(error.detail || `HTTP ${response.status}`); throw new Error(error.detail || `HTTP ${response.status}`);
} }
} }
// Access Permission API
export interface MyAccessResponse {
project: string;
access_level: AccessLevel | null;
is_owner: boolean;
}
export async function getMyProjectAccess(projectName: string): Promise<MyAccessResponse> {
const response = await fetch(`${API_BASE}/project/${projectName}/my-access`, {
credentials: 'include',
});
return handleResponse<MyAccessResponse>(response);
}
export async function listProjectPermissions(projectName: string): Promise<AccessPermission[]> {
const response = await fetch(`${API_BASE}/project/${projectName}/permissions`, {
credentials: 'include',
});
return handleResponse<AccessPermission[]>(response);
}
export async function grantProjectAccess(
projectName: string,
data: AccessPermissionCreate
): Promise<AccessPermission> {
const response = await fetch(`${API_BASE}/project/${projectName}/permissions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include',
});
return handleResponse<AccessPermission>(response);
}
export async function updateProjectAccess(
projectName: string,
username: string,
data: AccessPermissionUpdate
): Promise<AccessPermission> {
const response = await fetch(`${API_BASE}/project/${projectName}/permissions/${username}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include',
});
return handleResponse<AccessPermission>(response);
}
export async function revokeProjectAccess(projectName: string, username: string): Promise<void> {
const response = await fetch(`${API_BASE}/project/${projectName}/permissions/${username}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
}

View File

@@ -0,0 +1,116 @@
.access-management {
margin-top: 1.5rem;
}
.access-management__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.access-management__header h3 {
margin: 0;
}
.access-management__form {
background: var(--bg-tertiary);
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.access-management__form .form-row {
display: flex;
gap: 1rem;
align-items: flex-end;
}
.access-management__form .form-group {
flex: 1;
}
.access-management__form .form-group:last-of-type {
flex: 0 0 auto;
}
.access-management__list {
margin-top: 1rem;
}
.access-table {
width: 100%;
border-collapse: collapse;
}
.access-table th,
.access-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.access-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.875rem;
}
.access-table td.actions {
display: flex;
gap: 0.5rem;
}
.access-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: capitalize;
}
.access-badge--read {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.access-badge--write {
background: var(--color-info-bg);
color: var(--color-info);
}
.access-badge--admin {
background: var(--color-success-bg);
color: var(--color-success);
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.btn-danger {
background: var(--color-error);
color: white;
}
.btn-danger:hover {
background: #c0392b;
}
/* Expired permission styling */
.expired {
color: var(--color-error);
font-weight: 500;
}
/* Date input styling in table */
.access-table input[type="date"] {
padding: 0.25rem 0.5rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 4px;
font-size: 0.875rem;
color: var(--text-primary);
}

View File

@@ -0,0 +1,296 @@
import { useState, useEffect, useCallback } from 'react';
import { AccessPermission, AccessLevel } from '../types';
import {
listProjectPermissions,
grantProjectAccess,
updateProjectAccess,
revokeProjectAccess,
} from '../api';
import './AccessManagement.css';
interface AccessManagementProps {
projectName: string;
}
export function AccessManagement({ projectName }: AccessManagementProps) {
const [permissions, setPermissions] = useState<AccessPermission[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// Form state
const [showAddForm, setShowAddForm] = useState(false);
const [newUsername, setNewUsername] = useState('');
const [newLevel, setNewLevel] = useState<AccessLevel>('read');
const [newExpiresAt, setNewExpiresAt] = useState('');
const [submitting, setSubmitting] = useState(false);
// Edit state
const [editingUser, setEditingUser] = useState<string | null>(null);
const [editLevel, setEditLevel] = useState<AccessLevel>('read');
const [editExpiresAt, setEditExpiresAt] = useState('');
const loadPermissions = useCallback(async () => {
try {
setLoading(true);
const data = await listProjectPermissions(projectName);
setPermissions(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load permissions');
} finally {
setLoading(false);
}
}, [projectName]);
useEffect(() => {
loadPermissions();
}, [loadPermissions]);
const handleGrant = async (e: React.FormEvent) => {
e.preventDefault();
if (!newUsername.trim()) return;
try {
setSubmitting(true);
setError(null);
await grantProjectAccess(projectName, {
username: newUsername.trim(),
level: newLevel,
expires_at: newExpiresAt || undefined,
});
setSuccess(`Access granted to ${newUsername}`);
setNewUsername('');
setNewLevel('read');
setNewExpiresAt('');
setShowAddForm(false);
await loadPermissions();
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to grant access');
} finally {
setSubmitting(false);
}
};
const handleUpdate = async (username: string) => {
try {
setSubmitting(true);
setError(null);
await updateProjectAccess(projectName, username, {
level: editLevel,
expires_at: editExpiresAt || null,
});
setSuccess(`Updated access for ${username}`);
setEditingUser(null);
await loadPermissions();
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update access');
} finally {
setSubmitting(false);
}
};
const handleRevoke = async (username: string) => {
if (!confirm(`Revoke access for ${username}?`)) return;
try {
setSubmitting(true);
setError(null);
await revokeProjectAccess(projectName, username);
setSuccess(`Access revoked for ${username}`);
await loadPermissions();
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to revoke access');
} finally {
setSubmitting(false);
}
};
const startEdit = (permission: AccessPermission) => {
setEditingUser(permission.user_id);
setEditLevel(permission.level as AccessLevel);
// Convert ISO date to local date format for date input
setEditExpiresAt(permission.expires_at ? permission.expires_at.split('T')[0] : '');
};
const cancelEdit = () => {
setEditingUser(null);
setEditExpiresAt('');
};
const formatExpiration = (expiresAt: string | null) => {
if (!expiresAt) return 'Never';
const date = new Date(expiresAt);
const now = new Date();
const isExpired = date < now;
return (
<span className={isExpired ? 'expired' : ''}>
{date.toLocaleDateString()}
{isExpired && ' (Expired)'}
</span>
);
};
if (loading) {
return <div className="access-management loading">Loading permissions...</div>;
}
return (
<div className="access-management card">
<div className="access-management__header">
<h3>Access Management</h3>
<button
className="btn btn-primary btn-sm"
onClick={() => setShowAddForm(!showAddForm)}
>
{showAddForm ? 'Cancel' : '+ Add User'}
</button>
</div>
{error && <div className="error-message">{error}</div>}
{success && <div className="success-message">{success}</div>}
{showAddForm && (
<form className="access-management__form" onSubmit={handleGrant}>
<div className="form-row">
<div className="form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
placeholder="Enter username"
required
disabled={submitting}
/>
</div>
<div className="form-group">
<label htmlFor="level">Access Level</label>
<select
id="level"
value={newLevel}
onChange={(e) => setNewLevel(e.target.value as AccessLevel)}
disabled={submitting}
>
<option value="read">Read</option>
<option value="write">Write</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="form-group">
<label htmlFor="expires_at">Expires (optional)</label>
<input
id="expires_at"
type="date"
value={newExpiresAt}
onChange={(e) => setNewExpiresAt(e.target.value)}
disabled={submitting}
min={new Date().toISOString().split('T')[0]}
/>
</div>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? 'Granting...' : 'Grant Access'}
</button>
</div>
</form>
)}
<div className="access-management__list">
{permissions.length === 0 ? (
<p className="text-muted">No explicit permissions set. Only the project owner has access.</p>
) : (
<table className="access-table">
<thead>
<tr>
<th>User</th>
<th>Access Level</th>
<th>Granted</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{permissions.map((p) => (
<tr key={p.id}>
<td>{p.user_id}</td>
<td>
{editingUser === p.user_id ? (
<select
value={editLevel}
onChange={(e) => setEditLevel(e.target.value as AccessLevel)}
disabled={submitting}
>
<option value="read">Read</option>
<option value="write">Write</option>
<option value="admin">Admin</option>
</select>
) : (
<span className={`access-badge access-badge--${p.level}`}>
{p.level}
</span>
)}
</td>
<td>{new Date(p.created_at).toLocaleDateString()}</td>
<td>
{editingUser === p.user_id ? (
<input
type="date"
value={editExpiresAt}
onChange={(e) => setEditExpiresAt(e.target.value)}
disabled={submitting}
min={new Date().toISOString().split('T')[0]}
/>
) : (
formatExpiration(p.expires_at)
)}
</td>
<td className="actions">
{editingUser === p.user_id ? (
<>
<button
className="btn btn-sm btn-primary"
onClick={() => handleUpdate(p.user_id)}
disabled={submitting}
>
Save
</button>
<button
className="btn btn-sm"
onClick={cancelEdit}
disabled={submitting}
>
Cancel
</button>
</>
) : (
<>
<button
className="btn btn-sm"
onClick={() => startEdit(p)}
disabled={submitting}
>
Edit
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => handleRevoke(p.user_id)}
disabled={submitting}
>
Revoke
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ interface AuthContextType {
error: string | null; error: string | null;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
refreshUser: () => Promise<void>;
clearError: () => void; clearError: () => void;
} }
@@ -71,8 +72,17 @@ export function AuthProvider({ children }: AuthProviderProps) {
setError(null); setError(null);
}, []); }, []);
const refreshUser = useCallback(async () => {
try {
const currentUser = await getCurrentUser();
setUser(currentUser);
} catch {
setUser(null);
}
}, []);
return ( return (
<AuthContext.Provider value={{ user, loading, error, login, logout, clearError }}> <AuthContext.Provider value={{ user, loading, error, login, logout, refreshUser, clearError }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@@ -0,0 +1,156 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { changePassword } from '../api';
import './LoginPage.css';
function ChangePasswordPage() {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { user, refreshUser } = useAuth();
const navigate = useNavigate();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!currentPassword || !newPassword || !confirmPassword) {
setError('Please fill in all fields');
return;
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match');
return;
}
if (newPassword.length < 8) {
setError('New password must be at least 8 characters');
return;
}
if (newPassword === currentPassword) {
setError('New password must be different from current password');
return;
}
setIsSubmitting(true);
setError(null);
try {
await changePassword(currentPassword, newPassword);
// Refresh user to clear must_change_password flag
await refreshUser();
navigate('/', { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to change password');
} finally {
setIsSubmitting(false);
}
}
return (
<div className="login-page">
<div className="login-container">
<div className="login-card">
<div className="login-header">
<div className="login-logo">
<svg width="48" height="48" 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>
</div>
<h1>Change Password</h1>
{user?.must_change_password && (
<p className="login-subtitle login-warning">
You must change your password before continuing
</p>
)}
</div>
{error && (
<div className="login-error">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>{error}</span>
</div>
)}
<form onSubmit={handleSubmit} className="login-form">
<div className="login-form-group">
<label htmlFor="currentPassword">Current Password</label>
<input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password"
autoComplete="current-password"
autoFocus
disabled={isSubmitting}
/>
</div>
<div className="login-form-group">
<label htmlFor="newPassword">New Password</label>
<input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password (min 8 characters)"
autoComplete="new-password"
disabled={isSubmitting}
/>
</div>
<div className="login-form-group">
<label htmlFor="confirmPassword">Confirm New Password</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
autoComplete="new-password"
disabled={isSubmitting}
/>
</div>
<button
type="submit"
className="login-submit"
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<span className="login-spinner"></span>
Changing password...
</>
) : (
'Change Password'
)}
</button>
</form>
</div>
<div className="login-footer">
<p>Artifact storage and management system</p>
</div>
</div>
</div>
);
}
export default ChangePasswordPage;

View File

@@ -474,3 +474,16 @@
margin-top: 4px; margin-top: 4px;
font-size: 0.9375rem; font-size: 0.9375rem;
} }
/* Lock icon for private projects */
.lock-icon {
color: var(--warning);
flex-shrink: 0;
}
/* Project badges container */
.project-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
}

View File

@@ -7,8 +7,19 @@ import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterDropdown, FilterOption } from '../components/FilterDropdown'; import { FilterDropdown, FilterOption } from '../components/FilterDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { Pagination } from '../components/Pagination'; import { Pagination } from '../components/Pagination';
import { useAuth } from '../contexts/AuthContext';
import './Home.css'; import './Home.css';
// Lock icon SVG component
function LockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lock-icon">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
const SORT_OPTIONS: SortOption[] = [ const SORT_OPTIONS: SortOption[] = [
{ value: 'name', label: 'Name' }, { value: 'name', label: 'Name' },
{ value: 'created_at', label: 'Created' }, { value: 'created_at', label: 'Created' },
@@ -23,6 +34,7 @@ const VISIBILITY_OPTIONS: FilterOption[] = [
function Home() { function Home() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { user } = useAuth();
const [projectsData, setProjectsData] = useState<PaginatedResponse<Project> | null>(null); const [projectsData, setProjectsData] = useState<PaginatedResponse<Project> | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -117,9 +129,15 @@ function Home() {
<div className="home"> <div className="home">
<div className="page-header"> <div className="page-header">
<h1>Projects</h1> <h1>Projects</h1>
{user ? (
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}> <button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ New Project'} {showForm ? 'Cancel' : '+ New Project'}
</button> </button>
) : (
<Link to="/login" className="btn btn-secondary">
Login to create projects
</Link>
)}
</div> </div>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
@@ -199,12 +217,32 @@ function Home() {
<div className="project-grid"> <div className="project-grid">
{projects.map((project) => ( {projects.map((project) => (
<Link to={`/project/${project.name}`} key={project.id} className="project-card card"> <Link to={`/project/${project.name}`} key={project.id} className="project-card card">
<h3>{project.name}</h3> <h3>
{!project.is_public && <LockIcon />}
{project.name}
</h3>
{project.description && <p>{project.description}</p>} {project.description && <p>{project.description}</p>}
<div className="project-meta"> <div className="project-meta">
<div className="project-badges">
<Badge variant={project.is_public ? 'public' : 'private'}> <Badge variant={project.is_public ? 'public' : 'private'}>
{project.is_public ? 'Public' : 'Private'} {project.is_public ? 'Public' : 'Private'}
</Badge> </Badge>
{user && project.access_level && (
<Badge
variant={
project.is_owner
? 'success'
: project.access_level === 'admin'
? 'success'
: project.access_level === 'write'
? 'info'
: 'default'
}
>
{project.is_owner ? 'Owner' : project.access_level.charAt(0).toUpperCase() + project.access_level.slice(1)}
</Badge>
)}
</div>
<div className="project-meta__dates"> <div className="project-meta__dates">
<span className="date">Created {new Date(project.created_at).toLocaleDateString()}</span> <span className="date">Created {new Date(project.created_at).toLocaleDateString()}</span>
{project.updated_at !== project.created_at && ( {project.updated_at !== project.created_at && (

View File

@@ -69,6 +69,11 @@
font-size: 0.875rem; font-size: 0.875rem;
} }
.login-subtitle.login-warning {
color: var(--warning);
font-weight: 500;
}
/* Error message */ /* Error message */
.login-error { .login-error {
display: flex; display: flex;

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { TagDetail, Package, PaginatedResponse } from '../types'; import { TagDetail, Package, PaginatedResponse, AccessLevel } from '../types';
import { listTags, getDownloadUrl, getPackage } from '../api'; import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api';
import { Breadcrumb } from '../components/Breadcrumb'; import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput'; import { SearchInput } from '../components/SearchInput';
@@ -10,6 +10,7 @@ import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { DataTable } from '../components/DataTable'; import { DataTable } from '../components/DataTable';
import { Pagination } from '../components/Pagination'; import { Pagination } from '../components/Pagination';
import { DragDropUpload, UploadResult } from '../components/DragDropUpload'; import { DragDropUpload, UploadResult } from '../components/DragDropUpload';
import { useAuth } from '../contexts/AuthContext';
import './Home.css'; import './Home.css';
import './PackagePage.css'; import './PackagePage.css';
@@ -56,15 +57,22 @@ function CopyButton({ text }: { text: string }) {
function PackagePage() { function PackagePage() {
const { projectName, packageName } = useParams<{ projectName: string; packageName: string }>(); const { projectName, packageName } = useParams<{ projectName: string; packageName: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { user } = useAuth();
const [pkg, setPkg] = useState<Package | null>(null); const [pkg, setPkg] = useState<Package | null>(null);
const [tagsData, setTagsData] = useState<PaginatedResponse<TagDetail> | null>(null); const [tagsData, setTagsData] = useState<PaginatedResponse<TagDetail> | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState(false);
const [uploadTag, setUploadTag] = useState(''); const [uploadTag, setUploadTag] = useState('');
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null); const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [artifactIdInput, setArtifactIdInput] = useState(''); const [artifactIdInput, setArtifactIdInput] = useState('');
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null);
// Derived permissions
const canWrite = accessLevel === 'write' || accessLevel === 'admin';
// Get params from URL // Get params from URL
const page = parseInt(searchParams.get('page') || '1', 10); const page = parseInt(searchParams.get('page') || '1', 10);
@@ -92,19 +100,32 @@ function PackagePage() {
try { try {
setLoading(true); setLoading(true);
const [pkgData, tagsResult] = await Promise.all([ setAccessDenied(false);
const [pkgData, tagsResult, accessResult] = await Promise.all([
getPackage(projectName, packageName), getPackage(projectName, packageName),
listTags(projectName, packageName, { page, search, sort, order }), listTags(projectName, packageName, { page, search, sort, order }),
getMyProjectAccess(projectName),
]); ]);
setPkg(pkgData); setPkg(pkgData);
setTagsData(tagsResult); setTagsData(tagsResult);
setAccessLevel(accessResult.access_level);
setError(null); setError(null);
} catch (err) { } catch (err) {
if (err instanceof UnauthorizedError) {
navigate('/login', { state: { from: location.pathname } });
return;
}
if (err instanceof ForbiddenError) {
setAccessDenied(true);
setError('You do not have access to this package');
setLoading(false);
return;
}
setError(err instanceof Error ? err.message : 'Failed to load data'); setError(err instanceof Error ? err.message : 'Failed to load data');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [projectName, packageName, page, search, sort, order]); }, [projectName, packageName, page, search, sort, order, navigate, location.pathname]);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -226,6 +247,28 @@ function PackagePage() {
return <div className="loading">Loading...</div>; return <div className="loading">Loading...</div>;
} }
if (accessDenied) {
return (
<div className="home">
<Breadcrumb
items={[
{ label: 'Projects', href: '/' },
{ label: projectName!, href: `/project/${projectName}` },
]}
/>
<div className="error-message" style={{ textAlign: 'center', padding: '48px 24px' }}>
<h2>Access Denied</h2>
<p>You do not have permission to view this package.</p>
{!user && (
<p style={{ marginTop: '16px' }}>
<a href="/login" className="btn btn-primary">Sign in</a>
</p>
)}
</div>
</div>
);
}
return ( return (
<div className="home"> <div className="home">
<Breadcrumb <Breadcrumb
@@ -286,6 +329,7 @@ function PackagePage() {
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
{uploadSuccess && <div className="success-message">{uploadSuccess}</div>} {uploadSuccess && <div className="success-message">{uploadSuccess}</div>}
{canWrite ? (
<div className="upload-section card"> <div className="upload-section card">
<h3>Upload Artifact</h3> <h3>Upload Artifact</h3>
<div className="upload-form"> <div className="upload-form">
@@ -308,6 +352,12 @@ function PackagePage() {
/> />
</div> </div>
</div> </div>
) : user ? (
<div className="upload-section card">
<h3>Upload Artifact</h3>
<p className="text-muted">You have read-only access to this project and cannot upload artifacts.</p>
</div>
) : null}
<div className="section-header"> <div className="section-header">
<h2>Tags / Versions</h2> <h2>Tags / Versions</h2>

View File

@@ -1,13 +1,15 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, Link, useSearchParams, useNavigate } from 'react-router-dom'; import { useParams, Link, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { Project, Package, PaginatedResponse } from '../types'; import { Project, Package, PaginatedResponse, AccessLevel } from '../types';
import { getProject, listPackages, createPackage } from '../api'; import { getProject, listPackages, createPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api';
import { Breadcrumb } from '../components/Breadcrumb'; import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput'; import { SearchInput } from '../components/SearchInput';
import { SortDropdown, SortOption } from '../components/SortDropdown'; import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { Pagination } from '../components/Pagination'; import { Pagination } from '../components/Pagination';
import { AccessManagement } from '../components/AccessManagement';
import { useAuth } from '../contexts/AuthContext';
import './Home.css'; import './Home.css';
const SORT_OPTIONS: SortOption[] = [ const SORT_OPTIONS: SortOption[] = [
@@ -29,15 +31,24 @@ function formatBytes(bytes: number): string {
function ProjectPage() { function ProjectPage() {
const { projectName } = useParams<{ projectName: string }>(); const { projectName } = useParams<{ projectName: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { user } = useAuth();
const [project, setProject] = useState<Project | null>(null); const [project, setProject] = useState<Project | null>(null);
const [packagesData, setPackagesData] = useState<PaginatedResponse<Package> | null>(null); const [packagesData, setPackagesData] = useState<PaginatedResponse<Package> | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState(false);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [newPackage, setNewPackage] = useState({ name: '', description: '', format: 'generic', platform: 'any' }); const [newPackage, setNewPackage] = useState({ name: '', description: '', format: 'generic', platform: 'any' });
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null);
const [isOwner, setIsOwner] = useState(false);
// Derived permissions
const canWrite = accessLevel === 'write' || accessLevel === 'admin';
const canAdmin = accessLevel === 'admin';
// Get params from URL // Get params from URL
const page = parseInt(searchParams.get('page') || '1', 10); const page = parseInt(searchParams.get('page') || '1', 10);
@@ -66,19 +77,33 @@ function ProjectPage() {
try { try {
setLoading(true); setLoading(true);
const [projectData, packagesResult] = await Promise.all([ setAccessDenied(false);
const [projectData, packagesResult, accessResult] = await Promise.all([
getProject(projectName), getProject(projectName),
listPackages(projectName, { page, search, sort, order, format: format || undefined }), listPackages(projectName, { page, search, sort, order, format: format || undefined }),
getMyProjectAccess(projectName),
]); ]);
setProject(projectData); setProject(projectData);
setPackagesData(packagesResult); setPackagesData(packagesResult);
setAccessLevel(accessResult.access_level);
setIsOwner(accessResult.is_owner);
setError(null); setError(null);
} catch (err) { } catch (err) {
if (err instanceof UnauthorizedError) {
navigate('/login', { state: { from: location.pathname } });
return;
}
if (err instanceof ForbiddenError) {
setAccessDenied(true);
setError('You do not have access to this project');
setLoading(false);
return;
}
setError(err instanceof Error ? err.message : 'Failed to load data'); setError(err instanceof Error ? err.message : 'Failed to load data');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [projectName, page, search, sort, order, format]); }, [projectName, page, search, sort, order, format, navigate, location.pathname]);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -139,6 +164,23 @@ function ProjectPage() {
return <div className="loading">Loading...</div>; return <div className="loading">Loading...</div>;
} }
if (accessDenied) {
return (
<div className="home">
<Breadcrumb items={[{ label: 'Projects', href: '/' }]} />
<div className="error-message" style={{ textAlign: 'center', padding: '48px 24px' }}>
<h2>Access Denied</h2>
<p>You do not have permission to view this project.</p>
{!user && (
<p style={{ marginTop: '16px' }}>
<a href="/login" className="btn btn-primary">Sign in</a>
</p>
)}
</div>
</div>
);
}
if (!project) { if (!project) {
return <div className="error-message">Project not found</div>; return <div className="error-message">Project not found</div>;
} }
@@ -159,6 +201,11 @@ function ProjectPage() {
<Badge variant={project.is_public ? 'public' : 'private'}> <Badge variant={project.is_public ? 'public' : 'private'}>
{project.is_public ? 'Public' : 'Private'} {project.is_public ? 'Public' : 'Private'}
</Badge> </Badge>
{accessLevel && (
<Badge variant={accessLevel === 'admin' ? 'success' : accessLevel === 'write' ? 'info' : 'default'}>
{isOwner ? 'Owner' : accessLevel.charAt(0).toUpperCase() + accessLevel.slice(1)}
</Badge>
)}
</div> </div>
{project.description && <p className="description">{project.description}</p>} {project.description && <p className="description">{project.description}</p>}
<div className="page-header__meta"> <div className="page-header__meta">
@@ -169,14 +216,20 @@ function ProjectPage() {
<span className="meta-item">by {project.created_by}</span> <span className="meta-item">by {project.created_by}</span>
</div> </div>
</div> </div>
{canWrite ? (
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}> <button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ New Package'} {showForm ? 'Cancel' : '+ New Package'}
</button> </button>
) : user ? (
<span className="text-muted" title="You have read-only access to this project">
Read-only access
</span>
) : null}
</div> </div>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
{showForm && ( {showForm && canWrite && (
<form className="form card" onSubmit={handleCreatePackage}> <form className="form card" onSubmit={handleCreatePackage}>
<h3>Create New Package</h3> <h3>Create New Package</h3>
<div className="form-row"> <div className="form-row">
@@ -316,6 +369,10 @@ function ProjectPage() {
)} )}
</> </>
)} )}
{canAdmin && projectName && (
<AccessManagement projectName={projectName} />
)}
</div> </div>
); );
} }

View File

@@ -1,3 +1,6 @@
// Access Control types (moved to top for use in Project interface)
export type AccessLevel = 'read' | 'write' | 'admin';
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
@@ -6,6 +9,9 @@ export interface Project {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
created_by: string; created_by: string;
// Access level info (populated when listing projects)
access_level?: AccessLevel | null;
is_owner?: boolean;
} }
export interface TagSummary { export interface TagSummary {
@@ -232,6 +238,7 @@ export interface User {
username: string; username: string;
display_name: string | null; display_name: string | null;
is_admin: boolean; is_admin: boolean;
must_change_password?: boolean;
} }
export interface LoginCredentials { export interface LoginCredentials {
@@ -289,3 +296,36 @@ export interface UserUpdate {
is_admin?: boolean; is_admin?: boolean;
is_active?: boolean; is_active?: boolean;
} }
// Access Permission types
export interface AccessPermission {
id: string;
project_id: string;
user_id: string;
level: AccessLevel;
created_at: string;
expires_at: string | null;
}
export interface AccessPermissionCreate {
username: string;
level: AccessLevel;
expires_at?: string;
}
export interface AccessPermissionUpdate {
level?: AccessLevel;
expires_at?: string | null;
}
// Extended Project with user's access level
export interface ProjectWithAccess extends Project {
user_access_level?: AccessLevel;
}
// Current user with permissions context
export interface CurrentUser extends User {
permissions?: {
[projectId: string]: AccessLevel;
};
}