Compare commits
7 Commits
b1c17e8ab7
...
3ebdf51105
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ebdf51105 | ||
|
|
6b9f63a30e | ||
|
|
f7c91e94f6 | ||
|
|
ac625fa55f | ||
|
|
0bef44a292 | ||
|
|
6aa199b80b | ||
|
|
d61c7a71fb |
@@ -1,16 +1,20 @@
|
||||
"""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 secrets
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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)
|
||||
@@ -374,6 +378,147 @@ def create_default_admin(db: Session) -> Optional[User]:
|
||||
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 ---
|
||||
|
||||
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),
|
||||
authorization: Optional[str] = Header(None),
|
||||
) -> 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.
|
||||
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)
|
||||
|
||||
@@ -403,13 +553,24 @@ def get_current_user_optional(
|
||||
if user and user.is_active:
|
||||
return user
|
||||
|
||||
# Then try API key (CLI/programmatic access)
|
||||
if authorization:
|
||||
if authorization.startswith("Bearer "):
|
||||
api_key = authorization[7:] # Remove "Bearer " prefix
|
||||
user = auth_service.get_user_from_api_key(api_key)
|
||||
# Then try Bearer token (API key or JWT)
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
token = authorization[7:] # Remove "Bearer " prefix
|
||||
|
||||
# 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:
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
@@ -448,3 +609,261 @@ def require_admin(
|
||||
def get_auth_service(db: Session = Depends(get_db)) -> AuthService:
|
||||
"""Get an AuthService instance."""
|
||||
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
|
||||
|
||||
@@ -25,6 +25,7 @@ class Settings(BaseSettings):
|
||||
database_pool_recycle: int = (
|
||||
1800 # Recycle connections after this many seconds (30 min)
|
||||
)
|
||||
database_query_timeout: int = 30 # Query timeout in seconds (0 = no timeout)
|
||||
|
||||
# S3
|
||||
s3_endpoint: str = ""
|
||||
@@ -52,6 +53,17 @@ class Settings(BaseSettings):
|
||||
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
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
|
||||
def database_url(self) -> str:
|
||||
sslmode = f"?sslmode={self.database_sslmode}" if self.database_sslmode else ""
|
||||
|
||||
@@ -12,6 +12,12 @@ from .models import Base
|
||||
settings = get_settings()
|
||||
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
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
@@ -21,6 +27,7 @@ engine = create_engine(
|
||||
max_overflow=settings.database_max_overflow,
|
||||
pool_timeout=settings.database_pool_timeout,
|
||||
pool_recycle=settings.database_pool_recycle,
|
||||
connect_args=connect_args,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
import os
|
||||
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
|
||||
from .config import get_settings
|
||||
from .database import init_db, SessionLocal
|
||||
from .routes import router
|
||||
from .seed import seed_database
|
||||
from .auth import create_default_admin
|
||||
from .rate_limit import limiter
|
||||
|
||||
settings = get_settings()
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -55,6 +59,10 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Set up rate limiting
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# Include API routes
|
||||
app.include_router(router)
|
||||
|
||||
|
||||
16
backend/app/rate_limit.py
Normal file
16
backend/app/rate_limit.py
Normal 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")
|
||||
@@ -45,11 +45,13 @@ from .models import (
|
||||
Consumer,
|
||||
AuditLog,
|
||||
User,
|
||||
AccessPermission,
|
||||
)
|
||||
from .schemas import (
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
ProjectResponse,
|
||||
ProjectWithAccessResponse,
|
||||
PackageCreate,
|
||||
PackageUpdate,
|
||||
PackageResponse,
|
||||
@@ -107,6 +109,9 @@ from .schemas import (
|
||||
APIKeyCreate,
|
||||
APIKeyResponse,
|
||||
APIKeyCreateResponse,
|
||||
AccessPermissionCreate,
|
||||
AccessPermissionUpdate,
|
||||
AccessPermissionResponse,
|
||||
)
|
||||
from .metadata import extract_metadata
|
||||
from .config import get_settings
|
||||
@@ -371,10 +376,14 @@ from .auth import (
|
||||
validate_password_strength,
|
||||
PasswordTooShortError,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
check_project_access,
|
||||
AuthorizationService,
|
||||
)
|
||||
from .rate_limit import limiter, LOGIN_RATE_LIMIT
|
||||
|
||||
|
||||
@router.post("/api/v1/auth/login", response_model=LoginResponse)
|
||||
@limiter.limit(LOGIN_RATE_LIMIT)
|
||||
def login(
|
||||
login_request: LoginRequest,
|
||||
request: Request,
|
||||
@@ -940,7 +949,7 @@ def global_search(
|
||||
|
||||
|
||||
# Project routes
|
||||
@router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectResponse])
|
||||
@router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectWithAccessResponse])
|
||||
def list_projects(
|
||||
request: Request,
|
||||
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)"),
|
||||
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
|
||||
valid_sort_fields = {
|
||||
@@ -1015,8 +1025,51 @@ def list_projects(
|
||||
# Calculate total pages
|
||||
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(
|
||||
items=projects,
|
||||
items=items,
|
||||
pagination=PaginationMeta(
|
||||
page=page,
|
||||
limit=limit,
|
||||
@@ -1064,10 +1117,13 @@ def create_project(
|
||||
|
||||
|
||||
@router.get("/api/v1/projects/{project_name}", response_model=ProjectResponse)
|
||||
def get_project(project_name: str, db: Session = Depends(get_db)):
|
||||
project = db.query(Project).filter(Project.name == project_name).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
def get_project(
|
||||
project_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
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
|
||||
|
||||
|
||||
@@ -1077,13 +1133,11 @@ def update_project(
|
||||
project_update: ProjectUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""Update a project's metadata."""
|
||||
user_id = 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")
|
||||
"""Update a project's metadata. Requires admin access."""
|
||||
project = check_project_access(db, project_name, current_user, "admin")
|
||||
user_id = current_user.username if current_user else get_user_id(request)
|
||||
|
||||
# Track changes for audit log
|
||||
changes = {}
|
||||
@@ -1130,14 +1184,16 @@ def delete_project(
|
||||
project_name: str,
|
||||
request: Request,
|
||||
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
|
||||
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()
|
||||
if not project:
|
||||
@@ -1183,6 +1239,159 @@ def delete_project(
|
||||
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
|
||||
@router.get(
|
||||
"/api/v1/project/{project_name}/packages",
|
||||
@@ -1453,10 +1662,10 @@ def create_package(
|
||||
package: PackageCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
project = db.query(Project).filter(Project.name == project_name).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
"""Create a new package in a project. Requires write access."""
|
||||
project = check_project_access(db, project_name, current_user, "write")
|
||||
|
||||
# Validate format
|
||||
if package.format not in PACKAGE_FORMATS:
|
||||
@@ -1680,14 +1889,12 @@ def upload_artifact(
|
||||
- Authorization: Bearer <api-key> for authentication
|
||||
"""
|
||||
start_time = time.time()
|
||||
user_id = get_user_id_from_request(request, db, current_user)
|
||||
settings = get_settings()
|
||||
storage_result = None
|
||||
|
||||
# Get project and package
|
||||
project = db.query(Project).filter(Project.name == project_name).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
# Check authorization (write access required for uploads)
|
||||
project = check_project_access(db, project_name, current_user, "write")
|
||||
user_id = current_user.username if current_user else get_user_id_from_request(request, db, current_user)
|
||||
|
||||
package = (
|
||||
db.query(Package)
|
||||
@@ -2312,6 +2519,7 @@ def download_artifact(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
storage: S3Storage = Depends(get_storage),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
range: Optional[str] = Header(None),
|
||||
mode: Optional[Literal["proxy", "redirect", "presigned"]] = Query(
|
||||
default=None,
|
||||
@@ -2347,10 +2555,8 @@ def download_artifact(
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# Get project and package
|
||||
project = db.query(Project).filter(Project.name == project_name).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
# Check authorization (read access required for downloads)
|
||||
project = check_project_access(db, project_name, current_user, "read")
|
||||
|
||||
package = (
|
||||
db.query(Package)
|
||||
@@ -2568,10 +2774,8 @@ def get_artifact_url(
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# Get project and package
|
||||
project = db.query(Project).filter(Project.name == project_name).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
# Check authorization (read access required for downloads)
|
||||
project = check_project_access(db, project_name, current_user, "read")
|
||||
|
||||
package = (
|
||||
db.query(Package)
|
||||
@@ -2826,12 +3030,11 @@ def create_tag(
|
||||
tag: TagCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
user_id = 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")
|
||||
"""Create or update a tag. Requires write access."""
|
||||
project = check_project_access(db, project_name, current_user, "write")
|
||||
user_id = current_user.username if current_user else get_user_id(request)
|
||||
|
||||
package = (
|
||||
db.query(Package)
|
||||
|
||||
@@ -47,6 +47,13 @@ class ProjectUpdate(BaseModel):
|
||||
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_FORMATS = [
|
||||
"generic",
|
||||
@@ -776,3 +783,49 @@ class APIKeyCreateResponse(BaseModel):
|
||||
created_at: 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
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ pydantic-settings==2.1.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
slowapi==0.1.9
|
||||
|
||||
# Test dependencies
|
||||
pytest>=7.4.0
|
||||
|
||||
@@ -182,9 +182,10 @@ def test_app():
|
||||
@pytest.fixture
|
||||
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.
|
||||
Authenticates as admin for write operations.
|
||||
"""
|
||||
from httpx import Client
|
||||
|
||||
@@ -192,6 +193,15 @@ def integration_client():
|
||||
base_url = os.environ.get("ORCHARD_TEST_URL", "http://localhost:8080")
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +59,8 @@ class TestProjectCRUD:
|
||||
@pytest.mark.integration
|
||||
def test_list_projects(self, integration_client, test_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
|
||||
|
||||
data = response.json()
|
||||
@@ -107,9 +108,11 @@ class TestProjectListingFilters:
|
||||
@pytest.mark.integration
|
||||
def test_projects_search(self, integration_client, test_project):
|
||||
"""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(
|
||||
f"/api/v1/projects?search={test_project[:10]}"
|
||||
f"/api/v1/projects?search={unique_part}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@@ -286,6 +286,14 @@ class TestConcurrentUploads:
|
||||
expected_hash = compute_sha256(content)
|
||||
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 = []
|
||||
errors = []
|
||||
|
||||
@@ -306,6 +314,7 @@ class TestConcurrentUploads:
|
||||
f"/api/v1/project/{project}/{package}/upload",
|
||||
files=files,
|
||||
data={"tag": f"concurrent-{tag_suffix}"},
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
results.append(response.json())
|
||||
|
||||
@@ -24,6 +24,8 @@ services:
|
||||
- ORCHARD_S3_USE_PATH_STYLE=true
|
||||
- ORCHARD_REDIS_HOST=redis
|
||||
- ORCHARD_REDIS_PORT=6379
|
||||
# Higher rate limit for local development/testing
|
||||
- ORCHARD_LOGIN_RATE_LIMIT=1000/minute
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import Layout from './components/Layout';
|
||||
import Home from './pages/Home';
|
||||
import ProjectPage from './pages/ProjectPage';
|
||||
import PackagePage from './pages/PackagePage';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import ChangePasswordPage from './pages/ChangePasswordPage';
|
||||
import APIKeysPage from './pages/APIKeysPage';
|
||||
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 (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/change-password" element={<ChangePasswordPage />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<RequirePasswordChange>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
@@ -27,9 +46,17 @@ function App() {
|
||||
<Route path="/project/:projectName/:packageName" element={<PackagePage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</RequirePasswordChange>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,14 +25,51 @@ import {
|
||||
AdminUser,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
AccessPermission,
|
||||
AccessPermissionCreate,
|
||||
AccessPermissionUpdate,
|
||||
AccessLevel,
|
||||
} from './types';
|
||||
|
||||
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> {
|
||||
if (!response.ok) {
|
||||
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();
|
||||
}
|
||||
@@ -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> {
|
||||
try {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
116
frontend/src/components/AccessManagement.css
Normal file
116
frontend/src/components/AccessManagement.css
Normal 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);
|
||||
}
|
||||
296
frontend/src/components/AccessManagement.tsx
Normal file
296
frontend/src/components/AccessManagement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ interface AuthContextType {
|
||||
error: string | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
@@ -71,8 +72,17 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
try {
|
||||
const currentUser = await getCurrentUser();
|
||||
setUser(currentUser);
|
||||
} catch {
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, error, login, logout, clearError }}>
|
||||
<AuthContext.Provider value={{ user, loading, error, login, logout, refreshUser, clearError }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
156
frontend/src/pages/ChangePasswordPage.tsx
Normal file
156
frontend/src/pages/ChangePasswordPage.tsx
Normal 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;
|
||||
@@ -474,3 +474,16 @@
|
||||
margin-top: 4px;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,19 @@ import { SortDropdown, SortOption } from '../components/SortDropdown';
|
||||
import { FilterDropdown, FilterOption } from '../components/FilterDropdown';
|
||||
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
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[] = [
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'created_at', label: 'Created' },
|
||||
@@ -23,6 +34,7 @@ const VISIBILITY_OPTIONS: FilterOption[] = [
|
||||
|
||||
function Home() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [projectsData, setProjectsData] = useState<PaginatedResponse<Project> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -117,9 +129,15 @@ function Home() {
|
||||
<div className="home">
|
||||
<div className="page-header">
|
||||
<h1>Projects</h1>
|
||||
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
||||
{showForm ? 'Cancel' : '+ New Project'}
|
||||
</button>
|
||||
{user ? (
|
||||
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
||||
{showForm ? 'Cancel' : '+ New Project'}
|
||||
</button>
|
||||
) : (
|
||||
<Link to="/login" className="btn btn-secondary">
|
||||
Login to create projects
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
@@ -199,12 +217,32 @@ function Home() {
|
||||
<div className="project-grid">
|
||||
{projects.map((project) => (
|
||||
<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>}
|
||||
<div className="project-meta">
|
||||
<Badge variant={project.is_public ? 'public' : 'private'}>
|
||||
{project.is_public ? 'Public' : 'Private'}
|
||||
</Badge>
|
||||
<div className="project-badges">
|
||||
<Badge variant={project.is_public ? 'public' : 'private'}>
|
||||
{project.is_public ? 'Public' : 'Private'}
|
||||
</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">
|
||||
<span className="date">Created {new Date(project.created_at).toLocaleDateString()}</span>
|
||||
{project.updated_at !== project.created_at && (
|
||||
|
||||
@@ -69,6 +69,11 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.login-subtitle.login-warning {
|
||||
color: var(--warning);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Error message */
|
||||
.login-error {
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { TagDetail, Package, PaginatedResponse } from '../types';
|
||||
import { listTags, getDownloadUrl, getPackage } from '../api';
|
||||
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { TagDetail, Package, PaginatedResponse, AccessLevel } from '../types';
|
||||
import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api';
|
||||
import { Breadcrumb } from '../components/Breadcrumb';
|
||||
import { Badge } from '../components/Badge';
|
||||
import { SearchInput } from '../components/SearchInput';
|
||||
@@ -10,6 +10,7 @@ import { FilterChip, FilterChipGroup } from '../components/FilterChip';
|
||||
import { DataTable } from '../components/DataTable';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
import { DragDropUpload, UploadResult } from '../components/DragDropUpload';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './Home.css';
|
||||
import './PackagePage.css';
|
||||
|
||||
@@ -56,15 +57,22 @@ function CopyButton({ text }: { text: string }) {
|
||||
function PackagePage() {
|
||||
const { projectName, packageName } = useParams<{ projectName: string; packageName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [pkg, setPkg] = useState<Package | null>(null);
|
||||
const [tagsData, setTagsData] = useState<PaginatedResponse<TagDetail> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [accessDenied, setAccessDenied] = useState(false);
|
||||
const [uploadTag, setUploadTag] = useState('');
|
||||
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
|
||||
const [artifactIdInput, setArtifactIdInput] = useState('');
|
||||
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null);
|
||||
|
||||
// Derived permissions
|
||||
const canWrite = accessLevel === 'write' || accessLevel === 'admin';
|
||||
|
||||
// Get params from URL
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
@@ -92,19 +100,32 @@ function PackagePage() {
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const [pkgData, tagsResult] = await Promise.all([
|
||||
setAccessDenied(false);
|
||||
const [pkgData, tagsResult, accessResult] = await Promise.all([
|
||||
getPackage(projectName, packageName),
|
||||
listTags(projectName, packageName, { page, search, sort, order }),
|
||||
getMyProjectAccess(projectName),
|
||||
]);
|
||||
setPkg(pkgData);
|
||||
setTagsData(tagsResult);
|
||||
setAccessLevel(accessResult.access_level);
|
||||
setError(null);
|
||||
} 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');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectName, packageName, page, search, sort, order]);
|
||||
}, [projectName, packageName, page, search, sort, order, navigate, location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -226,6 +247,28 @@ function PackagePage() {
|
||||
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 (
|
||||
<div className="home">
|
||||
<Breadcrumb
|
||||
@@ -286,28 +329,35 @@ function PackagePage() {
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{uploadSuccess && <div className="success-message">{uploadSuccess}</div>}
|
||||
|
||||
<div className="upload-section card">
|
||||
<h3>Upload Artifact</h3>
|
||||
<div className="upload-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="upload-tag">Tag (optional)</label>
|
||||
<input
|
||||
id="upload-tag"
|
||||
type="text"
|
||||
value={uploadTag}
|
||||
onChange={(e) => setUploadTag(e.target.value)}
|
||||
placeholder="v1.0.0, latest, stable..."
|
||||
{canWrite ? (
|
||||
<div className="upload-section card">
|
||||
<h3>Upload Artifact</h3>
|
||||
<div className="upload-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="upload-tag">Tag (optional)</label>
|
||||
<input
|
||||
id="upload-tag"
|
||||
type="text"
|
||||
value={uploadTag}
|
||||
onChange={(e) => setUploadTag(e.target.value)}
|
||||
placeholder="v1.0.0, latest, stable..."
|
||||
/>
|
||||
</div>
|
||||
<DragDropUpload
|
||||
projectName={projectName!}
|
||||
packageName={packageName!}
|
||||
tag={uploadTag || undefined}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
onUploadError={handleUploadError}
|
||||
/>
|
||||
</div>
|
||||
<DragDropUpload
|
||||
projectName={projectName!}
|
||||
packageName={packageName!}
|
||||
tag={uploadTag || undefined}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
onUploadError={handleUploadError}
|
||||
/>
|
||||
</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">
|
||||
<h2>Tags / Versions</h2>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, Link, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Project, Package, PaginatedResponse } from '../types';
|
||||
import { getProject, listPackages, createPackage } from '../api';
|
||||
import { useParams, Link, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Project, Package, PaginatedResponse, AccessLevel } from '../types';
|
||||
import { getProject, listPackages, createPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api';
|
||||
import { Breadcrumb } from '../components/Breadcrumb';
|
||||
import { Badge } from '../components/Badge';
|
||||
import { SearchInput } from '../components/SearchInput';
|
||||
import { SortDropdown, SortOption } from '../components/SortDropdown';
|
||||
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
import { AccessManagement } from '../components/AccessManagement';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './Home.css';
|
||||
|
||||
const SORT_OPTIONS: SortOption[] = [
|
||||
@@ -29,15 +31,24 @@ function formatBytes(bytes: number): string {
|
||||
function ProjectPage() {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [packagesData, setPackagesData] = useState<PaginatedResponse<Package> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [accessDenied, setAccessDenied] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [newPackage, setNewPackage] = useState({ name: '', description: '', format: 'generic', platform: 'any' });
|
||||
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
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
@@ -66,19 +77,33 @@ function ProjectPage() {
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const [projectData, packagesResult] = await Promise.all([
|
||||
setAccessDenied(false);
|
||||
const [projectData, packagesResult, accessResult] = await Promise.all([
|
||||
getProject(projectName),
|
||||
listPackages(projectName, { page, search, sort, order, format: format || undefined }),
|
||||
getMyProjectAccess(projectName),
|
||||
]);
|
||||
setProject(projectData);
|
||||
setPackagesData(packagesResult);
|
||||
setAccessLevel(accessResult.access_level);
|
||||
setIsOwner(accessResult.is_owner);
|
||||
setError(null);
|
||||
} 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');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectName, page, search, sort, order, format]);
|
||||
}, [projectName, page, search, sort, order, format, navigate, location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -139,6 +164,23 @@ function ProjectPage() {
|
||||
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) {
|
||||
return <div className="error-message">Project not found</div>;
|
||||
}
|
||||
@@ -159,6 +201,11 @@ function ProjectPage() {
|
||||
<Badge variant={project.is_public ? 'public' : 'private'}>
|
||||
{project.is_public ? 'Public' : 'Private'}
|
||||
</Badge>
|
||||
{accessLevel && (
|
||||
<Badge variant={accessLevel === 'admin' ? 'success' : accessLevel === 'write' ? 'info' : 'default'}>
|
||||
{isOwner ? 'Owner' : accessLevel.charAt(0).toUpperCase() + accessLevel.slice(1)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{project.description && <p className="description">{project.description}</p>}
|
||||
<div className="page-header__meta">
|
||||
@@ -169,14 +216,20 @@ function ProjectPage() {
|
||||
<span className="meta-item">by {project.created_by}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
||||
{showForm ? 'Cancel' : '+ New Package'}
|
||||
</button>
|
||||
{canWrite ? (
|
||||
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
||||
{showForm ? 'Cancel' : '+ New Package'}
|
||||
</button>
|
||||
) : user ? (
|
||||
<span className="text-muted" title="You have read-only access to this project">
|
||||
Read-only access
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{showForm && (
|
||||
{showForm && canWrite && (
|
||||
<form className="form card" onSubmit={handleCreatePackage}>
|
||||
<h3>Create New Package</h3>
|
||||
<div className="form-row">
|
||||
@@ -316,6 +369,10 @@ function ProjectPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canAdmin && projectName && (
|
||||
<AccessManagement projectName={projectName} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Access Control types (moved to top for use in Project interface)
|
||||
export type AccessLevel = 'read' | 'write' | 'admin';
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -6,6 +9,9 @@ export interface Project {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string;
|
||||
// Access level info (populated when listing projects)
|
||||
access_level?: AccessLevel | null;
|
||||
is_owner?: boolean;
|
||||
}
|
||||
|
||||
export interface TagSummary {
|
||||
@@ -232,6 +238,7 @@ export interface User {
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
is_admin: boolean;
|
||||
must_change_password?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
@@ -289,3 +296,36 @@ export interface UserUpdate {
|
||||
is_admin?: 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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user