From 2a68708a791f990d3f207754950b206f6a5a59d6 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Thu, 8 Jan 2026 15:01:37 -0600 Subject: [PATCH] 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 --- CHANGELOG.md | 30 + backend/app/auth.py | 412 +++++++++++++ backend/app/main.py | 21 +- backend/app/models.py | 87 ++- backend/app/routes.py | 515 +++++++++++++++- backend/app/schemas.py | 90 +++ backend/tests/integration/test_auth_api.py | 383 ++++++++++++ frontend/src/App.tsx | 28 +- frontend/src/api.ts | 113 ++++ frontend/src/components/Layout.css | 164 +++++ frontend/src/components/Layout.tsx | 109 +++- frontend/src/contexts/AuthContext.tsx | 87 +++ frontend/src/pages/APIKeysPage.css | 580 ++++++++++++++++++ frontend/src/pages/APIKeysPage.tsx | 371 ++++++++++++ frontend/src/pages/AdminUsersPage.css | 667 +++++++++++++++++++++ frontend/src/pages/AdminUsersPage.tsx | 529 ++++++++++++++++ frontend/src/pages/LoginPage.css | 231 +++++++ frontend/src/pages/LoginPage.tsx | 142 +++++ frontend/src/types.ts | 64 ++ migrations/006_auth_tables.sql | 86 +++ 20 files changed, 4690 insertions(+), 19 deletions(-) create mode 100644 backend/app/auth.py create mode 100644 backend/tests/integration/test_auth_api.py create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/pages/APIKeysPage.css create mode 100644 frontend/src/pages/APIKeysPage.tsx create mode 100644 frontend/src/pages/AdminUsersPage.css create mode 100644 frontend/src/pages/AdminUsersPage.tsx create mode 100644 frontend/src/pages/LoginPage.css create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 migrations/006_auth_tables.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b452d2..cce2b38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Added user authentication system with session-based login (#50) + - `users` table with password hashing (bcrypt), admin flag, active status + - `sessions` table for web login sessions (24-hour expiry) + - `auth_settings` table for future OIDC configuration + - Default admin user created on first boot (username: admin, password: admin) +- Added auth API endpoints (#50) + - `POST /api/v1/auth/login` - Login with username/password + - `POST /api/v1/auth/logout` - Logout and clear session + - `GET /api/v1/auth/me` - Get current user info + - `POST /api/v1/auth/change-password` - Change own password +- Added API key management with user ownership (#50) + - `POST /api/v1/auth/keys` - Create API key (format: `orch_`) + - `GET /api/v1/auth/keys` - List user's API keys + - `DELETE /api/v1/auth/keys/{id}` - Revoke API key + - Added `owner_id`, `scopes`, `description` columns to `api_keys` table +- Added admin user management endpoints (#50) + - `GET /api/v1/admin/users` - List all users + - `POST /api/v1/admin/users` - Create user + - `GET /api/v1/admin/users/{username}` - Get user details + - `PUT /api/v1/admin/users/{username}` - Update user (admin/active status) + - `POST /api/v1/admin/users/{username}/reset-password` - Reset password +- Added `auth.py` module with AuthService class and FastAPI dependencies (#50) +- Added auth schemas: LoginRequest, LoginResponse, UserResponse, APIKeyResponse (#50) +- Added migration `006_auth_tables.sql` for auth database tables (#50) +- Added frontend Login page with session management (#50) +- Added frontend API Keys management page (#50) +- Added frontend Admin Users page (admin-only) (#50) +- Added AuthContext for frontend session state (#50) +- Added user menu to Layout header with login/logout (#50) +- Added 15 integration tests for auth system (#50) - Added reusable `DragDropUpload` component for artifact uploads (#8) - Drag-and-drop file selection with visual feedback - Click-to-browse fallback diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..01bcab6 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,412 @@ +"""Authentication service for Orchard. + +Handles password hashing, session management, and API key operations. +""" + +import hashlib +import secrets +from datetime import datetime, timedelta +from typing import Optional +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from .models import User, Session as UserSession, APIKey + + +# 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 + + +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. + """ + user = self.get_user_by_username(username) + if not user: + return None + if not user.password_hash: + return None # OIDC-only user + if not user.is_active: + return None + if not verify_password(password, user.password_hash): + return None + return user + + def change_password(self, user: User, new_password: str) -> None: + """Change a user's password.""" + user.password_hash = hash_password(new_password) + user.must_change_password = False + self.db.commit() + + def update_last_login(self, user: User) -> None: + """Update the user's last login timestamp.""" + user.last_login = datetime.utcnow() + 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 == 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).""" + user.password_hash = hash_password(new_password) + user.must_change_password = True + self.db.commit() + + # --- 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.utcnow() + 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.utcnow(): + # Session has expired, delete it + self.db.delete(session) + self.db.commit() + return None + + # Update last accessed time + session.last_accessed = datetime.utcnow() + 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.utcnow()) + .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.utcnow(): + return None + + # Update last used time + api_key.last_used = datetime.utcnow() + 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="admin", + is_admin=True, + must_change_password=True, + ) + + return admin + + +# --- 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 or API key. + + Returns None if no valid authentication is provided. + Does not raise an exception for unauthenticated requests. + """ + 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 API key (CLI/programmatic access) + if authorization: + if authorization.startswith("Bearer "): + api_key = authorization[7:] # Remove "Bearer " prefix + user = auth_service.get_user_from_api_key(api_key) + 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) diff --git a/backend/app/main.py b/backend/app/main.py index d59fbff..10fdccb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from .config import get_settings from .database import init_db, SessionLocal from .routes import router from .seed import seed_database +from .auth import create_default_admin settings = get_settings() logging.basicConfig(level=logging.INFO) @@ -20,6 +21,18 @@ async def lifespan(app: FastAPI): # Startup: initialize database init_db() + # Create default admin user if no users exist + db = SessionLocal() + try: + admin = create_default_admin(db) + if admin: + logger.warning( + "Default admin user created with username 'admin' and password 'admin'. " + "CHANGE THIS PASSWORD IMMEDIATELY!" + ) + finally: + db.close() + # Seed test data in development mode if settings.is_development: logger.info(f"Running in {settings.env} mode - checking for seed data") @@ -48,7 +61,11 @@ app.include_router(router) # Serve static files (React build) if the directory exists static_dir = os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "dist") if os.path.exists(static_dir): - app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets") + app.mount( + "/assets", + StaticFiles(directory=os.path.join(static_dir, "assets")), + name="assets", + ) @app.get("/") async def serve_spa(): @@ -60,6 +77,7 @@ if os.path.exists(static_dir): # Don't catch API routes or health endpoint if full_path.startswith("api/") or full_path.startswith("health"): from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Not found") # Serve SPA for all other routes (including /project/*) @@ -68,4 +86,5 @@ if os.path.exists(static_dir): return FileResponse(index_path) from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Not found") diff --git a/backend/app/models.py b/backend/app/models.py index 37f23ef..17ea3cd 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -11,6 +11,7 @@ from sqlalchemy import ( CheckConstraint, Index, JSON, + ARRAY, ) from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship, declarative_base @@ -302,20 +303,104 @@ class AccessPermission(Base): ) +class User(Base): + """User account for authentication.""" + + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + username = Column(String(255), unique=True, nullable=False) + password_hash = Column(String(255)) # NULL if OIDC-only user + email = Column(String(255)) + is_admin = Column(Boolean, default=False) + is_active = Column(Boolean, default=True) + must_change_password = Column(Boolean, default=False) + oidc_subject = Column(String(255)) # OIDC subject claim + oidc_issuer = Column(String(512)) # OIDC issuer URL + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + updated_at = Column( + DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow + ) + last_login = Column(DateTime(timezone=True)) + + # Relationships + api_keys = relationship( + "APIKey", back_populates="owner", cascade="all, delete-orphan" + ) + sessions = relationship( + "Session", back_populates="user", cascade="all, delete-orphan" + ) + + __table_args__ = ( + Index("idx_users_username", "username"), + Index("idx_users_email", "email"), + Index("idx_users_oidc_subject", "oidc_subject"), + ) + + +class Session(Base): + """User session for web login.""" + + __tablename__ = "sessions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + token_hash = Column(String(64), unique=True, nullable=False) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + expires_at = Column(DateTime(timezone=True), nullable=False) + last_accessed = Column(DateTime(timezone=True), default=datetime.utcnow) + user_agent = Column(String(512)) + ip_address = Column(String(45)) + + user = relationship("User", back_populates="sessions") + + __table_args__ = ( + Index("idx_sessions_user_id", "user_id"), + Index("idx_sessions_token_hash", "token_hash"), + Index("idx_sessions_expires_at", "expires_at"), + ) + + +class AuthSettings(Base): + """Authentication settings for OIDC configuration.""" + + __tablename__ = "auth_settings" + + key = Column(String(255), primary_key=True) + value = Column(Text, nullable=False) + updated_at = Column(DateTime(timezone=True), default=datetime.utcnow) + + class APIKey(Base): __tablename__ = "api_keys" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) key_hash = Column(String(64), unique=True, nullable=False) name = Column(String(255), nullable=False) - user_id = Column(String(255), nullable=False) + user_id = Column( + String(255), nullable=False + ) # Legacy field, kept for compatibility + owner_id = Column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=True, # Nullable for migration compatibility + ) + description = Column(Text) + scopes = Column(ARRAY(String), default=["read", "write"]) created_at = Column(DateTime(timezone=True), default=datetime.utcnow) expires_at = Column(DateTime(timezone=True)) last_used = Column(DateTime(timezone=True)) + owner = relationship("User", back_populates="api_keys") + __table_args__ = ( Index("idx_api_keys_user_id", "user_id"), Index("idx_api_keys_key_hash", "key_hash"), + Index("idx_api_keys_owner_id", "owner_id"), ) diff --git a/backend/app/routes.py b/backend/app/routes.py index ff6603f..48f6750 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -11,6 +11,8 @@ from fastapi import ( Query, Header, Response, + Cookie, + status, ) from fastapi.responses import StreamingResponse, RedirectResponse from sqlalchemy.orm import Session @@ -42,6 +44,7 @@ from .models import ( UploadLock, Consumer, AuditLog, + User, ) from .schemas import ( ProjectCreate, @@ -94,6 +97,16 @@ from .schemas import ( StatsReportResponse, GlobalArtifactResponse, GlobalTagResponse, + LoginRequest, + LoginResponse, + ChangePasswordRequest, + UserResponse, + UserCreate, + UserUpdate, + ResetPasswordRequest, + APIKeyCreate, + APIKeyResponse, + APIKeyCreateResponse, ) from .metadata import extract_metadata from .config import get_settings @@ -118,14 +131,39 @@ def sanitize_filename(filename: str) -> str: return re.sub(r'[\r\n"]', "", filename) +def get_user_id_from_request( + request: Request, + db: Session, + current_user: Optional[User] = None, +) -> str: + """Extract user ID from request using auth system. + + If a current_user is provided (from auth dependency), use their username. + Otherwise, try to authenticate from headers and fall back to 'anonymous'. + """ + if current_user: + return current_user.username + + # Try to authenticate from API key header + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + api_key = auth_header[7:] + auth_service = AuthService(db) + user = auth_service.get_user_from_api_key(api_key) + if user: + return user.username + + return "anonymous" + + def get_user_id(request: Request) -> str: - """Extract user ID from request (simplified for now)""" - api_key = request.headers.get("X-Orchard-API-Key") - if api_key: - return "api-user" + """Legacy function for backward compatibility. + + DEPRECATED: Use get_user_id_from_request with db session for proper auth. + """ auth = request.headers.get("Authorization") - if auth: - return "bearer-user" + if auth and auth.startswith("Bearer "): + return "authenticated-user" return "anonymous" @@ -320,6 +358,460 @@ 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, +) + + +@router.post("/api/v1/auth/login", response_model=LoginResponse) +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", + ) + + # Change password + auth_service.change_password(current_user, password_request.new_password) + + # 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", + ) + + 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 == True, User.is_active == 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", + ) + + auth_service.reset_user_password(user, reset_request.new_password) + + # Log audit + _log_audit( + auth_service.db, + "admin.password_reset", + f"user:{username}", + current_user.username, + request, + ) + + return {"message": f"Password reset for user {username}"} + + # Global search @router.get("/api/v1/search", response_model=GlobalSearchResponse) def global_search( @@ -513,9 +1005,12 @@ def list_projects( @router.post("/api/v1/projects", response_model=ProjectResponse) def create_project( - project: ProjectCreate, request: Request, db: Session = Depends(get_db) + project: ProjectCreate, + request: Request, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional), ): - user_id = get_user_id(request) + user_id = get_user_id_from_request(request, db, current_user) existing = db.query(Project).filter(Project.name == project.name).first() if existing: @@ -1150,6 +1645,7 @@ def upload_artifact( content_length: Optional[int] = Header(None, alias="Content-Length"), user_agent: Optional[str] = Header(None, alias="User-Agent"), client_checksum: Optional[str] = Header(None, alias="X-Checksum-SHA256"), + current_user: Optional[User] = Depends(get_current_user_optional), ): """ Upload an artifact to a package. @@ -1157,9 +1653,10 @@ def upload_artifact( Headers: - X-Checksum-SHA256: Optional client-provided SHA256 for verification - User-Agent: Captured for audit purposes + - Authorization: Bearer for authentication """ start_time = time.time() - user_id = get_user_id(request) + user_id = get_user_id_from_request(request, db, current_user) settings = get_settings() storage_result = None diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 9bd3701..70bbda3 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -686,3 +686,93 @@ class StatsReportResponse(BaseModel): format: str # "json", "csv", "markdown" generated_at: datetime content: str # The report content + + +# Authentication schemas +class LoginRequest(BaseModel): + """Login request with username and password""" + username: str + password: str + + +class LoginResponse(BaseModel): + """Login response with user info""" + id: UUID + username: str + email: Optional[str] + is_admin: bool + must_change_password: bool + + +class ChangePasswordRequest(BaseModel): + """Change password request""" + current_password: str + new_password: str + + +class UserResponse(BaseModel): + """User information response""" + id: UUID + username: str + email: Optional[str] + is_admin: bool + is_active: bool + must_change_password: bool + created_at: datetime + last_login: Optional[datetime] + + class Config: + from_attributes = True + + +class UserCreate(BaseModel): + """Create user request (admin only)""" + username: str + password: str + email: Optional[str] = None + is_admin: bool = False + + +class UserUpdate(BaseModel): + """Update user request (admin only)""" + email: Optional[str] = None + is_admin: Optional[bool] = None + is_active: Optional[bool] = None + + +class ResetPasswordRequest(BaseModel): + """Reset password request (admin only)""" + new_password: str + + +class APIKeyCreate(BaseModel): + """Create API key request""" + name: str + description: Optional[str] = None + scopes: Optional[List[str]] = None + + +class APIKeyResponse(BaseModel): + """API key response (without the secret key)""" + id: UUID + name: str + description: Optional[str] + scopes: Optional[List[str]] + created_at: datetime + expires_at: Optional[datetime] + last_used: Optional[datetime] + + class Config: + from_attributes = True + + +class APIKeyCreateResponse(BaseModel): + """API key creation response (includes the secret key - only shown once)""" + id: UUID + name: str + description: Optional[str] + scopes: Optional[List[str]] + key: str # The actual API key - only returned on creation + created_at: datetime + expires_at: Optional[datetime] + diff --git a/backend/tests/integration/test_auth_api.py b/backend/tests/integration/test_auth_api.py new file mode 100644 index 0000000..49824ea --- /dev/null +++ b/backend/tests/integration/test_auth_api.py @@ -0,0 +1,383 @@ +"""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": "admin"}, + ) + 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": "admin"}, + ) + 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": "admin"}, + ) + + 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": "admin"}, + ) + + # Change password + response = integration_client.post( + "/api/v1/auth/change-password", + json={"current_password": "admin", "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": "admin"}, + ) + 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 + integration_client.post( + "/api/v1/auth/change-password", + json={"current_password": "newpassword123", "new_password": "admin"}, + ) + + @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": "admin"}, + ) + + 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": "admin"}, + ) + + # 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": "admin"}, + ) + 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": "admin"}, + ) + 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": "admin"}, + ) + 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": "admin"}, + ) + + 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": "admin"}, + ) + + # 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": "admin"}, + ) + + # 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": "admin"}, + ) + + # 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": "admin"}, + ) + 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"] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aa31ff4..0a6f583 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,20 +1,36 @@ import { Routes, Route } from 'react-router-dom'; +import { AuthProvider } from './contexts/AuthContext'; import Layout from './components/Layout'; import Home from './pages/Home'; import ProjectPage from './pages/ProjectPage'; import PackagePage from './pages/PackagePage'; import Dashboard from './pages/Dashboard'; +import LoginPage from './pages/LoginPage'; +import APIKeysPage from './pages/APIKeysPage'; +import AdminUsersPage from './pages/AdminUsersPage'; function App() { return ( - + - } /> - } /> - } /> - } /> + } /> + + + } /> + } /> + } /> + } /> + } /> + } /> + + + } + /> - + ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3f5b0c7..155d3fa 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -17,6 +17,14 @@ import { DeduplicationStats, TimelineStats, CrossProjectStats, + User, + LoginCredentials, + APIKey, + APIKeyCreate, + APIKeyCreateResponse, + AdminUser, + UserCreate, + UserUpdate, } from './types'; const API_BASE = '/api/v1'; @@ -40,6 +48,42 @@ function buildQueryString(params: Record): string { return query ? `?${query}` : ''; } +// Auth API +export async function login(credentials: LoginCredentials): Promise { + const response = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function logout(): Promise { + const response = await fetch(`${API_BASE}/auth/logout`, { + method: 'POST', + credentials: 'include', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } +} + +export async function getCurrentUser(): Promise { + try { + const response = await fetch(`${API_BASE}/auth/me`, { + credentials: 'include', + }); + if (response.status === 401) { + return null; + } + return handleResponse(response); + } catch { + return null; + } +} + // Global Search API export async function globalSearch(query: string, limit: number = 5): Promise { const params = buildQueryString({ q: query, limit }); @@ -186,3 +230,72 @@ export async function getCrossProjectStats(): Promise { const response = await fetch(`${API_BASE}/stats/cross-project`); return handleResponse(response); } + +export async function listAPIKeys(): Promise { + const response = await fetch(`${API_BASE}/auth/keys`, { + credentials: 'include', + }); + return handleResponse(response); +} + +export async function createAPIKey(data: APIKeyCreate): Promise { + const response = await fetch(`${API_BASE}/auth/keys`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function deleteAPIKey(id: string): Promise { + const response = await fetch(`${API_BASE}/auth/keys/${id}`, { + method: 'DELETE', + credentials: 'include', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } +} + +// Admin User Management API +export async function listUsers(): Promise { + const response = await fetch(`${API_BASE}/admin/users`, { + credentials: 'include', + }); + return handleResponse(response); +} + +export async function createUser(data: UserCreate): Promise { + const response = await fetch(`${API_BASE}/admin/users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function updateUser(username: string, data: UserUpdate): Promise { + const response = await fetch(`${API_BASE}/admin/users/${username}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export async function resetUserPassword(username: string, newPassword: string): Promise { + const response = await fetch(`${API_BASE}/admin/users/${username}/reset-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ new_password: newPassword }), + credentials: 'include', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } +} diff --git a/frontend/src/components/Layout.css b/frontend/src/components/Layout.css index bf32738..bad02a8 100644 --- a/frontend/src/components/Layout.css +++ b/frontend/src/components/Layout.css @@ -98,6 +98,170 @@ opacity: 0.7; } +/* Login link */ +.nav-login { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + color: var(--text-primary); + font-size: 0.875rem; + font-weight: 500; + border-radius: var(--radius-md); + transition: all var(--transition-fast); + margin-left: 8px; + border: 1px solid var(--border-primary); +} + +.nav-login:hover { + color: var(--text-primary); + background: var(--bg-hover); + border-color: var(--border-secondary); +} + +/* User Menu */ +.user-menu { + position: relative; + margin-left: 8px; +} + +.user-menu-trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: transparent; + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); +} + +.user-menu-trigger:hover { + background: var(--bg-hover); + border-color: var(--border-secondary); +} + +.user-avatar { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: var(--accent-gradient); + border-radius: var(--radius-sm); + color: white; + font-weight: 600; + font-size: 0.8125rem; +} + +.user-name { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-menu-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + min-width: 200px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: 200; + overflow: hidden; +} + +.user-menu-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; +} + +.user-menu-username { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); +} + +.user-menu-badge { + padding: 2px 8px; + background: var(--accent-gradient); + border-radius: 100px; + font-size: 0.6875rem; + font-weight: 600; + color: white; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.user-menu-divider { + height: 1px; + background: var(--border-primary); +} + +.user-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 12px 16px; + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 0.875rem; + cursor: pointer; + transition: all var(--transition-fast); + text-align: left; + text-decoration: none; +} + +.user-menu-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.user-menu-item svg { + opacity: 0.7; +} + +.user-menu-item:hover svg { + opacity: 1; +} + +/* User menu loading state */ +.user-menu-loading { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin-left: 8px; +} + +.user-menu-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border-secondary); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: user-menu-spin 0.6s linear infinite; +} + +@keyframes user-menu-spin { + to { + transform: rotate(360deg); + } +} + /* Main content */ .main { flex: 1; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 09d8832..8b75049 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,5 +1,6 @@ -import { ReactNode } from 'react'; -import { Link, useLocation } from 'react-router-dom'; +import { ReactNode, useState, useRef, useEffect } from 'react'; +import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; import { GlobalSearch } from './GlobalSearch'; import './Layout.css'; @@ -9,6 +10,31 @@ interface LayoutProps { function Layout({ children }: LayoutProps) { const location = useLocation(); + const navigate = useNavigate(); + const { user, loading, logout } = useAuth(); + const [showUserMenu, setShowUserMenu] = useState(false); + const menuRef = useRef(null); + + // Close menu when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setShowUserMenu(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + async function handleLogout() { + try { + await logout(); + setShowUserMenu(false); + navigate('/'); + } catch { + // Error handled in context + } + } return (
@@ -60,6 +86,85 @@ function Layout({ children }: LayoutProps) { Docs + + {/* User Menu */} + {loading ? ( +
+
+
+ ) : user ? ( +
+ + + {showUserMenu && ( +
+
+ {user.username} + {user.is_admin && ( + Admin + )} +
+
+ setShowUserMenu(false)} + > + + + + API Keys + + {user.is_admin && ( + setShowUserMenu(false)} + > + + + + + + + User Management + + )} +
+ +
+ )} +
+ ) : ( + + + + + + + Login + + )}
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..fe2ae30 --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,87 @@ +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; + logout: () => Promise; + clearError: () => void; +} + +const AuthContext = createContext(undefined); + +interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/frontend/src/pages/APIKeysPage.css b/frontend/src/pages/APIKeysPage.css new file mode 100644 index 0000000..33bdbd5 --- /dev/null +++ b/frontend/src/pages/APIKeysPage.css @@ -0,0 +1,580 @@ +.api-keys-page { + max-width: 900px; + margin: 0 auto; +} + +.api-keys-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 32px; + gap: 24px; +} + +.api-keys-header-content h1 { + font-size: 1.75rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; + letter-spacing: -0.02em; +} + +.api-keys-subtitle { + color: var(--text-tertiary); + font-size: 0.9375rem; +} + +.api-keys-create-button { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + background: var(--accent-gradient); + border: none; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: white; + cursor: pointer; + transition: all var(--transition-fast); + box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2); + flex-shrink: 0; +} + +.api-keys-create-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3); +} + +.api-keys-create-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.api-keys-error { + display: flex; + align-items: center; + gap: 10px; + background: var(--error-bg); + border: 1px solid rgba(239, 68, 68, 0.2); + color: var(--error); + padding: 12px 16px; + border-radius: var(--radius-md); + margin-bottom: 24px; + font-size: 0.875rem; +} + +.api-keys-error svg { + flex-shrink: 0; +} + +.api-keys-error span { + flex: 1; +} + +.api-keys-error-dismiss { + background: transparent; + border: none; + padding: 4px; + color: var(--error); + cursor: pointer; + opacity: 0.7; + transition: opacity var(--transition-fast); +} + +.api-keys-error-dismiss:hover { + opacity: 1; +} + +.api-keys-new-key-banner { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.08) 100%); + border: 1px solid rgba(16, 185, 129, 0.3); + border-radius: var(--radius-lg); + padding: 24px; + margin-bottom: 24px; +} + +.api-keys-new-key-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + color: var(--accent-primary); +} + +.api-keys-new-key-title { + font-size: 1rem; + font-weight: 600; +} + +.api-keys-new-key-warning { + background: var(--warning-bg); + border: 1px solid rgba(245, 158, 11, 0.3); + color: var(--warning); + padding: 10px 14px; + border-radius: var(--radius-md); + font-size: 0.8125rem; + font-weight: 500; + margin-bottom: 16px; +} + +.api-keys-new-key-value-container { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.api-keys-new-key-value { + flex: 1; + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + padding: 14px 16px; + font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, monospace; + font-size: 0.8125rem; + color: var(--text-primary); + word-break: break-all; + line-height: 1.5; +} + +.api-keys-copy-button { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + flex-shrink: 0; +} + +.api-keys-copy-button:hover { + background: var(--bg-hover); + border-color: var(--border-secondary); + color: var(--text-primary); +} + +.api-keys-done-button { + padding: 10px 20px; + background: var(--accent-gradient); + border: none; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: white; + cursor: pointer; + transition: all var(--transition-fast); +} + +.api-keys-done-button:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.api-keys-create-form-card { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: 24px; + margin-bottom: 24px; +} + +.api-keys-create-form-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.api-keys-create-form-header h2 { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.api-keys-create-form-close { + background: transparent; + border: none; + padding: 4px; + color: var(--text-tertiary); + cursor: pointer; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.api-keys-create-form-close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.api-keys-create-error { + background: var(--error-bg); + border: 1px solid rgba(239, 68, 68, 0.2); + color: var(--error); + padding: 10px 14px; + border-radius: var(--radius-md); + font-size: 0.8125rem; + margin-bottom: 16px; +} + +.api-keys-create-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.api-keys-form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.api-keys-form-group label { + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary); +} + +.api-keys-form-group input { + padding: 12px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + font-size: 0.875rem; + color: var(--text-primary); + transition: all var(--transition-fast); +} + +.api-keys-form-group input::placeholder { + color: var(--text-muted); +} + +.api-keys-form-group input:hover:not(:disabled) { + border-color: var(--border-secondary); + background: var(--bg-elevated); +} + +.api-keys-form-group input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); + background: var(--bg-elevated); +} + +.api-keys-form-group input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.api-keys-form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 8px; +} + +.api-keys-cancel-button { + padding: 10px 18px; + background: transparent; + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.api-keys-cancel-button:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--border-secondary); + color: var(--text-primary); +} + +.api-keys-cancel-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.api-keys-submit-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 18px; + background: var(--accent-gradient); + border: none; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: white; + cursor: pointer; + transition: all var(--transition-fast); + min-width: 110px; +} + +.api-keys-submit-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2); +} + +.api-keys-submit-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.api-keys-button-spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: api-keys-spin 0.6s linear infinite; +} + +@keyframes api-keys-spin { + to { + transform: rotate(360deg); + } +} + +.api-keys-list-container { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.api-keys-list-loading, +.api-keys-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 64px 24px; + color: var(--text-tertiary); + font-size: 0.9375rem; +} + +.api-keys-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-secondary); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: api-keys-spin 0.6s linear infinite; +} + +.api-keys-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + text-align: center; +} + +.api-keys-empty-icon { + color: var(--text-muted); + margin-bottom: 16px; + opacity: 0.5; +} + +.api-keys-empty h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.api-keys-empty p { + color: var(--text-tertiary); + font-size: 0.875rem; +} + +.api-keys-list { + display: flex; + flex-direction: column; +} + +.api-keys-list-header { + display: grid; + grid-template-columns: 1fr 160px 160px 140px; + gap: 16px; + padding: 14px 20px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-primary); + font-size: 0.75rem; + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.api-keys-list-item { + display: grid; + grid-template-columns: 1fr 160px 160px 140px; + gap: 16px; + padding: 16px 20px; + align-items: center; + border-bottom: 1px solid var(--border-primary); + transition: background var(--transition-fast); +} + +.api-keys-list-item:last-child { + border-bottom: none; +} + +.api-keys-list-item:hover { + background: var(--bg-tertiary); +} + +.api-keys-item-name { + font-weight: 500; + color: var(--text-primary); + font-size: 0.9375rem; +} + +.api-keys-item-description { + color: var(--text-tertiary); + font-size: 0.8125rem; + margin-top: 4px; +} + +.api-keys-col-created, +.api-keys-col-used { + color: var(--text-secondary); + font-size: 0.8125rem; +} + +.api-keys-col-actions { + display: flex; + justify-content: flex-end; +} + +.api-keys-revoke-button { + padding: 6px 14px; + background: transparent; + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: var(--radius-md); + font-size: 0.8125rem; + font-weight: 500; + color: var(--error); + cursor: pointer; + transition: all var(--transition-fast); +} + +.api-keys-revoke-button:hover { + background: var(--error-bg); + border-color: rgba(239, 68, 68, 0.5); +} + +.api-keys-delete-confirm { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8125rem; + color: var(--text-secondary); +} + +.api-keys-confirm-yes { + padding: 4px 12px; + background: var(--error); + border: none; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 500; + color: white; + cursor: pointer; + transition: all var(--transition-fast); +} + +.api-keys-confirm-yes:hover:not(:disabled) { + opacity: 0.9; +} + +.api-keys-confirm-yes:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.api-keys-confirm-no { + padding: 4px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.api-keys-confirm-no:hover:not(:disabled) { + background: var(--bg-hover); +} + +.api-keys-confirm-no:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +@media (max-width: 768px) { + .api-keys-header { + flex-direction: column; + align-items: stretch; + } + + .api-keys-create-button { + align-self: flex-start; + } + + .api-keys-list-header { + display: none; + } + + .api-keys-list-item { + grid-template-columns: 1fr; + gap: 8px; + } + + .api-keys-col-name { + order: 1; + } + + .api-keys-col-created, + .api-keys-col-used { + font-size: 0.75rem; + } + + .api-keys-col-created::before { + content: 'Created: '; + color: var(--text-muted); + } + + .api-keys-col-used::before { + content: 'Last used: '; + color: var(--text-muted); + } + + .api-keys-col-actions { + justify-content: flex-start; + margin-top: 8px; + } + + .api-keys-new-key-value-container { + flex-direction: column; + } + + .api-keys-copy-button { + align-self: flex-start; + } +} diff --git a/frontend/src/pages/APIKeysPage.tsx b/frontend/src/pages/APIKeysPage.tsx new file mode 100644 index 0000000..f8323b3 --- /dev/null +++ b/frontend/src/pages/APIKeysPage.tsx @@ -0,0 +1,371 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { listAPIKeys, createAPIKey, deleteAPIKey } from '../api'; +import { APIKey, APIKeyCreateResponse } from '../types'; +import './APIKeysPage.css'; + +function APIKeysPage() { + const { user, loading: authLoading } = useAuth(); + const navigate = useNavigate(); + + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [showCreateForm, setShowCreateForm] = useState(false); + const [createName, setCreateName] = useState(''); + const [createDescription, setCreateDescription] = useState(''); + const [isCreating, setIsCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); + const [copied, setCopied] = useState(false); + + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + useEffect(() => { + if (!authLoading && !user) { + navigate('/login', { state: { from: '/settings/api-keys' } }); + } + }, [user, authLoading, navigate]); + + useEffect(() => { + if (user) { + loadKeys(); + } + }, [user]); + + async function loadKeys() { + setLoading(true); + setError(null); + try { + const data = await listAPIKeys(); + setKeys(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load API keys'); + } finally { + setLoading(false); + } + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!createName.trim()) { + setCreateError('Name is required'); + return; + } + + setIsCreating(true); + setCreateError(null); + try { + const response = await createAPIKey({ + name: createName.trim(), + description: createDescription.trim() || undefined, + }); + setNewlyCreatedKey(response); + setShowCreateForm(false); + setCreateName(''); + setCreateDescription(''); + await loadKeys(); + } catch (err) { + setCreateError(err instanceof Error ? err.message : 'Failed to create API key'); + } finally { + setIsCreating(false); + } + } + + async function handleDelete(id: string) { + setIsDeleting(true); + try { + await deleteAPIKey(id); + setDeleteConfirmId(null); + await loadKeys(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to revoke API key'); + } finally { + setIsDeleting(false); + } + } + + async function handleCopyKey() { + if (newlyCreatedKey) { + try { + await navigator.clipboard.writeText(newlyCreatedKey.key); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setError('Failed to copy to clipboard'); + } + } + } + + function handleDismissNewKey() { + setNewlyCreatedKey(null); + setCopied(false); + } + + function formatDate(dateString: string | null): string { + if (!dateString) return 'Never'; + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } + + if (authLoading) { + return ( +
+
+
+ Loading... +
+
+ ); + } + + if (!user) { + return null; + } + + return ( +
+
+
+

API Keys

+

+ Manage API keys for programmatic access to Orchard +

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

Create New API Key

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

No API Keys

+

Create an API key to access Orchard programmatically

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

Access Denied

+

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

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

User Management

+

+ Manage user accounts and permissions +

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

Create New User

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

Reset Password

+ +
+

+ Set a new password for {resetPasswordUsername} +

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

No Users

+

Create a user to get started

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

Sign in to Orchard

+

Content-Addressable Storage

+
+ + {error && ( +
+ + + + + + {error} +
+ )} + +
+
+ + setUsername(e.target.value)} + placeholder="Enter your username" + autoComplete="username" + autoFocus + disabled={isSubmitting} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Enter your password" + autoComplete="current-password" + disabled={isSubmitting} + /> +
+ + +
+
+ +
+

Artifact storage and management system

+
+
+
+ ); +} + +export default LoginPage; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a42636c..e1076a5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -225,3 +225,67 @@ export interface CrossProjectStats { bytes_saved_cross_project: number; duplicates: CrossProjectDuplicate[]; } + +// Auth types +export interface User { + id: string; + username: string; + display_name: string | null; + is_admin: boolean; +} + +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; +} diff --git a/migrations/006_auth_tables.sql b/migrations/006_auth_tables.sql new file mode 100644 index 0000000..59fd1ee --- /dev/null +++ b/migrations/006_auth_tables.sql @@ -0,0 +1,86 @@ +-- Authentication Tables Migration +-- Adds users table and updates api_keys with foreign key + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255), + email VARCHAR(255), + is_admin BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + must_change_password BOOLEAN DEFAULT FALSE, + oidc_subject VARCHAR(255), + oidc_issuer VARCHAR(512), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_login TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_oidc_subject ON users(oidc_subject) WHERE oidc_subject IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active) WHERE is_active = TRUE; + +-- Sessions table for web login +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + last_accessed TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + user_agent VARCHAR(512), + ip_address VARCHAR(45) +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + +-- Auth settings for OIDC configuration (future use) +CREATE TABLE IF NOT EXISTS auth_settings ( + key VARCHAR(255) PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Add user_id foreign key to api_keys table +-- First add the column (nullable initially) +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS owner_id UUID REFERENCES users(id) ON DELETE CASCADE; + +-- Add scopes column for API key permissions +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS scopes TEXT[] DEFAULT ARRAY['read', 'write']; + +-- Add description column +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS description TEXT; + +-- Create index for owner_id +CREATE INDEX IF NOT EXISTS idx_api_keys_owner_id ON api_keys(owner_id) WHERE owner_id IS NOT NULL; + +-- Trigger to update users.updated_at +CREATE TRIGGER users_updated_at_trigger + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Trigger to update sessions.last_accessed on access +CREATE OR REPLACE FUNCTION update_session_last_accessed() +RETURNS TRIGGER AS $$ +BEGIN + NEW.last_accessed = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Function to clean up expired sessions (can be called periodically) +CREATE OR REPLACE FUNCTION cleanup_expired_sessions() +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + DELETE FROM sessions WHERE expires_at < NOW(); + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql;