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
This commit is contained in:
30
CHANGELOG.md
30
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_<random>`)
|
||||
- `GET /api/v1/auth/keys` - List user's API keys
|
||||
- `DELETE /api/v1/auth/keys/{id}` - Revoke API key
|
||||
- Added `owner_id`, `scopes`, `description` columns to `api_keys` table
|
||||
- Added admin user management endpoints (#50)
|
||||
- `GET /api/v1/admin/users` - List all users
|
||||
- `POST /api/v1/admin/users` - Create user
|
||||
- `GET /api/v1/admin/users/{username}` - Get user details
|
||||
- `PUT /api/v1/admin/users/{username}` - Update user (admin/active status)
|
||||
- `POST /api/v1/admin/users/{username}/reset-password` - Reset password
|
||||
- Added `auth.py` module with AuthService class and FastAPI dependencies (#50)
|
||||
- Added auth schemas: LoginRequest, LoginResponse, UserResponse, APIKeyResponse (#50)
|
||||
- Added migration `006_auth_tables.sql` for auth database tables (#50)
|
||||
- Added frontend Login page with session management (#50)
|
||||
- Added frontend API Keys management page (#50)
|
||||
- Added frontend Admin Users page (admin-only) (#50)
|
||||
- Added AuthContext for frontend session state (#50)
|
||||
- Added user menu to Layout header with login/logout (#50)
|
||||
- Added 15 integration tests for auth system (#50)
|
||||
- Added reusable `DragDropUpload` component for artifact uploads (#8)
|
||||
- Drag-and-drop file selection with visual feedback
|
||||
- Click-to-browse fallback
|
||||
|
||||
412
backend/app/auth.py
Normal file
412
backend/app/auth.py
Normal file
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 <api-key> 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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
383
backend/tests/integration/test_auth_api.py
Normal file
383
backend/tests/integration/test_auth_api.py
Normal file
@@ -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"]
|
||||
@@ -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 (
|
||||
<Layout>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/project/:projectName" element={<ProjectPage />} />
|
||||
<Route path="/project/:projectName/:packageName" element={<PackagePage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/settings/api-keys" element={<APIKeysPage />} />
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/project/:projectName" element={<ProjectPage />} />
|
||||
<Route path="/project/:projectName/:packageName" element={<PackagePage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Layout>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, unknown>): string {
|
||||
return query ? `?${query}` : '';
|
||||
}
|
||||
|
||||
// Auth API
|
||||
export async function login(credentials: LoginCredentials): Promise<User> {
|
||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<User>(response);
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<User | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/auth/me`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.status === 401) {
|
||||
return null;
|
||||
}
|
||||
return handleResponse<User>(response);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Global Search API
|
||||
export async function globalSearch(query: string, limit: number = 5): Promise<GlobalSearchResponse> {
|
||||
const params = buildQueryString({ q: query, limit });
|
||||
@@ -186,3 +230,72 @@ export async function getCrossProjectStats(): Promise<CrossProjectStats> {
|
||||
const response = await fetch(`${API_BASE}/stats/cross-project`);
|
||||
return handleResponse<CrossProjectStats>(response);
|
||||
}
|
||||
|
||||
export async function listAPIKeys(): Promise<APIKey[]> {
|
||||
const response = await fetch(`${API_BASE}/auth/keys`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<APIKey[]>(response);
|
||||
}
|
||||
|
||||
export async function createAPIKey(data: APIKeyCreate): Promise<APIKeyCreateResponse> {
|
||||
const response = await fetch(`${API_BASE}/auth/keys`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<APIKeyCreateResponse>(response);
|
||||
}
|
||||
|
||||
export async function deleteAPIKey(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/auth/keys/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Admin User Management API
|
||||
export async function listUsers(): Promise<AdminUser[]> {
|
||||
const response = await fetch(`${API_BASE}/admin/users`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<AdminUser[]>(response);
|
||||
}
|
||||
|
||||
export async function createUser(data: UserCreate): Promise<AdminUser> {
|
||||
const response = await fetch(`${API_BASE}/admin/users`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<AdminUser>(response);
|
||||
}
|
||||
|
||||
export async function updateUser(username: string, data: UserUpdate): Promise<AdminUser> {
|
||||
const response = await fetch(`${API_BASE}/admin/users/${username}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<AdminUser>(response);
|
||||
}
|
||||
|
||||
export async function resetUserPassword(username: string, newPassword: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/admin/users/${username}/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_password: newPassword }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setShowUserMenu(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await logout();
|
||||
setShowUserMenu(false);
|
||||
navigate('/');
|
||||
} catch {
|
||||
// Error handled in context
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
@@ -60,6 +86,85 @@ function Layout({ children }: LayoutProps) {
|
||||
</svg>
|
||||
Docs
|
||||
</a>
|
||||
|
||||
{/* User Menu */}
|
||||
{loading ? (
|
||||
<div className="user-menu-loading">
|
||||
<div className="user-menu-spinner"></div>
|
||||
</div>
|
||||
) : user ? (
|
||||
<div className="user-menu" ref={menuRef}>
|
||||
<button
|
||||
className="user-menu-trigger"
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
aria-expanded={showUserMenu}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<div className="user-avatar">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="user-name">{user.display_name || user.username}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="user-menu-dropdown">
|
||||
<div className="user-menu-header">
|
||||
<span className="user-menu-username">{user.username}</span>
|
||||
{user.is_admin && (
|
||||
<span className="user-menu-badge">Admin</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="user-menu-divider"></div>
|
||||
<NavLink
|
||||
to="/settings/api-keys"
|
||||
className="user-menu-item"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
|
||||
</svg>
|
||||
API Keys
|
||||
</NavLink>
|
||||
{user.is_admin && (
|
||||
<NavLink
|
||||
to="/admin/users"
|
||||
className="user-menu-item"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
User Management
|
||||
</NavLink>
|
||||
)}
|
||||
<div className="user-menu-divider"></div>
|
||||
<button className="user-menu-item" onClick={handleLogout}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Link to="/login" className="nav-login">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
||||
<polyline points="10 17 15 12 10 7"/>
|
||||
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||
</svg>
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
87
frontend/src/contexts/AuthContext.tsx
Normal file
87
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -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<void>;
|
||||
logout: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Check session on initial load
|
||||
useEffect(() => {
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const currentUser = await getCurrentUser();
|
||||
setUser(currentUser);
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const loggedInUser = await apiLogin({ username, password });
|
||||
setUser(loggedInUser);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Login failed';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiLogout();
|
||||
setUser(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Logout failed';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, error, login, logout, clearError }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
580
frontend/src/pages/APIKeysPage.css
Normal file
580
frontend/src/pages/APIKeysPage.css
Normal file
@@ -0,0 +1,580 @@
|
||||
.api-keys-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.api-keys-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 32px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.api-keys-header-content h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.api-keys-subtitle {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.api-keys-create-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: var(--accent-gradient);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.api-keys-create-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.api-keys-create-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.api-keys-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 24px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.api-keys-error svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.api-keys-error span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.api-keys-error-dismiss {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
color: var(--error);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.api-keys-error-dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.api-keys-new-key-banner {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.08) 100%);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.api-keys-new-key-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.api-keys-new-key-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.api-keys-new-key-warning {
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
color: var(--warning);
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.api-keys-new-key-value-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.api-keys-new-key-value {
|
||||
flex: 1;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.api-keys-copy-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.api-keys-copy-button:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.api-keys-done-button {
|
||||
padding: 10px 20px;
|
||||
background: var(--accent-gradient);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.api-keys-done-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.api-keys-create-form-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.api-keys-create-form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.api-keys-create-form-header h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.api-keys-create-form-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.api-keys-create-form-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.api-keys-create-error {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.api-keys-create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.api-keys-form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.api-keys-form-group label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.api-keys-form-group input {
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.api-keys-form-group input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.api-keys-form-group input:hover:not(:disabled) {
|
||||
border-color: var(--border-secondary);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.api-keys-form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.api-keys-form-group input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.api-keys-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.api-keys-cancel-button {
|
||||
padding: 10px 18px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.api-keys-cancel-button:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.api-keys-cancel-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.api-keys-submit-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
background: var(--accent-gradient);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.api-keys-submit-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.api-keys-submit-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.api-keys-button-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: api-keys-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes api-keys-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.api-keys-list-container {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.api-keys-list-loading,
|
||||
.api-keys-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 64px 24px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.api-keys-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-secondary);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: api-keys-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.api-keys-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.api-keys-empty-icon {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.api-keys-empty h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.api-keys-empty p {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.api-keys-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.api-keys-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px 160px 140px;
|
||||
gap: 16px;
|
||||
padding: 14px 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.api-keys-list-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px 160px 140px;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.api-keys-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.api-keys-list-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.api-keys-item-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.api-keys-item-description {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.8125rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.api-keys-col-created,
|
||||
.api-keys-col-used {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.api-keys-col-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.api-keys-revoke-button {
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--error);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.api-keys-revoke-button:hover {
|
||||
background: var(--error-bg);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.api-keys-delete-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.api-keys-confirm-yes {
|
||||
padding: 4px 12px;
|
||||
background: var(--error);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.api-keys-confirm-yes:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.api-keys-confirm-yes:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.api-keys-confirm-no {
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.api-keys-confirm-no:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.api-keys-confirm-no:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.api-keys-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.api-keys-create-button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.api-keys-list-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.api-keys-list-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.api-keys-col-name {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.api-keys-col-created,
|
||||
.api-keys-col-used {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.api-keys-col-created::before {
|
||||
content: 'Created: ';
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.api-keys-col-used::before {
|
||||
content: 'Last used: ';
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.api-keys-col-actions {
|
||||
justify-content: flex-start;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.api-keys-new-key-value-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.api-keys-copy-button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
371
frontend/src/pages/APIKeysPage.tsx
Normal file
371
frontend/src/pages/APIKeysPage.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { listAPIKeys, createAPIKey, deleteAPIKey } from '../api';
|
||||
import { APIKey, APIKeyCreateResponse } from '../types';
|
||||
import './APIKeysPage.css';
|
||||
|
||||
function APIKeysPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [keys, setKeys] = useState<APIKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [createDescription, setCreateDescription] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<APIKeyCreateResponse | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
navigate('/login', { state: { from: '/settings/api-keys' } });
|
||||
}
|
||||
}, [user, authLoading, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadKeys();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
async function loadKeys() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listAPIKeys();
|
||||
setKeys(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load API keys');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!createName.trim()) {
|
||||
setCreateError('Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const response = await createAPIKey({
|
||||
name: createName.trim(),
|
||||
description: createDescription.trim() || undefined,
|
||||
});
|
||||
setNewlyCreatedKey(response);
|
||||
setShowCreateForm(false);
|
||||
setCreateName('');
|
||||
setCreateDescription('');
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : 'Failed to create API key');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteAPIKey(id);
|
||||
setDeleteConfirmId(null);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to revoke API key');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyKey() {
|
||||
if (newlyCreatedKey) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(newlyCreatedKey.key);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
setError('Failed to copy to clipboard');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismissNewKey() {
|
||||
setNewlyCreatedKey(null);
|
||||
setCopied(false);
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | null): string {
|
||||
if (!dateString) return 'Never';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="api-keys-page">
|
||||
<div className="api-keys-loading">
|
||||
<div className="api-keys-spinner"></div>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="api-keys-page">
|
||||
<div className="api-keys-header">
|
||||
<div className="api-keys-header-content">
|
||||
<h1>API Keys</h1>
|
||||
<p className="api-keys-subtitle">
|
||||
Manage API keys for programmatic access to Orchard
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="api-keys-create-button"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
disabled={showCreateForm}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Create New Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="api-keys-error">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="api-keys-error-dismiss">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newlyCreatedKey && (
|
||||
<div className="api-keys-new-key-banner">
|
||||
<div className="api-keys-new-key-header">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
<span className="api-keys-new-key-title">New API Key Created</span>
|
||||
</div>
|
||||
<div className="api-keys-new-key-warning">
|
||||
Copy this key now! It won't be shown again.
|
||||
</div>
|
||||
<div className="api-keys-new-key-value-container">
|
||||
<code className="api-keys-new-key-value">{newlyCreatedKey.key}</code>
|
||||
<button
|
||||
className="api-keys-copy-button"
|
||||
onClick={handleCopyKey}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
)}
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<button className="api-keys-done-button" onClick={handleDismissNewKey}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateForm && (
|
||||
<div className="api-keys-create-form-card">
|
||||
<div className="api-keys-create-form-header">
|
||||
<h2>Create New API Key</h2>
|
||||
<button
|
||||
className="api-keys-create-form-close"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setCreateName('');
|
||||
setCreateDescription('');
|
||||
setCreateError(null);
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<div className="api-keys-create-error">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCreate} className="api-keys-create-form">
|
||||
<div className="api-keys-form-group">
|
||||
<label htmlFor="key-name">Name</label>
|
||||
<input
|
||||
id="key-name"
|
||||
type="text"
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
placeholder="e.g., CI/CD Pipeline, Local Development"
|
||||
autoFocus
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="api-keys-form-group">
|
||||
<label htmlFor="key-description">Description (optional)</label>
|
||||
<input
|
||||
id="key-description"
|
||||
type="text"
|
||||
value={createDescription}
|
||||
onChange={(e) => setCreateDescription(e.target.value)}
|
||||
placeholder="What will this key be used for?"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="api-keys-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="api-keys-cancel-button"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setCreateName('');
|
||||
setCreateDescription('');
|
||||
setCreateError(null);
|
||||
}}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="api-keys-submit-button"
|
||||
disabled={isCreating || !createName.trim()}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<span className="api-keys-button-spinner"></span>
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Key'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="api-keys-list-container">
|
||||
{loading ? (
|
||||
<div className="api-keys-list-loading">
|
||||
<div className="api-keys-spinner"></div>
|
||||
<span>Loading API keys...</span>
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="api-keys-empty">
|
||||
<div className="api-keys-empty-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>No API Keys</h3>
|
||||
<p>Create an API key to access Orchard programmatically</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="api-keys-list">
|
||||
<div className="api-keys-list-header">
|
||||
<span className="api-keys-col-name">Name</span>
|
||||
<span className="api-keys-col-created">Created</span>
|
||||
<span className="api-keys-col-used">Last Used</span>
|
||||
<span className="api-keys-col-actions">Actions</span>
|
||||
</div>
|
||||
{keys.map((key) => (
|
||||
<div key={key.id} className="api-keys-list-item">
|
||||
<div className="api-keys-col-name">
|
||||
<div className="api-keys-item-name">{key.name}</div>
|
||||
{key.description && (
|
||||
<div className="api-keys-item-description">{key.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="api-keys-col-created">
|
||||
{formatDate(key.created_at)}
|
||||
</div>
|
||||
<div className="api-keys-col-used">
|
||||
{formatDate(key.last_used)}
|
||||
</div>
|
||||
<div className="api-keys-col-actions">
|
||||
{deleteConfirmId === key.id ? (
|
||||
<div className="api-keys-delete-confirm">
|
||||
<span>Revoke?</span>
|
||||
<button
|
||||
className="api-keys-confirm-yes"
|
||||
onClick={() => handleDelete(key.id)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Revoking...' : 'Yes'}
|
||||
</button>
|
||||
<button
|
||||
className="api-keys-confirm-no"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="api-keys-revoke-button"
|
||||
onClick={() => setDeleteConfirmId(key.id)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default APIKeysPage;
|
||||
667
frontend/src/pages/AdminUsersPage.css
Normal file
667
frontend/src/pages/AdminUsersPage.css
Normal file
@@ -0,0 +1,667 @@
|
||||
.admin-users-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.admin-users-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 32px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.admin-users-header-content h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.admin-users-subtitle {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.admin-users-create-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: var(--accent-gradient);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-users-create-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.admin-users-create-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.admin-users-success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--success-bg);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
color: var(--success);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 24px;
|
||||
font-size: 0.875rem;
|
||||
animation: admin-users-fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes admin-users-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-users-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 24px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-users-error svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-users-error span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-users-error-dismiss {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
color: var(--error);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-users-error-dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.admin-users-access-denied {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-users-access-denied-icon {
|
||||
color: var(--error);
|
||||
margin-bottom: 24px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.admin-users-access-denied h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-users-access-denied p {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9375rem;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.admin-users-create-form-card,
|
||||
.admin-users-reset-password-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-users-create-form-header,
|
||||
.admin-users-reset-password-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-users-create-form-header h2,
|
||||
.admin-users-reset-password-header h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-users-create-form-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-users-create-form-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-users-reset-password-info {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-users-reset-password-info strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-users-create-error {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-users-create-form,
|
||||
.admin-users-reset-password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-users-form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-users-form-group label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-users-form-group input[type="text"],
|
||||
.admin-users-form-group input[type="password"],
|
||||
.admin-users-form-group input[type="email"] {
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-users-form-group input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-users-form-group input:hover:not(:disabled) {
|
||||
border-color: var(--border-secondary);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.admin-users-form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.admin-users-form-group input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-users-checkbox-group {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-users-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.admin-users-checkbox-label input[type="checkbox"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.admin-users-checkbox-custom {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.admin-users-checkbox-label input[type="checkbox"]:checked + .admin-users-checkbox-custom {
|
||||
background: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.admin-users-checkbox-label input[type="checkbox"]:checked + .admin-users-checkbox-custom::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 2px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.admin-users-checkbox-label input[type="checkbox"]:focus + .admin-users-checkbox-custom {
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.admin-users-checkbox-label:hover .admin-users-checkbox-custom {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.admin-users-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.admin-users-cancel-button {
|
||||
padding: 10px 18px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-users-cancel-button:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-users-cancel-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-users-submit-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
background: var(--accent-gradient);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.admin-users-submit-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.admin-users-submit-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.admin-users-button-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: admin-users-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes admin-users-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-users-list-container {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-users-list-loading,
|
||||
.admin-users-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 64px 24px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.admin-users-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-secondary);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: admin-users-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.admin-users-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-users-empty-icon {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.admin-users-empty h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.admin-users-empty p {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-users-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-users-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 100px 140px 140px 1fr;
|
||||
gap: 16px;
|
||||
padding: 14px 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.admin-users-list-item {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 100px 140px 140px 1fr;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-users-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.admin-users-list-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.admin-users-list-item.admin-users-inactive {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.admin-users-col-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-users-item-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-gradient);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-users-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-users-item-username {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-users-admin-badge {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.1) 100%);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 20px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.admin-users-item-email {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.8125rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.admin-users-col-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-users-status-badge {
|
||||
display: inline-flex;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-users-status-badge.active {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.admin-users-status-badge.inactive {
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.admin-users-col-created,
|
||||
.admin-users-col-login {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.admin-users-col-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-users-actions-menu {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-users-action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-users-action-button:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-users-action-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-users-action-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border-secondary);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: admin-users-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.admin-users-list-header {
|
||||
grid-template-columns: 2fr 100px 1fr;
|
||||
}
|
||||
|
||||
.admin-users-list-item {
|
||||
grid-template-columns: 2fr 100px 1fr;
|
||||
}
|
||||
|
||||
.admin-users-col-created,
|
||||
.admin-users-col-login {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-users-list-header .admin-users-col-created,
|
||||
.admin-users-list-header .admin-users-col-login {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-users-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.admin-users-create-button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.admin-users-list-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-users-list-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-users-col-user {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.admin-users-col-status {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.admin-users-col-actions {
|
||||
order: 3;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.admin-users-actions-menu {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
529
frontend/src/pages/AdminUsersPage.tsx
Normal file
529
frontend/src/pages/AdminUsersPage.tsx
Normal file
@@ -0,0 +1,529 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { listUsers, createUser, updateUser, resetUserPassword } from '../api';
|
||||
import { AdminUser } from '../types';
|
||||
import './AdminUsersPage.css';
|
||||
|
||||
function AdminUsersPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createUsername, setCreateUsername] = useState('');
|
||||
const [createPassword, setCreatePassword] = useState('');
|
||||
const [createEmail, setCreateEmail] = useState('');
|
||||
const [createIsAdmin, setCreateIsAdmin] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const [resetPasswordUsername, setResetPasswordUsername] = useState<string | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const [togglingUser, setTogglingUser] = useState<string | null>(null);
|
||||
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
navigate('/login', { state: { from: '/admin/users' } });
|
||||
}
|
||||
}, [user, authLoading, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.is_admin) {
|
||||
loadUsers();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (successMessage) {
|
||||
const timer = setTimeout(() => setSuccessMessage(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [successMessage]);
|
||||
|
||||
async function loadUsers() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listUsers();
|
||||
setUsers(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!createUsername.trim()) {
|
||||
setCreateError('Username is required');
|
||||
return;
|
||||
}
|
||||
if (!createPassword.trim()) {
|
||||
setCreateError('Password is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
await createUser({
|
||||
username: createUsername.trim(),
|
||||
password: createPassword,
|
||||
email: createEmail.trim() || undefined,
|
||||
is_admin: createIsAdmin,
|
||||
});
|
||||
setShowCreateForm(false);
|
||||
setCreateUsername('');
|
||||
setCreatePassword('');
|
||||
setCreateEmail('');
|
||||
setCreateIsAdmin(false);
|
||||
setSuccessMessage('User created successfully');
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : 'Failed to create user');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleAdmin(targetUser: AdminUser) {
|
||||
setTogglingUser(targetUser.username);
|
||||
try {
|
||||
await updateUser(targetUser.username, { is_admin: !targetUser.is_admin });
|
||||
setSuccessMessage(`${targetUser.username} is ${!targetUser.is_admin ? 'now' : 'no longer'} an admin`);
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update user');
|
||||
} finally {
|
||||
setTogglingUser(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleActive(targetUser: AdminUser) {
|
||||
setTogglingUser(targetUser.username);
|
||||
try {
|
||||
await updateUser(targetUser.username, { is_active: !targetUser.is_active });
|
||||
setSuccessMessage(`${targetUser.username} has been ${!targetUser.is_active ? 'enabled' : 'disabled'}`);
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update user');
|
||||
} finally {
|
||||
setTogglingUser(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!resetPasswordUsername || !newPassword.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResetting(true);
|
||||
try {
|
||||
await resetUserPassword(resetPasswordUsername, newPassword);
|
||||
setResetPasswordUsername(null);
|
||||
setNewPassword('');
|
||||
setSuccessMessage(`Password reset for ${resetPasswordUsername}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to reset password');
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | null): string {
|
||||
if (!dateString) return 'Never';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="admin-users-page">
|
||||
<div className="admin-users-loading">
|
||||
<div className="admin-users-spinner"></div>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!user.is_admin) {
|
||||
return (
|
||||
<div className="admin-users-page">
|
||||
<div className="admin-users-access-denied">
|
||||
<div className="admin-users-access-denied-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Access Denied</h2>
|
||||
<p>You do not have permission to access this page. Admin privileges are required.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-users-page">
|
||||
<div className="admin-users-header">
|
||||
<div className="admin-users-header-content">
|
||||
<h1>User Management</h1>
|
||||
<p className="admin-users-subtitle">
|
||||
Manage user accounts and permissions
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="admin-users-create-button"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
disabled={showCreateForm}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{successMessage && (
|
||||
<div className="admin-users-success">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
<span>{successMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="admin-users-error">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="admin-users-error-dismiss">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateForm && (
|
||||
<div className="admin-users-create-form-card">
|
||||
<div className="admin-users-create-form-header">
|
||||
<h2>Create New User</h2>
|
||||
<button
|
||||
className="admin-users-create-form-close"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setCreateUsername('');
|
||||
setCreatePassword('');
|
||||
setCreateEmail('');
|
||||
setCreateIsAdmin(false);
|
||||
setCreateError(null);
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<div className="admin-users-create-error">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCreate} className="admin-users-create-form">
|
||||
<div className="admin-users-form-group">
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={createUsername}
|
||||
onChange={(e) => setCreateUsername(e.target.value)}
|
||||
placeholder="Enter username"
|
||||
autoFocus
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-users-form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={createPassword}
|
||||
onChange={(e) => setCreatePassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-users-form-group">
|
||||
<label htmlFor="email">Email (optional)</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={createEmail}
|
||||
onChange={(e) => setCreateEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-users-form-group admin-users-checkbox-group">
|
||||
<label className="admin-users-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createIsAdmin}
|
||||
onChange={(e) => setCreateIsAdmin(e.target.checked)}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
<span className="admin-users-checkbox-custom"></span>
|
||||
Grant admin privileges
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-users-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-users-cancel-button"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setCreateUsername('');
|
||||
setCreatePassword('');
|
||||
setCreateEmail('');
|
||||
setCreateIsAdmin(false);
|
||||
setCreateError(null);
|
||||
}}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="admin-users-submit-button"
|
||||
disabled={isCreating || !createUsername.trim() || !createPassword.trim()}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<span className="admin-users-button-spinner"></span>
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create User'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetPasswordUsername && (
|
||||
<div className="admin-users-reset-password-card">
|
||||
<div className="admin-users-reset-password-header">
|
||||
<h2>Reset Password</h2>
|
||||
<button
|
||||
className="admin-users-create-form-close"
|
||||
onClick={() => {
|
||||
setResetPasswordUsername(null);
|
||||
setNewPassword('');
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="admin-users-reset-password-info">
|
||||
Set a new password for <strong>{resetPasswordUsername}</strong>
|
||||
</p>
|
||||
<form onSubmit={handleResetPassword} className="admin-users-reset-password-form">
|
||||
<div className="admin-users-form-group">
|
||||
<label htmlFor="new-password">New Password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Enter new password"
|
||||
autoFocus
|
||||
disabled={isResetting}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-users-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-users-cancel-button"
|
||||
onClick={() => {
|
||||
setResetPasswordUsername(null);
|
||||
setNewPassword('');
|
||||
}}
|
||||
disabled={isResetting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="admin-users-submit-button"
|
||||
disabled={isResetting || !newPassword.trim()}
|
||||
>
|
||||
{isResetting ? (
|
||||
<>
|
||||
<span className="admin-users-button-spinner"></span>
|
||||
Resetting...
|
||||
</>
|
||||
) : (
|
||||
'Reset Password'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-users-list-container">
|
||||
{loading ? (
|
||||
<div className="admin-users-list-loading">
|
||||
<div className="admin-users-spinner"></div>
|
||||
<span>Loading users...</span>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="admin-users-empty">
|
||||
<div className="admin-users-empty-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>No Users</h3>
|
||||
<p>Create a user to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-users-list">
|
||||
<div className="admin-users-list-header">
|
||||
<span className="admin-users-col-user">User</span>
|
||||
<span className="admin-users-col-status">Status</span>
|
||||
<span className="admin-users-col-created">Created</span>
|
||||
<span className="admin-users-col-login">Last Login</span>
|
||||
<span className="admin-users-col-actions">Actions</span>
|
||||
</div>
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className={`admin-users-list-item ${!u.is_active ? 'admin-users-inactive' : ''}`}>
|
||||
<div className="admin-users-col-user">
|
||||
<div className="admin-users-item-avatar">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="admin-users-item-info">
|
||||
<div className="admin-users-item-username">
|
||||
{u.username}
|
||||
{u.is_admin && <span className="admin-users-admin-badge">Admin</span>}
|
||||
</div>
|
||||
{u.email && (
|
||||
<div className="admin-users-item-email">{u.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-users-col-status">
|
||||
<span className={`admin-users-status-badge ${u.is_active ? 'active' : 'inactive'}`}>
|
||||
{u.is_active ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-users-col-created">
|
||||
{formatDate(u.created_at)}
|
||||
</div>
|
||||
<div className="admin-users-col-login">
|
||||
{formatDate(u.last_login)}
|
||||
</div>
|
||||
<div className="admin-users-col-actions">
|
||||
<div className="admin-users-actions-menu">
|
||||
<button
|
||||
className="admin-users-action-button"
|
||||
onClick={() => handleToggleAdmin(u)}
|
||||
disabled={togglingUser === u.username || u.username === user.username}
|
||||
title={u.is_admin ? 'Remove admin' : 'Make admin'}
|
||||
>
|
||||
{togglingUser === u.username ? (
|
||||
<span className="admin-users-action-spinner"></span>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
)}
|
||||
{u.is_admin ? 'Revoke' : 'Admin'}
|
||||
</button>
|
||||
<button
|
||||
className="admin-users-action-button"
|
||||
onClick={() => handleToggleActive(u)}
|
||||
disabled={togglingUser === u.username || u.username === user.username}
|
||||
title={u.is_active ? 'Disable user' : 'Enable user'}
|
||||
>
|
||||
{togglingUser === u.username ? (
|
||||
<span className="admin-users-action-spinner"></span>
|
||||
) : u.is_active ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
)}
|
||||
{u.is_active ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
className="admin-users-action-button"
|
||||
onClick={() => setResetPasswordUsername(u.username)}
|
||||
disabled={togglingUser === u.username}
|
||||
title="Reset password"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminUsersPage;
|
||||
231
frontend/src/pages/LoginPage.css
Normal file
231
frontend/src/pages/LoginPage.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
142
frontend/src/pages/LoginPage.tsx
Normal file
142
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './LoginPage.css';
|
||||
|
||||
function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { user, login, loading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Get the return URL from location state, default to home
|
||||
const from = (location.state as { from?: string })?.from || '/';
|
||||
|
||||
// Redirect if already logged in
|
||||
useEffect(() => {
|
||||
if (user && !authLoading) {
|
||||
navigate(from, { replace: true });
|
||||
}
|
||||
}, [user, authLoading, navigate, from]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!username.trim() || !password) {
|
||||
setError('Please enter both username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate(from, { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading while checking auth state
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-container">
|
||||
<div className="login-loading">Checking session...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<div className="login-logo">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 14 Q6 8 3 8 Q6 4 6 4 Q6 4 9 8 Q6 8 6 14" fill="currentColor" opacity="0.6"/>
|
||||
<rect x="5.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
|
||||
<path d="M12 12 Q12 5 8 5 Q12 1 12 1 Q12 1 16 5 Q12 5 12 12" fill="currentColor"/>
|
||||
<rect x="11.25" y="11" width="1.5" height="5" fill="currentColor"/>
|
||||
<path d="M18 14 Q18 8 15 8 Q18 4 18 4 Q18 4 21 8 Q18 8 18 14" fill="currentColor" opacity="0.6"/>
|
||||
<rect x="17.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
|
||||
<ellipse cx="12" cy="19" rx="9" ry="1.5" fill="currentColor" opacity="0.3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Sign in to Orchard</h1>
|
||||
<p className="login-subtitle">Content-Addressable Storage</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="login-error">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<div className="login-form-group">
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="login-form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
autoComplete="current-password"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="login-submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="login-spinner"></span>
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="login-footer">
|
||||
<p>Artifact storage and management system</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
86
migrations/006_auth_tables.sql
Normal file
86
migrations/006_auth_tables.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
-- Authentication Tables Migration
|
||||
-- Adds users table and updates api_keys with foreign key
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255),
|
||||
email VARCHAR(255),
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
must_change_password BOOLEAN DEFAULT FALSE,
|
||||
oidc_subject VARCHAR(255),
|
||||
oidc_issuer VARCHAR(512),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
last_login TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_oidc_subject ON users(oidc_subject) WHERE oidc_subject IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active) WHERE is_active = TRUE;
|
||||
|
||||
-- Sessions table for web login
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
last_accessed TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
user_agent VARCHAR(512),
|
||||
ip_address VARCHAR(45)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
|
||||
-- Auth settings for OIDC configuration (future use)
|
||||
CREATE TABLE IF NOT EXISTS auth_settings (
|
||||
key VARCHAR(255) PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add user_id foreign key to api_keys table
|
||||
-- First add the column (nullable initially)
|
||||
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS owner_id UUID REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- Add scopes column for API key permissions
|
||||
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS scopes TEXT[] DEFAULT ARRAY['read', 'write'];
|
||||
|
||||
-- Add description column
|
||||
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS description TEXT;
|
||||
|
||||
-- Create index for owner_id
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_owner_id ON api_keys(owner_id) WHERE owner_id IS NOT NULL;
|
||||
|
||||
-- Trigger to update users.updated_at
|
||||
CREATE TRIGGER users_updated_at_trigger
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Trigger to update sessions.last_accessed on access
|
||||
CREATE OR REPLACE FUNCTION update_session_last_accessed()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.last_accessed = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to clean up expired sessions (can be called periodically)
|
||||
CREATE OR REPLACE FUNCTION cleanup_expired_sessions()
|
||||
RETURNS INTEGER AS $$
|
||||
DECLARE
|
||||
deleted_count INTEGER;
|
||||
BEGIN
|
||||
DELETE FROM sessions WHERE expires_at < NOW();
|
||||
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||
RETURN deleted_count;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
Reference in New Issue
Block a user