Compare commits
7 Commits
b1c17e8ab7
...
3ebdf51105
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ebdf51105 | ||
|
|
6b9f63a30e | ||
|
|
f7c91e94f6 | ||
|
|
ac625fa55f | ||
|
|
0bef44a292 | ||
|
|
6aa199b80b | ||
|
|
d61c7a71fb |
@@ -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,13 +553,24 @@ 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:
|
if user:
|
||||||
return 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
|
return None
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
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,
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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;
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
{user ? (
|
||||||
{showForm ? 'Cancel' : '+ New Project'}
|
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
||||||
</button>
|
{showForm ? 'Cancel' : '+ New Project'}
|
||||||
|
</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">
|
||||||
<Badge variant={project.is_public ? 'public' : 'private'}>
|
<div className="project-badges">
|
||||||
{project.is_public ? 'Public' : 'Private'}
|
<Badge variant={project.is_public ? 'public' : 'private'}>
|
||||||
</Badge>
|
{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">
|
<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 && (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,28 +329,35 @@ 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>}
|
||||||
|
|
||||||
<div className="upload-section card">
|
{canWrite ? (
|
||||||
<h3>Upload Artifact</h3>
|
<div className="upload-section card">
|
||||||
<div className="upload-form">
|
<h3>Upload Artifact</h3>
|
||||||
<div className="form-group">
|
<div className="upload-form">
|
||||||
<label htmlFor="upload-tag">Tag (optional)</label>
|
<div className="form-group">
|
||||||
<input
|
<label htmlFor="upload-tag">Tag (optional)</label>
|
||||||
id="upload-tag"
|
<input
|
||||||
type="text"
|
id="upload-tag"
|
||||||
value={uploadTag}
|
type="text"
|
||||||
onChange={(e) => setUploadTag(e.target.value)}
|
value={uploadTag}
|
||||||
placeholder="v1.0.0, latest, stable..."
|
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>
|
</div>
|
||||||
<DragDropUpload
|
|
||||||
projectName={projectName!}
|
|
||||||
packageName={packageName!}
|
|
||||||
tag={uploadTag || undefined}
|
|
||||||
onUploadComplete={handleUploadComplete}
|
|
||||||
onUploadError={handleUploadError}
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
{canWrite ? (
|
||||||
{showForm ? 'Cancel' : '+ New Package'}
|
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
||||||
</button>
|
{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>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user