diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b452d2..cce2b38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Added user authentication system with session-based login (#50) + - `users` table with password hashing (bcrypt), admin flag, active status + - `sessions` table for web login sessions (24-hour expiry) + - `auth_settings` table for future OIDC configuration + - Default admin user created on first boot (username: admin, password: admin) +- Added auth API endpoints (#50) + - `POST /api/v1/auth/login` - Login with username/password + - `POST /api/v1/auth/logout` - Logout and clear session + - `GET /api/v1/auth/me` - Get current user info + - `POST /api/v1/auth/change-password` - Change own password +- Added API key management with user ownership (#50) + - `POST /api/v1/auth/keys` - Create API key (format: `orch_`) + - `GET /api/v1/auth/keys` - List user's API keys + - `DELETE /api/v1/auth/keys/{id}` - Revoke API key + - Added `owner_id`, `scopes`, `description` columns to `api_keys` table +- Added admin user management endpoints (#50) + - `GET /api/v1/admin/users` - List all users + - `POST /api/v1/admin/users` - Create user + - `GET /api/v1/admin/users/{username}` - Get user details + - `PUT /api/v1/admin/users/{username}` - Update user (admin/active status) + - `POST /api/v1/admin/users/{username}/reset-password` - Reset password +- Added `auth.py` module with AuthService class and FastAPI dependencies (#50) +- Added auth schemas: LoginRequest, LoginResponse, UserResponse, APIKeyResponse (#50) +- Added migration `006_auth_tables.sql` for auth database tables (#50) +- Added frontend Login page with session management (#50) +- Added frontend API Keys management page (#50) +- Added frontend Admin Users page (admin-only) (#50) +- Added AuthContext for frontend session state (#50) +- Added user menu to Layout header with login/logout (#50) +- Added 15 integration tests for auth system (#50) - Added reusable `DragDropUpload` component for artifact uploads (#8) - Drag-and-drop file selection with visual feedback - Click-to-browse fallback diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..c35ca38 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,1208 @@ +"""Authentication service for Orchard. + +Handles password hashing, session management, API key operations, and JWT validation. +""" + +import hashlib +import secrets +import logging +from datetime import datetime, timedelta, timezone +from typing import Optional +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from .models import User, Session as UserSession, APIKey +from .config import get_settings + +logger = logging.getLogger(__name__) + + +# Password hashing context (bcrypt with cost factor 12) +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# API key prefix +API_KEY_PREFIX = "orch_" + +# Session duration (24 hours default) +SESSION_DURATION_HOURS = 24 + +# Password requirements +MIN_PASSWORD_LENGTH = 8 + + +class PasswordTooShortError(ValueError): + """Raised when password doesn't meet minimum length requirement.""" + + pass + + +def validate_password_strength(password: str) -> None: + """Validate password meets minimum requirements. + + Raises PasswordTooShortError if password is too short. + """ + if not password or len(password) < MIN_PASSWORD_LENGTH: + raise PasswordTooShortError( + f"Password must be at least {MIN_PASSWORD_LENGTH} characters" + ) + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt.""" + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash.""" + return pwd_context.verify(plain_password, hashed_password) + + +def hash_token(token: str) -> str: + """Hash a token (session or API key) using SHA256.""" + return hashlib.sha256(token.encode()).hexdigest() + + +def generate_session_token() -> str: + """Generate a cryptographically secure session token.""" + return secrets.token_urlsafe(32) + + +def generate_api_key() -> str: + """Generate a new API key with prefix. + + Format: orch_<32 random bytes as hex> + """ + random_part = secrets.token_hex(32) + return f"{API_KEY_PREFIX}{random_part}" + + +class AuthService: + """Authentication service for user management and session handling.""" + + def __init__(self, db: Session): + self.db = db + + # --- User Operations --- + + def create_user( + self, + username: str, + password: Optional[str] = None, + email: Optional[str] = None, + is_admin: bool = False, + must_change_password: bool = False, + ) -> User: + """Create a new user account.""" + user = User( + username=username, + password_hash=hash_password(password) if password else None, + email=email, + is_admin=is_admin, + must_change_password=must_change_password, + ) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + + def get_user_by_username(self, username: str) -> Optional[User]: + """Get a user by username.""" + return self.db.query(User).filter(User.username == username).first() + + def get_user_by_id(self, user_id: str) -> Optional[User]: + """Get a user by ID.""" + return self.db.query(User).filter(User.id == user_id).first() + + def authenticate_user(self, username: str, password: str) -> Optional[User]: + """Authenticate a user with username and password. + + Returns the user if authentication succeeds, None otherwise. + Uses constant-time comparison to prevent timing-based user enumeration. + """ + user = self.get_user_by_username(username) + + # Always perform password verification to prevent timing attacks + # Use a dummy hash if user doesn't exist + dummy_hash = "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYA1vQ9S9sXa" + password_hash = user.password_hash if user and user.password_hash else dummy_hash + + # Verify password (constant time even if user doesn't exist) + password_valid = verify_password(password, password_hash) + + # Check all conditions + if not user: + return None + if not user.password_hash: + return None # OIDC-only user + if not user.is_active: + return None + if not password_valid: + return None + return user + + def change_password(self, user: User, new_password: str) -> None: + """Change a user's password and invalidate all existing sessions.""" + validate_password_strength(new_password) + user.password_hash = hash_password(new_password) + user.must_change_password = False + self.db.commit() + # Invalidate all existing sessions for security + self.delete_user_sessions(user) + + def update_last_login(self, user: User) -> None: + """Update the user's last login timestamp.""" + user.last_login = datetime.now(timezone.utc) + self.db.commit() + + def list_users(self, include_inactive: bool = False) -> list[User]: + """List all users.""" + query = self.db.query(User) + if not include_inactive: + query = query.filter(User.is_active.is_(True)) + return query.order_by(User.username).all() + + def set_user_active(self, user: User, is_active: bool) -> None: + """Enable or disable a user account.""" + user.is_active = is_active + self.db.commit() + + def set_user_admin(self, user: User, is_admin: bool) -> None: + """Grant or revoke admin privileges.""" + user.is_admin = is_admin + self.db.commit() + + def reset_user_password(self, user: User, new_password: str) -> None: + """Reset a user's password (admin action) and invalidate all sessions.""" + validate_password_strength(new_password) + user.password_hash = hash_password(new_password) + user.must_change_password = True + self.db.commit() + # Invalidate all existing sessions for security + self.delete_user_sessions(user) + + # --- Session Operations --- + + def create_session( + self, + user: User, + user_agent: Optional[str] = None, + ip_address: Optional[str] = None, + ) -> tuple[UserSession, str]: + """Create a new session for a user. + + Returns a tuple of (session, token) where token is the plaintext + token that should be sent to the client. The token is only returned + once and should be stored securely. + """ + token = generate_session_token() + token_hash = hash_token(token) + + session = UserSession( + user_id=user.id, + token_hash=token_hash, + expires_at=datetime.now(timezone.utc) + + timedelta(hours=SESSION_DURATION_HOURS), + user_agent=user_agent, + ip_address=ip_address, + ) + self.db.add(session) + self.db.commit() + self.db.refresh(session) + + return session, token + + def get_session_by_token(self, token: str) -> Optional[UserSession]: + """Get a session by its token. + + Returns None if the session doesn't exist or has expired. + """ + token_hash = hash_token(token) + session = ( + self.db.query(UserSession) + .filter(UserSession.token_hash == token_hash) + .first() + ) + + if not session: + return None + + if session.expires_at < datetime.now(timezone.utc): + # Session has expired, delete it + self.db.delete(session) + self.db.commit() + return None + + # Update last accessed time + session.last_accessed = datetime.now(timezone.utc) + self.db.commit() + + return session + + def delete_session(self, session: UserSession) -> None: + """Delete a session (logout).""" + self.db.delete(session) + self.db.commit() + + def delete_user_sessions(self, user: User) -> int: + """Delete all sessions for a user. Returns count of deleted sessions.""" + count = ( + self.db.query(UserSession).filter(UserSession.user_id == user.id).delete() + ) + self.db.commit() + return count + + def cleanup_expired_sessions(self) -> int: + """Delete all expired sessions. Returns count of deleted sessions.""" + count = ( + self.db.query(UserSession) + .filter(UserSession.expires_at < datetime.now(timezone.utc)) + .delete() + ) + self.db.commit() + return count + + # --- API Key Operations --- + + def create_api_key( + self, + user: User, + name: str, + description: Optional[str] = None, + scopes: Optional[list[str]] = None, + expires_at: Optional[datetime] = None, + ) -> tuple[APIKey, str]: + """Create a new API key for a user. + + Returns a tuple of (api_key, key) where key is the plaintext + API key that should be sent to the client. The key is only returned + once and should be stored securely by the user. + """ + key = generate_api_key() + key_hash = hash_token(key) + + api_key = APIKey( + key_hash=key_hash, + name=name, + user_id=user.username, # Legacy field + owner_id=user.id, + description=description, + scopes=scopes or ["read", "write"], + expires_at=expires_at, + ) + self.db.add(api_key) + self.db.commit() + self.db.refresh(api_key) + + return api_key, key + + def get_api_key_by_key(self, key: str) -> Optional[APIKey]: + """Get an API key by its plaintext key. + + Returns None if the key doesn't exist or has expired. + """ + if not key.startswith(API_KEY_PREFIX): + return None + + key_hash = hash_token(key) + api_key = self.db.query(APIKey).filter(APIKey.key_hash == key_hash).first() + + if not api_key: + return None + + # Check expiration + if api_key.expires_at and api_key.expires_at < datetime.now(timezone.utc): + return None + + # Update last used time + api_key.last_used = datetime.now(timezone.utc) + self.db.commit() + + return api_key + + def get_api_key_by_id(self, key_id: str) -> Optional[APIKey]: + """Get an API key by its ID.""" + return self.db.query(APIKey).filter(APIKey.id == key_id).first() + + def list_user_api_keys(self, user: User) -> list[APIKey]: + """List all API keys for a user.""" + return ( + self.db.query(APIKey) + .filter(APIKey.owner_id == user.id) + .order_by(APIKey.created_at.desc()) + .all() + ) + + def delete_api_key(self, api_key: APIKey) -> None: + """Delete an API key.""" + self.db.delete(api_key) + self.db.commit() + + def get_user_from_api_key(self, key: str) -> Optional[User]: + """Get the user associated with an API key. + + Returns None if the key is invalid or the user is inactive. + """ + api_key = self.get_api_key_by_key(key) + if not api_key: + return None + + if not api_key.owner_id: + return None + + user = self.db.query(User).filter(User.id == api_key.owner_id).first() + if not user or not user.is_active: + return None + + return user + + +def create_default_admin(db: Session) -> Optional[User]: + """Create the default admin user if no users exist. + + Returns the created user, or None if users already exist. + """ + # Check if any users exist + user_count = db.query(User).count() + if user_count > 0: + return None + + # Create default admin + auth_service = AuthService(db) + admin = auth_service.create_user( + username="admin", + password="changeme123", + is_admin=True, + must_change_password=True, + ) + + return admin + + +# --- JWT Validation --- + + +def validate_jwt_token(token: str) -> Optional[dict]: + """Validate a JWT token and return the decoded payload. + + Returns None if validation fails or JWT is not configured. + Uses python-jose for JWT operations. + """ + settings = get_settings() + + if not settings.jwt_enabled: + return None + + try: + from jose import jwt, JWTError, ExpiredSignatureError + from jose.exceptions import JWTClaimsError + except ImportError: + logger.warning("python-jose not installed, JWT authentication disabled") + return None + + try: + # Build decode options + decode_options = {} + + # Set up key for validation + if settings.jwt_algorithm.startswith("RS"): + # RS256/RS384/RS512 - use JWKS + if not settings.jwt_jwks_url: + logger.error("JWT JWKS URL not configured for RSA algorithm") + return None + + try: + import httpx + + # Fetch JWKS from the URL + response = httpx.get(settings.jwt_jwks_url, timeout=10.0) + response.raise_for_status() + jwks = response.json() + + # Get the key ID from the token header + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get("kid") + + # Find the matching key + rsa_key = None + for key in jwks.get("keys", []): + if key.get("kid") == kid: + rsa_key = key + break + + if not rsa_key: + logger.error(f"No matching key found in JWKS for kid: {kid}") + return None + + key = rsa_key + except Exception as e: + logger.error(f"Failed to get signing key from JWKS: {e}") + return None + else: + # HS256/HS384/HS512 - use secret + if not settings.jwt_secret: + logger.error("JWT secret not configured for HMAC algorithm") + return None + key = settings.jwt_secret + + # Build decode kwargs + decode_kwargs = { + "algorithms": [settings.jwt_algorithm], + "options": decode_options, + } + + # Add issuer validation if configured + if settings.jwt_issuer: + decode_kwargs["issuer"] = settings.jwt_issuer + + # Add audience validation if configured + if settings.jwt_audience: + decode_kwargs["audience"] = settings.jwt_audience + + # Decode and validate the token + payload = jwt.decode(token, key, **decode_kwargs) + return payload + + except ExpiredSignatureError: + logger.debug("JWT token expired") + return None + except JWTClaimsError as e: + logger.debug(f"JWT claims error: {e}") + return None + except JWTError as e: + logger.debug(f"Invalid JWT token: {e}") + return None + except Exception as e: + logger.error(f"JWT validation error: {e}") + return None + + +def get_or_create_user_from_jwt(db: Session, payload: dict) -> Optional[User]: + """Get or create a user from JWT payload. + + Uses the configured username claim to extract the username. + Creates a new user if one doesn't exist (for SSO auto-provisioning). + """ + settings = get_settings() + username = payload.get(settings.jwt_username_claim) + + if not username: + logger.warning(f"JWT missing username claim: {settings.jwt_username_claim}") + return None + + # Sanitize username (remove domain from email if needed) + if "@" in username and settings.jwt_username_claim == "email": + # Keep full email as username for email-based auth + pass + + auth_service = AuthService(db) + user = auth_service.get_user_by_username(username) + + if user: + if not user.is_active: + logger.debug(f"JWT user {username} is inactive") + return None + return user + + # Auto-provision user from JWT + logger.info(f"Auto-provisioning user from JWT: {username}") + try: + user = auth_service.create_user( + username=username, + password=None, # No password for SSO users + email=payload.get("email"), + is_admin=False, + must_change_password=False, + ) + return user + except Exception as e: + logger.error(f"Failed to auto-provision JWT user: {e}") + return None + + +# --- FastAPI Dependencies --- + +from fastapi import Depends, HTTPException, status, Cookie, Header +from .database import get_db + +# Cookie name for session token +SESSION_COOKIE_NAME = "orchard_session" + + +def get_current_user_optional( + db: Session = Depends(get_db), + session_token: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME), + authorization: Optional[str] = Header(None), +) -> Optional[User]: + """Get the current user from session cookie, API key, or JWT token. + + Returns None if no valid authentication is provided. + Does not raise an exception for unauthenticated requests. + + Authentication methods are tried in order: + 1. Session cookie (web UI) + 2. API key (Bearer token starting with 'orch_') + 3. JWT token (Bearer token that's a valid JWT) + """ + auth_service = AuthService(db) + + # First try session cookie (web UI) + if session_token: + session = auth_service.get_session_by_token(session_token) + if session: + user = auth_service.get_user_by_id(str(session.user_id)) + if user and user.is_active: + return user + + # Then try Bearer token (API key or JWT) + if authorization and authorization.startswith("Bearer "): + token = authorization[7:] # Remove "Bearer " prefix + + # Check if it's an API key (starts with orch_) + if token.startswith(API_KEY_PREFIX): + user = auth_service.get_user_from_api_key(token) + if user: + return user + else: + # Try JWT validation + settings = get_settings() + if settings.jwt_enabled: + payload = validate_jwt_token(token) + if payload: + user = get_or_create_user_from_jwt(db, payload) + if user: + return user + + return None + + +def get_current_user( + user: Optional[User] = Depends(get_current_user_optional), +) -> User: + """Get the current authenticated user. + + Raises HTTPException 401 if not authenticated. + """ + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user + + +def require_admin( + user: User = Depends(get_current_user), +) -> User: + """Require the current user to be an admin. + + Raises HTTPException 403 if user is not an admin. + """ + if not user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin privileges required", + ) + return user + + +def get_auth_service(db: Session = Depends(get_db)) -> AuthService: + """Get an AuthService instance.""" + return AuthService(db) + + +# --- Authorization --- + +# Access levels in order of increasing privilege +ACCESS_LEVELS = ["read", "write", "admin"] + + +def get_access_level_rank(level: str) -> int: + """Get numeric rank for access level comparison.""" + try: + return ACCESS_LEVELS.index(level) + except ValueError: + return -1 + + +def has_sufficient_access(user_level: str, required_level: str) -> bool: + """Check if user_level is sufficient for required_level. + + Access levels are hierarchical: admin > write > read + """ + return get_access_level_rank(user_level) >= get_access_level_rank(required_level) + + +class AuthorizationService: + """Service for checking project-level authorization.""" + + def __init__(self, db: Session): + self.db = db + + def get_user_access_level( + self, project_id: str, user: Optional[User] + ) -> Optional[str]: + """Get the user's access level for a project. + + Returns the highest access level the user has, or None if no access. + Checks in order: + 1. System admin - gets admin access to all projects + 2. Project owner (created_by) - gets admin access + 3. Explicit permission in access_permissions table + """ + from .models import Project, AccessPermission + + # Get the project + project = self.db.query(Project).filter(Project.id == project_id).first() + if not project: + return None + + # Anonymous users only get access to public projects + if not user: + return "read" if project.is_public else None + + # System admins get admin access everywhere + if user.is_admin: + return "admin" + + # Project owner gets admin access + if project.created_by == user.username: + return "admin" + + # Check explicit permissions + permission = ( + self.db.query(AccessPermission) + .filter( + AccessPermission.project_id == project_id, + AccessPermission.user_id == user.username, + ) + .first() + ) + + if permission: + # Check expiration + if permission.expires_at and permission.expires_at < datetime.now(timezone.utc): + return "read" if project.is_public else None + return permission.level + + # Fall back to public access + return "read" if project.is_public else None + + def check_access( + self, + project_id: str, + user: Optional[User], + required_level: str, + ) -> bool: + """Check if user has required access level for project.""" + user_level = self.get_user_access_level(project_id, user) + if not user_level: + return False + return has_sufficient_access(user_level, required_level) + + def grant_access( + self, + project_id: str, + username: str, + level: str, + expires_at: Optional[datetime] = None, + ) -> "AccessPermission": + """Grant access to a user for a project.""" + from .models import AccessPermission + + # Check if permission already exists + existing = ( + self.db.query(AccessPermission) + .filter( + AccessPermission.project_id == project_id, + AccessPermission.user_id == username, + ) + .first() + ) + + if existing: + existing.level = level + existing.expires_at = expires_at + self.db.commit() + return existing + + permission = AccessPermission( + project_id=project_id, + user_id=username, + level=level, + expires_at=expires_at, + ) + self.db.add(permission) + self.db.commit() + self.db.refresh(permission) + return permission + + def revoke_access(self, project_id: str, username: str) -> bool: + """Revoke a user's access to a project. Returns True if deleted.""" + from .models import AccessPermission + + count = ( + self.db.query(AccessPermission) + .filter( + AccessPermission.project_id == project_id, + AccessPermission.user_id == username, + ) + .delete() + ) + self.db.commit() + return count > 0 + + def list_project_permissions(self, project_id: str) -> list: + """List all permissions for a project.""" + from .models import AccessPermission + + return ( + self.db.query(AccessPermission) + .filter(AccessPermission.project_id == project_id) + .all() + ) + + +def get_authorization_service(db: Session = Depends(get_db)) -> AuthorizationService: + """Get an AuthorizationService instance.""" + return AuthorizationService(db) + + +class ProjectAccessChecker: + """Dependency for checking project access in route handlers.""" + + def __init__(self, required_level: str = "read"): + self.required_level = required_level + + def __call__( + self, + project: str, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional), + ) -> User: + """Check if user has required access to project. + + Raises 404 if project not found, 403 if insufficient access. + Returns the current user (or None for public read access). + """ + from .models import Project + + # Find project by name + proj = db.query(Project).filter(Project.name == project).first() + if not proj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Project '{project}' not found", + ) + + auth_service = AuthorizationService(db) + + if not auth_service.check_access(str(proj.id), current_user, self.required_level): + if not current_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required for private project", + headers={"WWW-Authenticate": "Bearer"}, + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Insufficient permissions. Required: {self.required_level}", + ) + + return current_user + + +# Pre-configured access checkers for common use cases +require_project_read = ProjectAccessChecker("read") +require_project_write = ProjectAccessChecker("write") +require_project_admin = ProjectAccessChecker("admin") + + +def check_project_access( + db: Session, + project_name: str, + user: Optional[User], + required_level: str = "read", +) -> "Project": + """Check if user has required access to project. + + This is a helper function for use in route handlers. + + Args: + db: Database session + project_name: Name of the project + user: Current user (can be None for anonymous) + required_level: Required access level (read, write, admin) + + Returns: + The Project object if access is granted + + Raises: + HTTPException 404: Project not found + HTTPException 401: Authentication required for private project + HTTPException 403: Insufficient permissions + """ + from .models import Project + + # Find project by name + project = db.query(Project).filter(Project.name == project_name).first() + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Project '{project_name}' not found", + ) + + auth_service = AuthorizationService(db) + + if not auth_service.check_access(str(project.id), user, required_level): + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required for private project", + headers={"WWW-Authenticate": "Bearer"}, + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Insufficient permissions. Required: {required_level}", + ) + + return project + + +# --- OIDC Configuration Service --- + + +class OIDCConfig: + """OIDC configuration data class.""" + + def __init__( + self, + enabled: bool = False, + issuer_url: str = "", + client_id: str = "", + client_secret: str = "", + scopes: list[str] = None, + auto_create_users: bool = True, + admin_group: str = "", # Group/role that grants admin access + ): + self.enabled = enabled + self.issuer_url = issuer_url.rstrip("/") if issuer_url else "" + self.client_id = client_id + self.client_secret = client_secret + self.scopes = scopes or ["openid", "profile", "email"] + self.auto_create_users = auto_create_users + self.admin_group = admin_group + + @property + def discovery_url(self) -> str: + """Get the OIDC discovery URL.""" + if not self.issuer_url: + return "" + return f"{self.issuer_url}/.well-known/openid-configuration" + + def to_dict(self) -> dict: + """Convert to dictionary for storage.""" + return { + "enabled": self.enabled, + "issuer_url": self.issuer_url, + "client_id": self.client_id, + "client_secret": self.client_secret, + "scopes": self.scopes, + "auto_create_users": self.auto_create_users, + "admin_group": self.admin_group, + } + + @classmethod + def from_dict(cls, data: dict) -> "OIDCConfig": + """Create from dictionary.""" + return cls( + enabled=data.get("enabled", False), + issuer_url=data.get("issuer_url", ""), + client_id=data.get("client_id", ""), + client_secret=data.get("client_secret", ""), + scopes=data.get("scopes", ["openid", "profile", "email"]), + auto_create_users=data.get("auto_create_users", True), + admin_group=data.get("admin_group", ""), + ) + + +class OIDCConfigService: + """Service for managing OIDC configuration.""" + + OIDC_CONFIG_KEY = "oidc_config" + + def __init__(self, db: Session): + self.db = db + + def get_config(self) -> OIDCConfig: + """Get the current OIDC configuration.""" + from .models import AuthSettings + import json + + setting = ( + self.db.query(AuthSettings) + .filter(AuthSettings.key == self.OIDC_CONFIG_KEY) + .first() + ) + + if not setting: + return OIDCConfig() + + try: + data = json.loads(setting.value) + return OIDCConfig.from_dict(data) + except (json.JSONDecodeError, KeyError): + return OIDCConfig() + + def save_config(self, config: OIDCConfig) -> None: + """Save OIDC configuration.""" + from .models import AuthSettings + import json + + setting = ( + self.db.query(AuthSettings) + .filter(AuthSettings.key == self.OIDC_CONFIG_KEY) + .first() + ) + + if setting: + setting.value = json.dumps(config.to_dict()) + setting.updated_at = datetime.now(timezone.utc) + else: + setting = AuthSettings( + key=self.OIDC_CONFIG_KEY, + value=json.dumps(config.to_dict()), + ) + self.db.add(setting) + + self.db.commit() + + def is_enabled(self) -> bool: + """Check if OIDC is enabled.""" + config = self.get_config() + return config.enabled and bool(config.issuer_url) and bool(config.client_id) + + +def get_oidc_config_service(db: Session = Depends(get_db)) -> OIDCConfigService: + """Get an OIDCConfigService instance.""" + return OIDCConfigService(db) + + +# --- OIDC Authentication Flow --- + + +class OIDCService: + """Service for OIDC authentication flow.""" + + def __init__(self, db: Session, config: OIDCConfig): + self.db = db + self.config = config + self._discovery_doc: Optional[dict] = None + + def get_discovery_document(self) -> Optional[dict]: + """Fetch and cache the OIDC discovery document.""" + if self._discovery_doc: + return self._discovery_doc + + if not self.config.discovery_url: + return None + + try: + import httpx + + response = httpx.get(self.config.discovery_url, timeout=10.0) + response.raise_for_status() + self._discovery_doc = response.json() + return self._discovery_doc + except Exception as e: + logger.error(f"Failed to fetch OIDC discovery document: {e}") + return None + + def get_authorization_url(self, redirect_uri: str, state: str) -> Optional[str]: + """Generate the OIDC authorization URL.""" + discovery = self.get_discovery_document() + if not discovery: + return None + + auth_endpoint = discovery.get("authorization_endpoint") + if not auth_endpoint: + logger.error("No authorization_endpoint in discovery document") + return None + + import urllib.parse + + params = { + "client_id": self.config.client_id, + "response_type": "code", + "scope": " ".join(self.config.scopes), + "redirect_uri": redirect_uri, + "state": state, + } + + return f"{auth_endpoint}?{urllib.parse.urlencode(params)}" + + def exchange_code_for_tokens( + self, code: str, redirect_uri: str + ) -> Optional[dict]: + """Exchange authorization code for tokens.""" + discovery = self.get_discovery_document() + if not discovery: + return None + + token_endpoint = discovery.get("token_endpoint") + if not token_endpoint: + logger.error("No token_endpoint in discovery document") + return None + + try: + import httpx + + response = httpx.post( + token_endpoint, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": self.config.client_id, + "client_secret": self.config.client_secret, + }, + timeout=10.0, + ) + response.raise_for_status() + return response.json() + except Exception as e: + logger.error(f"Failed to exchange code for tokens: {e}") + return None + + def validate_id_token(self, id_token: str) -> Optional[dict]: + """Validate and decode the ID token.""" + discovery = self.get_discovery_document() + if not discovery: + return None + + try: + from jose import jwt, JWTError + import httpx + + # Get JWKS + jwks_uri = discovery.get("jwks_uri") + if not jwks_uri: + logger.error("No jwks_uri in discovery document") + return None + + response = httpx.get(jwks_uri, timeout=10.0) + response.raise_for_status() + jwks = response.json() + + # Get the key ID from the token header + unverified_header = jwt.get_unverified_header(id_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 + + # Decode and validate the token + payload = jwt.decode( + id_token, + rsa_key, + algorithms=["RS256"], + audience=self.config.client_id, + issuer=self.config.issuer_url, + ) + + return payload + + except JWTError as e: + logger.error(f"ID token validation failed: {e}") + return None + except Exception as e: + logger.error(f"Error validating ID token: {e}") + return None + + def get_or_create_user(self, id_token_claims: dict) -> Optional[User]: + """Get or create a user from ID token claims.""" + # Extract user info from claims + subject = id_token_claims.get("sub") + email = id_token_claims.get("email") + name = id_token_claims.get("name") or id_token_claims.get("preferred_username") + + if not subject: + logger.error("No 'sub' claim in ID token") + return None + + # Try to find existing user by OIDC subject + user = ( + self.db.query(User) + .filter( + User.oidc_subject == subject, + User.oidc_issuer == self.config.issuer_url, + ) + .first() + ) + + if user: + # Update last login + user.last_login = datetime.now(timezone.utc) + self.db.commit() + return user + + # Try to find by email and link accounts + if email: + user = self.db.query(User).filter(User.email == email).first() + if user: + # Link OIDC identity to existing user + user.oidc_subject = subject + user.oidc_issuer = self.config.issuer_url + user.last_login = datetime.now(timezone.utc) + self.db.commit() + logger.info(f"Linked OIDC identity to existing user: {user.username}") + return user + + # Create new user if auto-creation is enabled + if not self.config.auto_create_users: + logger.warning(f"Auto-creation disabled, rejecting new OIDC user: {subject}") + return None + + # Determine username (use email prefix or subject) + username = email.split("@")[0] if email else subject + + # Check for username collision + existing = self.db.query(User).filter(User.username == username).first() + if existing: + # Append part of subject to make unique + username = f"{username}_{subject[:8]}" + + # Check if user should be admin based on groups/roles + is_admin = False + if self.config.admin_group: + groups = id_token_claims.get("groups", []) + roles = id_token_claims.get("roles", []) + is_admin = ( + self.config.admin_group in groups + or self.config.admin_group in roles + ) + + # Create the user + user = User( + username=username, + email=email, + password_hash=None, # OIDC users don't have passwords + oidc_subject=subject, + oidc_issuer=self.config.issuer_url, + is_admin=is_admin, + must_change_password=False, + ) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + + logger.info(f"Created new OIDC user: {username} (admin={is_admin})") + return user diff --git a/backend/app/config.py b/backend/app/config.py index fa78674..8a19d89 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -25,6 +25,7 @@ class Settings(BaseSettings): database_pool_recycle: int = ( 1800 # Recycle connections after this many seconds (30 min) ) + database_query_timeout: int = 30 # Query timeout in seconds (0 = no timeout) # S3 s3_endpoint: str = "" @@ -52,6 +53,17 @@ class Settings(BaseSettings): log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL log_format: str = "auto" # "json", "standard", or "auto" (json in production) + # JWT Authentication settings (optional, for external identity providers) + jwt_enabled: bool = False # Enable JWT token validation + jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS + jwt_algorithm: str = "HS256" # HS256 or RS256 + jwt_issuer: str = "" # Expected issuer (iss claim), leave empty to skip validation + jwt_audience: str = "" # Expected audience (aud claim), leave empty to skip validation + jwt_jwks_url: str = "" # JWKS URL for RS256 (e.g., https://auth.example.com/.well-known/jwks.json) + jwt_username_claim: str = ( + "sub" # JWT claim to use as username (sub, email, preferred_username, etc.) + ) + @property def database_url(self) -> str: sslmode = f"?sslmode={self.database_sslmode}" if self.database_sslmode else "" diff --git a/backend/app/database.py b/backend/app/database.py index 25a4cc0..17f7054 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -12,6 +12,12 @@ from .models import Base settings = get_settings() logger = logging.getLogger(__name__) +# Build connect_args with query timeout if configured +connect_args = {} +if settings.database_query_timeout > 0: + # PostgreSQL statement_timeout is in milliseconds + connect_args["options"] = f"-c statement_timeout={settings.database_query_timeout * 1000}" + # Create engine with connection pool configuration engine = create_engine( settings.database_url, @@ -21,6 +27,7 @@ engine = create_engine( max_overflow=settings.database_max_overflow, pool_timeout=settings.database_pool_timeout, pool_recycle=settings.database_pool_recycle, + connect_args=connect_args, ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/backend/app/main.py b/backend/app/main.py index d59fbff..f733e54 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,14 +1,19 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from contextlib import asynccontextmanager import logging import os +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded + from .config import get_settings from .database import init_db, SessionLocal from .routes import router from .seed import seed_database +from .auth import create_default_admin +from .rate_limit import limiter settings = get_settings() logging.basicConfig(level=logging.INFO) @@ -20,6 +25,18 @@ async def lifespan(app: FastAPI): # Startup: initialize database init_db() + # Create default admin user if no users exist + db = SessionLocal() + try: + admin = create_default_admin(db) + if admin: + logger.warning( + "Default admin user created with username 'admin' and password 'changeme123'. " + "CHANGE THIS PASSWORD IMMEDIATELY!" + ) + finally: + db.close() + # Seed test data in development mode if settings.is_development: logger.info(f"Running in {settings.env} mode - checking for seed data") @@ -42,13 +59,21 @@ app = FastAPI( lifespan=lifespan, ) +# Set up rate limiting +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + # Include API routes app.include_router(router) # Serve static files (React build) if the directory exists static_dir = os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "dist") if os.path.exists(static_dir): - app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets") + app.mount( + "/assets", + StaticFiles(directory=os.path.join(static_dir, "assets")), + name="assets", + ) @app.get("/") async def serve_spa(): @@ -60,6 +85,7 @@ if os.path.exists(static_dir): # Don't catch API routes or health endpoint if full_path.startswith("api/") or full_path.startswith("health"): from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Not found") # Serve SPA for all other routes (including /project/*) @@ -68,4 +94,5 @@ if os.path.exists(static_dir): return FileResponse(index_path) from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Not found") diff --git a/backend/app/models.py b/backend/app/models.py index 37f23ef..17ea3cd 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -11,6 +11,7 @@ from sqlalchemy import ( CheckConstraint, Index, JSON, + ARRAY, ) from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship, declarative_base @@ -302,20 +303,104 @@ class AccessPermission(Base): ) +class User(Base): + """User account for authentication.""" + + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + username = Column(String(255), unique=True, nullable=False) + password_hash = Column(String(255)) # NULL if OIDC-only user + email = Column(String(255)) + is_admin = Column(Boolean, default=False) + is_active = Column(Boolean, default=True) + must_change_password = Column(Boolean, default=False) + oidc_subject = Column(String(255)) # OIDC subject claim + oidc_issuer = Column(String(512)) # OIDC issuer URL + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + updated_at = Column( + DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow + ) + last_login = Column(DateTime(timezone=True)) + + # Relationships + api_keys = relationship( + "APIKey", back_populates="owner", cascade="all, delete-orphan" + ) + sessions = relationship( + "Session", back_populates="user", cascade="all, delete-orphan" + ) + + __table_args__ = ( + Index("idx_users_username", "username"), + Index("idx_users_email", "email"), + Index("idx_users_oidc_subject", "oidc_subject"), + ) + + +class Session(Base): + """User session for web login.""" + + __tablename__ = "sessions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + token_hash = Column(String(64), unique=True, nullable=False) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + expires_at = Column(DateTime(timezone=True), nullable=False) + last_accessed = Column(DateTime(timezone=True), default=datetime.utcnow) + user_agent = Column(String(512)) + ip_address = Column(String(45)) + + user = relationship("User", back_populates="sessions") + + __table_args__ = ( + Index("idx_sessions_user_id", "user_id"), + Index("idx_sessions_token_hash", "token_hash"), + Index("idx_sessions_expires_at", "expires_at"), + ) + + +class AuthSettings(Base): + """Authentication settings for OIDC configuration.""" + + __tablename__ = "auth_settings" + + key = Column(String(255), primary_key=True) + value = Column(Text, nullable=False) + updated_at = Column(DateTime(timezone=True), default=datetime.utcnow) + + class APIKey(Base): __tablename__ = "api_keys" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) key_hash = Column(String(64), unique=True, nullable=False) name = Column(String(255), nullable=False) - user_id = Column(String(255), nullable=False) + user_id = Column( + String(255), nullable=False + ) # Legacy field, kept for compatibility + owner_id = Column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=True, # Nullable for migration compatibility + ) + description = Column(Text) + scopes = Column(ARRAY(String), default=["read", "write"]) created_at = Column(DateTime(timezone=True), default=datetime.utcnow) expires_at = Column(DateTime(timezone=True)) last_used = Column(DateTime(timezone=True)) + owner = relationship("User", back_populates="api_keys") + __table_args__ = ( Index("idx_api_keys_user_id", "user_id"), Index("idx_api_keys_key_hash", "key_hash"), + Index("idx_api_keys_owner_id", "owner_id"), ) diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py new file mode 100644 index 0000000..80184d1 --- /dev/null +++ b/backend/app/rate_limit.py @@ -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") diff --git a/backend/app/routes.py b/backend/app/routes.py index ff6603f..5c9e821 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -11,6 +11,8 @@ from fastapi import ( Query, Header, Response, + Cookie, + status, ) from fastapi.responses import StreamingResponse, RedirectResponse from sqlalchemy.orm import Session @@ -42,11 +44,14 @@ from .models import ( UploadLock, Consumer, AuditLog, + User, + AccessPermission, ) from .schemas import ( ProjectCreate, ProjectUpdate, ProjectResponse, + ProjectWithAccessResponse, PackageCreate, PackageUpdate, PackageResponse, @@ -94,6 +99,23 @@ from .schemas import ( StatsReportResponse, GlobalArtifactResponse, GlobalTagResponse, + LoginRequest, + LoginResponse, + ChangePasswordRequest, + UserResponse, + UserCreate, + UserUpdate, + ResetPasswordRequest, + APIKeyCreate, + APIKeyResponse, + APIKeyCreateResponse, + AccessPermissionCreate, + AccessPermissionUpdate, + AccessPermissionResponse, + OIDCConfigResponse, + OIDCConfigUpdate, + OIDCStatusResponse, + OIDCLoginResponse, ) from .metadata import extract_metadata from .config import get_settings @@ -118,14 +140,39 @@ def sanitize_filename(filename: str) -> str: return re.sub(r'[\r\n"]', "", filename) +def get_user_id_from_request( + request: Request, + db: Session, + current_user: Optional[User] = None, +) -> str: + """Extract user ID from request using auth system. + + If a current_user is provided (from auth dependency), use their username. + Otherwise, try to authenticate from headers and fall back to 'anonymous'. + """ + if current_user: + return current_user.username + + # Try to authenticate from API key header + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + api_key = auth_header[7:] + auth_service = AuthService(db) + user = auth_service.get_user_from_api_key(api_key) + if user: + return user.username + + return "anonymous" + + def get_user_id(request: Request) -> str: - """Extract user ID from request (simplified for now)""" - api_key = request.headers.get("X-Orchard-API-Key") - if api_key: - return "api-user" + """Legacy function for backward compatibility. + + DEPRECATED: Use get_user_id_from_request with db session for proper auth. + """ auth = request.headers.get("Authorization") - if auth: - return "bearer-user" + if auth and auth.startswith("Bearer "): + return "authenticated-user" return "anonymous" @@ -320,6 +367,758 @@ def health_check( ) +# --- Authentication Routes --- + +from .auth import ( + AuthService, + get_current_user, + get_current_user_optional, + require_admin, + get_auth_service, + SESSION_COOKIE_NAME, + verify_password, + validate_password_strength, + PasswordTooShortError, + MIN_PASSWORD_LENGTH, + check_project_access, + AuthorizationService, +) +from .rate_limit import limiter, LOGIN_RATE_LIMIT + + +@router.post("/api/v1/auth/login", response_model=LoginResponse) +@limiter.limit(LOGIN_RATE_LIMIT) +def login( + login_request: LoginRequest, + request: Request, + response: Response, + auth_service: AuthService = Depends(get_auth_service), +): + """ + Login with username and password. + Returns user info and sets a session cookie. + """ + user = auth_service.authenticate_user( + login_request.username, login_request.password + ) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password", + ) + + # Create session + session, token = auth_service.create_session( + user, + user_agent=request.headers.get("User-Agent"), + ip_address=request.client.host if request.client else None, + ) + + # Update last login + auth_service.update_last_login(user) + + # Set session cookie + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=token, + httponly=True, + secure=request.url.scheme == "https", + samesite="lax", + max_age=24 * 60 * 60, # 24 hours + ) + + # Log audit + _log_audit( + auth_service.db, + "auth.login", + f"user:{user.username}", + user.username, + request, + {"user_id": str(user.id)}, + ) + + return LoginResponse( + id=user.id, + username=user.username, + email=user.email, + is_admin=user.is_admin, + must_change_password=user.must_change_password, + ) + + +@router.post("/api/v1/auth/logout") +def logout( + request: Request, + response: Response, + db: Session = Depends(get_db), + session_token: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME), +): + """ + Logout and invalidate the session. + """ + if session_token: + auth_service = AuthService(db) + session = auth_service.get_session_by_token(session_token) + if session: + auth_service.delete_session(session) + + # Clear the session cookie + response.delete_cookie(key=SESSION_COOKIE_NAME) + + return {"message": "Logged out successfully"} + + +@router.get("/api/v1/auth/me", response_model=UserResponse) +def get_current_user_info( + current_user: User = Depends(get_current_user), +): + """ + Get information about the currently authenticated user. + """ + return UserResponse( + id=current_user.id, + username=current_user.username, + email=current_user.email, + is_admin=current_user.is_admin, + is_active=current_user.is_active, + must_change_password=current_user.must_change_password, + created_at=current_user.created_at, + last_login=current_user.last_login, + ) + + +@router.post("/api/v1/auth/change-password") +def change_password( + password_request: ChangePasswordRequest, + request: Request, + current_user: User = Depends(get_current_user), + auth_service: AuthService = Depends(get_auth_service), +): + """ + Change the current user's password. + Requires the current password for verification. + """ + # Verify current password + if not verify_password( + password_request.current_password, current_user.password_hash + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is incorrect", + ) + + # Validate and change password + try: + auth_service.change_password(current_user, password_request.new_password) + except PasswordTooShortError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Password must be at least {MIN_PASSWORD_LENGTH} characters", + ) + + # Log audit + _log_audit( + auth_service.db, + "auth.password_change", + f"user:{current_user.username}", + current_user.username, + request, + ) + + return {"message": "Password changed successfully"} + + +# --- API Key Routes --- + + +@router.post("/api/v1/auth/keys", response_model=APIKeyCreateResponse) +def create_api_key( + key_request: APIKeyCreate, + request: Request, + current_user: User = Depends(get_current_user), + auth_service: AuthService = Depends(get_auth_service), +): + """ + Create a new API key for the current user. + The key is only returned once - store it securely! + """ + api_key, key = auth_service.create_api_key( + user=current_user, + name=key_request.name, + description=key_request.description, + scopes=key_request.scopes, + ) + + # Log audit + _log_audit( + auth_service.db, + "auth.api_key_create", + f"api_key:{api_key.id}", + current_user.username, + request, + {"key_name": key_request.name}, + ) + + return APIKeyCreateResponse( + id=api_key.id, + name=api_key.name, + description=api_key.description, + scopes=api_key.scopes, + key=key, + created_at=api_key.created_at, + expires_at=api_key.expires_at, + ) + + +@router.get("/api/v1/auth/keys", response_model=List[APIKeyResponse]) +def list_api_keys( + current_user: User = Depends(get_current_user), + auth_service: AuthService = Depends(get_auth_service), +): + """ + List all API keys for the current user. + Does not include the secret key. + """ + keys = auth_service.list_user_api_keys(current_user) + return [ + APIKeyResponse( + id=k.id, + name=k.name, + description=k.description, + scopes=k.scopes, + created_at=k.created_at, + expires_at=k.expires_at, + last_used=k.last_used, + ) + for k in keys + ] + + +@router.delete("/api/v1/auth/keys/{key_id}") +def delete_api_key( + key_id: str, + request: Request, + current_user: User = Depends(get_current_user), + auth_service: AuthService = Depends(get_auth_service), +): + """ + Revoke an API key. + Users can only delete their own keys, unless they are an admin. + """ + api_key = auth_service.get_api_key_by_id(key_id) + if not api_key: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="API key not found", + ) + + # Check ownership (admins can delete any key) + if api_key.owner_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot delete another user's API key", + ) + + key_name = api_key.name + auth_service.delete_api_key(api_key) + + # Log audit + _log_audit( + auth_service.db, + "auth.api_key_delete", + f"api_key:{key_id}", + current_user.username, + request, + {"key_name": key_name}, + ) + + return {"message": "API key deleted successfully"} + + +# --- OIDC Configuration Routes --- + + +@router.get("/api/v1/auth/oidc/status") +def get_oidc_status( + db: Session = Depends(get_db), +): + """ + Get OIDC status (public endpoint). + Returns whether OIDC is enabled and the issuer URL if so. + """ + from .auth import OIDCConfigService + from .schemas import OIDCStatusResponse + + oidc_service = OIDCConfigService(db) + config = oidc_service.get_config() + + if config.enabled and config.issuer_url and config.client_id: + return OIDCStatusResponse(enabled=True, issuer_url=config.issuer_url) + return OIDCStatusResponse(enabled=False) + + +@router.get("/api/v1/auth/oidc/config") +def get_oidc_config( + current_user: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """ + Get OIDC configuration (admin only). + Client secret is not exposed. + """ + from .auth import OIDCConfigService + from .schemas import OIDCConfigResponse + + oidc_service = OIDCConfigService(db) + config = oidc_service.get_config() + + return OIDCConfigResponse( + enabled=config.enabled, + issuer_url=config.issuer_url, + client_id=config.client_id, + has_client_secret=bool(config.client_secret), + scopes=config.scopes, + auto_create_users=config.auto_create_users, + admin_group=config.admin_group, + ) + + +@router.put("/api/v1/auth/oidc/config") +def update_oidc_config( + config_update: "OIDCConfigUpdate", + request: Request, + current_user: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """ + Update OIDC configuration (admin only). + """ + from .auth import OIDCConfigService, OIDCConfig + from .schemas import OIDCConfigUpdate, OIDCConfigResponse + + oidc_service = OIDCConfigService(db) + current_config = oidc_service.get_config() + + # Update only provided fields + new_config = OIDCConfig( + enabled=config_update.enabled if config_update.enabled is not None else current_config.enabled, + issuer_url=config_update.issuer_url if config_update.issuer_url is not None else current_config.issuer_url, + client_id=config_update.client_id if config_update.client_id is not None else current_config.client_id, + client_secret=config_update.client_secret if config_update.client_secret is not None else current_config.client_secret, + scopes=config_update.scopes if config_update.scopes is not None else current_config.scopes, + auto_create_users=config_update.auto_create_users if config_update.auto_create_users is not None else current_config.auto_create_users, + admin_group=config_update.admin_group if config_update.admin_group is not None else current_config.admin_group, + ) + + oidc_service.save_config(new_config) + + # Log audit + _log_audit( + db, + "auth.oidc_config_update", + "oidc_config", + current_user.username, + request, + {"enabled": new_config.enabled, "issuer_url": new_config.issuer_url}, + ) + + return OIDCConfigResponse( + enabled=new_config.enabled, + issuer_url=new_config.issuer_url, + client_id=new_config.client_id, + has_client_secret=bool(new_config.client_secret), + scopes=new_config.scopes, + auto_create_users=new_config.auto_create_users, + admin_group=new_config.admin_group, + ) + + +@router.get("/api/v1/auth/oidc/login") +def oidc_login( + request: Request, + redirect_uri: Optional[str] = Query(None, description="Override redirect URI"), + db: Session = Depends(get_db), +): + """ + Initiate OIDC login flow. + Redirects to the OIDC provider's authorization endpoint. + """ + from .auth import OIDCConfigService, OIDCService + + oidc_config_service = OIDCConfigService(db) + config = oidc_config_service.get_config() + + if not config.enabled or not config.issuer_url or not config.client_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="OIDC is not configured", + ) + + # Generate state for CSRF protection + state = secrets.token_urlsafe(32) + + # Determine redirect URI + if not redirect_uri: + # Use the request's base URL + base_url = str(request.base_url).rstrip("/") + redirect_uri = f"{base_url}/api/v1/auth/oidc/callback" + + # Store state in session (using a simple cookie for now) + oidc_service = OIDCService(db, config) + auth_url = oidc_service.get_authorization_url(redirect_uri, state) + + if not auth_url: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to generate authorization URL", + ) + + # Return redirect response with state cookie + response = RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND) + response.set_cookie( + key="oidc_state", + value=state, + httponly=True, + secure=request.url.scheme == "https", + samesite="lax", + max_age=600, # 10 minutes + ) + response.set_cookie( + key="oidc_redirect_uri", + value=redirect_uri, + httponly=True, + secure=request.url.scheme == "https", + samesite="lax", + max_age=600, + ) + + return response + + +@router.get("/api/v1/auth/oidc/callback") +def oidc_callback( + request: Request, + code: str = Query(..., description="Authorization code"), + state: str = Query(..., description="State parameter"), + oidc_state: Optional[str] = Cookie(None), + oidc_redirect_uri: Optional[str] = Cookie(None), + db: Session = Depends(get_db), +): + """ + Handle OIDC callback after user authenticates. + Exchanges the authorization code for tokens and creates a session. + """ + from .auth import OIDCConfigService, OIDCService, AuthService + + # Verify state + if not oidc_state or state != oidc_state: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid state parameter", + ) + + oidc_config_service = OIDCConfigService(db) + config = oidc_config_service.get_config() + + if not config.enabled: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="OIDC is not configured", + ) + + # Determine redirect URI (must match what was used in login) + if not oidc_redirect_uri: + base_url = str(request.base_url).rstrip("/") + oidc_redirect_uri = f"{base_url}/api/v1/auth/oidc/callback" + + oidc_service = OIDCService(db, config) + + # Exchange code for tokens + tokens = oidc_service.exchange_code_for_tokens(code, oidc_redirect_uri) + if not tokens: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Failed to exchange authorization code", + ) + + id_token = tokens.get("id_token") + if not id_token: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No ID token in response", + ) + + # Validate ID token + claims = oidc_service.validate_id_token(id_token) + if not claims: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid ID token", + ) + + # Get or create user + user = oidc_service.get_or_create_user(claims) + if not user: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User creation not allowed", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is disabled", + ) + + # Create session + auth_service = AuthService(db) + session, token = auth_service.create_session( + user, + user_agent=request.headers.get("User-Agent"), + ip_address=request.client.host if request.client else None, + ) + + # Log audit + _log_audit( + db, + "auth.oidc_login", + f"user:{user.id}", + user.username, + request, + {"oidc_subject": claims.get("sub")}, + ) + + # Redirect to frontend with session cookie + response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=token, + httponly=True, + secure=request.url.scheme == "https", + samesite="lax", + max_age=SESSION_DURATION_HOURS * 3600, + ) + # Clear OIDC state cookies + response.delete_cookie("oidc_state") + response.delete_cookie("oidc_redirect_uri") + + return response + + +# --- Admin User Management Routes --- + + +@router.get("/api/v1/admin/users", response_model=List[UserResponse]) +def list_users( + include_inactive: bool = Query(default=False), + current_user: User = Depends(require_admin), + auth_service: AuthService = Depends(get_auth_service), +): + """ + List all users (admin only). + """ + users = auth_service.list_users(include_inactive=include_inactive) + return [ + UserResponse( + id=u.id, + username=u.username, + email=u.email, + is_admin=u.is_admin, + is_active=u.is_active, + must_change_password=u.must_change_password, + created_at=u.created_at, + last_login=u.last_login, + ) + for u in users + ] + + +@router.post("/api/v1/admin/users", response_model=UserResponse) +def create_user( + user_create: UserCreate, + request: Request, + current_user: User = Depends(require_admin), + auth_service: AuthService = Depends(get_auth_service), +): + """ + Create a new user (admin only). + """ + # Check if username already exists + existing = auth_service.get_user_by_username(user_create.username) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username already exists", + ) + + # Validate password strength + try: + validate_password_strength(user_create.password) + except PasswordTooShortError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Password must be at least {MIN_PASSWORD_LENGTH} characters", + ) + + user = auth_service.create_user( + username=user_create.username, + password=user_create.password, + email=user_create.email, + is_admin=user_create.is_admin, + ) + + # Log audit + _log_audit( + auth_service.db, + "admin.user_create", + f"user:{user.username}", + current_user.username, + request, + {"new_user": user_create.username, "is_admin": user_create.is_admin}, + ) + + return UserResponse( + id=user.id, + username=user.username, + email=user.email, + is_admin=user.is_admin, + is_active=user.is_active, + must_change_password=user.must_change_password, + created_at=user.created_at, + last_login=user.last_login, + ) + + +@router.get("/api/v1/admin/users/{username}", response_model=UserResponse) +def get_user( + username: str, + current_user: User = Depends(require_admin), + auth_service: AuthService = Depends(get_auth_service), +): + """ + Get a specific user by username (admin only). + """ + user = auth_service.get_user_by_username(username) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + return UserResponse( + id=user.id, + username=user.username, + email=user.email, + is_admin=user.is_admin, + is_active=user.is_active, + must_change_password=user.must_change_password, + created_at=user.created_at, + last_login=user.last_login, + ) + + +@router.put("/api/v1/admin/users/{username}", response_model=UserResponse) +def update_user( + username: str, + user_update: UserUpdate, + request: Request, + current_user: User = Depends(require_admin), + auth_service: AuthService = Depends(get_auth_service), +): + """ + Update a user (admin only). + """ + user = auth_service.get_user_by_username(username) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + # Prevent removing the last admin + if user_update.is_admin is False and user.is_admin: + admin_count = ( + auth_service.db.query(User) + .filter(User.is_admin.is_(True), User.is_active.is_(True)) + .count() + ) + if admin_count <= 1: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot remove the last admin", + ) + + # Update fields + if user_update.email is not None: + user.email = user_update.email + if user_update.is_admin is not None: + user.is_admin = user_update.is_admin + if user_update.is_active is not None: + user.is_active = user_update.is_active + + auth_service.db.commit() + + # Log audit + _log_audit( + auth_service.db, + "admin.user_update", + f"user:{username}", + current_user.username, + request, + {"updates": user_update.model_dump(exclude_none=True)}, + ) + + return UserResponse( + id=user.id, + username=user.username, + email=user.email, + is_admin=user.is_admin, + is_active=user.is_active, + must_change_password=user.must_change_password, + created_at=user.created_at, + last_login=user.last_login, + ) + + +@router.post("/api/v1/admin/users/{username}/reset-password") +def reset_user_password( + username: str, + reset_request: ResetPasswordRequest, + request: Request, + current_user: User = Depends(require_admin), + auth_service: AuthService = Depends(get_auth_service), +): + """ + Reset a user's password (admin only). + Sets must_change_password to True. + """ + user = auth_service.get_user_by_username(username) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + try: + auth_service.reset_user_password(user, reset_request.new_password) + except PasswordTooShortError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Password must be at least {MIN_PASSWORD_LENGTH} characters", + ) + + # Log audit + _log_audit( + auth_service.db, + "admin.password_reset", + f"user:{username}", + current_user.username, + request, + ) + + return {"message": f"Password reset for user {username}"} + + # Global search @router.get("/api/v1/search", response_model=GlobalSearchResponse) def global_search( @@ -424,7 +1223,7 @@ def global_search( # Project routes -@router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectResponse]) +@router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectWithAccessResponse]) def list_projects( request: Request, page: int = Query(default=1, ge=1, description="Page number"), @@ -440,8 +1239,9 @@ def list_projects( ), order: str = Query(default="asc", description="Sort order (asc, desc)"), db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional), ): - user_id = get_user_id(request) + user_id = current_user.username if current_user else get_user_id(request) # Validate sort field valid_sort_fields = { @@ -499,8 +1299,51 @@ def list_projects( # Calculate total pages total_pages = math.ceil(total / limit) if total > 0 else 1 + # Build access level info for each project + project_ids = [p.id for p in projects] + access_map = {} + + if current_user and project_ids: + # Get access permissions for this user across these projects + permissions = ( + db.query(AccessPermission) + .filter( + AccessPermission.project_id.in_(project_ids), + AccessPermission.user_id == current_user.username, + ) + .all() + ) + access_map = {p.project_id: p.level for p in permissions} + + # Build response with access levels + items = [] + for p in projects: + is_owner = p.created_by == user_id + access_level = None + + if is_owner: + access_level = "admin" + elif p.id in access_map: + access_level = access_map[p.id] + elif p.is_public: + access_level = "read" + + items.append( + ProjectWithAccessResponse( + id=p.id, + name=p.name, + description=p.description, + is_public=p.is_public, + created_at=p.created_at, + updated_at=p.updated_at, + created_by=p.created_by, + access_level=access_level, + is_owner=is_owner, + ) + ) + return PaginatedResponse( - items=projects, + items=items, pagination=PaginationMeta( page=page, limit=limit, @@ -513,9 +1356,12 @@ def list_projects( @router.post("/api/v1/projects", response_model=ProjectResponse) def create_project( - project: ProjectCreate, request: Request, db: Session = Depends(get_db) + project: ProjectCreate, + request: Request, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional), ): - user_id = get_user_id(request) + user_id = get_user_id_from_request(request, db, current_user) existing = db.query(Project).filter(Project.name == project.name).first() if existing: @@ -545,10 +1391,13 @@ def create_project( @router.get("/api/v1/projects/{project_name}", response_model=ProjectResponse) -def get_project(project_name: str, db: Session = Depends(get_db)): - project = db.query(Project).filter(Project.name == project_name).first() - if not project: - raise HTTPException(status_code=404, detail="Project not found") +def get_project( + project_name: str, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional), +): + """Get a single project by name. Requires read access for private projects.""" + project = check_project_access(db, project_name, current_user, "read") return project @@ -558,13 +1407,11 @@ def update_project( project_update: ProjectUpdate, request: Request, db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional), ): - """Update a project's metadata.""" - user_id = get_user_id(request) - - project = db.query(Project).filter(Project.name == project_name).first() - if not project: - raise HTTPException(status_code=404, detail="Project not found") + """Update a project's metadata. Requires admin access.""" + project = check_project_access(db, project_name, current_user, "admin") + user_id = current_user.username if current_user else get_user_id(request) # Track changes for audit log changes = {} @@ -611,14 +1458,16 @@ def delete_project( project_name: str, request: Request, db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional), ): """ - Delete a project and all its packages. + Delete a project and all its packages. Requires admin access. Decrements ref_count for all artifacts referenced by tags in all packages within this project. """ - user_id = get_user_id(request) + check_project_access(db, project_name, current_user, "admin") + user_id = current_user.username if current_user else get_user_id(request) project = db.query(Project).filter(Project.name == project_name).first() if not project: @@ -664,6 +1513,159 @@ def delete_project( return None +# Access Permission routes +@router.get( + "/api/v1/project/{project_name}/permissions", + response_model=List[AccessPermissionResponse], +) +def list_project_permissions( + project_name: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + List all access permissions for a project. + Requires admin access to the project. + """ + project = check_project_access(db, project_name, current_user, "admin") + + auth_service = AuthorizationService(db) + permissions = auth_service.list_project_permissions(str(project.id)) + + return permissions + + +@router.post( + "/api/v1/project/{project_name}/permissions", + response_model=AccessPermissionResponse, +) +def grant_project_access( + project_name: str, + permission: AccessPermissionCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Grant access to a user for a project. + Requires admin access to the project. + """ + project = check_project_access(db, project_name, current_user, "admin") + + auth_service = AuthorizationService(db) + new_permission = auth_service.grant_access( + str(project.id), + permission.username, + permission.level, + permission.expires_at, + ) + + return new_permission + + +@router.put( + "/api/v1/project/{project_name}/permissions/{username}", + response_model=AccessPermissionResponse, +) +def update_project_access( + project_name: str, + username: str, + permission: AccessPermissionUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Update a user's access level for a project. + Requires admin access to the project. + """ + project = check_project_access(db, project_name, current_user, "admin") + + auth_service = AuthorizationService(db) + + # Get existing permission + from .models import AccessPermission + existing = ( + db.query(AccessPermission) + .filter( + AccessPermission.project_id == project.id, + AccessPermission.user_id == username, + ) + .first() + ) + + if not existing: + raise HTTPException( + status_code=404, + detail=f"No access permission found for user '{username}'", + ) + + # Update fields + if permission.level is not None: + existing.level = permission.level + if permission.expires_at is not None: + existing.expires_at = permission.expires_at + + db.commit() + db.refresh(existing) + + return existing + + +@router.delete( + "/api/v1/project/{project_name}/permissions/{username}", + status_code=204, +) +def revoke_project_access( + project_name: str, + username: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Revoke a user's access to a project. + Requires admin access to the project. + """ + project = check_project_access(db, project_name, current_user, "admin") + + auth_service = AuthorizationService(db) + deleted = auth_service.revoke_access(str(project.id), username) + + if not deleted: + raise HTTPException( + status_code=404, + detail=f"No access permission found for user '{username}'", + ) + + return None + + +@router.get( + "/api/v1/project/{project_name}/my-access", +) +def get_my_project_access( + project_name: str, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional), +): + """ + Get the current user's access level for a project. + Returns null for anonymous users on private projects. + """ + from .models import Project + + project = db.query(Project).filter(Project.name == project_name).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + auth_service = AuthorizationService(db) + access_level = auth_service.get_user_access_level(str(project.id), current_user) + + return { + "project": project_name, + "access_level": access_level, + "is_owner": current_user and project.created_by == current_user.username, + } + + # Package routes @router.get( "/api/v1/project/{project_name}/packages", @@ -934,10 +1936,10 @@ def create_package( package: PackageCreate, request: Request, db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional), ): - project = db.query(Project).filter(Project.name == project_name).first() - if not project: - raise HTTPException(status_code=404, detail="Project not found") + """Create a new package in a project. Requires write access.""" + project = check_project_access(db, project_name, current_user, "write") # Validate format if package.format not in PACKAGE_FORMATS: @@ -1150,6 +2152,7 @@ def upload_artifact( content_length: Optional[int] = Header(None, alias="Content-Length"), user_agent: Optional[str] = Header(None, alias="User-Agent"), client_checksum: Optional[str] = Header(None, alias="X-Checksum-SHA256"), + current_user: Optional[User] = Depends(get_current_user_optional), ): """ Upload an artifact to a package. @@ -1157,16 +2160,15 @@ def upload_artifact( Headers: - X-Checksum-SHA256: Optional client-provided SHA256 for verification - User-Agent: Captured for audit purposes + - Authorization: Bearer for authentication """ start_time = time.time() - user_id = get_user_id(request) settings = get_settings() storage_result = None - # Get project and package - project = db.query(Project).filter(Project.name == project_name).first() - if not project: - raise HTTPException(status_code=404, detail="Project not found") + # Check authorization (write access required for uploads) + project = check_project_access(db, project_name, current_user, "write") + user_id = current_user.username if current_user else get_user_id_from_request(request, db, current_user) package = ( db.query(Package) @@ -1791,6 +2793,7 @@ def download_artifact( request: Request, db: Session = Depends(get_db), storage: S3Storage = Depends(get_storage), + current_user: Optional[User] = Depends(get_current_user_optional), range: Optional[str] = Header(None), mode: Optional[Literal["proxy", "redirect", "presigned"]] = Query( default=None, @@ -1826,10 +2829,8 @@ def download_artifact( """ settings = get_settings() - # Get project and package - project = db.query(Project).filter(Project.name == project_name).first() - if not project: - raise HTTPException(status_code=404, detail="Project not found") + # Check authorization (read access required for downloads) + project = check_project_access(db, project_name, current_user, "read") package = ( db.query(Package) @@ -2047,10 +3048,8 @@ def get_artifact_url( """ settings = get_settings() - # Get project and package - project = db.query(Project).filter(Project.name == project_name).first() - if not project: - raise HTTPException(status_code=404, detail="Project not found") + # Check authorization (read access required for downloads) + project = check_project_access(db, project_name, current_user, "read") package = ( db.query(Package) @@ -2305,12 +3304,11 @@ def create_tag( tag: TagCreate, request: Request, db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional), ): - user_id = get_user_id(request) - - project = db.query(Project).filter(Project.name == project_name).first() - if not project: - raise HTTPException(status_code=404, detail="Project not found") + """Create or update a tag. Requires write access.""" + project = check_project_access(db, project_name, current_user, "write") + user_id = current_user.username if current_user else get_user_id(request) package = ( db.query(Package) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 9bd3701..1b53d7d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -47,6 +47,13 @@ class ProjectUpdate(BaseModel): is_public: Optional[bool] = None +class ProjectWithAccessResponse(ProjectResponse): + """Project response with user's access level included""" + + access_level: Optional[str] = None # 'read', 'write', 'admin', or None + is_owner: bool = False + + # Package format and platform enums PACKAGE_FORMATS = [ "generic", @@ -686,3 +693,173 @@ class StatsReportResponse(BaseModel): format: str # "json", "csv", "markdown" generated_at: datetime content: str # The report content + + +# Authentication schemas +class LoginRequest(BaseModel): + """Login request with username and password""" + username: str + password: str + + +class LoginResponse(BaseModel): + """Login response with user info""" + id: UUID + username: str + email: Optional[str] + is_admin: bool + must_change_password: bool + + +class ChangePasswordRequest(BaseModel): + """Change password request""" + current_password: str + new_password: str + + +class UserResponse(BaseModel): + """User information response""" + id: UUID + username: str + email: Optional[str] + is_admin: bool + is_active: bool + must_change_password: bool + created_at: datetime + last_login: Optional[datetime] + + class Config: + from_attributes = True + + +class UserCreate(BaseModel): + """Create user request (admin only)""" + username: str + password: str + email: Optional[str] = None + is_admin: bool = False + + +class UserUpdate(BaseModel): + """Update user request (admin only)""" + email: Optional[str] = None + is_admin: Optional[bool] = None + is_active: Optional[bool] = None + + +class ResetPasswordRequest(BaseModel): + """Reset password request (admin only)""" + new_password: str + + +class APIKeyCreate(BaseModel): + """Create API key request""" + name: str + description: Optional[str] = None + scopes: Optional[List[str]] = None + + +class APIKeyResponse(BaseModel): + """API key response (without the secret key)""" + id: UUID + name: str + description: Optional[str] + scopes: Optional[List[str]] + created_at: datetime + expires_at: Optional[datetime] + last_used: Optional[datetime] + + class Config: + from_attributes = True + + +class APIKeyCreateResponse(BaseModel): + """API key creation response (includes the secret key - only shown once)""" + id: UUID + name: str + description: Optional[str] + scopes: Optional[List[str]] + key: str # The actual API key - only returned on creation + created_at: datetime + expires_at: Optional[datetime] + + +# OIDC Configuration schemas +class OIDCConfigResponse(BaseModel): + """OIDC configuration response (hides client secret)""" + enabled: bool + issuer_url: str + client_id: str + has_client_secret: bool # True if secret is configured, but don't expose it + scopes: List[str] + auto_create_users: bool + admin_group: str + + +class OIDCConfigUpdate(BaseModel): + """Update OIDC configuration""" + enabled: Optional[bool] = None + issuer_url: Optional[str] = None + client_id: Optional[str] = None + client_secret: Optional[str] = None # Only set if changing + scopes: Optional[List[str]] = None + auto_create_users: Optional[bool] = None + admin_group: Optional[str] = None + + +class OIDCStatusResponse(BaseModel): + """Public OIDC status response""" + enabled: bool + issuer_url: Optional[str] = None # Only included if enabled + + +class OIDCLoginResponse(BaseModel): + """OIDC login initiation response""" + authorization_url: str + + +# 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 + diff --git a/backend/requirements.txt b/backend/requirements.txt index 67a4138..604f19c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,6 +9,8 @@ pydantic==2.5.3 pydantic-settings==2.1.0 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +slowapi==0.1.9 # Test dependencies pytest>=7.4.0 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 34111d8..9064602 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -182,9 +182,10 @@ def test_app(): @pytest.fixture def integration_client(): """ - Create a test client for integration tests. + Create an authenticated test client for integration tests. Uses the real database and MinIO from docker-compose.local.yml. + Authenticates as admin for write operations. """ from httpx import Client @@ -192,6 +193,15 @@ def integration_client(): base_url = os.environ.get("ORCHARD_TEST_URL", "http://localhost:8080") with Client(base_url=base_url, timeout=30.0) as client: + # Login as admin to enable write operations + login_response = client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + # If login fails, tests will fail - that's expected if auth is broken + if login_response.status_code != 200: + # Try to continue without auth for backward compatibility + pass yield client diff --git a/backend/tests/integration/test_auth_api.py b/backend/tests/integration/test_auth_api.py new file mode 100644 index 0000000..817694a --- /dev/null +++ b/backend/tests/integration/test_auth_api.py @@ -0,0 +1,760 @@ +"""Integration tests for authentication API endpoints.""" + +import pytest +from uuid import uuid4 + + +class TestAuthLogin: + """Tests for login endpoint.""" + + @pytest.mark.integration + def test_login_success(self, integration_client): + """Test successful login with default admin credentials.""" + response = integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["username"] == "admin" + assert data["is_admin"] is True + assert "orchard_session" in response.cookies + + @pytest.mark.integration + def test_login_invalid_password(self, integration_client): + """Test login with wrong password.""" + response = integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "wrongpassword"}, + ) + assert response.status_code == 401 + assert "Invalid username or password" in response.json()["detail"] + + @pytest.mark.integration + def test_login_nonexistent_user(self, integration_client): + """Test login with non-existent user.""" + response = integration_client.post( + "/api/v1/auth/login", + json={"username": "nonexistent", "password": "password"}, + ) + assert response.status_code == 401 + + +class TestAuthLogout: + """Tests for logout endpoint.""" + + @pytest.mark.integration + def test_logout_success(self, integration_client): + """Test successful logout.""" + # First login + login_response = integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + assert login_response.status_code == 200 + + # Then logout + logout_response = integration_client.post("/api/v1/auth/logout") + assert logout_response.status_code == 200 + assert "Logged out successfully" in logout_response.json()["message"] + + @pytest.mark.integration + def test_logout_without_session(self, integration_client): + """Test logout without being logged in.""" + response = integration_client.post("/api/v1/auth/logout") + # Should succeed even without session + assert response.status_code == 200 + + +class TestAuthMe: + """Tests for get current user endpoint.""" + + @pytest.mark.integration + def test_get_me_authenticated(self, integration_client): + """Test getting current user when authenticated.""" + # Login first + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + response = integration_client.get("/api/v1/auth/me") + assert response.status_code == 200 + data = response.json() + assert data["username"] == "admin" + assert data["is_admin"] is True + assert "id" in data + assert "created_at" in data + + @pytest.mark.integration + def test_get_me_unauthenticated(self, integration_client): + """Test getting current user without authentication.""" + # Clear any existing cookies + integration_client.cookies.clear() + + response = integration_client.get("/api/v1/auth/me") + assert response.status_code == 401 + assert "Not authenticated" in response.json()["detail"] + + +class TestAuthChangePassword: + """Tests for change password endpoint.""" + + @pytest.mark.integration + def test_change_password_success(self, integration_client): + """Test successful password change.""" + # Login first + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + # Change password + response = integration_client.post( + "/api/v1/auth/change-password", + json={"current_password": "changeme123", "new_password": "newpassword123"}, + ) + assert response.status_code == 200 + + # Verify old password no longer works + integration_client.cookies.clear() + response = integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + assert response.status_code == 401 + + # Verify new password works + response = integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "newpassword123"}, + ) + assert response.status_code == 200 + + # Reset password back to original for other tests + reset_response = integration_client.post( + "/api/v1/auth/change-password", + json={"current_password": "newpassword123", "new_password": "changeme123"}, + ) + assert reset_response.status_code == 200, "Failed to reset admin password back to default" + + @pytest.mark.integration + def test_change_password_wrong_current(self, integration_client): + """Test password change with wrong current password.""" + # Login first + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + response = integration_client.post( + "/api/v1/auth/change-password", + json={"current_password": "wrongpassword", "new_password": "newpassword"}, + ) + assert response.status_code == 400 + assert "Current password is incorrect" in response.json()["detail"] + + +class TestAPIKeys: + """Tests for API key management endpoints.""" + + @pytest.mark.integration + def test_create_and_list_api_key(self, integration_client): + """Test creating and listing API keys.""" + # Login first + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + # Create API key + create_response = integration_client.post( + "/api/v1/auth/keys", + json={"name": "test-key", "description": "Test API key"}, + ) + assert create_response.status_code == 200 + data = create_response.json() + assert data["name"] == "test-key" + assert data["description"] == "Test API key" + assert "key" in data + assert data["key"].startswith("orch_") + key_id = data["id"] + api_key = data["key"] + + # List API keys + list_response = integration_client.get("/api/v1/auth/keys") + assert list_response.status_code == 200 + keys = list_response.json() + assert any(k["id"] == key_id for k in keys) + + # Clean up - delete the key + integration_client.delete(f"/api/v1/auth/keys/{key_id}") + + @pytest.mark.integration + def test_use_api_key_for_auth(self, integration_client): + """Test using API key for authentication.""" + # Login and create API key + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + create_response = integration_client.post( + "/api/v1/auth/keys", + json={"name": "auth-test-key"}, + ) + api_key = create_response.json()["key"] + key_id = create_response.json()["id"] + + # Clear cookies and use API key + integration_client.cookies.clear() + response = integration_client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {api_key}"}, + ) + assert response.status_code == 200 + assert response.json()["username"] == "admin" + + # Clean up + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + integration_client.delete(f"/api/v1/auth/keys/{key_id}") + + @pytest.mark.integration + def test_delete_api_key(self, integration_client): + """Test revoking an API key.""" + # Login and create API key + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + create_response = integration_client.post( + "/api/v1/auth/keys", + json={"name": "delete-test-key"}, + ) + key_id = create_response.json()["id"] + api_key = create_response.json()["key"] + + # Delete the key + delete_response = integration_client.delete(f"/api/v1/auth/keys/{key_id}") + assert delete_response.status_code == 200 + + # Verify key no longer works + integration_client.cookies.clear() + response = integration_client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {api_key}"}, + ) + assert response.status_code == 401 + + +class TestAdminUserManagement: + """Tests for admin user management endpoints.""" + + @pytest.mark.integration + def test_list_users(self, integration_client): + """Test listing users as admin.""" + # Login as admin + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + response = integration_client.get("/api/v1/admin/users") + assert response.status_code == 200 + users = response.json() + assert len(users) >= 1 + assert any(u["username"] == "admin" for u in users) + + @pytest.mark.integration + def test_create_user(self, integration_client): + """Test creating a new user as admin.""" + # Login as admin + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + # Create new user + test_username = f"testuser_{uuid4().hex[:8]}" + response = integration_client.post( + "/api/v1/admin/users", + json={ + "username": test_username, + "password": "testpassword", + "email": "test@example.com", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["username"] == test_username + assert data["email"] == "test@example.com" + assert data["is_admin"] is False + + # Verify new user can login + integration_client.cookies.clear() + login_response = integration_client.post( + "/api/v1/auth/login", + json={"username": test_username, "password": "testpassword"}, + ) + assert login_response.status_code == 200 + + @pytest.mark.integration + def test_update_user(self, integration_client): + """Test updating a user as admin.""" + # Login as admin + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + # Create a test user + test_username = f"updateuser_{uuid4().hex[:8]}" + integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "password"}, + ) + + # Update the user + response = integration_client.put( + f"/api/v1/admin/users/{test_username}", + json={"email": "updated@example.com", "is_admin": True}, + ) + assert response.status_code == 200 + data = response.json() + assert data["email"] == "updated@example.com" + assert data["is_admin"] is True + + @pytest.mark.integration + def test_reset_user_password(self, integration_client): + """Test resetting a user's password as admin.""" + # Login as admin + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + # Create a test user + test_username = f"resetuser_{uuid4().hex[:8]}" + integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "oldpassword"}, + ) + + # Reset password + response = integration_client.post( + f"/api/v1/admin/users/{test_username}/reset-password", + json={"new_password": "newpassword"}, + ) + assert response.status_code == 200 + + # Verify new password works + integration_client.cookies.clear() + login_response = integration_client.post( + "/api/v1/auth/login", + json={"username": test_username, "password": "newpassword"}, + ) + assert login_response.status_code == 200 + + @pytest.mark.integration + def test_non_admin_cannot_access_admin_endpoints(self, integration_client): + """Test that non-admin users cannot access admin endpoints.""" + # Login as admin and create non-admin user + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + test_username = f"nonadmin_{uuid4().hex[:8]}" + integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "password", "is_admin": False}, + ) + + # Login as non-admin + integration_client.cookies.clear() + integration_client.post( + "/api/v1/auth/login", + json={"username": test_username, "password": "password"}, + ) + + # Try to access admin endpoints + response = integration_client.get("/api/v1/admin/users") + assert response.status_code == 403 + assert "Admin privileges required" in response.json()["detail"] + + +class TestSecurityEdgeCases: + """Tests for security edge cases and validation.""" + + @pytest.mark.integration + def test_login_inactive_user(self, integration_client): + """Test that inactive users cannot login.""" + # Login as admin and create a user + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + test_username = f"inactive_{uuid4().hex[:8]}" + integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "password123"}, + ) + + # Deactivate the user + integration_client.put( + f"/api/v1/admin/users/{test_username}", + json={"is_active": False}, + ) + + # Try to login as inactive user + integration_client.cookies.clear() + response = integration_client.post( + "/api/v1/auth/login", + json={"username": test_username, "password": "password123"}, + ) + assert response.status_code == 401 + assert "Invalid username or password" in response.json()["detail"] + + @pytest.mark.integration + def test_password_too_short_on_create(self, integration_client): + """Test that short passwords are rejected when creating users.""" + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + response = integration_client.post( + "/api/v1/admin/users", + json={"username": f"shortpw_{uuid4().hex[:8]}", "password": "short"}, + ) + assert response.status_code == 400 + assert "at least 8 characters" in response.json()["detail"] + + @pytest.mark.integration + def test_password_too_short_on_change(self, integration_client): + """Test that short passwords are rejected when changing password.""" + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + response = integration_client.post( + "/api/v1/auth/change-password", + json={"current_password": "changeme123", "new_password": "short"}, + ) + assert response.status_code == 400 + assert "at least 8 characters" in response.json()["detail"] + + @pytest.mark.integration + def test_password_too_short_on_reset(self, integration_client): + """Test that short passwords are rejected when resetting password.""" + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + # Create a test user first + test_username = f"resetshort_{uuid4().hex[:8]}" + integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "password123"}, + ) + + response = integration_client.post( + f"/api/v1/admin/users/{test_username}/reset-password", + json={"new_password": "short"}, + ) + assert response.status_code == 400 + assert "at least 8 characters" in response.json()["detail"] + + @pytest.mark.integration + def test_duplicate_username_rejected(self, integration_client): + """Test that duplicate usernames are rejected.""" + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + test_username = f"duplicate_{uuid4().hex[:8]}" + # Create user first time + response1 = integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "password123"}, + ) + assert response1.status_code == 200 + + # Try to create same username again + response2 = integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "password456"}, + ) + assert response2.status_code == 409 + assert "already exists" in response2.json()["detail"] + + @pytest.mark.integration + def test_cannot_delete_other_users_api_key(self, integration_client): + """Test that users cannot delete API keys owned by other users.""" + # Login as admin and create an API key + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + create_response = integration_client.post( + "/api/v1/auth/keys", + json={"name": "admin-key"}, + ) + admin_key_id = create_response.json()["id"] + + # Create a non-admin user + test_username = f"nonadmin_{uuid4().hex[:8]}" + integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "password123"}, + ) + + # Login as non-admin + integration_client.cookies.clear() + integration_client.post( + "/api/v1/auth/login", + json={"username": test_username, "password": "password123"}, + ) + + # Try to delete admin's API key + response = integration_client.delete(f"/api/v1/auth/keys/{admin_key_id}") + assert response.status_code == 403 + assert "Cannot delete another user's API key" in response.json()["detail"] + + # Cleanup: login as admin and delete the key + integration_client.cookies.clear() + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + integration_client.delete(f"/api/v1/auth/keys/{admin_key_id}") + + @pytest.mark.integration + def test_sessions_invalidated_on_password_change(self, integration_client): + """Test that all sessions are invalidated when password is changed.""" + # Create a test user + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + test_username = f"sessiontest_{uuid4().hex[:8]}" + integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "password123"}, + ) + + # Login as test user + integration_client.cookies.clear() + login_response = integration_client.post( + "/api/v1/auth/login", + json={"username": test_username, "password": "password123"}, + ) + assert login_response.status_code == 200 + + # Verify session works + me_response = integration_client.get("/api/v1/auth/me") + assert me_response.status_code == 200 + + # Change password + integration_client.post( + "/api/v1/auth/change-password", + json={"current_password": "password123", "new_password": "newpassword123"}, + ) + + # Old session should be invalidated - try to access /me + # (note: the change-password call itself may have cleared the session cookie) + me_response2 = integration_client.get("/api/v1/auth/me") + # This should fail because all sessions were invalidated + assert me_response2.status_code == 401 + + +class TestSecurityEdgeCases: + """Tests for security edge cases and validation.""" + + @pytest.mark.integration + def test_login_inactive_user(self, integration_client): + """Test that inactive users cannot login.""" + # Login as admin and create a user + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + test_username = f"inactive_{uuid4().hex[:8]}" + integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "password123"}, + ) + + # Deactivate the user + integration_client.put( + f"/api/v1/admin/users/{test_username}", + json={"is_active": False}, + ) + + # Try to login as inactive user + integration_client.cookies.clear() + response = integration_client.post( + "/api/v1/auth/login", + json={"username": test_username, "password": "password123"}, + ) + assert response.status_code == 401 + assert "Invalid username or password" in response.json()["detail"] + + @pytest.mark.integration + def test_password_too_short_on_create(self, integration_client): + """Test that short passwords are rejected when creating users.""" + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + response = integration_client.post( + "/api/v1/admin/users", + json={"username": f"shortpw_{uuid4().hex[:8]}", "password": "short"}, + ) + assert response.status_code == 400 + assert "at least 8 characters" in response.json()["detail"] + + @pytest.mark.integration + def test_password_too_short_on_change(self, integration_client): + """Test that short passwords are rejected when changing password.""" + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + response = integration_client.post( + "/api/v1/auth/change-password", + json={"current_password": "changeme123", "new_password": "short"}, + ) + assert response.status_code == 400 + assert "at least 8 characters" in response.json()["detail"] + + @pytest.mark.integration + def test_password_too_short_on_reset(self, integration_client): + """Test that short passwords are rejected when resetting password.""" + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + # Create a test user first + test_username = f"resetshort_{uuid4().hex[:8]}" + integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "password123"}, + ) + + response = integration_client.post( + f"/api/v1/admin/users/{test_username}/reset-password", + json={"new_password": "short"}, + ) + assert response.status_code == 400 + assert "at least 8 characters" in response.json()["detail"] + + @pytest.mark.integration + def test_duplicate_username_rejected(self, integration_client): + """Test that duplicate usernames are rejected.""" + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + + test_username = f"duplicate_{uuid4().hex[:8]}" + # Create user first time + response1 = integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "password123"}, + ) + assert response1.status_code == 200 + + # Try to create same username again + response2 = integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "password456"}, + ) + assert response2.status_code == 409 + assert "already exists" in response2.json()["detail"] + + @pytest.mark.integration + def test_cannot_delete_other_users_api_key(self, integration_client): + """Test that users cannot delete API keys owned by other users.""" + # Login as admin and create an API key + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + create_response = integration_client.post( + "/api/v1/auth/keys", + json={"name": "admin-key"}, + ) + admin_key_id = create_response.json()["id"] + + # Create a non-admin user + test_username = f"nonadmin_{uuid4().hex[:8]}" + integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "password123"}, + ) + + # Login as non-admin + integration_client.cookies.clear() + integration_client.post( + "/api/v1/auth/login", + json={"username": test_username, "password": "password123"}, + ) + + # Try to delete admin's API key + response = integration_client.delete(f"/api/v1/auth/keys/{admin_key_id}") + assert response.status_code == 403 + assert "Cannot delete another user's API key" in response.json()["detail"] + + # Cleanup: login as admin and delete the key + integration_client.cookies.clear() + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + integration_client.delete(f"/api/v1/auth/keys/{admin_key_id}") + + @pytest.mark.integration + def test_sessions_invalidated_on_password_change(self, integration_client): + """Test that all sessions are invalidated when password is changed.""" + # Create a test user + integration_client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "changeme123"}, + ) + test_username = f"sessiontest_{uuid4().hex[:8]}" + integration_client.post( + "/api/v1/admin/users", + json={"username": test_username, "password": "password123"}, + ) + + # Login as test user + integration_client.cookies.clear() + login_response = integration_client.post( + "/api/v1/auth/login", + json={"username": test_username, "password": "password123"}, + ) + assert login_response.status_code == 200 + + # Verify session works + me_response = integration_client.get("/api/v1/auth/me") + assert me_response.status_code == 200 + + # Change password + integration_client.post( + "/api/v1/auth/change-password", + json={"current_password": "password123", "new_password": "newpassword123"}, + ) + + # Old session should be invalidated - try to access /me + # (note: the change-password call itself may have cleared the session cookie) + me_response2 = integration_client.get("/api/v1/auth/me") + # This should fail because all sessions were invalidated + assert me_response2.status_code == 401 diff --git a/backend/tests/integration/test_projects_api.py b/backend/tests/integration/test_projects_api.py index 0de9554..49ed5c4 100644 --- a/backend/tests/integration/test_projects_api.py +++ b/backend/tests/integration/test_projects_api.py @@ -59,7 +59,8 @@ class TestProjectCRUD: @pytest.mark.integration def test_list_projects(self, integration_client, test_project): """Test listing projects includes created project.""" - response = integration_client.get("/api/v1/projects") + # Search specifically for our test project to avoid pagination issues + response = integration_client.get(f"/api/v1/projects?search={test_project}") assert response.status_code == 200 data = response.json() @@ -107,9 +108,11 @@ class TestProjectListingFilters: @pytest.mark.integration def test_projects_search(self, integration_client, test_project): """Test project search by name.""" - # Search for our test project + # Search using the unique portion of our test project name + # test_project format is "test-project-test-{uuid[:8]}" + unique_part = test_project.split("-")[-1] # Get the UUID portion response = integration_client.get( - f"/api/v1/projects?search={test_project[:10]}" + f"/api/v1/projects?search={unique_part}" ) assert response.status_code == 200 diff --git a/backend/tests/integration/test_upload_download_api.py b/backend/tests/integration/test_upload_download_api.py index 8b83e02..4d9b8b2 100644 --- a/backend/tests/integration/test_upload_download_api.py +++ b/backend/tests/integration/test_upload_download_api.py @@ -286,6 +286,14 @@ class TestConcurrentUploads: expected_hash = compute_sha256(content) num_concurrent = 5 + # Create an API key for worker threads + api_key_response = integration_client.post( + "/api/v1/auth/keys", + json={"name": "concurrent-test-key"}, + ) + assert api_key_response.status_code == 200, f"Failed to create API key: {api_key_response.text}" + api_key = api_key_response.json()["key"] + results = [] errors = [] @@ -306,6 +314,7 @@ class TestConcurrentUploads: f"/api/v1/project/{project}/{package}/upload", files=files, data={"tag": f"concurrent-{tag_suffix}"}, + headers={"Authorization": f"Bearer {api_key}"}, ) if response.status_code == 200: results.append(response.json()) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 4706417..543a943 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -24,6 +24,8 @@ services: - ORCHARD_S3_USE_PATH_STYLE=true - ORCHARD_REDIS_HOST=redis - ORCHARD_REDIS_PORT=6379 + # Higher rate limit for local development/testing + - ORCHARD_LOGIN_RATE_LIMIT=1000/minute depends_on: postgres: condition: service_healthy diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aa31ff4..49f820e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,20 +1,65 @@ -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; import Layout from './components/Layout'; import Home from './pages/Home'; import ProjectPage from './pages/ProjectPage'; import PackagePage from './pages/PackagePage'; import Dashboard from './pages/Dashboard'; +import LoginPage from './pages/LoginPage'; +import ChangePasswordPage from './pages/ChangePasswordPage'; +import APIKeysPage from './pages/APIKeysPage'; +import AdminUsersPage from './pages/AdminUsersPage'; +import AdminOIDCPage from './pages/AdminOIDCPage'; + +// 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 ; + } + + return <>{children}; +} + +function AppRoutes() { + return ( + + } /> + } /> + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> + + ); +} function App() { return ( - - - } /> - } /> - } /> - } /> - - + + + ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3f5b0c7..a2b5b51 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -17,14 +17,62 @@ import { DeduplicationStats, TimelineStats, CrossProjectStats, + User, + LoginCredentials, + APIKey, + APIKeyCreate, + APIKeyCreateResponse, + AdminUser, + UserCreate, + UserUpdate, + AccessPermission, + AccessPermissionCreate, + AccessPermissionUpdate, + AccessLevel, + OIDCConfig, + OIDCConfigUpdate, + OIDCStatus, } from './types'; const API_BASE = '/api/v1'; +// Custom error classes for better error handling +export class ApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + +export class UnauthorizedError extends ApiError { + constructor(message: string = 'Not authenticated') { + super(message, 401); + this.name = 'UnauthorizedError'; + } +} + +export class ForbiddenError extends ApiError { + constructor(message: string = 'Access denied') { + super(message, 403); + this.name = 'ForbiddenError'; + } +} + async function handleResponse(response: Response): Promise { if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Unknown error' })); - throw new Error(error.detail || `HTTP ${response.status}`); + const message = error.detail || `HTTP ${response.status}`; + + if (response.status === 401) { + throw new UnauthorizedError(message); + } + if (response.status === 403) { + throw new ForbiddenError(message); + } + throw new ApiError(message, response.status); } return response.json(); } @@ -40,6 +88,55 @@ function buildQueryString(params: Record): string { return query ? `?${query}` : ''; } +// Auth API +export async function login(credentials: LoginCredentials): Promise { + const response = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function logout(): Promise { + const response = await fetch(`${API_BASE}/auth/logout`, { + method: 'POST', + 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 changePassword(currentPassword: string, newPassword: string): Promise { + 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 { + try { + const response = await fetch(`${API_BASE}/auth/me`, { + credentials: 'include', + }); + if (response.status === 401) { + return null; + } + return handleResponse(response); + } catch { + return null; + } +} + // Global Search API export async function globalSearch(query: string, limit: number = 5): Promise { const params = buildQueryString({ q: query, limit }); @@ -186,3 +283,163 @@ export async function getCrossProjectStats(): Promise { const response = await fetch(`${API_BASE}/stats/cross-project`); return handleResponse(response); } + +export async function listAPIKeys(): Promise { + const response = await fetch(`${API_BASE}/auth/keys`, { + credentials: 'include', + }); + return handleResponse(response); +} + +export async function createAPIKey(data: APIKeyCreate): Promise { + const response = await fetch(`${API_BASE}/auth/keys`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function deleteAPIKey(id: string): Promise { + const response = await fetch(`${API_BASE}/auth/keys/${id}`, { + method: 'DELETE', + credentials: 'include', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } +} + +// Admin User Management API +export async function listUsers(): Promise { + const response = await fetch(`${API_BASE}/admin/users`, { + credentials: 'include', + }); + return handleResponse(response); +} + +export async function createUser(data: UserCreate): Promise { + const response = await fetch(`${API_BASE}/admin/users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function updateUser(username: string, data: UserUpdate): Promise { + const response = await fetch(`${API_BASE}/admin/users/${username}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function resetUserPassword(username: string, newPassword: string): Promise { + const response = await fetch(`${API_BASE}/admin/users/${username}/reset-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ 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}`); + } +} + +// Access Permission API +export interface MyAccessResponse { + project: string; + access_level: AccessLevel | null; + is_owner: boolean; +} + +export async function getMyProjectAccess(projectName: string): Promise { + const response = await fetch(`${API_BASE}/project/${projectName}/my-access`, { + credentials: 'include', + }); + return handleResponse(response); +} + +export async function listProjectPermissions(projectName: string): Promise { + const response = await fetch(`${API_BASE}/project/${projectName}/permissions`, { + credentials: 'include', + }); + return handleResponse(response); +} + +export async function grantProjectAccess( + projectName: string, + data: AccessPermissionCreate +): Promise { + const response = await fetch(`${API_BASE}/project/${projectName}/permissions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function updateProjectAccess( + projectName: string, + username: string, + data: AccessPermissionUpdate +): Promise { + 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(response); +} + +export async function revokeProjectAccess(projectName: string, username: string): Promise { + 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}`); + } +} + +// OIDC API +export async function getOIDCStatus(): Promise { + const response = await fetch(`${API_BASE}/auth/oidc/status`); + return handleResponse(response); +} + +export async function getOIDCConfig(): Promise { + const response = await fetch(`${API_BASE}/auth/oidc/config`, { + credentials: 'include', + }); + return handleResponse(response); +} + +export async function updateOIDCConfig(data: OIDCConfigUpdate): Promise { + const response = await fetch(`${API_BASE}/auth/oidc/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export function getOIDCLoginUrl(returnTo?: string): string { + const params = new URLSearchParams(); + if (returnTo) { + params.set('return_to', returnTo); + } + const query = params.toString(); + return `${API_BASE}/auth/oidc/login${query ? `?${query}` : ''}`; +} diff --git a/frontend/src/components/AccessManagement.css b/frontend/src/components/AccessManagement.css new file mode 100644 index 0000000..21c8d5d --- /dev/null +++ b/frontend/src/components/AccessManagement.css @@ -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); +} diff --git a/frontend/src/components/AccessManagement.tsx b/frontend/src/components/AccessManagement.tsx new file mode 100644 index 0000000..6201661 --- /dev/null +++ b/frontend/src/components/AccessManagement.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // Form state + const [showAddForm, setShowAddForm] = useState(false); + const [newUsername, setNewUsername] = useState(''); + const [newLevel, setNewLevel] = useState('read'); + const [newExpiresAt, setNewExpiresAt] = useState(''); + const [submitting, setSubmitting] = useState(false); + + // Edit state + const [editingUser, setEditingUser] = useState(null); + const [editLevel, setEditLevel] = useState('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 ( + + {date.toLocaleDateString()} + {isExpired && ' (Expired)'} + + ); + }; + + if (loading) { + return
Loading permissions...
; + } + + return ( +
+
+

Access Management

+ +
+ + {error &&
{error}
} + {success &&
{success}
} + + {showAddForm && ( +
+
+
+ + setNewUsername(e.target.value)} + placeholder="Enter username" + required + disabled={submitting} + /> +
+
+ + +
+
+ + setNewExpiresAt(e.target.value)} + disabled={submitting} + min={new Date().toISOString().split('T')[0]} + /> +
+ +
+
+ )} + +
+ {permissions.length === 0 ? ( +

No explicit permissions set. Only the project owner has access.

+ ) : ( + + + + + + + + + + + + {permissions.map((p) => ( + + + + + + + + ))} + +
UserAccess LevelGrantedExpiresActions
{p.user_id} + {editingUser === p.user_id ? ( + + ) : ( + + {p.level} + + )} + {new Date(p.created_at).toLocaleDateString()} + {editingUser === p.user_id ? ( + setEditExpiresAt(e.target.value)} + disabled={submitting} + min={new Date().toISOString().split('T')[0]} + /> + ) : ( + formatExpiration(p.expires_at) + )} + + {editingUser === p.user_id ? ( + <> + + + + ) : ( + <> + + + + )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/DragDropUpload.css b/frontend/src/components/DragDropUpload.css index 55b3467..ca4112d 100644 --- a/frontend/src/components/DragDropUpload.css +++ b/frontend/src/components/DragDropUpload.css @@ -42,6 +42,17 @@ border-style: solid; } +.drop-zone--disabled { + cursor: not-allowed; + opacity: 0.6; + background: var(--bg-disabled, #f5f5f5); +} + +.drop-zone--disabled:hover { + border-color: var(--border-color, #ddd); + background: var(--bg-disabled, #f5f5f5); +} + .drop-zone__input { display: none; } diff --git a/frontend/src/components/DragDropUpload.tsx b/frontend/src/components/DragDropUpload.tsx index a2b65c8..e9f6a90 100644 --- a/frontend/src/components/DragDropUpload.tsx +++ b/frontend/src/components/DragDropUpload.tsx @@ -89,6 +89,8 @@ export interface DragDropUploadProps { maxRetries?: number; tag?: string; className?: string; + disabled?: boolean; + disabledReason?: string; } // Utility functions @@ -230,6 +232,8 @@ export function DragDropUpload({ maxRetries = 3, tag, className = '', + disabled = false, + disabledReason, }: DragDropUploadProps) { const [isDragOver, setIsDragOver] = useState(false); const [uploadQueue, setUploadQueue] = useState([]); @@ -649,20 +653,22 @@ export function DragDropUpload({ const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); + if (disabled) return; dragCounterRef.current++; if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { setIsDragOver(true); } - }, []); + }, [disabled]); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); + if (disabled) return; dragCounterRef.current--; if (dragCounterRef.current === 0) { setIsDragOver(false); } - }, []); + }, [disabled]); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -675,18 +681,22 @@ export function DragDropUpload({ setIsDragOver(false); dragCounterRef.current = 0; + if (disabled) return; + const files = e.dataTransfer.files; if (files && files.length > 0) { addFiles(files); } - }, [addFiles]); + }, [addFiles, disabled]); // Click to browse const handleClick = useCallback(() => { + if (disabled) return; fileInputRef.current?.click(); - }, []); + }, [disabled]); const handleFileChange = useCallback((e: React.ChangeEvent) => { + if (disabled) return; const files = e.target.files; if (files && files.length > 0) { addFiles(files); @@ -695,7 +705,7 @@ export function DragDropUpload({ if (fileInputRef.current) { fileInputRef.current.value = ''; } - }, [addFiles]); + }, [addFiles, disabled]); // Remove item from queue const removeItem = useCallback((id: string) => { @@ -738,15 +748,17 @@ export function DragDropUpload({ )}
e.key === 'Enter' && handleClick()} + aria-disabled={disabled} + title={disabled ? disabledReason : undefined} >

- Drag files here or click to browse -

-

- {maxFileSize && `Max file size: ${formatBytes(maxFileSize)}`} - {!allowAllTypes && allowedTypes && ` • Accepted: ${allowedTypes.join(', ')}`} + {disabled ? ( + {disabledReason || 'Upload disabled'} + ) : ( + <>Drag files here or click to browse + )}

+ {!disabled && ( +

+ {maxFileSize && `Max file size: ${formatBytes(maxFileSize)}`} + {!allowAllTypes && allowedTypes && ` • Accepted: ${allowedTypes.join(', ')}`} +

+ )}
diff --git a/frontend/src/components/Layout.css b/frontend/src/components/Layout.css index bf32738..bad02a8 100644 --- a/frontend/src/components/Layout.css +++ b/frontend/src/components/Layout.css @@ -98,6 +98,170 @@ opacity: 0.7; } +/* Login link */ +.nav-login { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + color: var(--text-primary); + font-size: 0.875rem; + font-weight: 500; + border-radius: var(--radius-md); + transition: all var(--transition-fast); + margin-left: 8px; + border: 1px solid var(--border-primary); +} + +.nav-login:hover { + color: var(--text-primary); + background: var(--bg-hover); + border-color: var(--border-secondary); +} + +/* User Menu */ +.user-menu { + position: relative; + margin-left: 8px; +} + +.user-menu-trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: transparent; + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); +} + +.user-menu-trigger:hover { + background: var(--bg-hover); + border-color: var(--border-secondary); +} + +.user-avatar { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: var(--accent-gradient); + border-radius: var(--radius-sm); + color: white; + font-weight: 600; + font-size: 0.8125rem; +} + +.user-name { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-menu-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + min-width: 200px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: 200; + overflow: hidden; +} + +.user-menu-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; +} + +.user-menu-username { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); +} + +.user-menu-badge { + padding: 2px 8px; + background: var(--accent-gradient); + border-radius: 100px; + font-size: 0.6875rem; + font-weight: 600; + color: white; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.user-menu-divider { + height: 1px; + background: var(--border-primary); +} + +.user-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 12px 16px; + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 0.875rem; + cursor: pointer; + transition: all var(--transition-fast); + text-align: left; + text-decoration: none; +} + +.user-menu-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.user-menu-item svg { + opacity: 0.7; +} + +.user-menu-item:hover svg { + opacity: 1; +} + +/* User menu loading state */ +.user-menu-loading { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin-left: 8px; +} + +.user-menu-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border-secondary); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: user-menu-spin 0.6s linear infinite; +} + +@keyframes user-menu-spin { + to { + transform: rotate(360deg); + } +} + /* Main content */ .main { flex: 1; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 09d8832..73b5f7b 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,5 +1,6 @@ -import { ReactNode } from 'react'; -import { Link, useLocation } from 'react-router-dom'; +import { ReactNode, useState, useRef, useEffect } from 'react'; +import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; import { GlobalSearch } from './GlobalSearch'; import './Layout.css'; @@ -9,6 +10,31 @@ interface LayoutProps { function Layout({ children }: LayoutProps) { const location = useLocation(); + const navigate = useNavigate(); + const { user, loading, logout } = useAuth(); + const [showUserMenu, setShowUserMenu] = useState(false); + const menuRef = useRef(null); + + // Close menu when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setShowUserMenu(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + async function handleLogout() { + try { + await logout(); + setShowUserMenu(false); + navigate('/'); + } catch { + // Error handled in context + } + } return (
@@ -60,6 +86,97 @@ function Layout({ children }: LayoutProps) { Docs + + {/* User Menu */} + {loading ? ( +
+
+
+ ) : user ? ( +
+ + + {showUserMenu && ( +
+
+ {user.username} + {user.is_admin && ( + Admin + )} +
+
+ setShowUserMenu(false)} + > + + + + API Keys + + {user.is_admin && ( + <> + setShowUserMenu(false)} + > + + + + + + + User Management + + setShowUserMenu(false)} + > + + + + SSO Configuration + + + )} +
+ +
+ )} +
+ ) : ( + + + + + + + Login + + )}
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..187784a --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,166 @@ +import { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react'; +import { User, AccessLevel } from '../types'; +import { getCurrentUser, login as apiLogin, logout as apiLogout, getMyProjectAccess } from '../api'; + +interface PermissionCacheEntry { + accessLevel: AccessLevel | null; + timestamp: number; +} + +interface AuthContextType { + user: User | null; + loading: boolean; + error: string | null; + login: (username: string, password: string) => Promise; + logout: () => Promise; + refreshUser: () => Promise; + clearError: () => void; + getProjectPermission: (projectName: string) => Promise; + invalidatePermissionCache: (projectName?: string) => void; +} + +const AuthContext = createContext(undefined); + +interface AuthProviderProps { + children: ReactNode; +} + +// Cache TTL in milliseconds (5 minutes) +const PERMISSION_CACHE_TTL = 5 * 60 * 1000; + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const permissionCacheRef = useRef>(new Map()); + + // Clear permission cache + const clearPermissionCache = useCallback(() => { + permissionCacheRef.current.clear(); + }, []); + + // Check session on initial load + useEffect(() => { + async function checkAuth() { + try { + const currentUser = await getCurrentUser(); + setUser(currentUser); + } catch { + setUser(null); + } finally { + setLoading(false); + } + } + checkAuth(); + }, []); + + const login = useCallback(async (username: string, password: string) => { + setLoading(true); + setError(null); + try { + const loggedInUser = await apiLogin({ username, password }); + setUser(loggedInUser); + // Clear permission cache on login - permissions may have changed + clearPermissionCache(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Login failed'; + setError(message); + throw err; + } finally { + setLoading(false); + } + }, [clearPermissionCache]); + + const logout = useCallback(async () => { + setLoading(true); + setError(null); + try { + await apiLogout(); + setUser(null); + // Clear permission cache on logout + clearPermissionCache(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Logout failed'; + setError(message); + throw err; + } finally { + setLoading(false); + } + }, [clearPermissionCache]); + + const clearError = useCallback(() => { + setError(null); + }, []); + + const refreshUser = useCallback(async () => { + try { + const currentUser = await getCurrentUser(); + setUser(currentUser); + } catch { + setUser(null); + } + }, []); + + // Get project permission with caching + const getProjectPermission = useCallback(async (projectName: string): Promise => { + const cached = permissionCacheRef.current.get(projectName); + const now = Date.now(); + + // Return cached value if still valid + if (cached && (now - cached.timestamp) < PERMISSION_CACHE_TTL) { + return cached.accessLevel; + } + + // Fetch fresh permission + try { + const result = await getMyProjectAccess(projectName); + const entry: PermissionCacheEntry = { + accessLevel: result.access_level, + timestamp: now, + }; + permissionCacheRef.current.set(projectName, entry); + return result.access_level; + } catch { + // On error, cache null to avoid repeated failed requests + const entry: PermissionCacheEntry = { + accessLevel: null, + timestamp: now, + }; + permissionCacheRef.current.set(projectName, entry); + return null; + } + }, []); + + // Invalidate permission cache for a specific project or all projects + const invalidatePermissionCache = useCallback((projectName?: string) => { + if (projectName) { + permissionCacheRef.current.delete(projectName); + } else { + clearPermissionCache(); + } + }, [clearPermissionCache]); + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/frontend/src/pages/APIKeysPage.css b/frontend/src/pages/APIKeysPage.css new file mode 100644 index 0000000..33bdbd5 --- /dev/null +++ b/frontend/src/pages/APIKeysPage.css @@ -0,0 +1,580 @@ +.api-keys-page { + max-width: 900px; + margin: 0 auto; +} + +.api-keys-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 32px; + gap: 24px; +} + +.api-keys-header-content h1 { + font-size: 1.75rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; + letter-spacing: -0.02em; +} + +.api-keys-subtitle { + color: var(--text-tertiary); + font-size: 0.9375rem; +} + +.api-keys-create-button { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + background: var(--accent-gradient); + border: none; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: white; + cursor: pointer; + transition: all var(--transition-fast); + box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2); + flex-shrink: 0; +} + +.api-keys-create-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3); +} + +.api-keys-create-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.api-keys-error { + display: flex; + align-items: center; + gap: 10px; + background: var(--error-bg); + border: 1px solid rgba(239, 68, 68, 0.2); + color: var(--error); + padding: 12px 16px; + border-radius: var(--radius-md); + margin-bottom: 24px; + font-size: 0.875rem; +} + +.api-keys-error svg { + flex-shrink: 0; +} + +.api-keys-error span { + flex: 1; +} + +.api-keys-error-dismiss { + background: transparent; + border: none; + padding: 4px; + color: var(--error); + cursor: pointer; + opacity: 0.7; + transition: opacity var(--transition-fast); +} + +.api-keys-error-dismiss:hover { + opacity: 1; +} + +.api-keys-new-key-banner { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.08) 100%); + border: 1px solid rgba(16, 185, 129, 0.3); + border-radius: var(--radius-lg); + padding: 24px; + margin-bottom: 24px; +} + +.api-keys-new-key-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + color: var(--accent-primary); +} + +.api-keys-new-key-title { + font-size: 1rem; + font-weight: 600; +} + +.api-keys-new-key-warning { + background: var(--warning-bg); + border: 1px solid rgba(245, 158, 11, 0.3); + color: var(--warning); + padding: 10px 14px; + border-radius: var(--radius-md); + font-size: 0.8125rem; + font-weight: 500; + margin-bottom: 16px; +} + +.api-keys-new-key-value-container { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.api-keys-new-key-value { + flex: 1; + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + padding: 14px 16px; + font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, monospace; + font-size: 0.8125rem; + color: var(--text-primary); + word-break: break-all; + line-height: 1.5; +} + +.api-keys-copy-button { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + flex-shrink: 0; +} + +.api-keys-copy-button:hover { + background: var(--bg-hover); + border-color: var(--border-secondary); + color: var(--text-primary); +} + +.api-keys-done-button { + padding: 10px 20px; + background: var(--accent-gradient); + border: none; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: white; + cursor: pointer; + transition: all var(--transition-fast); +} + +.api-keys-done-button:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.api-keys-create-form-card { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: 24px; + margin-bottom: 24px; +} + +.api-keys-create-form-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.api-keys-create-form-header h2 { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.api-keys-create-form-close { + background: transparent; + border: none; + padding: 4px; + color: var(--text-tertiary); + cursor: pointer; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.api-keys-create-form-close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.api-keys-create-error { + background: var(--error-bg); + border: 1px solid rgba(239, 68, 68, 0.2); + color: var(--error); + padding: 10px 14px; + border-radius: var(--radius-md); + font-size: 0.8125rem; + margin-bottom: 16px; +} + +.api-keys-create-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.api-keys-form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.api-keys-form-group label { + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary); +} + +.api-keys-form-group input { + padding: 12px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + font-size: 0.875rem; + color: var(--text-primary); + transition: all var(--transition-fast); +} + +.api-keys-form-group input::placeholder { + color: var(--text-muted); +} + +.api-keys-form-group input:hover:not(:disabled) { + border-color: var(--border-secondary); + background: var(--bg-elevated); +} + +.api-keys-form-group input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); + background: var(--bg-elevated); +} + +.api-keys-form-group input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.api-keys-form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 8px; +} + +.api-keys-cancel-button { + padding: 10px 18px; + background: transparent; + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.api-keys-cancel-button:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--border-secondary); + color: var(--text-primary); +} + +.api-keys-cancel-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.api-keys-submit-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 18px; + background: var(--accent-gradient); + border: none; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: white; + cursor: pointer; + transition: all var(--transition-fast); + min-width: 110px; +} + +.api-keys-submit-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2); +} + +.api-keys-submit-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.api-keys-button-spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: api-keys-spin 0.6s linear infinite; +} + +@keyframes api-keys-spin { + to { + transform: rotate(360deg); + } +} + +.api-keys-list-container { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.api-keys-list-loading, +.api-keys-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 64px 24px; + color: var(--text-tertiary); + font-size: 0.9375rem; +} + +.api-keys-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-secondary); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: api-keys-spin 0.6s linear infinite; +} + +.api-keys-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + text-align: center; +} + +.api-keys-empty-icon { + color: var(--text-muted); + margin-bottom: 16px; + opacity: 0.5; +} + +.api-keys-empty h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.api-keys-empty p { + color: var(--text-tertiary); + font-size: 0.875rem; +} + +.api-keys-list { + display: flex; + flex-direction: column; +} + +.api-keys-list-header { + display: grid; + grid-template-columns: 1fr 160px 160px 140px; + gap: 16px; + padding: 14px 20px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-primary); + font-size: 0.75rem; + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.api-keys-list-item { + display: grid; + grid-template-columns: 1fr 160px 160px 140px; + gap: 16px; + padding: 16px 20px; + align-items: center; + border-bottom: 1px solid var(--border-primary); + transition: background var(--transition-fast); +} + +.api-keys-list-item:last-child { + border-bottom: none; +} + +.api-keys-list-item:hover { + background: var(--bg-tertiary); +} + +.api-keys-item-name { + font-weight: 500; + color: var(--text-primary); + font-size: 0.9375rem; +} + +.api-keys-item-description { + color: var(--text-tertiary); + font-size: 0.8125rem; + margin-top: 4px; +} + +.api-keys-col-created, +.api-keys-col-used { + color: var(--text-secondary); + font-size: 0.8125rem; +} + +.api-keys-col-actions { + display: flex; + justify-content: flex-end; +} + +.api-keys-revoke-button { + padding: 6px 14px; + background: transparent; + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: var(--radius-md); + font-size: 0.8125rem; + font-weight: 500; + color: var(--error); + cursor: pointer; + transition: all var(--transition-fast); +} + +.api-keys-revoke-button:hover { + background: var(--error-bg); + border-color: rgba(239, 68, 68, 0.5); +} + +.api-keys-delete-confirm { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8125rem; + color: var(--text-secondary); +} + +.api-keys-confirm-yes { + padding: 4px 12px; + background: var(--error); + border: none; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 500; + color: white; + cursor: pointer; + transition: all var(--transition-fast); +} + +.api-keys-confirm-yes:hover:not(:disabled) { + opacity: 0.9; +} + +.api-keys-confirm-yes:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.api-keys-confirm-no { + padding: 4px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.api-keys-confirm-no:hover:not(:disabled) { + background: var(--bg-hover); +} + +.api-keys-confirm-no:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +@media (max-width: 768px) { + .api-keys-header { + flex-direction: column; + align-items: stretch; + } + + .api-keys-create-button { + align-self: flex-start; + } + + .api-keys-list-header { + display: none; + } + + .api-keys-list-item { + grid-template-columns: 1fr; + gap: 8px; + } + + .api-keys-col-name { + order: 1; + } + + .api-keys-col-created, + .api-keys-col-used { + font-size: 0.75rem; + } + + .api-keys-col-created::before { + content: 'Created: '; + color: var(--text-muted); + } + + .api-keys-col-used::before { + content: 'Last used: '; + color: var(--text-muted); + } + + .api-keys-col-actions { + justify-content: flex-start; + margin-top: 8px; + } + + .api-keys-new-key-value-container { + flex-direction: column; + } + + .api-keys-copy-button { + align-self: flex-start; + } +} diff --git a/frontend/src/pages/APIKeysPage.tsx b/frontend/src/pages/APIKeysPage.tsx new file mode 100644 index 0000000..f8323b3 --- /dev/null +++ b/frontend/src/pages/APIKeysPage.tsx @@ -0,0 +1,371 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { listAPIKeys, createAPIKey, deleteAPIKey } from '../api'; +import { APIKey, APIKeyCreateResponse } from '../types'; +import './APIKeysPage.css'; + +function APIKeysPage() { + const { user, loading: authLoading } = useAuth(); + const navigate = useNavigate(); + + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [showCreateForm, setShowCreateForm] = useState(false); + const [createName, setCreateName] = useState(''); + const [createDescription, setCreateDescription] = useState(''); + const [isCreating, setIsCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); + const [copied, setCopied] = useState(false); + + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + useEffect(() => { + if (!authLoading && !user) { + navigate('/login', { state: { from: '/settings/api-keys' } }); + } + }, [user, authLoading, navigate]); + + useEffect(() => { + if (user) { + loadKeys(); + } + }, [user]); + + async function loadKeys() { + setLoading(true); + setError(null); + try { + const data = await listAPIKeys(); + setKeys(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load API keys'); + } finally { + setLoading(false); + } + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!createName.trim()) { + setCreateError('Name is required'); + return; + } + + setIsCreating(true); + setCreateError(null); + try { + const response = await createAPIKey({ + name: createName.trim(), + description: createDescription.trim() || undefined, + }); + setNewlyCreatedKey(response); + setShowCreateForm(false); + setCreateName(''); + setCreateDescription(''); + await loadKeys(); + } catch (err) { + setCreateError(err instanceof Error ? err.message : 'Failed to create API key'); + } finally { + setIsCreating(false); + } + } + + async function handleDelete(id: string) { + setIsDeleting(true); + try { + await deleteAPIKey(id); + setDeleteConfirmId(null); + await loadKeys(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to revoke API key'); + } finally { + setIsDeleting(false); + } + } + + async function handleCopyKey() { + if (newlyCreatedKey) { + try { + await navigator.clipboard.writeText(newlyCreatedKey.key); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setError('Failed to copy to clipboard'); + } + } + } + + function handleDismissNewKey() { + setNewlyCreatedKey(null); + setCopied(false); + } + + function formatDate(dateString: string | null): string { + if (!dateString) return 'Never'; + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } + + if (authLoading) { + return ( +
+
+
+ Loading... +
+
+ ); + } + + if (!user) { + return null; + } + + return ( +
+
+
+

API Keys

+

+ Manage API keys for programmatic access to Orchard +

+
+ +
+ + {error && ( +
+ + + + + + {error} + +
+ )} + + {newlyCreatedKey && ( +
+
+ + + + New API Key Created +
+
+ Copy this key now! It won't be shown again. +
+
+ {newlyCreatedKey.key} + +
+ +
+ )} + + {showCreateForm && ( +
+
+

Create New API Key

+ +
+ + {createError && ( +
+ {createError} +
+ )} + +
+
+ + setCreateName(e.target.value)} + placeholder="e.g., CI/CD Pipeline, Local Development" + autoFocus + disabled={isCreating} + /> +
+ +
+ + setCreateDescription(e.target.value)} + placeholder="What will this key be used for?" + disabled={isCreating} + /> +
+ +
+ + +
+
+
+ )} + +
+ {loading ? ( +
+
+ Loading API keys... +
+ ) : keys.length === 0 ? ( +
+
+ + + +
+

No API Keys

+

Create an API key to access Orchard programmatically

+
+ ) : ( +
+
+ Name + Created + Last Used + Actions +
+ {keys.map((key) => ( +
+
+
{key.name}
+ {key.description && ( +
{key.description}
+ )} +
+
+ {formatDate(key.created_at)} +
+
+ {formatDate(key.last_used)} +
+
+ {deleteConfirmId === key.id ? ( +
+ Revoke? + + +
+ ) : ( + + )} +
+
+ ))} +
+ )} +
+
+ ); +} + +export default APIKeysPage; diff --git a/frontend/src/pages/AdminOIDCPage.css b/frontend/src/pages/AdminOIDCPage.css new file mode 100644 index 0000000..c235a92 --- /dev/null +++ b/frontend/src/pages/AdminOIDCPage.css @@ -0,0 +1,405 @@ +.admin-oidc-page { + max-width: 800px; + margin: 0 auto; +} + +.admin-oidc-header { + margin-bottom: 32px; +} + +.admin-oidc-header-content h1 { + font-size: 1.75rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; + letter-spacing: -0.02em; +} + +.admin-oidc-subtitle { + color: var(--text-tertiary); + font-size: 0.9375rem; +} + +.admin-oidc-success { + display: flex; + align-items: center; + gap: 10px; + background: var(--success-bg); + border: 1px solid rgba(34, 197, 94, 0.2); + color: var(--success); + padding: 12px 16px; + border-radius: var(--radius-md); + margin-bottom: 24px; + font-size: 0.875rem; + animation: admin-oidc-fade-in 0.2s ease; +} + +@keyframes admin-oidc-fade-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.admin-oidc-error { + display: flex; + align-items: center; + gap: 10px; + background: var(--error-bg); + border: 1px solid rgba(239, 68, 68, 0.2); + color: var(--error); + padding: 12px 16px; + border-radius: var(--radius-md); + margin-bottom: 24px; + font-size: 0.875rem; +} + +.admin-oidc-error svg { + flex-shrink: 0; +} + +.admin-oidc-error span { + flex: 1; +} + +.admin-oidc-error-dismiss { + background: transparent; + border: none; + padding: 4px; + color: var(--error); + cursor: pointer; + opacity: 0.7; + transition: opacity var(--transition-fast); +} + +.admin-oidc-error-dismiss:hover { + opacity: 1; +} + +.admin-oidc-access-denied { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 24px; + text-align: center; +} + +.admin-oidc-access-denied-icon { + color: var(--error); + margin-bottom: 24px; + opacity: 0.8; +} + +.admin-oidc-access-denied h2 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 12px; +} + +.admin-oidc-access-denied p { + color: var(--text-tertiary); + font-size: 0.9375rem; + max-width: 400px; +} + +.admin-oidc-card { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: 24px; + margin-bottom: 24px; +} + +.admin-oidc-section { + margin-bottom: 32px; + padding-bottom: 24px; + border-bottom: 1px solid var(--border-primary); +} + +.admin-oidc-section:last-of-type { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.admin-oidc-section h2 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 16px; +} + +.admin-oidc-form-group { + margin-bottom: 16px; +} + +.admin-oidc-form-group:last-child { + margin-bottom: 0; +} + +.admin-oidc-form-group label { + display: block; + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.admin-oidc-form-group input[type="text"], +.admin-oidc-form-group input[type="password"], +.admin-oidc-form-group input[type="url"] { + width: 100%; + padding: 12px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + font-size: 0.875rem; + color: var(--text-primary); + transition: all var(--transition-fast); +} + +.admin-oidc-form-group input::placeholder { + color: var(--text-muted); +} + +.admin-oidc-form-group input:hover:not(:disabled) { + border-color: var(--border-secondary); + background: var(--bg-elevated); +} + +.admin-oidc-form-group input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); + background: var(--bg-elevated); +} + +.admin-oidc-form-group input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.admin-oidc-form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.admin-oidc-field-help { + margin-top: 6px; + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.4; +} + +.admin-oidc-field-help code { + background: var(--bg-tertiary); + padding: 1px 4px; + border-radius: 3px; + font-size: 0.6875rem; +} + +.admin-oidc-secret-status { + color: var(--success); + font-weight: 400; + font-size: 0.75rem; +} + +.admin-oidc-toggle-group { + margin-bottom: 16px; +} + +.admin-oidc-toggle-label { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); + user-select: none; +} + +.admin-oidc-toggle-label input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.admin-oidc-toggle-custom { + width: 44px; + height: 24px; + background: var(--bg-tertiary); + border: 1px solid var(--border-secondary); + border-radius: 12px; + transition: all var(--transition-fast); + position: relative; + flex-shrink: 0; +} + +.admin-oidc-toggle-custom::after { + content: ''; + position: absolute; + left: 2px; + top: 2px; + width: 18px; + height: 18px; + background: var(--text-muted); + border-radius: 50%; + transition: all var(--transition-fast); +} + +.admin-oidc-toggle-label input[type="checkbox"]:checked + .admin-oidc-toggle-custom { + background: var(--accent-primary); + border-color: var(--accent-primary); +} + +.admin-oidc-toggle-label input[type="checkbox"]:checked + .admin-oidc-toggle-custom::after { + left: 22px; + background: white; +} + +.admin-oidc-toggle-label input[type="checkbox"]:focus + .admin-oidc-toggle-custom { + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); +} + +.admin-oidc-toggle-label:hover .admin-oidc-toggle-custom { + border-color: var(--accent-primary); +} + +.admin-oidc-form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--border-primary); +} + +.admin-oidc-cancel-button { + padding: 10px 18px; + background: transparent; + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.admin-oidc-cancel-button:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--border-secondary); + color: var(--text-primary); +} + +.admin-oidc-cancel-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.admin-oidc-submit-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 18px; + background: var(--accent-gradient); + border: none; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: white; + cursor: pointer; + transition: all var(--transition-fast); + min-width: 160px; +} + +.admin-oidc-submit-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2); +} + +.admin-oidc-submit-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.admin-oidc-button-spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: admin-oidc-spin 0.6s linear infinite; +} + +@keyframes admin-oidc-spin { + to { + transform: rotate(360deg); + } +} + +.admin-oidc-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 64px 24px; + color: var(--text-tertiary); + font-size: 0.9375rem; +} + +.admin-oidc-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-secondary); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: admin-oidc-spin 0.6s linear infinite; +} + +.admin-oidc-info-card { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: 20px 24px; +} + +.admin-oidc-info-card h3 { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.admin-oidc-info-card p { + font-size: 0.8125rem; + color: var(--text-tertiary); + margin-bottom: 12px; +} + +.admin-oidc-callback-url { + display: block; + background: var(--bg-tertiary); + padding: 12px 16px; + border-radius: var(--radius-md); + font-size: 0.8125rem; + color: var(--text-primary); + word-break: break-all; +} + +@media (max-width: 640px) { + .admin-oidc-form-row { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/pages/AdminOIDCPage.tsx b/frontend/src/pages/AdminOIDCPage.tsx new file mode 100644 index 0000000..a0a2dfd --- /dev/null +++ b/frontend/src/pages/AdminOIDCPage.tsx @@ -0,0 +1,342 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { getOIDCConfig, updateOIDCConfig } from '../api'; +import { OIDCConfig } from '../types'; +import './AdminOIDCPage.css'; + +function AdminOIDCPage() { + const { user, loading: authLoading } = useAuth(); + const navigate = useNavigate(); + + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + // Form state + const [enabled, setEnabled] = useState(false); + const [issuerUrl, setIssuerUrl] = useState(''); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + const [scopes, setScopes] = useState('openid profile email'); + const [autoCreateUsers, setAutoCreateUsers] = useState(true); + const [adminGroup, setAdminGroup] = useState(''); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (!authLoading && !user) { + navigate('/login', { state: { from: '/admin/oidc' } }); + } + }, [user, authLoading, navigate]); + + useEffect(() => { + if (user && user.is_admin) { + loadConfig(); + } + }, [user]); + + useEffect(() => { + if (successMessage) { + const timer = setTimeout(() => setSuccessMessage(null), 3000); + return () => clearTimeout(timer); + } + }, [successMessage]); + + async function loadConfig() { + setLoading(true); + setError(null); + try { + const data = await getOIDCConfig(); + setConfig(data); + setEnabled(data.enabled); + setIssuerUrl(data.issuer_url); + setClientId(data.client_id); + setScopes(data.scopes.join(' ')); + setAutoCreateUsers(data.auto_create_users); + setAdminGroup(data.admin_group); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load OIDC configuration'); + } finally { + setLoading(false); + } + } + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + + if (enabled && !issuerUrl.trim()) { + setError('Issuer URL is required when OIDC is enabled'); + return; + } + if (enabled && !clientId.trim()) { + setError('Client ID is required when OIDC is enabled'); + return; + } + + setIsSaving(true); + setError(null); + + try { + const scopesList = scopes.split(/\s+/).filter(s => s.length > 0); + const updateData: Record = { + enabled, + issuer_url: issuerUrl.trim(), + client_id: clientId.trim(), + scopes: scopesList, + auto_create_users: autoCreateUsers, + admin_group: adminGroup.trim(), + }; + + if (clientSecret) { + updateData.client_secret = clientSecret; + } + + await updateOIDCConfig(updateData); + setSuccessMessage('OIDC configuration saved successfully'); + setClientSecret(''); + await loadConfig(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save OIDC configuration'); + } finally { + setIsSaving(false); + } + } + + if (authLoading) { + return ( +
+
+
+ Loading... +
+
+ ); + } + + if (!user) { + return null; + } + + if (!user.is_admin) { + return ( +
+
+
+ + + + +
+

Access Denied

+

You do not have permission to access this page. Admin privileges are required.

+
+
+ ); + } + + return ( +
+
+
+

Single Sign-On (OIDC)

+

+ Configure OpenID Connect for SSO authentication +

+
+
+ + {successMessage && ( +
+ + + + + {successMessage} +
+ )} + + {error && ( +
+ + + + + + {error} + +
+ )} + + {loading ? ( +
+
+
+ Loading configuration... +
+
+ ) : ( +
+
+

Status

+
+ +

+ When enabled, users can sign in using your organization's identity provider. +

+
+
+ +
+

Provider Configuration

+ +
+ + setIssuerUrl(e.target.value)} + placeholder="https://your-provider.com" + disabled={isSaving} + /> +

+ The base URL of your OIDC provider. Discovery document will be fetched from /.well-known/openid-configuration. +

+
+ +
+
+ + setClientId(e.target.value)} + placeholder="your-client-id" + disabled={isSaving} + /> +
+ +
+ + setClientSecret(e.target.value)} + placeholder={config?.has_client_secret ? 'Leave blank to keep current' : 'Enter client secret'} + disabled={isSaving} + /> +
+
+ +
+ + setScopes(e.target.value)} + placeholder="openid profile email" + disabled={isSaving} + /> +

+ Space-separated list of OIDC scopes to request. Common scopes: openid, profile, email, groups. +

+
+
+ +
+

User Provisioning

+ +
+ +

+ When enabled, new users will be created automatically when they sign in via OIDC for the first time. +

+
+ +
+ + setAdminGroup(e.target.value)} + placeholder="admin, orchard-admins" + disabled={isSaving} + /> +

+ Users in this group (from the groups claim) will be granted admin privileges. Leave blank to disable automatic admin assignment. +

+
+
+ +
+ + +
+
+ )} + +
+

Callback URL

+

Configure your identity provider with the following callback URL:

+ + {window.location.origin}/api/v1/auth/oidc/callback + +
+
+ ); +} + +export default AdminOIDCPage; diff --git a/frontend/src/pages/AdminUsersPage.css b/frontend/src/pages/AdminUsersPage.css new file mode 100644 index 0000000..78295eb --- /dev/null +++ b/frontend/src/pages/AdminUsersPage.css @@ -0,0 +1,667 @@ +.admin-users-page { + max-width: 1100px; + margin: 0 auto; +} + +.admin-users-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 32px; + gap: 24px; +} + +.admin-users-header-content h1 { + font-size: 1.75rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; + letter-spacing: -0.02em; +} + +.admin-users-subtitle { + color: var(--text-tertiary); + font-size: 0.9375rem; +} + +.admin-users-create-button { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + background: var(--accent-gradient); + border: none; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: white; + cursor: pointer; + transition: all var(--transition-fast); + box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2); + flex-shrink: 0; +} + +.admin-users-create-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3); +} + +.admin-users-create-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.admin-users-success { + display: flex; + align-items: center; + gap: 10px; + background: var(--success-bg); + border: 1px solid rgba(34, 197, 94, 0.2); + color: var(--success); + padding: 12px 16px; + border-radius: var(--radius-md); + margin-bottom: 24px; + font-size: 0.875rem; + animation: admin-users-fade-in 0.2s ease; +} + +@keyframes admin-users-fade-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.admin-users-error { + display: flex; + align-items: center; + gap: 10px; + background: var(--error-bg); + border: 1px solid rgba(239, 68, 68, 0.2); + color: var(--error); + padding: 12px 16px; + border-radius: var(--radius-md); + margin-bottom: 24px; + font-size: 0.875rem; +} + +.admin-users-error svg { + flex-shrink: 0; +} + +.admin-users-error span { + flex: 1; +} + +.admin-users-error-dismiss { + background: transparent; + border: none; + padding: 4px; + color: var(--error); + cursor: pointer; + opacity: 0.7; + transition: opacity var(--transition-fast); +} + +.admin-users-error-dismiss:hover { + opacity: 1; +} + +.admin-users-access-denied { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 24px; + text-align: center; +} + +.admin-users-access-denied-icon { + color: var(--error); + margin-bottom: 24px; + opacity: 0.8; +} + +.admin-users-access-denied h2 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 12px; +} + +.admin-users-access-denied p { + color: var(--text-tertiary); + font-size: 0.9375rem; + max-width: 400px; +} + +.admin-users-create-form-card, +.admin-users-reset-password-card { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: 24px; + margin-bottom: 24px; +} + +.admin-users-create-form-header, +.admin-users-reset-password-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.admin-users-create-form-header h2, +.admin-users-reset-password-header h2 { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.admin-users-create-form-close { + background: transparent; + border: none; + padding: 4px; + color: var(--text-tertiary); + cursor: pointer; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.admin-users-create-form-close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.admin-users-reset-password-info { + color: var(--text-secondary); + font-size: 0.875rem; + margin-bottom: 16px; +} + +.admin-users-reset-password-info strong { + color: var(--text-primary); +} + +.admin-users-create-error { + background: var(--error-bg); + border: 1px solid rgba(239, 68, 68, 0.2); + color: var(--error); + padding: 10px 14px; + border-radius: var(--radius-md); + font-size: 0.8125rem; + margin-bottom: 16px; +} + +.admin-users-create-form, +.admin-users-reset-password-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.admin-users-form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.admin-users-form-group label { + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary); +} + +.admin-users-form-group input[type="text"], +.admin-users-form-group input[type="password"], +.admin-users-form-group input[type="email"] { + padding: 12px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + font-size: 0.875rem; + color: var(--text-primary); + transition: all var(--transition-fast); +} + +.admin-users-form-group input::placeholder { + color: var(--text-muted); +} + +.admin-users-form-group input:hover:not(:disabled) { + border-color: var(--border-secondary); + background: var(--bg-elevated); +} + +.admin-users-form-group input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); + background: var(--bg-elevated); +} + +.admin-users-form-group input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.admin-users-checkbox-group { + flex-direction: row; + align-items: center; +} + +.admin-users-checkbox-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 400; + color: var(--text-secondary); + user-select: none; +} + +.admin-users-checkbox-label input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.admin-users-checkbox-custom { + width: 18px; + height: 18px; + background: var(--bg-tertiary); + border: 1px solid var(--border-secondary); + border-radius: var(--radius-sm); + transition: all var(--transition-fast); + position: relative; +} + +.admin-users-checkbox-label input[type="checkbox"]:checked + .admin-users-checkbox-custom { + background: var(--accent-primary); + border-color: var(--accent-primary); +} + +.admin-users-checkbox-label input[type="checkbox"]:checked + .admin-users-checkbox-custom::after { + content: ''; + position: absolute; + left: 5px; + top: 2px; + width: 5px; + height: 9px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.admin-users-checkbox-label input[type="checkbox"]:focus + .admin-users-checkbox-custom { + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); +} + +.admin-users-checkbox-label:hover .admin-users-checkbox-custom { + border-color: var(--accent-primary); +} + +.admin-users-form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 8px; +} + +.admin-users-cancel-button { + padding: 10px 18px; + background: transparent; + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.admin-users-cancel-button:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--border-secondary); + color: var(--text-primary); +} + +.admin-users-cancel-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.admin-users-submit-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 18px; + background: var(--accent-gradient); + border: none; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: white; + cursor: pointer; + transition: all var(--transition-fast); + min-width: 120px; +} + +.admin-users-submit-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2); +} + +.admin-users-submit-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.admin-users-button-spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: admin-users-spin 0.6s linear infinite; +} + +@keyframes admin-users-spin { + to { + transform: rotate(360deg); + } +} + +.admin-users-list-container { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.admin-users-list-loading, +.admin-users-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 64px 24px; + color: var(--text-tertiary); + font-size: 0.9375rem; +} + +.admin-users-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-secondary); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: admin-users-spin 0.6s linear infinite; +} + +.admin-users-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + text-align: center; +} + +.admin-users-empty-icon { + color: var(--text-muted); + margin-bottom: 16px; + opacity: 0.5; +} + +.admin-users-empty h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.admin-users-empty p { + color: var(--text-tertiary); + font-size: 0.875rem; +} + +.admin-users-list { + display: flex; + flex-direction: column; +} + +.admin-users-list-header { + display: grid; + grid-template-columns: 2fr 100px 140px 140px 1fr; + gap: 16px; + padding: 14px 20px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-primary); + font-size: 0.75rem; + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-users-list-item { + display: grid; + grid-template-columns: 2fr 100px 140px 140px 1fr; + gap: 16px; + padding: 16px 20px; + align-items: center; + border-bottom: 1px solid var(--border-primary); + transition: background var(--transition-fast); +} + +.admin-users-list-item:last-child { + border-bottom: none; +} + +.admin-users-list-item:hover { + background: var(--bg-tertiary); +} + +.admin-users-list-item.admin-users-inactive { + opacity: 0.6; +} + +.admin-users-col-user { + display: flex; + align-items: center; + gap: 12px; +} + +.admin-users-item-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--accent-gradient); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 0.875rem; + flex-shrink: 0; +} + +.admin-users-item-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.admin-users-item-username { + font-weight: 500; + color: var(--text-primary); + font-size: 0.9375rem; + display: flex; + align-items: center; + gap: 8px; +} + +.admin-users-admin-badge { + display: inline-flex; + padding: 2px 8px; + background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.1) 100%); + border: 1px solid rgba(16, 185, 129, 0.3); + border-radius: 20px; + font-size: 0.6875rem; + font-weight: 600; + color: var(--accent-primary); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.admin-users-item-email { + color: var(--text-tertiary); + font-size: 0.8125rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.admin-users-col-status { + display: flex; + align-items: center; +} + +.admin-users-status-badge { + display: inline-flex; + padding: 4px 10px; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 500; +} + +.admin-users-status-badge.active { + background: var(--success-bg); + color: var(--success); +} + +.admin-users-status-badge.inactive { + background: var(--error-bg); + color: var(--error); +} + +.admin-users-col-created, +.admin-users-col-login { + color: var(--text-secondary); + font-size: 0.8125rem; +} + +.admin-users-col-actions { + display: flex; + justify-content: flex-end; +} + +.admin-users-actions-menu { + display: flex; + gap: 6px; +} + +.admin-users-action-button { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; +} + +.admin-users-action-button:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--border-secondary); + color: var(--text-primary); +} + +.admin-users-action-button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.admin-users-action-spinner { + width: 12px; + height: 12px; + border: 2px solid var(--border-secondary); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: admin-users-spin 0.6s linear infinite; +} + +@media (max-width: 1024px) { + .admin-users-list-header { + grid-template-columns: 2fr 100px 1fr; + } + + .admin-users-list-item { + grid-template-columns: 2fr 100px 1fr; + } + + .admin-users-col-created, + .admin-users-col-login { + display: none; + } + + .admin-users-list-header .admin-users-col-created, + .admin-users-list-header .admin-users-col-login { + display: none; + } +} + +@media (max-width: 768px) { + .admin-users-header { + flex-direction: column; + align-items: stretch; + } + + .admin-users-create-button { + align-self: flex-start; + } + + .admin-users-list-header { + display: none; + } + + .admin-users-list-item { + grid-template-columns: 1fr; + gap: 12px; + padding: 16px; + } + + .admin-users-col-user { + order: 1; + } + + .admin-users-col-status { + order: 2; + } + + .admin-users-col-actions { + order: 3; + justify-content: flex-start; + } + + .admin-users-actions-menu { + flex-wrap: wrap; + } +} diff --git a/frontend/src/pages/AdminUsersPage.tsx b/frontend/src/pages/AdminUsersPage.tsx new file mode 100644 index 0000000..6ec3b0e --- /dev/null +++ b/frontend/src/pages/AdminUsersPage.tsx @@ -0,0 +1,529 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { listUsers, createUser, updateUser, resetUserPassword } from '../api'; +import { AdminUser } from '../types'; +import './AdminUsersPage.css'; + +function AdminUsersPage() { + const { user, loading: authLoading } = useAuth(); + const navigate = useNavigate(); + + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [showCreateForm, setShowCreateForm] = useState(false); + const [createUsername, setCreateUsername] = useState(''); + const [createPassword, setCreatePassword] = useState(''); + const [createEmail, setCreateEmail] = useState(''); + const [createIsAdmin, setCreateIsAdmin] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + const [resetPasswordUsername, setResetPasswordUsername] = useState(null); + const [newPassword, setNewPassword] = useState(''); + const [isResetting, setIsResetting] = useState(false); + + const [togglingUser, setTogglingUser] = useState(null); + + const [successMessage, setSuccessMessage] = useState(null); + + useEffect(() => { + if (!authLoading && !user) { + navigate('/login', { state: { from: '/admin/users' } }); + } + }, [user, authLoading, navigate]); + + useEffect(() => { + if (user && user.is_admin) { + loadUsers(); + } + }, [user]); + + useEffect(() => { + if (successMessage) { + const timer = setTimeout(() => setSuccessMessage(null), 3000); + return () => clearTimeout(timer); + } + }, [successMessage]); + + async function loadUsers() { + setLoading(true); + setError(null); + try { + const data = await listUsers(); + setUsers(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load users'); + } finally { + setLoading(false); + } + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!createUsername.trim()) { + setCreateError('Username is required'); + return; + } + if (!createPassword.trim()) { + setCreateError('Password is required'); + return; + } + + setIsCreating(true); + setCreateError(null); + try { + await createUser({ + username: createUsername.trim(), + password: createPassword, + email: createEmail.trim() || undefined, + is_admin: createIsAdmin, + }); + setShowCreateForm(false); + setCreateUsername(''); + setCreatePassword(''); + setCreateEmail(''); + setCreateIsAdmin(false); + setSuccessMessage('User created successfully'); + await loadUsers(); + } catch (err) { + setCreateError(err instanceof Error ? err.message : 'Failed to create user'); + } finally { + setIsCreating(false); + } + } + + async function handleToggleAdmin(targetUser: AdminUser) { + setTogglingUser(targetUser.username); + try { + await updateUser(targetUser.username, { is_admin: !targetUser.is_admin }); + setSuccessMessage(`${targetUser.username} is ${!targetUser.is_admin ? 'now' : 'no longer'} an admin`); + await loadUsers(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update user'); + } finally { + setTogglingUser(null); + } + } + + async function handleToggleActive(targetUser: AdminUser) { + setTogglingUser(targetUser.username); + try { + await updateUser(targetUser.username, { is_active: !targetUser.is_active }); + setSuccessMessage(`${targetUser.username} has been ${!targetUser.is_active ? 'enabled' : 'disabled'}`); + await loadUsers(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update user'); + } finally { + setTogglingUser(null); + } + } + + async function handleResetPassword(e: React.FormEvent) { + e.preventDefault(); + if (!resetPasswordUsername || !newPassword.trim()) { + return; + } + + setIsResetting(true); + try { + await resetUserPassword(resetPasswordUsername, newPassword); + setResetPasswordUsername(null); + setNewPassword(''); + setSuccessMessage(`Password reset for ${resetPasswordUsername}`); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to reset password'); + } finally { + setIsResetting(false); + } + } + + function formatDate(dateString: string | null): string { + if (!dateString) return 'Never'; + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } + + if (authLoading) { + return ( +
+
+
+ Loading... +
+
+ ); + } + + if (!user) { + return null; + } + + if (!user.is_admin) { + return ( +
+
+
+ + + + +
+

Access Denied

+

You do not have permission to access this page. Admin privileges are required.

+
+
+ ); + } + + return ( +
+
+
+

User Management

+

+ Manage user accounts and permissions +

+
+ +
+ + {successMessage && ( +
+ + + + + {successMessage} +
+ )} + + {error && ( +
+ + + + + + {error} + +
+ )} + + {showCreateForm && ( +
+
+

Create New User

+ +
+ + {createError && ( +
+ {createError} +
+ )} + +
+
+ + setCreateUsername(e.target.value)} + placeholder="Enter username" + autoFocus + disabled={isCreating} + /> +
+ +
+ + setCreatePassword(e.target.value)} + placeholder="Enter password" + disabled={isCreating} + /> +
+ +
+ + setCreateEmail(e.target.value)} + placeholder="user@example.com" + disabled={isCreating} + /> +
+ +
+ +
+ +
+ + +
+
+
+ )} + + {resetPasswordUsername && ( +
+
+

Reset Password

+ +
+

+ Set a new password for {resetPasswordUsername} +

+
+
+ + setNewPassword(e.target.value)} + placeholder="Enter new password" + autoFocus + disabled={isResetting} + /> +
+
+ + +
+
+
+ )} + +
+ {loading ? ( +
+
+ Loading users... +
+ ) : users.length === 0 ? ( +
+
+ + + + + + +
+

No Users

+

Create a user to get started

+
+ ) : ( +
+
+ User + Status + Created + Last Login + Actions +
+ {users.map((u) => ( +
+
+
+ {u.username.charAt(0).toUpperCase()} +
+
+
+ {u.username} + {u.is_admin && Admin} +
+ {u.email && ( +
{u.email}
+ )} +
+
+
+ + {u.is_active ? 'Active' : 'Disabled'} + +
+
+ {formatDate(u.created_at)} +
+
+ {formatDate(u.last_login)} +
+
+
+ + + +
+
+
+ ))} +
+ )} +
+
+ ); +} + +export default AdminUsersPage; diff --git a/frontend/src/pages/ChangePasswordPage.tsx b/frontend/src/pages/ChangePasswordPage.tsx new file mode 100644 index 0000000..3d97953 --- /dev/null +++ b/frontend/src/pages/ChangePasswordPage.tsx @@ -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(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 ( +
+
+
+
+
+ + + + + + + + + +
+

Change Password

+ {user?.must_change_password && ( +

+ You must change your password before continuing +

+ )} +
+ + {error && ( +
+ + + + + + {error} +
+ )} + +
+
+ + setCurrentPassword(e.target.value)} + placeholder="Enter current password" + autoComplete="current-password" + autoFocus + disabled={isSubmitting} + /> +
+ +
+ + setNewPassword(e.target.value)} + placeholder="Enter new password (min 8 characters)" + autoComplete="new-password" + disabled={isSubmitting} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + autoComplete="new-password" + disabled={isSubmitting} + /> +
+ + +
+
+ +
+

