10 Commits

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

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

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

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

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

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

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

Tests:
- Fix pagination flakiness in test_list_projects
- Fix pagination flakiness in test_projects_search
- Add API key authentication to concurrent upload test
2026-01-08 16:20:42 -06:00
Mondo Diaz
b1c17e8ab7 Add security hardening and additional auth tests
Security improvements:
- Add password strength validation (min 8 characters)
- Invalidate all sessions on password change/reset
- Add timing-safe user lookup to prevent enumeration attacks
- Fix SQLAlchemy boolean comparisons (== True -> is_(True))
- Change default admin password to 'changeme123' (meets min length)

New tests (7 additional):
- Inactive user login attempt blocked
- Short password rejected on create/change/reset
- Duplicate username rejected (409)
- Non-owner API key deletion blocked (403)
- Sessions invalidated on password change
2026-01-08 15:37:53 -06:00
Mondo Diaz
696793c84f Fix auth datetime comparison and bcrypt dependency
- Use timezone-aware datetimes (datetime.now(timezone.utc)) for session expiry comparison
- Add explicit bcrypt==4.0.1 dependency for passlib bcrypt backend
2026-01-08 15:14:54 -06:00
Mondo Diaz
2a68708a79 Add user authentication system with API key management (#50)
- Add User, Session, AuthSettings models with bcrypt password hashing
- Add auth endpoints: login, logout, change-password, me
- Add API key CRUD: create (orch_xxx format), list, revoke
- Add admin user management: list, create, update, reset-password
- Create default admin user on startup (admin/admin)
- Add frontend: Login page, API Keys page, Admin Users page
- Add AuthContext for session state management
- Add user menu to Layout header with login/logout/settings
- Add 15 integration tests for auth system
- Add migration 006_auth_tables.sql
2026-01-08 15:01:37 -06:00
35 changed files with 6874 additions and 103 deletions

View File

@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### 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_<random>`)
- `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) - Added reusable `DragDropUpload` component for artifact uploads (#8)
- Drag-and-drop file selection with visual feedback - Drag-and-drop file selection with visual feedback
- Click-to-browse fallback - Click-to-browse fallback

869
backend/app/auth.py Normal file
View File

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

View File

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

View File

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

View File

@@ -1,14 +1,19 @@
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import logging import logging
import os import os
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from .config import get_settings from .config import get_settings
from .database import init_db, SessionLocal from .database import init_db, SessionLocal
from .routes import router from .routes import router
from .seed import seed_database from .seed import seed_database
from .auth import create_default_admin
from .rate_limit import limiter
settings = get_settings() settings = get_settings()
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -20,6 +25,18 @@ async def lifespan(app: FastAPI):
# Startup: initialize database # Startup: initialize database
init_db() 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 # Seed test data in development mode
if settings.is_development: if settings.is_development:
logger.info(f"Running in {settings.env} mode - checking for seed data") logger.info(f"Running in {settings.env} mode - checking for seed data")
@@ -42,13 +59,21 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# Set up rate limiting
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Include API routes # Include API routes
app.include_router(router) app.include_router(router)
# Serve static files (React build) if the directory exists # Serve static files (React build) if the directory exists
static_dir = os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "dist") static_dir = os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "dist")
if os.path.exists(static_dir): 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("/") @app.get("/")
async def serve_spa(): async def serve_spa():
@@ -60,6 +85,7 @@ if os.path.exists(static_dir):
# Don't catch API routes or health endpoint # Don't catch API routes or health endpoint
if full_path.startswith("api/") or full_path.startswith("health"): if full_path.startswith("api/") or full_path.startswith("health"):
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not found") raise HTTPException(status_code=404, detail="Not found")
# Serve SPA for all other routes (including /project/*) # Serve SPA for all other routes (including /project/*)
@@ -68,4 +94,5 @@ if os.path.exists(static_dir):
return FileResponse(index_path) return FileResponse(index_path)
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not found") raise HTTPException(status_code=404, detail="Not found")

View File

@@ -11,6 +11,7 @@ from sqlalchemy import (
CheckConstraint, CheckConstraint,
Index, Index,
JSON, JSON,
ARRAY,
) )
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship, declarative_base 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): class APIKey(Base):
__tablename__ = "api_keys" __tablename__ = "api_keys"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
key_hash = Column(String(64), unique=True, nullable=False) key_hash = Column(String(64), unique=True, nullable=False)
name = Column(String(255), 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) created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
expires_at = Column(DateTime(timezone=True)) expires_at = Column(DateTime(timezone=True))
last_used = Column(DateTime(timezone=True)) last_used = Column(DateTime(timezone=True))
owner = relationship("User", back_populates="api_keys")
__table_args__ = ( __table_args__ = (
Index("idx_api_keys_user_id", "user_id"), Index("idx_api_keys_user_id", "user_id"),
Index("idx_api_keys_key_hash", "key_hash"), Index("idx_api_keys_key_hash", "key_hash"),
Index("idx_api_keys_owner_id", "owner_id"),
) )

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

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

View File

@@ -11,6 +11,8 @@ from fastapi import (
Query, Query,
Header, Header,
Response, Response,
Cookie,
status,
) )
from fastapi.responses import StreamingResponse, RedirectResponse from fastapi.responses import StreamingResponse, RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -42,11 +44,14 @@ from .models import (
UploadLock, UploadLock,
Consumer, Consumer,
AuditLog, AuditLog,
User,
AccessPermission,
) )
from .schemas import ( from .schemas import (
ProjectCreate, ProjectCreate,
ProjectUpdate, ProjectUpdate,
ProjectResponse, ProjectResponse,
ProjectWithAccessResponse,
PackageCreate, PackageCreate,
PackageUpdate, PackageUpdate,
PackageResponse, PackageResponse,
@@ -94,6 +99,19 @@ from .schemas import (
StatsReportResponse, StatsReportResponse,
GlobalArtifactResponse, GlobalArtifactResponse,
GlobalTagResponse, GlobalTagResponse,
LoginRequest,
LoginResponse,
ChangePasswordRequest,
UserResponse,
UserCreate,
UserUpdate,
ResetPasswordRequest,
APIKeyCreate,
APIKeyResponse,
APIKeyCreateResponse,
AccessPermissionCreate,
AccessPermissionUpdate,
AccessPermissionResponse,
) )
from .metadata import extract_metadata from .metadata import extract_metadata
from .config import get_settings from .config import get_settings
@@ -118,14 +136,39 @@ def sanitize_filename(filename: str) -> str:
return re.sub(r'[\r\n"]', "", filename) 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: def get_user_id(request: Request) -> str:
"""Extract user ID from request (simplified for now)""" """Legacy function for backward compatibility.
api_key = request.headers.get("X-Orchard-API-Key")
if api_key: DEPRECATED: Use get_user_id_from_request with db session for proper auth.
return "api-user" """
auth = request.headers.get("Authorization") auth = request.headers.get("Authorization")
if auth: if auth and auth.startswith("Bearer "):
return "bearer-user" return "authenticated-user"
return "anonymous" return "anonymous"
@@ -320,6 +363,488 @@ 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"}
# --- 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 # Global search
@router.get("/api/v1/search", response_model=GlobalSearchResponse) @router.get("/api/v1/search", response_model=GlobalSearchResponse)
def global_search( def global_search(
@@ -424,7 +949,7 @@ def global_search(
# Project routes # Project routes
@router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectResponse]) @router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectWithAccessResponse])
def list_projects( def list_projects(
request: Request, request: Request,
page: int = Query(default=1, ge=1, description="Page number"), page: int = Query(default=1, ge=1, description="Page number"),
@@ -440,8 +965,9 @@ def list_projects(
), ),
order: str = Query(default="asc", description="Sort order (asc, desc)"), order: str = Query(default="asc", description="Sort order (asc, desc)"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
): ):
user_id = get_user_id(request) user_id = current_user.username if current_user else get_user_id(request)
# Validate sort field # Validate sort field
valid_sort_fields = { valid_sort_fields = {
@@ -499,8 +1025,51 @@ def list_projects(
# Calculate total pages # Calculate total pages
total_pages = math.ceil(total / limit) if total > 0 else 1 total_pages = math.ceil(total / limit) if total > 0 else 1
# Build access level info for each project
project_ids = [p.id for p in projects]
access_map = {}
if current_user and project_ids:
# Get access permissions for this user across these projects
permissions = (
db.query(AccessPermission)
.filter(
AccessPermission.project_id.in_(project_ids),
AccessPermission.user_id == current_user.username,
)
.all()
)
access_map = {p.project_id: p.level for p in permissions}
# Build response with access levels
items = []
for p in projects:
is_owner = p.created_by == user_id
access_level = None
if is_owner:
access_level = "admin"
elif p.id in access_map:
access_level = access_map[p.id]
elif p.is_public:
access_level = "read"
items.append(
ProjectWithAccessResponse(
id=p.id,
name=p.name,
description=p.description,
is_public=p.is_public,
created_at=p.created_at,
updated_at=p.updated_at,
created_by=p.created_by,
access_level=access_level,
is_owner=is_owner,
)
)
return PaginatedResponse( return PaginatedResponse(
items=projects, items=items,
pagination=PaginationMeta( pagination=PaginationMeta(
page=page, page=page,
limit=limit, limit=limit,
@@ -513,9 +1082,12 @@ def list_projects(
@router.post("/api/v1/projects", response_model=ProjectResponse) @router.post("/api/v1/projects", response_model=ProjectResponse)
def create_project( 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() existing = db.query(Project).filter(Project.name == project.name).first()
if existing: if existing:
@@ -545,10 +1117,13 @@ def create_project(
@router.get("/api/v1/projects/{project_name}", response_model=ProjectResponse) @router.get("/api/v1/projects/{project_name}", response_model=ProjectResponse)
def get_project(project_name: str, db: Session = Depends(get_db)): def get_project(
project = db.query(Project).filter(Project.name == project_name).first() project_name: str,
if not project: db: Session = Depends(get_db),
raise HTTPException(status_code=404, detail="Project not found") current_user: Optional[User] = Depends(get_current_user_optional),
):
"""Get a single project by name. Requires read access for private projects."""
project = check_project_access(db, project_name, current_user, "read")
return project return project
@@ -558,13 +1133,11 @@ def update_project(
project_update: ProjectUpdate, project_update: ProjectUpdate,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
): ):
"""Update a project's metadata.""" """Update a project's metadata. Requires admin access."""
user_id = get_user_id(request) project = check_project_access(db, project_name, current_user, "admin")
user_id = current_user.username if current_user else get_user_id(request)
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Track changes for audit log # Track changes for audit log
changes = {} changes = {}
@@ -611,14 +1184,16 @@ def delete_project(
project_name: str, project_name: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
): ):
""" """
Delete a project and all its packages. Delete a project and all its packages. Requires admin access.
Decrements ref_count for all artifacts referenced by tags in all packages Decrements ref_count for all artifacts referenced by tags in all packages
within this project. within this project.
""" """
user_id = get_user_id(request) check_project_access(db, project_name, current_user, "admin")
user_id = current_user.username if current_user else get_user_id(request)
project = db.query(Project).filter(Project.name == project_name).first() project = db.query(Project).filter(Project.name == project_name).first()
if not project: if not project:
@@ -664,6 +1239,159 @@ def delete_project(
return None return None
# Access Permission routes
@router.get(
"/api/v1/project/{project_name}/permissions",
response_model=List[AccessPermissionResponse],
)
def list_project_permissions(
project_name: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List all access permissions for a project.
Requires admin access to the project.
"""
project = check_project_access(db, project_name, current_user, "admin")
auth_service = AuthorizationService(db)
permissions = auth_service.list_project_permissions(str(project.id))
return permissions
@router.post(
"/api/v1/project/{project_name}/permissions",
response_model=AccessPermissionResponse,
)
def grant_project_access(
project_name: str,
permission: AccessPermissionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Grant access to a user for a project.
Requires admin access to the project.
"""
project = check_project_access(db, project_name, current_user, "admin")
auth_service = AuthorizationService(db)
new_permission = auth_service.grant_access(
str(project.id),
permission.username,
permission.level,
permission.expires_at,
)
return new_permission
@router.put(
"/api/v1/project/{project_name}/permissions/{username}",
response_model=AccessPermissionResponse,
)
def update_project_access(
project_name: str,
username: str,
permission: AccessPermissionUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Update a user's access level for a project.
Requires admin access to the project.
"""
project = check_project_access(db, project_name, current_user, "admin")
auth_service = AuthorizationService(db)
# Get existing permission
from .models import AccessPermission
existing = (
db.query(AccessPermission)
.filter(
AccessPermission.project_id == project.id,
AccessPermission.user_id == username,
)
.first()
)
if not existing:
raise HTTPException(
status_code=404,
detail=f"No access permission found for user '{username}'",
)
# Update fields
if permission.level is not None:
existing.level = permission.level
if permission.expires_at is not None:
existing.expires_at = permission.expires_at
db.commit()
db.refresh(existing)
return existing
@router.delete(
"/api/v1/project/{project_name}/permissions/{username}",
status_code=204,
)
def revoke_project_access(
project_name: str,
username: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Revoke a user's access to a project.
Requires admin access to the project.
"""
project = check_project_access(db, project_name, current_user, "admin")
auth_service = AuthorizationService(db)
deleted = auth_service.revoke_access(str(project.id), username)
if not deleted:
raise HTTPException(
status_code=404,
detail=f"No access permission found for user '{username}'",
)
return None
@router.get(
"/api/v1/project/{project_name}/my-access",
)
def get_my_project_access(
project_name: str,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
):
"""
Get the current user's access level for a project.
Returns null for anonymous users on private projects.
"""
from .models import Project
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
auth_service = AuthorizationService(db)
access_level = auth_service.get_user_access_level(str(project.id), current_user)
return {
"project": project_name,
"access_level": access_level,
"is_owner": current_user and project.created_by == current_user.username,
}
# Package routes # Package routes
@router.get( @router.get(
"/api/v1/project/{project_name}/packages", "/api/v1/project/{project_name}/packages",
@@ -934,10 +1662,10 @@ def create_package(
package: PackageCreate, package: PackageCreate,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
): ):
project = db.query(Project).filter(Project.name == project_name).first() """Create a new package in a project. Requires write access."""
if not project: project = check_project_access(db, project_name, current_user, "write")
raise HTTPException(status_code=404, detail="Project not found")
# Validate format # Validate format
if package.format not in PACKAGE_FORMATS: if package.format not in PACKAGE_FORMATS:
@@ -1150,6 +1878,7 @@ def upload_artifact(
content_length: Optional[int] = Header(None, alias="Content-Length"), content_length: Optional[int] = Header(None, alias="Content-Length"),
user_agent: Optional[str] = Header(None, alias="User-Agent"), user_agent: Optional[str] = Header(None, alias="User-Agent"),
client_checksum: Optional[str] = Header(None, alias="X-Checksum-SHA256"), 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. Upload an artifact to a package.
@@ -1157,16 +1886,15 @@ def upload_artifact(
Headers: Headers:
- X-Checksum-SHA256: Optional client-provided SHA256 for verification - X-Checksum-SHA256: Optional client-provided SHA256 for verification
- User-Agent: Captured for audit purposes - User-Agent: Captured for audit purposes
- Authorization: Bearer <api-key> for authentication
""" """
start_time = time.time() start_time = time.time()
user_id = get_user_id(request)
settings = get_settings() settings = get_settings()
storage_result = None storage_result = None
# Get project and package # Check authorization (write access required for uploads)
project = db.query(Project).filter(Project.name == project_name).first() project = check_project_access(db, project_name, current_user, "write")
if not project: user_id = current_user.username if current_user else get_user_id_from_request(request, db, current_user)
raise HTTPException(status_code=404, detail="Project not found")
package = ( package = (
db.query(Package) db.query(Package)
@@ -1791,6 +2519,7 @@ def download_artifact(
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
storage: S3Storage = Depends(get_storage), storage: S3Storage = Depends(get_storage),
current_user: Optional[User] = Depends(get_current_user_optional),
range: Optional[str] = Header(None), range: Optional[str] = Header(None),
mode: Optional[Literal["proxy", "redirect", "presigned"]] = Query( mode: Optional[Literal["proxy", "redirect", "presigned"]] = Query(
default=None, default=None,
@@ -1826,10 +2555,8 @@ def download_artifact(
""" """
settings = get_settings() settings = get_settings()
# Get project and package # Check authorization (read access required for downloads)
project = db.query(Project).filter(Project.name == project_name).first() project = check_project_access(db, project_name, current_user, "read")
if not project:
raise HTTPException(status_code=404, detail="Project not found")
package = ( package = (
db.query(Package) db.query(Package)
@@ -2047,10 +2774,8 @@ def get_artifact_url(
""" """
settings = get_settings() settings = get_settings()
# Get project and package # Check authorization (read access required for downloads)
project = db.query(Project).filter(Project.name == project_name).first() project = check_project_access(db, project_name, current_user, "read")
if not project:
raise HTTPException(status_code=404, detail="Project not found")
package = ( package = (
db.query(Package) db.query(Package)
@@ -2305,12 +3030,11 @@ def create_tag(
tag: TagCreate, tag: TagCreate,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
): ):
user_id = get_user_id(request) """Create or update a tag. Requires write access."""
project = check_project_access(db, project_name, current_user, "write")
project = db.query(Project).filter(Project.name == project_name).first() user_id = current_user.username if current_user else get_user_id(request)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
package = ( package = (
db.query(Package) db.query(Package)

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,63 @@
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 Layout from './components/Layout';
import Home from './pages/Home'; import Home from './pages/Home';
import ProjectPage from './pages/ProjectPage'; import ProjectPage from './pages/ProjectPage';
import PackagePage from './pages/PackagePage'; import PackagePage from './pages/PackagePage';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import LoginPage from './pages/LoginPage';
import ChangePasswordPage from './pages/ChangePasswordPage';
import APIKeysPage from './pages/APIKeysPage';
import AdminUsersPage from './pages/AdminUsersPage';
function App() { // Component that checks if user must change password
function RequirePasswordChange({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return null;
}
// If user is logged in and must change password, redirect to change password page
if (user?.must_change_password && location.pathname !== '/change-password') {
return <Navigate to="/change-password" replace />;
}
return <>{children}</>;
}
function AppRoutes() {
return ( return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/change-password" element={<ChangePasswordPage />} />
<Route
path="*"
element={
<RequirePasswordChange>
<Layout> <Layout>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings/api-keys" element={<APIKeysPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/project/:projectName" element={<ProjectPage />} /> <Route path="/project/:projectName" element={<ProjectPage />} />
<Route path="/project/:projectName/:packageName" element={<PackagePage />} /> <Route path="/project/:projectName/:packageName" element={<PackagePage />} />
</Routes> </Routes>
</Layout> </Layout>
</RequirePasswordChange>
}
/>
</Routes>
);
}
function App() {
return (
<AuthProvider>
<AppRoutes />
</AuthProvider>
); );
} }

View File

@@ -17,14 +17,59 @@ import {
DeduplicationStats, DeduplicationStats,
TimelineStats, TimelineStats,
CrossProjectStats, CrossProjectStats,
User,
LoginCredentials,
APIKey,
APIKeyCreate,
APIKeyCreateResponse,
AdminUser,
UserCreate,
UserUpdate,
AccessPermission,
AccessPermissionCreate,
AccessPermissionUpdate,
AccessLevel,
} from './types'; } from './types';
const API_BASE = '/api/v1'; const API_BASE = '/api/v1';
// Custom error classes for better error handling
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
export class UnauthorizedError extends ApiError {
constructor(message: string = 'Not authenticated') {
super(message, 401);
this.name = 'UnauthorizedError';
}
}
export class ForbiddenError extends ApiError {
constructor(message: string = 'Access denied') {
super(message, 403);
this.name = 'ForbiddenError';
}
}
async function handleResponse<T>(response: Response): Promise<T> { async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' })); const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `HTTP ${response.status}`); const message = error.detail || `HTTP ${response.status}`;
if (response.status === 401) {
throw new UnauthorizedError(message);
}
if (response.status === 403) {
throw new ForbiddenError(message);
}
throw new ApiError(message, response.status);
} }
return response.json(); return response.json();
} }
@@ -40,6 +85,55 @@ function buildQueryString(params: Record<string, unknown>): string {
return query ? `?${query}` : ''; return query ? `?${query}` : '';
} }
// Auth API
export async function login(credentials: LoginCredentials): Promise<User> {
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
credentials: 'include',
});
return handleResponse<User>(response);
}
export async function logout(): Promise<void> {
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<void> {
const response = await fetch(`${API_BASE}/auth/change-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
credentials: 'include',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
}
export async function getCurrentUser(): Promise<User | null> {
try {
const response = await fetch(`${API_BASE}/auth/me`, {
credentials: 'include',
});
if (response.status === 401) {
return null;
}
return handleResponse<User>(response);
} catch {
return null;
}
}
// Global Search API // Global Search API
export async function globalSearch(query: string, limit: number = 5): Promise<GlobalSearchResponse> { export async function globalSearch(query: string, limit: number = 5): Promise<GlobalSearchResponse> {
const params = buildQueryString({ q: query, limit }); const params = buildQueryString({ q: query, limit });
@@ -186,3 +280,131 @@ export async function getCrossProjectStats(): Promise<CrossProjectStats> {
const response = await fetch(`${API_BASE}/stats/cross-project`); const response = await fetch(`${API_BASE}/stats/cross-project`);
return handleResponse<CrossProjectStats>(response); return handleResponse<CrossProjectStats>(response);
} }
export async function listAPIKeys(): Promise<APIKey[]> {
const response = await fetch(`${API_BASE}/auth/keys`, {
credentials: 'include',
});
return handleResponse<APIKey[]>(response);
}
export async function createAPIKey(data: APIKeyCreate): Promise<APIKeyCreateResponse> {
const response = await fetch(`${API_BASE}/auth/keys`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include',
});
return handleResponse<APIKeyCreateResponse>(response);
}
export async function deleteAPIKey(id: string): Promise<void> {
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<AdminUser[]> {
const response = await fetch(`${API_BASE}/admin/users`, {
credentials: 'include',
});
return handleResponse<AdminUser[]>(response);
}
export async function createUser(data: UserCreate): Promise<AdminUser> {
const response = await fetch(`${API_BASE}/admin/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include',
});
return handleResponse<AdminUser>(response);
}
export async function updateUser(username: string, data: UserUpdate): Promise<AdminUser> {
const response = await fetch(`${API_BASE}/admin/users/${username}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include',
});
return handleResponse<AdminUser>(response);
}
export async function resetUserPassword(username: string, newPassword: string): Promise<void> {
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<MyAccessResponse> {
const response = await fetch(`${API_BASE}/project/${projectName}/my-access`, {
credentials: 'include',
});
return handleResponse<MyAccessResponse>(response);
}
export async function listProjectPermissions(projectName: string): Promise<AccessPermission[]> {
const response = await fetch(`${API_BASE}/project/${projectName}/permissions`, {
credentials: 'include',
});
return handleResponse<AccessPermission[]>(response);
}
export async function grantProjectAccess(
projectName: string,
data: AccessPermissionCreate
): Promise<AccessPermission> {
const response = await fetch(`${API_BASE}/project/${projectName}/permissions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include',
});
return handleResponse<AccessPermission>(response);
}
export async function updateProjectAccess(
projectName: string,
username: string,
data: AccessPermissionUpdate
): Promise<AccessPermission> {
const response = await fetch(`${API_BASE}/project/${projectName}/permissions/${username}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include',
});
return handleResponse<AccessPermission>(response);
}
export async function revokeProjectAccess(projectName: string, username: string): Promise<void> {
const response = await fetch(`${API_BASE}/project/${projectName}/permissions/${username}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
}

View File

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

View File

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

View File

@@ -98,6 +98,170 @@
opacity: 0.7; 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 content */
.main { .main {
flex: 1; flex: 1;

View File

@@ -1,5 +1,6 @@
import { ReactNode } from 'react'; import { ReactNode, useState, useRef, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { GlobalSearch } from './GlobalSearch'; import { GlobalSearch } from './GlobalSearch';
import './Layout.css'; import './Layout.css';
@@ -9,6 +10,31 @@ interface LayoutProps {
function Layout({ children }: LayoutProps) { function Layout({ children }: LayoutProps) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const { user, loading, logout } = useAuth();
const [showUserMenu, setShowUserMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(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 ( return (
<div className="layout"> <div className="layout">
@@ -60,6 +86,85 @@ function Layout({ children }: LayoutProps) {
</svg> </svg>
Docs Docs
</a> </a>
{/* User Menu */}
{loading ? (
<div className="user-menu-loading">
<div className="user-menu-spinner"></div>
</div>
) : user ? (
<div className="user-menu" ref={menuRef}>
<button
className="user-menu-trigger"
onClick={() => setShowUserMenu(!showUserMenu)}
aria-expanded={showUserMenu}
aria-haspopup="true"
>
<div className="user-avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<span className="user-name">{user.display_name || user.username}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{showUserMenu && (
<div className="user-menu-dropdown">
<div className="user-menu-header">
<span className="user-menu-username">{user.username}</span>
{user.is_admin && (
<span className="user-menu-badge">Admin</span>
)}
</div>
<div className="user-menu-divider"></div>
<NavLink
to="/settings/api-keys"
className="user-menu-item"
onClick={() => setShowUserMenu(false)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>
API Keys
</NavLink>
{user.is_admin && (
<NavLink
to="/admin/users"
className="user-menu-item"
onClick={() => setShowUserMenu(false)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
User Management
</NavLink>
)}
<div className="user-menu-divider"></div>
<button className="user-menu-item" onClick={handleLogout}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
Sign out
</button>
</div>
)}
</div>
) : (
<Link to="/login" className="nav-login">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
<polyline points="10 17 15 12 10 7"/>
<line x1="15" y1="12" x2="3" y2="12"/>
</svg>
Login
</Link>
)}
</nav> </nav>
</div> </div>
</header> </header>

View File

@@ -0,0 +1,97 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import { User } from '../types';
import { getCurrentUser, login as apiLogin, logout as apiLogout } from '../api';
interface AuthContextType {
user: User | null;
loading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
clearError: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 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);
} catch (err) {
const message = err instanceof Error ? err.message : 'Login failed';
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(async () => {
setLoading(true);
setError(null);
try {
await apiLogout();
setUser(null);
} catch (err) {
const message = err instanceof Error ? err.message : 'Logout failed';
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
const clearError = useCallback(() => {
setError(null);
}, []);
const refreshUser = useCallback(async () => {
try {
const currentUser = await getCurrentUser();
setUser(currentUser);
} catch {
setUser(null);
}
}, []);
return (
<AuthContext.Provider value={{ user, loading, error, login, logout, refreshUser, clearError }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -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;
}
}

View File

@@ -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<APIKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [createName, setCreateName] = useState('');
const [createDescription, setCreateDescription] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<APIKeyCreateResponse | null>(null);
const [copied, setCopied] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(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 (
<div className="api-keys-page">
<div className="api-keys-loading">
<div className="api-keys-spinner"></div>
<span>Loading...</span>
</div>
</div>
);
}
if (!user) {
return null;
}
return (
<div className="api-keys-page">
<div className="api-keys-header">
<div className="api-keys-header-content">
<h1>API Keys</h1>
<p className="api-keys-subtitle">
Manage API keys for programmatic access to Orchard
</p>
</div>
<button
className="api-keys-create-button"
onClick={() => setShowCreateForm(true)}
disabled={showCreateForm}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Create New Key
</button>
</div>
{error && (
<div className="api-keys-error">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>{error}</span>
<button onClick={() => setError(null)} className="api-keys-error-dismiss">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
)}
{newlyCreatedKey && (
<div className="api-keys-new-key-banner">
<div className="api-keys-new-key-header">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span className="api-keys-new-key-title">New API Key Created</span>
</div>
<div className="api-keys-new-key-warning">
Copy this key now! It won't be shown again.
</div>
<div className="api-keys-new-key-value-container">
<code className="api-keys-new-key-value">{newlyCreatedKey.key}</code>
<button
className="api-keys-copy-button"
onClick={handleCopyKey}
title="Copy to clipboard"
>
{copied ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
)}
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<button className="api-keys-done-button" onClick={handleDismissNewKey}>
Done
</button>
</div>
)}
{showCreateForm && (
<div className="api-keys-create-form-card">
<div className="api-keys-create-form-header">
<h2>Create New API Key</h2>
<button
className="api-keys-create-form-close"
onClick={() => {
setShowCreateForm(false);
setCreateName('');
setCreateDescription('');
setCreateError(null);
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{createError && (
<div className="api-keys-create-error">
{createError}
</div>
)}
<form onSubmit={handleCreate} className="api-keys-create-form">
<div className="api-keys-form-group">
<label htmlFor="key-name">Name</label>
<input
id="key-name"
type="text"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder="e.g., CI/CD Pipeline, Local Development"
autoFocus
disabled={isCreating}
/>
</div>
<div className="api-keys-form-group">
<label htmlFor="key-description">Description (optional)</label>
<input
id="key-description"
type="text"
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="What will this key be used for?"
disabled={isCreating}
/>
</div>
<div className="api-keys-form-actions">
<button
type="button"
className="api-keys-cancel-button"
onClick={() => {
setShowCreateForm(false);
setCreateName('');
setCreateDescription('');
setCreateError(null);
}}
disabled={isCreating}
>
Cancel
</button>
<button
type="submit"
className="api-keys-submit-button"
disabled={isCreating || !createName.trim()}
>
{isCreating ? (
<>
<span className="api-keys-button-spinner"></span>
Creating...
</>
) : (
'Create Key'
)}
</button>
</div>
</form>
</div>
)}
<div className="api-keys-list-container">
{loading ? (
<div className="api-keys-list-loading">
<div className="api-keys-spinner"></div>
<span>Loading API keys...</span>
</div>
) : keys.length === 0 ? (
<div className="api-keys-empty">
<div className="api-keys-empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>
</div>
<h3>No API Keys</h3>
<p>Create an API key to access Orchard programmatically</p>
</div>
) : (
<div className="api-keys-list">
<div className="api-keys-list-header">
<span className="api-keys-col-name">Name</span>
<span className="api-keys-col-created">Created</span>
<span className="api-keys-col-used">Last Used</span>
<span className="api-keys-col-actions">Actions</span>
</div>
{keys.map((key) => (
<div key={key.id} className="api-keys-list-item">
<div className="api-keys-col-name">
<div className="api-keys-item-name">{key.name}</div>
{key.description && (
<div className="api-keys-item-description">{key.description}</div>
)}
</div>
<div className="api-keys-col-created">
{formatDate(key.created_at)}
</div>
<div className="api-keys-col-used">
{formatDate(key.last_used)}
</div>
<div className="api-keys-col-actions">
{deleteConfirmId === key.id ? (
<div className="api-keys-delete-confirm">
<span>Revoke?</span>
<button
className="api-keys-confirm-yes"
onClick={() => handleDelete(key.id)}
disabled={isDeleting}
>
{isDeleting ? 'Revoking...' : 'Yes'}
</button>
<button
className="api-keys-confirm-no"
onClick={() => setDeleteConfirmId(null)}
disabled={isDeleting}
>
No
</button>
</div>
) : (
<button
className="api-keys-revoke-button"
onClick={() => setDeleteConfirmId(key.id)}
>
Revoke
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
export default APIKeysPage;

View File

@@ -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;
}
}

View File

@@ -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<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string | null>(null);
const [resetPasswordUsername, setResetPasswordUsername] = useState<string | null>(null);
const [newPassword, setNewPassword] = useState('');
const [isResetting, setIsResetting] = useState(false);
const [togglingUser, setTogglingUser] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(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 (
<div className="admin-users-page">
<div className="admin-users-loading">
<div className="admin-users-spinner"></div>
<span>Loading...</span>
</div>
</div>
);
}
if (!user) {
return null;
}
if (!user.is_admin) {
return (
<div className="admin-users-page">
<div className="admin-users-access-denied">
<div className="admin-users-access-denied-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
</div>
<h2>Access Denied</h2>
<p>You do not have permission to access this page. Admin privileges are required.</p>
</div>
</div>
);
}
return (
<div className="admin-users-page">
<div className="admin-users-header">
<div className="admin-users-header-content">
<h1>User Management</h1>
<p className="admin-users-subtitle">
Manage user accounts and permissions
</p>
</div>
<button
className="admin-users-create-button"
onClick={() => setShowCreateForm(true)}
disabled={showCreateForm}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Create User
</button>
</div>
{successMessage && (
<div className="admin-users-success">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<span>{successMessage}</span>
</div>
)}
{error && (
<div className="admin-users-error">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>{error}</span>
<button onClick={() => setError(null)} className="admin-users-error-dismiss">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
)}
{showCreateForm && (
<div className="admin-users-create-form-card">
<div className="admin-users-create-form-header">
<h2>Create New User</h2>
<button
className="admin-users-create-form-close"
onClick={() => {
setShowCreateForm(false);
setCreateUsername('');
setCreatePassword('');
setCreateEmail('');
setCreateIsAdmin(false);
setCreateError(null);
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{createError && (
<div className="admin-users-create-error">
{createError}
</div>
)}
<form onSubmit={handleCreate} className="admin-users-create-form">
<div className="admin-users-form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={createUsername}
onChange={(e) => setCreateUsername(e.target.value)}
placeholder="Enter username"
autoFocus
disabled={isCreating}
/>
</div>
<div className="admin-users-form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
placeholder="Enter password"
disabled={isCreating}
/>
</div>
<div className="admin-users-form-group">
<label htmlFor="email">Email (optional)</label>
<input
id="email"
type="email"
value={createEmail}
onChange={(e) => setCreateEmail(e.target.value)}
placeholder="user@example.com"
disabled={isCreating}
/>
</div>
<div className="admin-users-form-group admin-users-checkbox-group">
<label className="admin-users-checkbox-label">
<input
type="checkbox"
checked={createIsAdmin}
onChange={(e) => setCreateIsAdmin(e.target.checked)}
disabled={isCreating}
/>
<span className="admin-users-checkbox-custom"></span>
Grant admin privileges
</label>
</div>
<div className="admin-users-form-actions">
<button
type="button"
className="admin-users-cancel-button"
onClick={() => {
setShowCreateForm(false);
setCreateUsername('');
setCreatePassword('');
setCreateEmail('');
setCreateIsAdmin(false);
setCreateError(null);
}}
disabled={isCreating}
>
Cancel
</button>
<button
type="submit"
className="admin-users-submit-button"
disabled={isCreating || !createUsername.trim() || !createPassword.trim()}
>
{isCreating ? (
<>
<span className="admin-users-button-spinner"></span>
Creating...
</>
) : (
'Create User'
)}
</button>
</div>
</form>
</div>
)}
{resetPasswordUsername && (
<div className="admin-users-reset-password-card">
<div className="admin-users-reset-password-header">
<h2>Reset Password</h2>
<button
className="admin-users-create-form-close"
onClick={() => {
setResetPasswordUsername(null);
setNewPassword('');
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<p className="admin-users-reset-password-info">
Set a new password for <strong>{resetPasswordUsername}</strong>
</p>
<form onSubmit={handleResetPassword} className="admin-users-reset-password-form">
<div className="admin-users-form-group">
<label htmlFor="new-password">New Password</label>
<input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
autoFocus
disabled={isResetting}
/>
</div>
<div className="admin-users-form-actions">
<button
type="button"
className="admin-users-cancel-button"
onClick={() => {
setResetPasswordUsername(null);
setNewPassword('');
}}
disabled={isResetting}
>
Cancel
</button>
<button
type="submit"
className="admin-users-submit-button"
disabled={isResetting || !newPassword.trim()}
>
{isResetting ? (
<>
<span className="admin-users-button-spinner"></span>
Resetting...
</>
) : (
'Reset Password'
)}
</button>
</div>
</form>
</div>
)}
<div className="admin-users-list-container">
{loading ? (
<div className="admin-users-list-loading">
<div className="admin-users-spinner"></div>
<span>Loading users...</span>
</div>
) : users.length === 0 ? (
<div className="admin-users-empty">
<div className="admin-users-empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<h3>No Users</h3>
<p>Create a user to get started</p>
</div>
) : (
<div className="admin-users-list">
<div className="admin-users-list-header">
<span className="admin-users-col-user">User</span>
<span className="admin-users-col-status">Status</span>
<span className="admin-users-col-created">Created</span>
<span className="admin-users-col-login">Last Login</span>
<span className="admin-users-col-actions">Actions</span>
</div>
{users.map((u) => (
<div key={u.id} className={`admin-users-list-item ${!u.is_active ? 'admin-users-inactive' : ''}`}>
<div className="admin-users-col-user">
<div className="admin-users-item-avatar">
{u.username.charAt(0).toUpperCase()}
</div>
<div className="admin-users-item-info">
<div className="admin-users-item-username">
{u.username}
{u.is_admin && <span className="admin-users-admin-badge">Admin</span>}
</div>
{u.email && (
<div className="admin-users-item-email">{u.email}</div>
)}
</div>
</div>
<div className="admin-users-col-status">
<span className={`admin-users-status-badge ${u.is_active ? 'active' : 'inactive'}`}>
{u.is_active ? 'Active' : 'Disabled'}
</span>
</div>
<div className="admin-users-col-created">
{formatDate(u.created_at)}
</div>
<div className="admin-users-col-login">
{formatDate(u.last_login)}
</div>
<div className="admin-users-col-actions">
<div className="admin-users-actions-menu">
<button
className="admin-users-action-button"
onClick={() => handleToggleAdmin(u)}
disabled={togglingUser === u.username || u.username === user.username}
title={u.is_admin ? 'Remove admin' : 'Make admin'}
>
{togglingUser === u.username ? (
<span className="admin-users-action-spinner"></span>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
)}
{u.is_admin ? 'Revoke' : 'Admin'}
</button>
<button
className="admin-users-action-button"
onClick={() => handleToggleActive(u)}
disabled={togglingUser === u.username || u.username === user.username}
title={u.is_active ? 'Disable user' : 'Enable user'}
>
{togglingUser === u.username ? (
<span className="admin-users-action-spinner"></span>
) : u.is_active ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
)}
{u.is_active ? 'Disable' : 'Enable'}
</button>
<button
className="admin-users-action-button"
onClick={() => setResetPasswordUsername(u.username)}
disabled={togglingUser === u.username}
title="Reset password"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
Reset
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
export default AdminUsersPage;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,236 @@
/* 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;
}
/* 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;
}
}

View File

@@ -0,0 +1,142 @@
import { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import './LoginPage.css';
function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { user, login, loading: authLoading } = 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 || '/';
// 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 (
<div className="login-page">
<div className="login-container">
<div className="login-loading">Checking session...</div>
</div>
</div>
);
}
return (
<div className="login-page">
<div className="login-container">
<div className="login-card">
<div className="login-header">
<div className="login-logo">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 14 Q6 8 3 8 Q6 4 6 4 Q6 4 9 8 Q6 8 6 14" fill="currentColor" opacity="0.6"/>
<rect x="5.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
<path d="M12 12 Q12 5 8 5 Q12 1 12 1 Q12 1 16 5 Q12 5 12 12" fill="currentColor"/>
<rect x="11.25" y="11" width="1.5" height="5" fill="currentColor"/>
<path d="M18 14 Q18 8 15 8 Q18 4 18 4 Q18 4 21 8 Q18 8 18 14" fill="currentColor" opacity="0.6"/>
<rect x="17.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
<ellipse cx="12" cy="19" rx="9" ry="1.5" fill="currentColor" opacity="0.3"/>
</svg>
</div>
<h1>Sign in to Orchard</h1>
<p className="login-subtitle">Content-Addressable Storage</p>
</div>
{error && (
<div className="login-error">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>{error}</span>
</div>
)}
<form onSubmit={handleSubmit} className="login-form">
<div className="login-form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
autoComplete="username"
autoFocus
disabled={isSubmitting}
/>
</div>
<div className="login-form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
autoComplete="current-password"
disabled={isSubmitting}
/>
</div>
<button
type="submit"
className="login-submit"
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<span className="login-spinner"></span>
Signing in...
</>
) : (
'Sign in'
)}
</button>
</form>
</div>
<div className="login-footer">
<p>Artifact storage and management system</p>
</div>
</div>
</div>
);
}
export default LoginPage;

View File

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

View File

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

View File

@@ -1,3 +1,6 @@
// Access Control types (moved to top for use in Project interface)
export type AccessLevel = 'read' | 'write' | 'admin';
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
@@ -6,6 +9,9 @@ export interface Project {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
created_by: string; created_by: string;
// Access level info (populated when listing projects)
access_level?: AccessLevel | null;
is_owner?: boolean;
} }
export interface TagSummary { export interface TagSummary {
@@ -225,3 +231,101 @@ export interface CrossProjectStats {
bytes_saved_cross_project: number; bytes_saved_cross_project: number;
duplicates: CrossProjectDuplicate[]; 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;
};
}

View File

@@ -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;