Artifact storage and management system

+
+
+
+ ); +} + +export default ChangePasswordPage; diff --git a/frontend/src/pages/Home.css b/frontend/src/pages/Home.css index 3bde2bb..f5891d8 100644 --- a/frontend/src/pages/Home.css +++ b/frontend/src/pages/Home.css @@ -474,3 +474,16 @@ margin-top: 4px; font-size: 0.9375rem; } + +/* Lock icon for private projects */ +.lock-icon { + color: var(--warning); + flex-shrink: 0; +} + +/* Project badges container */ +.project-badges { + display: flex; + gap: 6px; + flex-wrap: wrap; +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index c409b23..6d45faf 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -7,8 +7,19 @@ import { SortDropdown, SortOption } from '../components/SortDropdown'; import { FilterDropdown, FilterOption } from '../components/FilterDropdown'; import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { Pagination } from '../components/Pagination'; +import { useAuth } from '../contexts/AuthContext'; import './Home.css'; +// Lock icon SVG component +function LockIcon() { + return ( + + + + + ); +} + const SORT_OPTIONS: SortOption[] = [ { value: 'name', label: 'Name' }, { value: 'created_at', label: 'Created' }, @@ -23,6 +34,7 @@ const VISIBILITY_OPTIONS: FilterOption[] = [ function Home() { const [searchParams, setSearchParams] = useSearchParams(); + const { user } = useAuth(); const [projectsData, setProjectsData] = useState | null>(null); const [loading, setLoading] = useState(true); @@ -117,9 +129,15 @@ function Home() {

Projects

- + {user ? ( + + ) : ( + + Login to create projects + + )}
{error &&
{error}
} @@ -199,12 +217,32 @@ function Home() {
{projects.map((project) => ( -

{project.name}

+

+ {!project.is_public && } + {project.name} +

{project.description &&

{project.description}

}
- - {project.is_public ? 'Public' : 'Private'} - +
+ + {project.is_public ? 'Public' : 'Private'} + + {user && project.access_level && ( + + {project.is_owner ? 'Owner' : project.access_level.charAt(0).toUpperCase() + project.access_level.slice(1)} + + )} +
Created {new Date(project.created_at).toLocaleDateString()} {project.updated_at !== project.created_at && ( diff --git a/frontend/src/pages/LoginPage.css b/frontend/src/pages/LoginPage.css new file mode 100644 index 0000000..f59a055 --- /dev/null +++ b/frontend/src/pages/LoginPage.css @@ -0,0 +1,292 @@ +/* Login Page - Full viewport centered layout */ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-primary); + padding: 24px; + position: relative; + overflow: hidden; +} + +/* Subtle background pattern */ +.login-page::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 20% 50%, rgba(16, 185, 129, 0.08) 0%, transparent 50%), + radial-gradient(circle at 80% 50%, rgba(16, 185, 129, 0.05) 0%, transparent 50%); + pointer-events: none; +} + +.login-container { + width: 100%; + max-width: 400px; + position: relative; + z-index: 1; +} + +/* Card styling */ +.login-card { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-xl); + padding: 40px; + box-shadow: var(--shadow-lg); +} + +/* Header section */ +.login-header { + text-align: center; + margin-bottom: 32px; +} + +.login-logo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; + background: var(--accent-gradient); + border-radius: var(--radius-lg); + color: white; + margin-bottom: 24px; + box-shadow: var(--shadow-glow); +} + +.login-header h1 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; + letter-spacing: -0.02em; +} + +.login-subtitle { + color: var(--text-tertiary); + font-size: 0.875rem; +} + +.login-subtitle.login-warning { + color: var(--warning); + font-weight: 500; +} + +/* Error message */ +.login-error { + display: flex; + align-items: center; + gap: 10px; + background: var(--error-bg); + border: 1px solid rgba(239, 68, 68, 0.2); + color: var(--error); + padding: 12px 16px; + border-radius: var(--radius-md); + margin-bottom: 24px; + font-size: 0.875rem; +} + +.login-error svg { + flex-shrink: 0; +} + +/* Form styling */ +.login-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.login-form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.login-form-group label { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); +} + +.login-form-group input { + width: 100%; + padding: 14px 16px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + font-size: 0.9375rem; + color: var(--text-primary); + transition: all var(--transition-fast); +} + +.login-form-group input::placeholder { + color: var(--text-muted); +} + +.login-form-group input:hover:not(:disabled) { + border-color: var(--border-secondary); + background: var(--bg-elevated); +} + +.login-form-group input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); + background: var(--bg-elevated); +} + +.login-form-group input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Submit button */ +.login-submit { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + padding: 14px 20px; + background: var(--accent-gradient); + border: none; + border-radius: var(--radius-md); + font-size: 0.9375rem; + font-weight: 500; + color: white; + cursor: pointer; + transition: all var(--transition-fast); + margin-top: 8px; + box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2); +} + +.login-submit:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3); +} + +.login-submit:active:not(:disabled) { + transform: translateY(0); +} + +.login-submit:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + +/* Loading spinner */ +.login-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Loading state */ +.login-loading { + text-align: center; + padding: 64px 32px; + color: var(--text-tertiary); + font-size: 0.9375rem; +} + +/* Footer */ +.login-footer { + text-align: center; + margin-top: 24px; + padding-top: 24px; +} + +.login-footer p { + color: var(--text-muted); + font-size: 0.8125rem; +} + +/* SSO Divider */ +.login-divider { + display: flex; + align-items: center; + gap: 16px; + margin: 24px 0; +} + +.login-divider::before, +.login-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border-primary); +} + +.login-divider span { + font-size: 0.8125rem; + color: var(--text-muted); + text-transform: lowercase; +} + +/* SSO Button */ +.login-sso-button { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + padding: 14px 20px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + font-size: 0.9375rem; + font-weight: 500; + color: var(--text-primary); + text-decoration: none; + cursor: pointer; + transition: all var(--transition-fast); +} + +.login-sso-button:hover { + background: var(--bg-hover); + border-color: var(--border-secondary); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.login-sso-button:active { + transform: translateY(0); +} + +.login-sso-button svg { + color: var(--accent-primary); +} + +/* Responsive adjustments */ +@media (max-width: 480px) { + .login-card { + padding: 32px 24px; + } + + .login-logo { + width: 64px; + height: 64px; + } + + .login-logo svg { + width: 36px; + height: 36px; + } + + .login-header h1 { + font-size: 1.25rem; + } +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..abe90de --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,186 @@ +import { useState, useEffect } from 'react'; +import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { getOIDCStatus, getOIDCLoginUrl } from '../api'; +import { OIDCStatus } from '../types'; +import './LoginPage.css'; + +function LoginPage() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [oidcStatus, setOidcStatus] = useState(null); + const [searchParams] = useSearchParams(); + + const { user, login, loading: authLoading, refreshUser } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + // Get the return URL from location state, default to home + const from = (location.state as { from?: string })?.from || '/'; + + // Load OIDC status on mount + useEffect(() => { + getOIDCStatus() + .then(setOidcStatus) + .catch(() => setOidcStatus({ enabled: false })); + }, []); + + // Handle SSO callback - check for oidc_success or oidc_error params + useEffect(() => { + const oidcSuccess = searchParams.get('oidc_success'); + const oidcError = searchParams.get('oidc_error'); + + if (oidcSuccess === 'true') { + refreshUser().then(() => { + navigate(from, { replace: true }); + }); + } else if (oidcError) { + setError(decodeURIComponent(oidcError)); + } + }, [searchParams, refreshUser, navigate, from]); + + // Redirect if already logged in + useEffect(() => { + if (user && !authLoading) { + navigate(from, { replace: true }); + } + }, [user, authLoading, navigate, from]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + if (!username.trim() || !password) { + setError('Please enter both username and password'); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + await login(username, password); + navigate(from, { replace: true }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed. Please try again.'); + } finally { + setIsSubmitting(false); + } + } + + // Show loading while checking auth state + if (authLoading) { + return ( +
+
+
Checking session...
+
+
+ ); + } + + return ( +
+
+
+
+
+ + + + + + + + + +
+

Sign in to Orchard

+

Content-Addressable Storage

+
+ + {error && ( +
+ + + + + + {error} +
+ )} + +
+
+ + setUsername(e.target.value)} + placeholder="Enter your username" + autoComplete="username" + autoFocus + disabled={isSubmitting} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Enter your password" + autoComplete="current-password" + disabled={isSubmitting} + /> +
+ + +
+ + {oidcStatus?.enabled && ( + <> +
+ or +
+ + + + + + + Sign in with SSO + + + )} +
+ +
+

Artifact storage and management system

+
+
+
+ ); +} + +export default LoginPage; diff --git a/frontend/src/pages/PackagePage.tsx b/frontend/src/pages/PackagePage.tsx index 2d68ee7..76284b2 100644 --- a/frontend/src/pages/PackagePage.tsx +++ b/frontend/src/pages/PackagePage.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; -import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; -import { TagDetail, Package, PaginatedResponse } from '../types'; -import { listTags, getDownloadUrl, getPackage } from '../api'; +import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom'; +import { TagDetail, Package, PaginatedResponse, AccessLevel } from '../types'; +import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api'; import { Breadcrumb } from '../components/Breadcrumb'; import { Badge } from '../components/Badge'; import { SearchInput } from '../components/SearchInput'; @@ -10,6 +10,7 @@ import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { DataTable } from '../components/DataTable'; import { Pagination } from '../components/Pagination'; import { DragDropUpload, UploadResult } from '../components/DragDropUpload'; +import { useAuth } from '../contexts/AuthContext'; import './Home.css'; import './PackagePage.css'; @@ -56,15 +57,22 @@ function CopyButton({ text }: { text: string }) { function PackagePage() { const { projectName, packageName } = useParams<{ projectName: string; packageName: string }>(); const navigate = useNavigate(); + const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); + const { user } = useAuth(); const [pkg, setPkg] = useState(null); const [tagsData, setTagsData] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [accessDenied, setAccessDenied] = useState(false); const [uploadTag, setUploadTag] = useState(''); const [uploadSuccess, setUploadSuccess] = useState(null); const [artifactIdInput, setArtifactIdInput] = useState(''); + const [accessLevel, setAccessLevel] = useState(null); + + // Derived permissions + const canWrite = accessLevel === 'write' || accessLevel === 'admin'; // Get params from URL const page = parseInt(searchParams.get('page') || '1', 10); @@ -92,19 +100,32 @@ function PackagePage() { try { setLoading(true); - const [pkgData, tagsResult] = await Promise.all([ + setAccessDenied(false); + const [pkgData, tagsResult, accessResult] = await Promise.all([ getPackage(projectName, packageName), listTags(projectName, packageName, { page, search, sort, order }), + getMyProjectAccess(projectName), ]); setPkg(pkgData); setTagsData(tagsResult); + setAccessLevel(accessResult.access_level); setError(null); } catch (err) { + if (err instanceof UnauthorizedError) { + navigate('/login', { state: { from: location.pathname } }); + return; + } + if (err instanceof ForbiddenError) { + setAccessDenied(true); + setError('You do not have access to this package'); + setLoading(false); + return; + } setError(err instanceof Error ? err.message : 'Failed to load data'); } finally { setLoading(false); } - }, [projectName, packageName, page, search, sort, order]); + }, [projectName, packageName, page, search, sort, order, navigate, location.pathname]); useEffect(() => { loadData(); @@ -226,6 +247,28 @@ function PackagePage() { return
Loading...
; } + if (accessDenied) { + return ( +
+ +
+

Access Denied

+

You do not have permission to view this package.

+ {!user && ( +

+ Sign in +

+ )} +
+
+ ); + } + return (
{error}
} {uploadSuccess &&
{uploadSuccess}
} -
-

Upload Artifact

-
-
- - setUploadTag(e.target.value)} - placeholder="v1.0.0, latest, stable..." + {user && ( +
+

Upload Artifact

+ {canWrite ? ( +
+
+ + setUploadTag(e.target.value)} + placeholder="v1.0.0, latest, stable..." + /> +
+ +
+ ) : ( + -
- + )}
-
+ )}

Tags / Versions

diff --git a/frontend/src/pages/ProjectPage.tsx b/frontend/src/pages/ProjectPage.tsx index c04bb2b..6b8a99e 100644 --- a/frontend/src/pages/ProjectPage.tsx +++ b/frontend/src/pages/ProjectPage.tsx @@ -1,13 +1,15 @@ import { useState, useEffect, useCallback } from 'react'; -import { useParams, Link, useSearchParams, useNavigate } from 'react-router-dom'; -import { Project, Package, PaginatedResponse } from '../types'; -import { getProject, listPackages, createPackage } from '../api'; +import { useParams, Link, useSearchParams, useNavigate, useLocation } from 'react-router-dom'; +import { Project, Package, PaginatedResponse, AccessLevel } from '../types'; +import { getProject, listPackages, createPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api'; import { Breadcrumb } from '../components/Breadcrumb'; import { Badge } from '../components/Badge'; import { SearchInput } from '../components/SearchInput'; import { SortDropdown, SortOption } from '../components/SortDropdown'; import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { Pagination } from '../components/Pagination'; +import { AccessManagement } from '../components/AccessManagement'; +import { useAuth } from '../contexts/AuthContext'; import './Home.css'; const SORT_OPTIONS: SortOption[] = [ @@ -29,15 +31,24 @@ function formatBytes(bytes: number): string { function ProjectPage() { const { projectName } = useParams<{ projectName: string }>(); const navigate = useNavigate(); + const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); + const { user } = useAuth(); const [project, setProject] = useState(null); const [packagesData, setPackagesData] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [accessDenied, setAccessDenied] = useState(false); const [showForm, setShowForm] = useState(false); const [newPackage, setNewPackage] = useState({ name: '', description: '', format: 'generic', platform: 'any' }); const [creating, setCreating] = useState(false); + const [accessLevel, setAccessLevel] = useState(null); + const [isOwner, setIsOwner] = useState(false); + + // Derived permissions + const canWrite = accessLevel === 'write' || accessLevel === 'admin'; + const canAdmin = accessLevel === 'admin'; // Get params from URL const page = parseInt(searchParams.get('page') || '1', 10); @@ -66,19 +77,33 @@ function ProjectPage() { try { setLoading(true); - const [projectData, packagesResult] = await Promise.all([ + setAccessDenied(false); + const [projectData, packagesResult, accessResult] = await Promise.all([ getProject(projectName), listPackages(projectName, { page, search, sort, order, format: format || undefined }), + getMyProjectAccess(projectName), ]); setProject(projectData); setPackagesData(packagesResult); + setAccessLevel(accessResult.access_level); + setIsOwner(accessResult.is_owner); setError(null); } catch (err) { + if (err instanceof UnauthorizedError) { + navigate('/login', { state: { from: location.pathname } }); + return; + } + if (err instanceof ForbiddenError) { + setAccessDenied(true); + setError('You do not have access to this project'); + setLoading(false); + return; + } setError(err instanceof Error ? err.message : 'Failed to load data'); } finally { setLoading(false); } - }, [projectName, page, search, sort, order, format]); + }, [projectName, page, search, sort, order, format, navigate, location.pathname]); useEffect(() => { loadData(); @@ -139,6 +164,23 @@ function ProjectPage() { return
Loading...
; } + if (accessDenied) { + return ( +
+ +
+

Access Denied

+

You do not have permission to view this project.

+ {!user && ( +

+ Sign in +

+ )} +
+
+ ); + } + if (!project) { return
Project not found
; } @@ -159,6 +201,11 @@ function ProjectPage() { {project.is_public ? 'Public' : 'Private'} + {accessLevel && ( + + {isOwner ? 'Owner' : accessLevel.charAt(0).toUpperCase() + accessLevel.slice(1)} + + )}
{project.description &&

{project.description}

}
@@ -169,14 +216,20 @@ function ProjectPage() { by {project.created_by}
- + {canWrite ? ( + + ) : user ? ( + + Read-only access + + ) : null}
{error &&
{error}
} - {showForm && ( + {showForm && canWrite && (

Create New Package

@@ -316,6 +369,10 @@ function ProjectPage() { )} )} + + {canAdmin && projectName && ( + + )}
); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a42636c..f1c2bd3 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,3 +1,6 @@ +// Access Control types (moved to top for use in Project interface) +export type AccessLevel = 'read' | 'write' | 'admin'; + export interface Project { id: string; name: string; @@ -6,6 +9,9 @@ export interface Project { created_at: string; updated_at: string; created_by: string; + // Access level info (populated when listing projects) + access_level?: AccessLevel | null; + is_owner?: boolean; } export interface TagSummary { @@ -225,3 +231,127 @@ export interface CrossProjectStats { bytes_saved_cross_project: number; duplicates: CrossProjectDuplicate[]; } + +// Auth types +export interface User { + id: string; + username: string; + display_name: string | null; + is_admin: boolean; + must_change_password?: boolean; +} + +export interface LoginCredentials { + username: string; + password: string; +} + +// API Key types +export interface APIKey { + id: string; + name: string; + description: string | null; + scopes: string[]; + created_at: string; + expires_at: string | null; + last_used: string | null; +} + +export interface APIKeyCreate { + name: string; + description?: string; +} + +export interface APIKeyCreateResponse { + id: string; + name: string; + description: string | null; + scopes: string[]; + key: string; + created_at: string; + expires_at: string | null; +} + +// Admin User Management types +export interface AdminUser { + id: string; + username: string; + email: string | null; + display_name: string | null; + is_admin: boolean; + is_active: boolean; + created_at: string; + last_login: string | null; +} + +export interface UserCreate { + username: string; + password: string; + email?: string; + is_admin?: boolean; +} + +export interface UserUpdate { + email?: string; + is_admin?: boolean; + is_active?: boolean; +} + +// Access Permission types +export interface AccessPermission { + id: string; + project_id: string; + user_id: string; + level: AccessLevel; + created_at: string; + expires_at: string | null; +} + +export interface AccessPermissionCreate { + username: string; + level: AccessLevel; + expires_at?: string; +} + +export interface AccessPermissionUpdate { + level?: AccessLevel; + expires_at?: string | null; +} + +// Extended Project with user's access level +export interface ProjectWithAccess extends Project { + user_access_level?: AccessLevel; +} + +// Current user with permissions context +export interface CurrentUser extends User { + permissions?: { + [projectId: string]: AccessLevel; + }; +} + +// OIDC types +export interface OIDCConfig { + enabled: boolean; + issuer_url: string; + client_id: string; + has_client_secret: boolean; + scopes: string[]; + auto_create_users: boolean; + admin_group: string; +} + +export interface OIDCConfigUpdate { + enabled?: boolean; + issuer_url?: string; + client_id?: string; + client_secret?: string; + scopes?: string[]; + auto_create_users?: boolean; + admin_group?: string; +} + +export interface OIDCStatus { + enabled: boolean; + issuer_url?: string; +} diff --git a/migrations/006_auth_tables.sql b/migrations/006_auth_tables.sql new file mode 100644 index 0000000..59fd1ee --- /dev/null +++ b/migrations/006_auth_tables.sql @@ -0,0 +1,86 @@ +-- Authentication Tables Migration +-- Adds users table and updates api_keys with foreign key + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255), + email VARCHAR(255), + is_admin BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + must_change_password BOOLEAN DEFAULT FALSE, + oidc_subject VARCHAR(255), + oidc_issuer VARCHAR(512), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_login TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_oidc_subject ON users(oidc_subject) WHERE oidc_subject IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active) WHERE is_active = TRUE; + +-- Sessions table for web login +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + last_accessed TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + user_agent VARCHAR(512), + ip_address VARCHAR(45) +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + +-- Auth settings for OIDC configuration (future use) +CREATE TABLE IF NOT EXISTS auth_settings ( + key VARCHAR(255) PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Add user_id foreign key to api_keys table +-- First add the column (nullable initially) +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS owner_id UUID REFERENCES users(id) ON DELETE CASCADE; + +-- Add scopes column for API key permissions +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS scopes TEXT[] DEFAULT ARRAY['read', 'write']; + +-- Add description column +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS description TEXT; + +-- Create index for owner_id +CREATE INDEX IF NOT EXISTS idx_api_keys_owner_id ON api_keys(owner_id) WHERE owner_id IS NOT NULL; + +-- Trigger to update users.updated_at +CREATE TRIGGER users_updated_at_trigger + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Trigger to update sessions.last_accessed on access +CREATE OR REPLACE FUNCTION update_session_last_accessed() +RETURNS TRIGGER AS $$ +BEGIN + NEW.last_accessed = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Function to clean up expired sessions (can be called periodically) +CREATE OR REPLACE FUNCTION cleanup_expired_sessions() +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + DELETE FROM sessions WHERE expires_at < NOW(); + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql;