Merge branch 'feature/auth-system' into 'main'

Implement authentication system with access control UI

Closes #50 and #18

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!24
This commit is contained in:
Dane Moss
2026-01-12 09:52:35 -07:00
39 changed files with 8561 additions and 116 deletions

View File

@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- Added user authentication system with session-based login (#50)
- `users` table with password hashing (bcrypt), admin flag, active status
- `sessions` table for web login sessions (24-hour expiry)
- `auth_settings` table for future OIDC configuration
- Default admin user created on first boot (username: admin, password: admin)
- Added auth API endpoints (#50)
- `POST /api/v1/auth/login` - Login with username/password
- `POST /api/v1/auth/logout` - Logout and clear session
- `GET /api/v1/auth/me` - Get current user info
- `POST /api/v1/auth/change-password` - Change own password
- Added API key management with user ownership (#50)
- `POST /api/v1/auth/keys` - Create API key (format: `orch_<random>`)
- `GET /api/v1/auth/keys` - List user's API keys
- `DELETE /api/v1/auth/keys/{id}` - Revoke API key
- Added `owner_id`, `scopes`, `description` columns to `api_keys` table
- Added admin user management endpoints (#50)
- `GET /api/v1/admin/users` - List all users
- `POST /api/v1/admin/users` - Create user
- `GET /api/v1/admin/users/{username}` - Get user details
- `PUT /api/v1/admin/users/{username}` - Update user (admin/active status)
- `POST /api/v1/admin/users/{username}/reset-password` - Reset password
- Added `auth.py` module with AuthService class and FastAPI dependencies (#50)
- Added auth schemas: LoginRequest, LoginResponse, UserResponse, APIKeyResponse (#50)
- Added migration `006_auth_tables.sql` for auth database tables (#50)
- Added frontend Login page with session management (#50)
- Added frontend API Keys management page (#50)
- Added frontend Admin Users page (admin-only) (#50)
- Added AuthContext for frontend session state (#50)
- Added user menu to Layout header with login/logout (#50)
- Added 15 integration tests for auth system (#50)
- Added reusable `DragDropUpload` component for artifact uploads (#8) - Added reusable `DragDropUpload` component for artifact uploads (#8)
- Drag-and-drop file selection with visual feedback - Drag-and-drop file selection with visual feedback
- Click-to-browse fallback - Click-to-browse fallback

1208
backend/app/auth.py Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ from sqlalchemy import (
CheckConstraint, CheckConstraint,
Index, Index,
JSON, JSON,
ARRAY,
) )
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship, declarative_base from sqlalchemy.orm import relationship, declarative_base
@@ -302,20 +303,104 @@ class AccessPermission(Base):
) )
class User(Base):
"""User account for authentication."""
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(String(255), unique=True, nullable=False)
password_hash = Column(String(255)) # NULL if OIDC-only user
email = Column(String(255))
is_admin = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
must_change_password = Column(Boolean, default=False)
oidc_subject = Column(String(255)) # OIDC subject claim
oidc_issuer = Column(String(512)) # OIDC issuer URL
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow
)
last_login = Column(DateTime(timezone=True))
# Relationships
api_keys = relationship(
"APIKey", back_populates="owner", cascade="all, delete-orphan"
)
sessions = relationship(
"Session", back_populates="user", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_users_username", "username"),
Index("idx_users_email", "email"),
Index("idx_users_oidc_subject", "oidc_subject"),
)
class Session(Base):
"""User session for web login."""
__tablename__ = "sessions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
token_hash = Column(String(64), unique=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
expires_at = Column(DateTime(timezone=True), nullable=False)
last_accessed = Column(DateTime(timezone=True), default=datetime.utcnow)
user_agent = Column(String(512))
ip_address = Column(String(45))
user = relationship("User", back_populates="sessions")
__table_args__ = (
Index("idx_sessions_user_id", "user_id"),
Index("idx_sessions_token_hash", "token_hash"),
Index("idx_sessions_expires_at", "expires_at"),
)
class AuthSettings(Base):
"""Authentication settings for OIDC configuration."""
__tablename__ = "auth_settings"
key = Column(String(255), primary_key=True)
value = Column(Text, nullable=False)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
class APIKey(Base): class APIKey(Base):
__tablename__ = "api_keys" __tablename__ = "api_keys"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
key_hash = Column(String(64), unique=True, nullable=False) key_hash = Column(String(64), unique=True, nullable=False)
name = Column(String(255), nullable=False) name = Column(String(255), nullable=False)
user_id = Column(String(255), nullable=False) user_id = Column(
String(255), nullable=False
) # Legacy field, kept for compatibility
owner_id = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True, # Nullable for migration compatibility
)
description = Column(Text)
scopes = Column(ARRAY(String), default=["read", "write"])
created_at = Column(DateTime(timezone=True), default=datetime.utcnow) created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
expires_at = Column(DateTime(timezone=True)) expires_at = Column(DateTime(timezone=True))
last_used = Column(DateTime(timezone=True)) last_used = Column(DateTime(timezone=True))
owner = relationship("User", back_populates="api_keys")
__table_args__ = ( __table_args__ = (
Index("idx_api_keys_user_id", "user_id"), Index("idx_api_keys_user_id", "user_id"),
Index("idx_api_keys_key_hash", "key_hash"), Index("idx_api_keys_key_hash", "key_hash"),
Index("idx_api_keys_owner_id", "owner_id"),
) )

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,13 @@ class ProjectUpdate(BaseModel):
is_public: Optional[bool] = None is_public: Optional[bool] = None
class ProjectWithAccessResponse(ProjectResponse):
"""Project response with user's access level included"""
access_level: Optional[str] = None # 'read', 'write', 'admin', or None
is_owner: bool = False
# Package format and platform enums # Package format and platform enums
PACKAGE_FORMATS = [ PACKAGE_FORMATS = [
"generic", "generic",
@@ -686,3 +693,173 @@ class StatsReportResponse(BaseModel):
format: str # "json", "csv", "markdown" format: str # "json", "csv", "markdown"
generated_at: datetime generated_at: datetime
content: str # The report content content: str # The report content
# Authentication schemas
class LoginRequest(BaseModel):
"""Login request with username and password"""
username: str
password: str
class LoginResponse(BaseModel):
"""Login response with user info"""
id: UUID
username: str
email: Optional[str]
is_admin: bool
must_change_password: bool
class ChangePasswordRequest(BaseModel):
"""Change password request"""
current_password: str
new_password: str
class UserResponse(BaseModel):
"""User information response"""
id: UUID
username: str
email: Optional[str]
is_admin: bool
is_active: bool
must_change_password: bool
created_at: datetime
last_login: Optional[datetime]
class Config:
from_attributes = True
class UserCreate(BaseModel):
"""Create user request (admin only)"""
username: str
password: str
email: Optional[str] = None
is_admin: bool = False
class UserUpdate(BaseModel):
"""Update user request (admin only)"""
email: Optional[str] = None
is_admin: Optional[bool] = None
is_active: Optional[bool] = None
class ResetPasswordRequest(BaseModel):
"""Reset password request (admin only)"""
new_password: str
class APIKeyCreate(BaseModel):
"""Create API key request"""
name: str
description: Optional[str] = None
scopes: Optional[List[str]] = None
class APIKeyResponse(BaseModel):
"""API key response (without the secret key)"""
id: UUID
name: str
description: Optional[str]
scopes: Optional[List[str]]
created_at: datetime
expires_at: Optional[datetime]
last_used: Optional[datetime]
class Config:
from_attributes = True
class APIKeyCreateResponse(BaseModel):
"""API key creation response (includes the secret key - only shown once)"""
id: UUID
name: str
description: Optional[str]
scopes: Optional[List[str]]
key: str # The actual API key - only returned on creation
created_at: datetime
expires_at: Optional[datetime]
# OIDC Configuration schemas
class OIDCConfigResponse(BaseModel):
"""OIDC configuration response (hides client secret)"""
enabled: bool
issuer_url: str
client_id: str
has_client_secret: bool # True if secret is configured, but don't expose it
scopes: List[str]
auto_create_users: bool
admin_group: str
class OIDCConfigUpdate(BaseModel):
"""Update OIDC configuration"""
enabled: Optional[bool] = None
issuer_url: Optional[str] = None
client_id: Optional[str] = None
client_secret: Optional[str] = None # Only set if changing
scopes: Optional[List[str]] = None
auto_create_users: Optional[bool] = None
admin_group: Optional[str] = None
class OIDCStatusResponse(BaseModel):
"""Public OIDC status response"""
enabled: bool
issuer_url: Optional[str] = None # Only included if enabled
class OIDCLoginResponse(BaseModel):
"""OIDC login initiation response"""
authorization_url: str
# Access Permission schemas
class AccessPermissionCreate(BaseModel):
"""Grant access to a user for a project"""
username: str
level: str # 'read', 'write', or 'admin'
expires_at: Optional[datetime] = None
@field_validator('level')
@classmethod
def validate_level(cls, v):
if v not in ('read', 'write', 'admin'):
raise ValueError("level must be 'read', 'write', or 'admin'")
return v
class AccessPermissionUpdate(BaseModel):
"""Update access permission"""
level: Optional[str] = None
expires_at: Optional[datetime] = None
@field_validator('level')
@classmethod
def validate_level(cls, v):
if v is not None and v not in ('read', 'write', 'admin'):
raise ValueError("level must be 'read', 'write', or 'admin'")
return v
class AccessPermissionResponse(BaseModel):
"""Access permission response"""
id: UUID
project_id: UUID
user_id: str
level: str
created_at: datetime
expires_at: Optional[datetime]
class Config:
from_attributes = True
class ProjectWithAccessResponse(ProjectResponse):
"""Project response with user's access level"""
user_access_level: Optional[str] = None

View File

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

View File

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

View File

@@ -0,0 +1,760 @@
"""Integration tests for authentication API endpoints."""
import pytest
from uuid import uuid4
class TestAuthLogin:
"""Tests for login endpoint."""
@pytest.mark.integration
def test_login_success(self, integration_client):
"""Test successful login with default admin credentials."""
response = integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
assert response.status_code == 200
data = response.json()
assert data["username"] == "admin"
assert data["is_admin"] is True
assert "orchard_session" in response.cookies
@pytest.mark.integration
def test_login_invalid_password(self, integration_client):
"""Test login with wrong password."""
response = integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "wrongpassword"},
)
assert response.status_code == 401
assert "Invalid username or password" in response.json()["detail"]
@pytest.mark.integration
def test_login_nonexistent_user(self, integration_client):
"""Test login with non-existent user."""
response = integration_client.post(
"/api/v1/auth/login",
json={"username": "nonexistent", "password": "password"},
)
assert response.status_code == 401
class TestAuthLogout:
"""Tests for logout endpoint."""
@pytest.mark.integration
def test_logout_success(self, integration_client):
"""Test successful logout."""
# First login
login_response = integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
assert login_response.status_code == 200
# Then logout
logout_response = integration_client.post("/api/v1/auth/logout")
assert logout_response.status_code == 200
assert "Logged out successfully" in logout_response.json()["message"]
@pytest.mark.integration
def test_logout_without_session(self, integration_client):
"""Test logout without being logged in."""
response = integration_client.post("/api/v1/auth/logout")
# Should succeed even without session
assert response.status_code == 200
class TestAuthMe:
"""Tests for get current user endpoint."""
@pytest.mark.integration
def test_get_me_authenticated(self, integration_client):
"""Test getting current user when authenticated."""
# Login first
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
response = integration_client.get("/api/v1/auth/me")
assert response.status_code == 200
data = response.json()
assert data["username"] == "admin"
assert data["is_admin"] is True
assert "id" in data
assert "created_at" in data
@pytest.mark.integration
def test_get_me_unauthenticated(self, integration_client):
"""Test getting current user without authentication."""
# Clear any existing cookies
integration_client.cookies.clear()
response = integration_client.get("/api/v1/auth/me")
assert response.status_code == 401
assert "Not authenticated" in response.json()["detail"]
class TestAuthChangePassword:
"""Tests for change password endpoint."""
@pytest.mark.integration
def test_change_password_success(self, integration_client):
"""Test successful password change."""
# Login first
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
# Change password
response = integration_client.post(
"/api/v1/auth/change-password",
json={"current_password": "changeme123", "new_password": "newpassword123"},
)
assert response.status_code == 200
# Verify old password no longer works
integration_client.cookies.clear()
response = integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
assert response.status_code == 401
# Verify new password works
response = integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "newpassword123"},
)
assert response.status_code == 200
# Reset password back to original for other tests
reset_response = integration_client.post(
"/api/v1/auth/change-password",
json={"current_password": "newpassword123", "new_password": "changeme123"},
)
assert reset_response.status_code == 200, "Failed to reset admin password back to default"
@pytest.mark.integration
def test_change_password_wrong_current(self, integration_client):
"""Test password change with wrong current password."""
# Login first
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
response = integration_client.post(
"/api/v1/auth/change-password",
json={"current_password": "wrongpassword", "new_password": "newpassword"},
)
assert response.status_code == 400
assert "Current password is incorrect" in response.json()["detail"]
class TestAPIKeys:
"""Tests for API key management endpoints."""
@pytest.mark.integration
def test_create_and_list_api_key(self, integration_client):
"""Test creating and listing API keys."""
# Login first
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
# Create API key
create_response = integration_client.post(
"/api/v1/auth/keys",
json={"name": "test-key", "description": "Test API key"},
)
assert create_response.status_code == 200
data = create_response.json()
assert data["name"] == "test-key"
assert data["description"] == "Test API key"
assert "key" in data
assert data["key"].startswith("orch_")
key_id = data["id"]
api_key = data["key"]
# List API keys
list_response = integration_client.get("/api/v1/auth/keys")
assert list_response.status_code == 200
keys = list_response.json()
assert any(k["id"] == key_id for k in keys)
# Clean up - delete the key
integration_client.delete(f"/api/v1/auth/keys/{key_id}")
@pytest.mark.integration
def test_use_api_key_for_auth(self, integration_client):
"""Test using API key for authentication."""
# Login and create API key
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
create_response = integration_client.post(
"/api/v1/auth/keys",
json={"name": "auth-test-key"},
)
api_key = create_response.json()["key"]
key_id = create_response.json()["id"]
# Clear cookies and use API key
integration_client.cookies.clear()
response = integration_client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {api_key}"},
)
assert response.status_code == 200
assert response.json()["username"] == "admin"
# Clean up
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
integration_client.delete(f"/api/v1/auth/keys/{key_id}")
@pytest.mark.integration
def test_delete_api_key(self, integration_client):
"""Test revoking an API key."""
# Login and create API key
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
create_response = integration_client.post(
"/api/v1/auth/keys",
json={"name": "delete-test-key"},
)
key_id = create_response.json()["id"]
api_key = create_response.json()["key"]
# Delete the key
delete_response = integration_client.delete(f"/api/v1/auth/keys/{key_id}")
assert delete_response.status_code == 200
# Verify key no longer works
integration_client.cookies.clear()
response = integration_client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {api_key}"},
)
assert response.status_code == 401
class TestAdminUserManagement:
"""Tests for admin user management endpoints."""
@pytest.mark.integration
def test_list_users(self, integration_client):
"""Test listing users as admin."""
# Login as admin
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
response = integration_client.get("/api/v1/admin/users")
assert response.status_code == 200
users = response.json()
assert len(users) >= 1
assert any(u["username"] == "admin" for u in users)
@pytest.mark.integration
def test_create_user(self, integration_client):
"""Test creating a new user as admin."""
# Login as admin
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
# Create new user
test_username = f"testuser_{uuid4().hex[:8]}"
response = integration_client.post(
"/api/v1/admin/users",
json={
"username": test_username,
"password": "testpassword",
"email": "test@example.com",
},
)
assert response.status_code == 200
data = response.json()
assert data["username"] == test_username
assert data["email"] == "test@example.com"
assert data["is_admin"] is False
# Verify new user can login
integration_client.cookies.clear()
login_response = integration_client.post(
"/api/v1/auth/login",
json={"username": test_username, "password": "testpassword"},
)
assert login_response.status_code == 200
@pytest.mark.integration
def test_update_user(self, integration_client):
"""Test updating a user as admin."""
# Login as admin
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
# Create a test user
test_username = f"updateuser_{uuid4().hex[:8]}"
integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "password"},
)
# Update the user
response = integration_client.put(
f"/api/v1/admin/users/{test_username}",
json={"email": "updated@example.com", "is_admin": True},
)
assert response.status_code == 200
data = response.json()
assert data["email"] == "updated@example.com"
assert data["is_admin"] is True
@pytest.mark.integration
def test_reset_user_password(self, integration_client):
"""Test resetting a user's password as admin."""
# Login as admin
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
# Create a test user
test_username = f"resetuser_{uuid4().hex[:8]}"
integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "oldpassword"},
)
# Reset password
response = integration_client.post(
f"/api/v1/admin/users/{test_username}/reset-password",
json={"new_password": "newpassword"},
)
assert response.status_code == 200
# Verify new password works
integration_client.cookies.clear()
login_response = integration_client.post(
"/api/v1/auth/login",
json={"username": test_username, "password": "newpassword"},
)
assert login_response.status_code == 200
@pytest.mark.integration
def test_non_admin_cannot_access_admin_endpoints(self, integration_client):
"""Test that non-admin users cannot access admin endpoints."""
# Login as admin and create non-admin user
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
test_username = f"nonadmin_{uuid4().hex[:8]}"
integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "password", "is_admin": False},
)
# Login as non-admin
integration_client.cookies.clear()
integration_client.post(
"/api/v1/auth/login",
json={"username": test_username, "password": "password"},
)
# Try to access admin endpoints
response = integration_client.get("/api/v1/admin/users")
assert response.status_code == 403
assert "Admin privileges required" in response.json()["detail"]
class TestSecurityEdgeCases:
"""Tests for security edge cases and validation."""
@pytest.mark.integration
def test_login_inactive_user(self, integration_client):
"""Test that inactive users cannot login."""
# Login as admin and create a user
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
test_username = f"inactive_{uuid4().hex[:8]}"
integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "password123"},
)
# Deactivate the user
integration_client.put(
f"/api/v1/admin/users/{test_username}",
json={"is_active": False},
)
# Try to login as inactive user
integration_client.cookies.clear()
response = integration_client.post(
"/api/v1/auth/login",
json={"username": test_username, "password": "password123"},
)
assert response.status_code == 401
assert "Invalid username or password" in response.json()["detail"]
@pytest.mark.integration
def test_password_too_short_on_create(self, integration_client):
"""Test that short passwords are rejected when creating users."""
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
response = integration_client.post(
"/api/v1/admin/users",
json={"username": f"shortpw_{uuid4().hex[:8]}", "password": "short"},
)
assert response.status_code == 400
assert "at least 8 characters" in response.json()["detail"]
@pytest.mark.integration
def test_password_too_short_on_change(self, integration_client):
"""Test that short passwords are rejected when changing password."""
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
response = integration_client.post(
"/api/v1/auth/change-password",
json={"current_password": "changeme123", "new_password": "short"},
)
assert response.status_code == 400
assert "at least 8 characters" in response.json()["detail"]
@pytest.mark.integration
def test_password_too_short_on_reset(self, integration_client):
"""Test that short passwords are rejected when resetting password."""
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
# Create a test user first
test_username = f"resetshort_{uuid4().hex[:8]}"
integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "password123"},
)
response = integration_client.post(
f"/api/v1/admin/users/{test_username}/reset-password",
json={"new_password": "short"},
)
assert response.status_code == 400
assert "at least 8 characters" in response.json()["detail"]
@pytest.mark.integration
def test_duplicate_username_rejected(self, integration_client):
"""Test that duplicate usernames are rejected."""
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
test_username = f"duplicate_{uuid4().hex[:8]}"
# Create user first time
response1 = integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "password123"},
)
assert response1.status_code == 200
# Try to create same username again
response2 = integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "password456"},
)
assert response2.status_code == 409
assert "already exists" in response2.json()["detail"]
@pytest.mark.integration
def test_cannot_delete_other_users_api_key(self, integration_client):
"""Test that users cannot delete API keys owned by other users."""
# Login as admin and create an API key
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
create_response = integration_client.post(
"/api/v1/auth/keys",
json={"name": "admin-key"},
)
admin_key_id = create_response.json()["id"]
# Create a non-admin user
test_username = f"nonadmin_{uuid4().hex[:8]}"
integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "password123"},
)
# Login as non-admin
integration_client.cookies.clear()
integration_client.post(
"/api/v1/auth/login",
json={"username": test_username, "password": "password123"},
)
# Try to delete admin's API key
response = integration_client.delete(f"/api/v1/auth/keys/{admin_key_id}")
assert response.status_code == 403
assert "Cannot delete another user's API key" in response.json()["detail"]
# Cleanup: login as admin and delete the key
integration_client.cookies.clear()
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
integration_client.delete(f"/api/v1/auth/keys/{admin_key_id}")
@pytest.mark.integration
def test_sessions_invalidated_on_password_change(self, integration_client):
"""Test that all sessions are invalidated when password is changed."""
# Create a test user
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
test_username = f"sessiontest_{uuid4().hex[:8]}"
integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "password123"},
)
# Login as test user
integration_client.cookies.clear()
login_response = integration_client.post(
"/api/v1/auth/login",
json={"username": test_username, "password": "password123"},
)
assert login_response.status_code == 200
# Verify session works
me_response = integration_client.get("/api/v1/auth/me")
assert me_response.status_code == 200
# Change password
integration_client.post(
"/api/v1/auth/change-password",
json={"current_password": "password123", "new_password": "newpassword123"},
)
# Old session should be invalidated - try to access /me
# (note: the change-password call itself may have cleared the session cookie)
me_response2 = integration_client.get("/api/v1/auth/me")
# This should fail because all sessions were invalidated
assert me_response2.status_code == 401
class TestSecurityEdgeCases:
"""Tests for security edge cases and validation."""
@pytest.mark.integration
def test_login_inactive_user(self, integration_client):
"""Test that inactive users cannot login."""
# Login as admin and create a user
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
test_username = f"inactive_{uuid4().hex[:8]}"
integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "password123"},
)
# Deactivate the user
integration_client.put(
f"/api/v1/admin/users/{test_username}",
json={"is_active": False},
)
# Try to login as inactive user
integration_client.cookies.clear()
response = integration_client.post(
"/api/v1/auth/login",
json={"username": test_username, "password": "password123"},
)
assert response.status_code == 401
assert "Invalid username or password" in response.json()["detail"]
@pytest.mark.integration
def test_password_too_short_on_create(self, integration_client):
"""Test that short passwords are rejected when creating users."""
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
response = integration_client.post(
"/api/v1/admin/users",
json={"username": f"shortpw_{uuid4().hex[:8]}", "password": "short"},
)
assert response.status_code == 400
assert "at least 8 characters" in response.json()["detail"]
@pytest.mark.integration
def test_password_too_short_on_change(self, integration_client):
"""Test that short passwords are rejected when changing password."""
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
response = integration_client.post(
"/api/v1/auth/change-password",
json={"current_password": "changeme123", "new_password": "short"},
)
assert response.status_code == 400
assert "at least 8 characters" in response.json()["detail"]
@pytest.mark.integration
def test_password_too_short_on_reset(self, integration_client):
"""Test that short passwords are rejected when resetting password."""
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
# Create a test user first
test_username = f"resetshort_{uuid4().hex[:8]}"
integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "password123"},
)
response = integration_client.post(
f"/api/v1/admin/users/{test_username}/reset-password",
json={"new_password": "short"},
)
assert response.status_code == 400
assert "at least 8 characters" in response.json()["detail"]
@pytest.mark.integration
def test_duplicate_username_rejected(self, integration_client):
"""Test that duplicate usernames are rejected."""
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
test_username = f"duplicate_{uuid4().hex[:8]}"
# Create user first time
response1 = integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "password123"},
)
assert response1.status_code == 200
# Try to create same username again
response2 = integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "password456"},
)
assert response2.status_code == 409
assert "already exists" in response2.json()["detail"]
@pytest.mark.integration
def test_cannot_delete_other_users_api_key(self, integration_client):
"""Test that users cannot delete API keys owned by other users."""
# Login as admin and create an API key
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
create_response = integration_client.post(
"/api/v1/auth/keys",
json={"name": "admin-key"},
)
admin_key_id = create_response.json()["id"]
# Create a non-admin user
test_username = f"nonadmin_{uuid4().hex[:8]}"
integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "password123"},
)
# Login as non-admin
integration_client.cookies.clear()
integration_client.post(
"/api/v1/auth/login",
json={"username": test_username, "password": "password123"},
)
# Try to delete admin's API key
response = integration_client.delete(f"/api/v1/auth/keys/{admin_key_id}")
assert response.status_code == 403
assert "Cannot delete another user's API key" in response.json()["detail"]
# Cleanup: login as admin and delete the key
integration_client.cookies.clear()
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
integration_client.delete(f"/api/v1/auth/keys/{admin_key_id}")
@pytest.mark.integration
def test_sessions_invalidated_on_password_change(self, integration_client):
"""Test that all sessions are invalidated when password is changed."""
# Create a test user
integration_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"},
)
test_username = f"sessiontest_{uuid4().hex[:8]}"
integration_client.post(
"/api/v1/admin/users",
json={"username": test_username, "password": "password123"},
)
# Login as test user
integration_client.cookies.clear()
login_response = integration_client.post(
"/api/v1/auth/login",
json={"username": test_username, "password": "password123"},
)
assert login_response.status_code == 200
# Verify session works
me_response = integration_client.get("/api/v1/auth/me")
assert me_response.status_code == 200
# Change password
integration_client.post(
"/api/v1/auth/change-password",
json={"current_password": "password123", "new_password": "newpassword123"},
)
# Old session should be invalidated - try to access /me
# (note: the change-password call itself may have cleared the session cookie)
me_response2 = integration_client.get("/api/v1/auth/me")
# This should fail because all sessions were invalidated
assert me_response2.status_code == 401

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,17 @@
border-style: solid; border-style: solid;
} }
.drop-zone--disabled {
cursor: not-allowed;
opacity: 0.6;
background: var(--bg-disabled, #f5f5f5);
}
.drop-zone--disabled:hover {
border-color: var(--border-color, #ddd);
background: var(--bg-disabled, #f5f5f5);
}
.drop-zone__input { .drop-zone__input {
display: none; display: none;
} }

View File

@@ -89,6 +89,8 @@ export interface DragDropUploadProps {
maxRetries?: number; maxRetries?: number;
tag?: string; tag?: string;
className?: string; className?: string;
disabled?: boolean;
disabledReason?: string;
} }
// Utility functions // Utility functions
@@ -230,6 +232,8 @@ export function DragDropUpload({
maxRetries = 3, maxRetries = 3,
tag, tag,
className = '', className = '',
disabled = false,
disabledReason,
}: DragDropUploadProps) { }: DragDropUploadProps) {
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [uploadQueue, setUploadQueue] = useState<UploadItem[]>([]); const [uploadQueue, setUploadQueue] = useState<UploadItem[]>([]);
@@ -649,20 +653,22 @@ export function DragDropUpload({
const handleDragEnter = useCallback((e: React.DragEvent) => { const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (disabled) return;
dragCounterRef.current++; dragCounterRef.current++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragOver(true); setIsDragOver(true);
} }
}, []); }, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => { const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (disabled) return;
dragCounterRef.current--; dragCounterRef.current--;
if (dragCounterRef.current === 0) { if (dragCounterRef.current === 0) {
setIsDragOver(false); setIsDragOver(false);
} }
}, []); }, [disabled]);
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@@ -675,18 +681,22 @@ export function DragDropUpload({
setIsDragOver(false); setIsDragOver(false);
dragCounterRef.current = 0; dragCounterRef.current = 0;
if (disabled) return;
const files = e.dataTransfer.files; const files = e.dataTransfer.files;
if (files && files.length > 0) { if (files && files.length > 0) {
addFiles(files); addFiles(files);
} }
}, [addFiles]); }, [addFiles, disabled]);
// Click to browse // Click to browse
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (disabled) return;
fileInputRef.current?.click(); fileInputRef.current?.click();
}, []); }, [disabled]);
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return;
const files = e.target.files; const files = e.target.files;
if (files && files.length > 0) { if (files && files.length > 0) {
addFiles(files); addFiles(files);
@@ -695,7 +705,7 @@ export function DragDropUpload({
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
}, [addFiles]); }, [addFiles, disabled]);
// Remove item from queue // Remove item from queue
const removeItem = useCallback((id: string) => { const removeItem = useCallback((id: string) => {
@@ -738,15 +748,17 @@ export function DragDropUpload({
)} )}
<div <div
className={`drop-zone ${isDragOver ? 'drop-zone--active' : ''}`} className={`drop-zone ${isDragOver ? 'drop-zone--active' : ''} ${disabled ? 'drop-zone--disabled' : ''}`}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDrop} onDrop={handleDrop}
onClick={handleClick} onClick={handleClick}
role="button" role="button"
tabIndex={0} tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => e.key === 'Enter' && handleClick()} onKeyDown={(e) => e.key === 'Enter' && handleClick()}
aria-disabled={disabled}
title={disabled ? disabledReason : undefined}
> >
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -755,16 +767,23 @@ export function DragDropUpload({
onChange={handleFileChange} onChange={handleFileChange}
className="drop-zone__input" className="drop-zone__input"
accept={!allowAllTypes && allowedTypes ? allowedTypes.join(',') : undefined} accept={!allowAllTypes && allowedTypes ? allowedTypes.join(',') : undefined}
disabled={disabled}
/> />
<div className="drop-zone__content"> <div className="drop-zone__content">
<UploadIcon /> <UploadIcon />
<p className="drop-zone__text"> <p className="drop-zone__text">
<strong>Drag files here</strong> or click to browse {disabled ? (
</p> <span>{disabledReason || 'Upload disabled'}</span>
<p className="drop-zone__hint"> ) : (
{maxFileSize && `Max file size: ${formatBytes(maxFileSize)}`} <><strong>Drag files here</strong> or click to browse</>
{!allowAllTypes && allowedTypes && ` • Accepted: ${allowedTypes.join(', ')}`} )}
</p> </p>
{!disabled && (
<p className="drop-zone__hint">
{maxFileSize && `Max file size: ${formatBytes(maxFileSize)}`}
{!allowAllTypes && allowedTypes && ` • Accepted: ${allowedTypes.join(', ')}`}
</p>
)}
</div> </div>
</div> </div>

View File

@@ -98,6 +98,170 @@
opacity: 0.7; opacity: 0.7;
} }
/* Login link */
.nav-login {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
margin-left: 8px;
border: 1px solid var(--border-primary);
}
.nav-login:hover {
color: var(--text-primary);
background: var(--bg-hover);
border-color: var(--border-secondary);
}
/* User Menu */
.user-menu {
position: relative;
margin-left: 8px;
}
.user-menu-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.user-menu-trigger:hover {
background: var(--bg-hover);
border-color: var(--border-secondary);
}
.user-avatar {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-gradient);
border-radius: var(--radius-sm);
color: white;
font-weight: 600;
font-size: 0.8125rem;
}
.user-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-menu-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
min-width: 200px;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 200;
overflow: hidden;
}
.user-menu-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
}
.user-menu-username {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
.user-menu-badge {
padding: 2px 8px;
background: var(--accent-gradient);
border-radius: 100px;
font-size: 0.6875rem;
font-weight: 600;
color: white;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.user-menu-divider {
height: 1px;
background: var(--border-primary);
}
.user-menu-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 12px 16px;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 0.875rem;
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
text-decoration: none;
}
.user-menu-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.user-menu-item svg {
opacity: 0.7;
}
.user-menu-item:hover svg {
opacity: 1;
}
/* User menu loading state */
.user-menu-loading {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin-left: 8px;
}
.user-menu-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border-secondary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: user-menu-spin 0.6s linear infinite;
}
@keyframes user-menu-spin {
to {
transform: rotate(360deg);
}
}
/* Main content */ /* Main content */
.main { .main {
flex: 1; flex: 1;

View File

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

View File

@@ -0,0 +1,166 @@
import { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react';
import { User, AccessLevel } from '../types';
import { getCurrentUser, login as apiLogin, logout as apiLogout, getMyProjectAccess } from '../api';
interface PermissionCacheEntry {
accessLevel: AccessLevel | null;
timestamp: number;
}
interface AuthContextType {
user: User | null;
loading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
clearError: () => void;
getProjectPermission: (projectName: string) => Promise<AccessLevel | null>;
invalidatePermissionCache: (projectName?: string) => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
// Cache TTL in milliseconds (5 minutes)
const PERMISSION_CACHE_TTL = 5 * 60 * 1000;
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const permissionCacheRef = useRef<Map<string, PermissionCacheEntry>>(new Map());
// Clear permission cache
const clearPermissionCache = useCallback(() => {
permissionCacheRef.current.clear();
}, []);
// Check session on initial load
useEffect(() => {
async function checkAuth() {
try {
const currentUser = await getCurrentUser();
setUser(currentUser);
} catch {
setUser(null);
} finally {
setLoading(false);
}
}
checkAuth();
}, []);
const login = useCallback(async (username: string, password: string) => {
setLoading(true);
setError(null);
try {
const loggedInUser = await apiLogin({ username, password });
setUser(loggedInUser);
// Clear permission cache on login - permissions may have changed
clearPermissionCache();
} catch (err) {
const message = err instanceof Error ? err.message : 'Login failed';
setError(message);
throw err;
} finally {
setLoading(false);
}
}, [clearPermissionCache]);
const logout = useCallback(async () => {
setLoading(true);
setError(null);
try {
await apiLogout();
setUser(null);
// Clear permission cache on logout
clearPermissionCache();
} catch (err) {
const message = err instanceof Error ? err.message : 'Logout failed';
setError(message);
throw err;
} finally {
setLoading(false);
}
}, [clearPermissionCache]);
const clearError = useCallback(() => {
setError(null);
}, []);
const refreshUser = useCallback(async () => {
try {
const currentUser = await getCurrentUser();
setUser(currentUser);
} catch {
setUser(null);
}
}, []);
// Get project permission with caching
const getProjectPermission = useCallback(async (projectName: string): Promise<AccessLevel | null> => {
const cached = permissionCacheRef.current.get(projectName);
const now = Date.now();
// Return cached value if still valid
if (cached && (now - cached.timestamp) < PERMISSION_CACHE_TTL) {
return cached.accessLevel;
}
// Fetch fresh permission
try {
const result = await getMyProjectAccess(projectName);
const entry: PermissionCacheEntry = {
accessLevel: result.access_level,
timestamp: now,
};
permissionCacheRef.current.set(projectName, entry);
return result.access_level;
} catch {
// On error, cache null to avoid repeated failed requests
const entry: PermissionCacheEntry = {
accessLevel: null,
timestamp: now,
};
permissionCacheRef.current.set(projectName, entry);
return null;
}
}, []);
// Invalidate permission cache for a specific project or all projects
const invalidatePermissionCache = useCallback((projectName?: string) => {
if (projectName) {
permissionCacheRef.current.delete(projectName);
} else {
clearPermissionCache();
}
}, [clearPermissionCache]);
return (
<AuthContext.Provider value={{
user,
loading,
error,
login,
logout,
refreshUser,
clearError,
getProjectPermission,
invalidatePermissionCache,
}}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,580 @@
.api-keys-page {
max-width: 900px;
margin: 0 auto;
}
.api-keys-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
gap: 24px;
}
.api-keys-header-content h1 {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.api-keys-subtitle {
color: var(--text-tertiary);
font-size: 0.9375rem;
}
.api-keys-create-button {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: var(--accent-gradient);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
flex-shrink: 0;
}
.api-keys-create-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3);
}
.api-keys-create-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.api-keys-error {
display: flex;
align-items: center;
gap: 10px;
background: var(--error-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 0.875rem;
}
.api-keys-error svg {
flex-shrink: 0;
}
.api-keys-error span {
flex: 1;
}
.api-keys-error-dismiss {
background: transparent;
border: none;
padding: 4px;
color: var(--error);
cursor: pointer;
opacity: 0.7;
transition: opacity var(--transition-fast);
}
.api-keys-error-dismiss:hover {
opacity: 1;
}
.api-keys-new-key-banner {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.08) 100%);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
}
.api-keys-new-key-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
color: var(--accent-primary);
}
.api-keys-new-key-title {
font-size: 1rem;
font-weight: 600;
}
.api-keys-new-key-warning {
background: var(--warning-bg);
border: 1px solid rgba(245, 158, 11, 0.3);
color: var(--warning);
padding: 10px 14px;
border-radius: var(--radius-md);
font-size: 0.8125rem;
font-weight: 500;
margin-bottom: 16px;
}
.api-keys-new-key-value-container {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.api-keys-new-key-value {
flex: 1;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 14px 16px;
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, monospace;
font-size: 0.8125rem;
color: var(--text-primary);
word-break: break-all;
line-height: 1.5;
}
.api-keys-copy-button {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.api-keys-copy-button:hover {
background: var(--bg-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
}
.api-keys-done-button {
padding: 10px 20px;
background: var(--accent-gradient);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
}
.api-keys-done-button:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.api-keys-create-form-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
}
.api-keys-create-form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.api-keys-create-form-header h2 {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.api-keys-create-form-close {
background: transparent;
border: none;
padding: 4px;
color: var(--text-tertiary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.api-keys-create-form-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.api-keys-create-error {
background: var(--error-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
padding: 10px 14px;
border-radius: var(--radius-md);
font-size: 0.8125rem;
margin-bottom: 16px;
}
.api-keys-create-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.api-keys-form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.api-keys-form-group label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
}
.api-keys-form-group input {
padding: 12px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
color: var(--text-primary);
transition: all var(--transition-fast);
}
.api-keys-form-group input::placeholder {
color: var(--text-muted);
}
.api-keys-form-group input:hover:not(:disabled) {
border-color: var(--border-secondary);
background: var(--bg-elevated);
}
.api-keys-form-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
background: var(--bg-elevated);
}
.api-keys-form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.api-keys-form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
.api-keys-cancel-button {
padding: 10px 18px;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.api-keys-cancel-button:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
}
.api-keys-cancel-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.api-keys-submit-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 18px;
background: var(--accent-gradient);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
min-width: 110px;
}
.api-keys-submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
}
.api-keys-submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.api-keys-button-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: api-keys-spin 0.6s linear infinite;
}
@keyframes api-keys-spin {
to {
transform: rotate(360deg);
}
}
.api-keys-list-container {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
.api-keys-list-loading,
.api-keys-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 64px 24px;
color: var(--text-tertiary);
font-size: 0.9375rem;
}
.api-keys-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-secondary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: api-keys-spin 0.6s linear infinite;
}
.api-keys-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
text-align: center;
}
.api-keys-empty-icon {
color: var(--text-muted);
margin-bottom: 16px;
opacity: 0.5;
}
.api-keys-empty h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.api-keys-empty p {
color: var(--text-tertiary);
font-size: 0.875rem;
}
.api-keys-list {
display: flex;
flex-direction: column;
}
.api-keys-list-header {
display: grid;
grid-template-columns: 1fr 160px 160px 140px;
gap: 16px;
padding: 14px 20px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-primary);
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.api-keys-list-item {
display: grid;
grid-template-columns: 1fr 160px 160px 140px;
gap: 16px;
padding: 16px 20px;
align-items: center;
border-bottom: 1px solid var(--border-primary);
transition: background var(--transition-fast);
}
.api-keys-list-item:last-child {
border-bottom: none;
}
.api-keys-list-item:hover {
background: var(--bg-tertiary);
}
.api-keys-item-name {
font-weight: 500;
color: var(--text-primary);
font-size: 0.9375rem;
}
.api-keys-item-description {
color: var(--text-tertiary);
font-size: 0.8125rem;
margin-top: 4px;
}
.api-keys-col-created,
.api-keys-col-used {
color: var(--text-secondary);
font-size: 0.8125rem;
}
.api-keys-col-actions {
display: flex;
justify-content: flex-end;
}
.api-keys-revoke-button {
padding: 6px 14px;
background: transparent;
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
font-size: 0.8125rem;
font-weight: 500;
color: var(--error);
cursor: pointer;
transition: all var(--transition-fast);
}
.api-keys-revoke-button:hover {
background: var(--error-bg);
border-color: rgba(239, 68, 68, 0.5);
}
.api-keys-delete-confirm {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.api-keys-confirm-yes {
padding: 4px 12px;
background: var(--error);
border: none;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
}
.api-keys-confirm-yes:hover:not(:disabled) {
opacity: 0.9;
}
.api-keys-confirm-yes:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.api-keys-confirm-no {
padding: 4px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.api-keys-confirm-no:hover:not(:disabled) {
background: var(--bg-hover);
}
.api-keys-confirm-no:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 768px) {
.api-keys-header {
flex-direction: column;
align-items: stretch;
}
.api-keys-create-button {
align-self: flex-start;
}
.api-keys-list-header {
display: none;
}
.api-keys-list-item {
grid-template-columns: 1fr;
gap: 8px;
}
.api-keys-col-name {
order: 1;
}
.api-keys-col-created,
.api-keys-col-used {
font-size: 0.75rem;
}
.api-keys-col-created::before {
content: 'Created: ';
color: var(--text-muted);
}
.api-keys-col-used::before {
content: 'Last used: ';
color: var(--text-muted);
}
.api-keys-col-actions {
justify-content: flex-start;
margin-top: 8px;
}
.api-keys-new-key-value-container {
flex-direction: column;
}
.api-keys-copy-button {
align-self: flex-start;
}
}

View File

@@ -0,0 +1,371 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { listAPIKeys, createAPIKey, deleteAPIKey } from '../api';
import { APIKey, APIKeyCreateResponse } from '../types';
import './APIKeysPage.css';
function APIKeysPage() {
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [keys, setKeys] = useState<APIKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [createName, setCreateName] = useState('');
const [createDescription, setCreateDescription] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<APIKeyCreateResponse | null>(null);
const [copied, setCopied] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
if (!authLoading && !user) {
navigate('/login', { state: { from: '/settings/api-keys' } });
}
}, [user, authLoading, navigate]);
useEffect(() => {
if (user) {
loadKeys();
}
}, [user]);
async function loadKeys() {
setLoading(true);
setError(null);
try {
const data = await listAPIKeys();
setKeys(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load API keys');
} finally {
setLoading(false);
}
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!createName.trim()) {
setCreateError('Name is required');
return;
}
setIsCreating(true);
setCreateError(null);
try {
const response = await createAPIKey({
name: createName.trim(),
description: createDescription.trim() || undefined,
});
setNewlyCreatedKey(response);
setShowCreateForm(false);
setCreateName('');
setCreateDescription('');
await loadKeys();
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Failed to create API key');
} finally {
setIsCreating(false);
}
}
async function handleDelete(id: string) {
setIsDeleting(true);
try {
await deleteAPIKey(id);
setDeleteConfirmId(null);
await loadKeys();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to revoke API key');
} finally {
setIsDeleting(false);
}
}
async function handleCopyKey() {
if (newlyCreatedKey) {
try {
await navigator.clipboard.writeText(newlyCreatedKey.key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
setError('Failed to copy to clipboard');
}
}
}
function handleDismissNewKey() {
setNewlyCreatedKey(null);
setCopied(false);
}
function formatDate(dateString: string | null): string {
if (!dateString) return 'Never';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
if (authLoading) {
return (
<div className="api-keys-page">
<div className="api-keys-loading">
<div className="api-keys-spinner"></div>
<span>Loading...</span>
</div>
</div>
);
}
if (!user) {
return null;
}
return (
<div className="api-keys-page">
<div className="api-keys-header">
<div className="api-keys-header-content">
<h1>API Keys</h1>
<p className="api-keys-subtitle">
Manage API keys for programmatic access to Orchard
</p>
</div>
<button
className="api-keys-create-button"
onClick={() => setShowCreateForm(true)}
disabled={showCreateForm}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Create New Key
</button>
</div>
{error && (
<div className="api-keys-error">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>{error}</span>
<button onClick={() => setError(null)} className="api-keys-error-dismiss">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
)}
{newlyCreatedKey && (
<div className="api-keys-new-key-banner">
<div className="api-keys-new-key-header">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span className="api-keys-new-key-title">New API Key Created</span>
</div>
<div className="api-keys-new-key-warning">
Copy this key now! It won't be shown again.
</div>
<div className="api-keys-new-key-value-container">
<code className="api-keys-new-key-value">{newlyCreatedKey.key}</code>
<button
className="api-keys-copy-button"
onClick={handleCopyKey}
title="Copy to clipboard"
>
{copied ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
)}
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<button className="api-keys-done-button" onClick={handleDismissNewKey}>
Done
</button>
</div>
)}
{showCreateForm && (
<div className="api-keys-create-form-card">
<div className="api-keys-create-form-header">
<h2>Create New API Key</h2>
<button
className="api-keys-create-form-close"
onClick={() => {
setShowCreateForm(false);
setCreateName('');
setCreateDescription('');
setCreateError(null);
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{createError && (
<div className="api-keys-create-error">
{createError}
</div>
)}
<form onSubmit={handleCreate} className="api-keys-create-form">
<div className="api-keys-form-group">
<label htmlFor="key-name">Name</label>
<input
id="key-name"
type="text"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder="e.g., CI/CD Pipeline, Local Development"
autoFocus
disabled={isCreating}
/>
</div>
<div className="api-keys-form-group">
<label htmlFor="key-description">Description (optional)</label>
<input
id="key-description"
type="text"
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="What will this key be used for?"
disabled={isCreating}
/>
</div>
<div className="api-keys-form-actions">
<button
type="button"
className="api-keys-cancel-button"
onClick={() => {
setShowCreateForm(false);
setCreateName('');
setCreateDescription('');
setCreateError(null);
}}
disabled={isCreating}
>
Cancel
</button>
<button
type="submit"
className="api-keys-submit-button"
disabled={isCreating || !createName.trim()}
>
{isCreating ? (
<>
<span className="api-keys-button-spinner"></span>
Creating...
</>
) : (
'Create Key'
)}
</button>
</div>
</form>
</div>
)}
<div className="api-keys-list-container">
{loading ? (
<div className="api-keys-list-loading">
<div className="api-keys-spinner"></div>
<span>Loading API keys...</span>
</div>
) : keys.length === 0 ? (
<div className="api-keys-empty">
<div className="api-keys-empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>
</div>
<h3>No API Keys</h3>
<p>Create an API key to access Orchard programmatically</p>
</div>
) : (
<div className="api-keys-list">
<div className="api-keys-list-header">
<span className="api-keys-col-name">Name</span>
<span className="api-keys-col-created">Created</span>
<span className="api-keys-col-used">Last Used</span>
<span className="api-keys-col-actions">Actions</span>
</div>
{keys.map((key) => (
<div key={key.id} className="api-keys-list-item">
<div className="api-keys-col-name">
<div className="api-keys-item-name">{key.name}</div>
{key.description && (
<div className="api-keys-item-description">{key.description}</div>
)}
</div>
<div className="api-keys-col-created">
{formatDate(key.created_at)}
</div>
<div className="api-keys-col-used">
{formatDate(key.last_used)}
</div>
<div className="api-keys-col-actions">
{deleteConfirmId === key.id ? (
<div className="api-keys-delete-confirm">
<span>Revoke?</span>
<button
className="api-keys-confirm-yes"
onClick={() => handleDelete(key.id)}
disabled={isDeleting}
>
{isDeleting ? 'Revoking...' : 'Yes'}
</button>
<button
className="api-keys-confirm-no"
onClick={() => setDeleteConfirmId(null)}
disabled={isDeleting}
>
No
</button>
</div>
) : (
<button
className="api-keys-revoke-button"
onClick={() => setDeleteConfirmId(key.id)}
>
Revoke
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
export default APIKeysPage;

View File

@@ -0,0 +1,405 @@
.admin-oidc-page {
max-width: 800px;
margin: 0 auto;
}
.admin-oidc-header {
margin-bottom: 32px;
}
.admin-oidc-header-content h1 {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.admin-oidc-subtitle {
color: var(--text-tertiary);
font-size: 0.9375rem;
}
.admin-oidc-success {
display: flex;
align-items: center;
gap: 10px;
background: var(--success-bg);
border: 1px solid rgba(34, 197, 94, 0.2);
color: var(--success);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 0.875rem;
animation: admin-oidc-fade-in 0.2s ease;
}
@keyframes admin-oidc-fade-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.admin-oidc-error {
display: flex;
align-items: center;
gap: 10px;
background: var(--error-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 0.875rem;
}
.admin-oidc-error svg {
flex-shrink: 0;
}
.admin-oidc-error span {
flex: 1;
}
.admin-oidc-error-dismiss {
background: transparent;
border: none;
padding: 4px;
color: var(--error);
cursor: pointer;
opacity: 0.7;
transition: opacity var(--transition-fast);
}
.admin-oidc-error-dismiss:hover {
opacity: 1;
}
.admin-oidc-access-denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 24px;
text-align: center;
}
.admin-oidc-access-denied-icon {
color: var(--error);
margin-bottom: 24px;
opacity: 0.8;
}
.admin-oidc-access-denied h2 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.admin-oidc-access-denied p {
color: var(--text-tertiary);
font-size: 0.9375rem;
max-width: 400px;
}
.admin-oidc-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
}
.admin-oidc-section {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border-primary);
}
.admin-oidc-section:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.admin-oidc-section h2 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
}
.admin-oidc-form-group {
margin-bottom: 16px;
}
.admin-oidc-form-group:last-child {
margin-bottom: 0;
}
.admin-oidc-form-group label {
display: block;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.admin-oidc-form-group input[type="text"],
.admin-oidc-form-group input[type="password"],
.admin-oidc-form-group input[type="url"] {
width: 100%;
padding: 12px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
color: var(--text-primary);
transition: all var(--transition-fast);
}
.admin-oidc-form-group input::placeholder {
color: var(--text-muted);
}
.admin-oidc-form-group input:hover:not(:disabled) {
border-color: var(--border-secondary);
background: var(--bg-elevated);
}
.admin-oidc-form-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
background: var(--bg-elevated);
}
.admin-oidc-form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.admin-oidc-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.admin-oidc-field-help {
margin-top: 6px;
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.4;
}
.admin-oidc-field-help code {
background: var(--bg-tertiary);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.6875rem;
}
.admin-oidc-secret-status {
color: var(--success);
font-weight: 400;
font-size: 0.75rem;
}
.admin-oidc-toggle-group {
margin-bottom: 16px;
}
.admin-oidc-toggle-label {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
user-select: none;
}
.admin-oidc-toggle-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.admin-oidc-toggle-custom {
width: 44px;
height: 24px;
background: var(--bg-tertiary);
border: 1px solid var(--border-secondary);
border-radius: 12px;
transition: all var(--transition-fast);
position: relative;
flex-shrink: 0;
}
.admin-oidc-toggle-custom::after {
content: '';
position: absolute;
left: 2px;
top: 2px;
width: 18px;
height: 18px;
background: var(--text-muted);
border-radius: 50%;
transition: all var(--transition-fast);
}
.admin-oidc-toggle-label input[type="checkbox"]:checked + .admin-oidc-toggle-custom {
background: var(--accent-primary);
border-color: var(--accent-primary);
}
.admin-oidc-toggle-label input[type="checkbox"]:checked + .admin-oidc-toggle-custom::after {
left: 22px;
background: white;
}
.admin-oidc-toggle-label input[type="checkbox"]:focus + .admin-oidc-toggle-custom {
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
}
.admin-oidc-toggle-label:hover .admin-oidc-toggle-custom {
border-color: var(--accent-primary);
}
.admin-oidc-form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--border-primary);
}
.admin-oidc-cancel-button {
padding: 10px 18px;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.admin-oidc-cancel-button:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
}
.admin-oidc-cancel-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.admin-oidc-submit-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 18px;
background: var(--accent-gradient);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
min-width: 160px;
}
.admin-oidc-submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
}
.admin-oidc-submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.admin-oidc-button-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: admin-oidc-spin 0.6s linear infinite;
}
@keyframes admin-oidc-spin {
to {
transform: rotate(360deg);
}
}
.admin-oidc-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 64px 24px;
color: var(--text-tertiary);
font-size: 0.9375rem;
}
.admin-oidc-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-secondary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: admin-oidc-spin 0.6s linear infinite;
}
.admin-oidc-info-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: 20px 24px;
}
.admin-oidc-info-card h3 {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.admin-oidc-info-card p {
font-size: 0.8125rem;
color: var(--text-tertiary);
margin-bottom: 12px;
}
.admin-oidc-callback-url {
display: block;
background: var(--bg-tertiary);
padding: 12px 16px;
border-radius: var(--radius-md);
font-size: 0.8125rem;
color: var(--text-primary);
word-break: break-all;
}
@media (max-width: 640px) {
.admin-oidc-form-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,342 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { getOIDCConfig, updateOIDCConfig } from '../api';
import { OIDCConfig } from '../types';
import './AdminOIDCPage.css';
function AdminOIDCPage() {
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [config, setConfig] = useState<OIDCConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Form state
const [enabled, setEnabled] = useState(false);
const [issuerUrl, setIssuerUrl] = useState('');
const [clientId, setClientId] = useState('');
const [clientSecret, setClientSecret] = useState('');
const [scopes, setScopes] = useState('openid profile email');
const [autoCreateUsers, setAutoCreateUsers] = useState(true);
const [adminGroup, setAdminGroup] = useState('');
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!authLoading && !user) {
navigate('/login', { state: { from: '/admin/oidc' } });
}
}, [user, authLoading, navigate]);
useEffect(() => {
if (user && user.is_admin) {
loadConfig();
}
}, [user]);
useEffect(() => {
if (successMessage) {
const timer = setTimeout(() => setSuccessMessage(null), 3000);
return () => clearTimeout(timer);
}
}, [successMessage]);
async function loadConfig() {
setLoading(true);
setError(null);
try {
const data = await getOIDCConfig();
setConfig(data);
setEnabled(data.enabled);
setIssuerUrl(data.issuer_url);
setClientId(data.client_id);
setScopes(data.scopes.join(' '));
setAutoCreateUsers(data.auto_create_users);
setAdminGroup(data.admin_group);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load OIDC configuration');
} finally {
setLoading(false);
}
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (enabled && !issuerUrl.trim()) {
setError('Issuer URL is required when OIDC is enabled');
return;
}
if (enabled && !clientId.trim()) {
setError('Client ID is required when OIDC is enabled');
return;
}
setIsSaving(true);
setError(null);
try {
const scopesList = scopes.split(/\s+/).filter(s => s.length > 0);
const updateData: Record<string, unknown> = {
enabled,
issuer_url: issuerUrl.trim(),
client_id: clientId.trim(),
scopes: scopesList,
auto_create_users: autoCreateUsers,
admin_group: adminGroup.trim(),
};
if (clientSecret) {
updateData.client_secret = clientSecret;
}
await updateOIDCConfig(updateData);
setSuccessMessage('OIDC configuration saved successfully');
setClientSecret('');
await loadConfig();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save OIDC configuration');
} finally {
setIsSaving(false);
}
}
if (authLoading) {
return (
<div className="admin-oidc-page">
<div className="admin-oidc-loading">
<div className="admin-oidc-spinner"></div>
<span>Loading...</span>
</div>
</div>
);
}
if (!user) {
return null;
}
if (!user.is_admin) {
return (
<div className="admin-oidc-page">
<div className="admin-oidc-access-denied">
<div className="admin-oidc-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-oidc-page">
<div className="admin-oidc-header">
<div className="admin-oidc-header-content">
<h1>Single Sign-On (OIDC)</h1>
<p className="admin-oidc-subtitle">
Configure OpenID Connect for SSO authentication
</p>
</div>
</div>
{successMessage && (
<div className="admin-oidc-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-oidc-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-oidc-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>
)}
{loading ? (
<div className="admin-oidc-card">
<div className="admin-oidc-loading">
<div className="admin-oidc-spinner"></div>
<span>Loading configuration...</span>
</div>
</div>
) : (
<form onSubmit={handleSave} className="admin-oidc-card">
<div className="admin-oidc-section">
<h2>Status</h2>
<div className="admin-oidc-toggle-group">
<label className="admin-oidc-toggle-label">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
disabled={isSaving}
/>
<span className="admin-oidc-toggle-custom"></span>
Enable OIDC Authentication
</label>
<p className="admin-oidc-field-help">
When enabled, users can sign in using your organization's identity provider.
</p>
</div>
</div>
<div className="admin-oidc-section">
<h2>Provider Configuration</h2>
<div className="admin-oidc-form-group">
<label htmlFor="issuer-url">Issuer URL</label>
<input
id="issuer-url"
type="url"
value={issuerUrl}
onChange={(e) => setIssuerUrl(e.target.value)}
placeholder="https://your-provider.com"
disabled={isSaving}
/>
<p className="admin-oidc-field-help">
The base URL of your OIDC provider. Discovery document will be fetched from <code>/.well-known/openid-configuration</code>.
</p>
</div>
<div className="admin-oidc-form-row">
<div className="admin-oidc-form-group">
<label htmlFor="client-id">Client ID</label>
<input
id="client-id"
type="text"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder="your-client-id"
disabled={isSaving}
/>
</div>
<div className="admin-oidc-form-group">
<label htmlFor="client-secret">
Client Secret
{config?.has_client_secret && (
<span className="admin-oidc-secret-status"> (configured)</span>
)}
</label>
<input
id="client-secret"
type="password"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={config?.has_client_secret ? 'Leave blank to keep current' : 'Enter client secret'}
disabled={isSaving}
/>
</div>
</div>
<div className="admin-oidc-form-group">
<label htmlFor="scopes">Scopes</label>
<input
id="scopes"
type="text"
value={scopes}
onChange={(e) => setScopes(e.target.value)}
placeholder="openid profile email"
disabled={isSaving}
/>
<p className="admin-oidc-field-help">
Space-separated list of OIDC scopes to request. Common scopes: openid, profile, email, groups.
</p>
</div>
</div>
<div className="admin-oidc-section">
<h2>User Provisioning</h2>
<div className="admin-oidc-toggle-group">
<label className="admin-oidc-toggle-label">
<input
type="checkbox"
checked={autoCreateUsers}
onChange={(e) => setAutoCreateUsers(e.target.checked)}
disabled={isSaving}
/>
<span className="admin-oidc-toggle-custom"></span>
Auto-create users on first login
</label>
<p className="admin-oidc-field-help">
When enabled, new users will be created automatically when they sign in via OIDC for the first time.
</p>
</div>
<div className="admin-oidc-form-group">
<label htmlFor="admin-group">Admin Group (optional)</label>
<input
id="admin-group"
type="text"
value={adminGroup}
onChange={(e) => setAdminGroup(e.target.value)}
placeholder="admin, orchard-admins"
disabled={isSaving}
/>
<p className="admin-oidc-field-help">
Users in this group (from the groups claim) will be granted admin privileges. Leave blank to disable automatic admin assignment.
</p>
</div>
</div>
<div className="admin-oidc-form-actions">
<button
type="button"
className="admin-oidc-cancel-button"
onClick={loadConfig}
disabled={isSaving}
>
Reset
</button>
<button
type="submit"
className="admin-oidc-submit-button"
disabled={isSaving}
>
{isSaving ? (
<>
<span className="admin-oidc-button-spinner"></span>
Saving...
</>
) : (
'Save Configuration'
)}
</button>
</div>
</form>
)}
<div className="admin-oidc-info-card">
<h3>Callback URL</h3>
<p>Configure your identity provider with the following callback URL:</p>
<code className="admin-oidc-callback-url">
{window.location.origin}/api/v1/auth/oidc/callback
</code>
</div>
</div>
);
}
export default AdminOIDCPage;

View File

@@ -0,0 +1,667 @@
.admin-users-page {
max-width: 1100px;
margin: 0 auto;
}
.admin-users-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
gap: 24px;
}
.admin-users-header-content h1 {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.admin-users-subtitle {
color: var(--text-tertiary);
font-size: 0.9375rem;
}
.admin-users-create-button {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: var(--accent-gradient);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
flex-shrink: 0;
}
.admin-users-create-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3);
}
.admin-users-create-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.admin-users-success {
display: flex;
align-items: center;
gap: 10px;
background: var(--success-bg);
border: 1px solid rgba(34, 197, 94, 0.2);
color: var(--success);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 0.875rem;
animation: admin-users-fade-in 0.2s ease;
}
@keyframes admin-users-fade-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.admin-users-error {
display: flex;
align-items: center;
gap: 10px;
background: var(--error-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 0.875rem;
}
.admin-users-error svg {
flex-shrink: 0;
}
.admin-users-error span {
flex: 1;
}
.admin-users-error-dismiss {
background: transparent;
border: none;
padding: 4px;
color: var(--error);
cursor: pointer;
opacity: 0.7;
transition: opacity var(--transition-fast);
}
.admin-users-error-dismiss:hover {
opacity: 1;
}
.admin-users-access-denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 24px;
text-align: center;
}
.admin-users-access-denied-icon {
color: var(--error);
margin-bottom: 24px;
opacity: 0.8;
}
.admin-users-access-denied h2 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.admin-users-access-denied p {
color: var(--text-tertiary);
font-size: 0.9375rem;
max-width: 400px;
}
.admin-users-create-form-card,
.admin-users-reset-password-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
}
.admin-users-create-form-header,
.admin-users-reset-password-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.admin-users-create-form-header h2,
.admin-users-reset-password-header h2 {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.admin-users-create-form-close {
background: transparent;
border: none;
padding: 4px;
color: var(--text-tertiary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.admin-users-create-form-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.admin-users-reset-password-info {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 16px;
}
.admin-users-reset-password-info strong {
color: var(--text-primary);
}
.admin-users-create-error {
background: var(--error-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
padding: 10px 14px;
border-radius: var(--radius-md);
font-size: 0.8125rem;
margin-bottom: 16px;
}
.admin-users-create-form,
.admin-users-reset-password-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-users-form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.admin-users-form-group label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
}
.admin-users-form-group input[type="text"],
.admin-users-form-group input[type="password"],
.admin-users-form-group input[type="email"] {
padding: 12px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
color: var(--text-primary);
transition: all var(--transition-fast);
}
.admin-users-form-group input::placeholder {
color: var(--text-muted);
}
.admin-users-form-group input:hover:not(:disabled) {
border-color: var(--border-secondary);
background: var(--bg-elevated);
}
.admin-users-form-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
background: var(--bg-elevated);
}
.admin-users-form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.admin-users-checkbox-group {
flex-direction: row;
align-items: center;
}
.admin-users-checkbox-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 400;
color: var(--text-secondary);
user-select: none;
}
.admin-users-checkbox-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.admin-users-checkbox-custom {
width: 18px;
height: 18px;
background: var(--bg-tertiary);
border: 1px solid var(--border-secondary);
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
position: relative;
}
.admin-users-checkbox-label input[type="checkbox"]:checked + .admin-users-checkbox-custom {
background: var(--accent-primary);
border-color: var(--accent-primary);
}
.admin-users-checkbox-label input[type="checkbox"]:checked + .admin-users-checkbox-custom::after {
content: '';
position: absolute;
left: 5px;
top: 2px;
width: 5px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.admin-users-checkbox-label input[type="checkbox"]:focus + .admin-users-checkbox-custom {
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
}
.admin-users-checkbox-label:hover .admin-users-checkbox-custom {
border-color: var(--accent-primary);
}
.admin-users-form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
.admin-users-cancel-button {
padding: 10px 18px;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.admin-users-cancel-button:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
}
.admin-users-cancel-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.admin-users-submit-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 18px;
background: var(--accent-gradient);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
min-width: 120px;
}
.admin-users-submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
}
.admin-users-submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.admin-users-button-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: admin-users-spin 0.6s linear infinite;
}
@keyframes admin-users-spin {
to {
transform: rotate(360deg);
}
}
.admin-users-list-container {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
.admin-users-list-loading,
.admin-users-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 64px 24px;
color: var(--text-tertiary);
font-size: 0.9375rem;
}
.admin-users-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-secondary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: admin-users-spin 0.6s linear infinite;
}
.admin-users-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
text-align: center;
}
.admin-users-empty-icon {
color: var(--text-muted);
margin-bottom: 16px;
opacity: 0.5;
}
.admin-users-empty h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.admin-users-empty p {
color: var(--text-tertiary);
font-size: 0.875rem;
}
.admin-users-list {
display: flex;
flex-direction: column;
}
.admin-users-list-header {
display: grid;
grid-template-columns: 2fr 100px 140px 140px 1fr;
gap: 16px;
padding: 14px 20px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-primary);
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.admin-users-list-item {
display: grid;
grid-template-columns: 2fr 100px 140px 140px 1fr;
gap: 16px;
padding: 16px 20px;
align-items: center;
border-bottom: 1px solid var(--border-primary);
transition: background var(--transition-fast);
}
.admin-users-list-item:last-child {
border-bottom: none;
}
.admin-users-list-item:hover {
background: var(--bg-tertiary);
}
.admin-users-list-item.admin-users-inactive {
opacity: 0.6;
}
.admin-users-col-user {
display: flex;
align-items: center;
gap: 12px;
}
.admin-users-item-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent-gradient);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
}
.admin-users-item-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.admin-users-item-username {
font-weight: 500;
color: var(--text-primary);
font-size: 0.9375rem;
display: flex;
align-items: center;
gap: 8px;
}
.admin-users-admin-badge {
display: inline-flex;
padding: 2px 8px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.1) 100%);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 20px;
font-size: 0.6875rem;
font-weight: 600;
color: var(--accent-primary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.admin-users-item-email {
color: var(--text-tertiary);
font-size: 0.8125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-users-col-status {
display: flex;
align-items: center;
}
.admin-users-status-badge {
display: inline-flex;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
}
.admin-users-status-badge.active {
background: var(--success-bg);
color: var(--success);
}
.admin-users-status-badge.inactive {
background: var(--error-bg);
color: var(--error);
}
.admin-users-col-created,
.admin-users-col-login {
color: var(--text-secondary);
font-size: 0.8125rem;
}
.admin-users-col-actions {
display: flex;
justify-content: flex-end;
}
.admin-users-actions-menu {
display: flex;
gap: 6px;
}
.admin-users-action-button {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.admin-users-action-button:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
}
.admin-users-action-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.admin-users-action-spinner {
width: 12px;
height: 12px;
border: 2px solid var(--border-secondary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: admin-users-spin 0.6s linear infinite;
}
@media (max-width: 1024px) {
.admin-users-list-header {
grid-template-columns: 2fr 100px 1fr;
}
.admin-users-list-item {
grid-template-columns: 2fr 100px 1fr;
}
.admin-users-col-created,
.admin-users-col-login {
display: none;
}
.admin-users-list-header .admin-users-col-created,
.admin-users-list-header .admin-users-col-login {
display: none;
}
}
@media (max-width: 768px) {
.admin-users-header {
flex-direction: column;
align-items: stretch;
}
.admin-users-create-button {
align-self: flex-start;
}
.admin-users-list-header {
display: none;
}
.admin-users-list-item {
grid-template-columns: 1fr;
gap: 12px;
padding: 16px;
}
.admin-users-col-user {
order: 1;
}
.admin-users-col-status {
order: 2;
}
.admin-users-col-actions {
order: 3;
justify-content: flex-start;
}
.admin-users-actions-menu {
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,529 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { listUsers, createUser, updateUser, resetUserPassword } from '../api';
import { AdminUser } from '../types';
import './AdminUsersPage.css';
function AdminUsersPage() {
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [createUsername, setCreateUsername] = useState('');
const [createPassword, setCreatePassword] = useState('');
const [createEmail, setCreateEmail] = useState('');
const [createIsAdmin, setCreateIsAdmin] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [resetPasswordUsername, setResetPasswordUsername] = useState<string | null>(null);
const [newPassword, setNewPassword] = useState('');
const [isResetting, setIsResetting] = useState(false);
const [togglingUser, setTogglingUser] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
useEffect(() => {
if (!authLoading && !user) {
navigate('/login', { state: { from: '/admin/users' } });
}
}, [user, authLoading, navigate]);
useEffect(() => {
if (user && user.is_admin) {
loadUsers();
}
}, [user]);
useEffect(() => {
if (successMessage) {
const timer = setTimeout(() => setSuccessMessage(null), 3000);
return () => clearTimeout(timer);
}
}, [successMessage]);
async function loadUsers() {
setLoading(true);
setError(null);
try {
const data = await listUsers();
setUsers(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load users');
} finally {
setLoading(false);
}
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!createUsername.trim()) {
setCreateError('Username is required');
return;
}
if (!createPassword.trim()) {
setCreateError('Password is required');
return;
}
setIsCreating(true);
setCreateError(null);
try {
await createUser({
username: createUsername.trim(),
password: createPassword,
email: createEmail.trim() || undefined,
is_admin: createIsAdmin,
});
setShowCreateForm(false);
setCreateUsername('');
setCreatePassword('');
setCreateEmail('');
setCreateIsAdmin(false);
setSuccessMessage('User created successfully');
await loadUsers();
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Failed to create user');
} finally {
setIsCreating(false);
}
}
async function handleToggleAdmin(targetUser: AdminUser) {
setTogglingUser(targetUser.username);
try {
await updateUser(targetUser.username, { is_admin: !targetUser.is_admin });
setSuccessMessage(`${targetUser.username} is ${!targetUser.is_admin ? 'now' : 'no longer'} an admin`);
await loadUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update user');
} finally {
setTogglingUser(null);
}
}
async function handleToggleActive(targetUser: AdminUser) {
setTogglingUser(targetUser.username);
try {
await updateUser(targetUser.username, { is_active: !targetUser.is_active });
setSuccessMessage(`${targetUser.username} has been ${!targetUser.is_active ? 'enabled' : 'disabled'}`);
await loadUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update user');
} finally {
setTogglingUser(null);
}
}
async function handleResetPassword(e: React.FormEvent) {
e.preventDefault();
if (!resetPasswordUsername || !newPassword.trim()) {
return;
}
setIsResetting(true);
try {
await resetUserPassword(resetPasswordUsername, newPassword);
setResetPasswordUsername(null);
setNewPassword('');
setSuccessMessage(`Password reset for ${resetPasswordUsername}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reset password');
} finally {
setIsResetting(false);
}
}
function formatDate(dateString: string | null): string {
if (!dateString) return 'Never';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
if (authLoading) {
return (
<div className="admin-users-page">
<div className="admin-users-loading">
<div className="admin-users-spinner"></div>
<span>Loading...</span>
</div>
</div>
);
}
if (!user) {
return null;
}
if (!user.is_admin) {
return (
<div className="admin-users-page">
<div className="admin-users-access-denied">
<div className="admin-users-access-denied-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
</div>
<h2>Access Denied</h2>
<p>You do not have permission to access this page. Admin privileges are required.</p>
</div>
</div>
);
}
return (
<div className="admin-users-page">
<div className="admin-users-header">
<div className="admin-users-header-content">
<h1>User Management</h1>
<p className="admin-users-subtitle">
Manage user accounts and permissions
</p>
</div>
<button
className="admin-users-create-button"
onClick={() => setShowCreateForm(true)}
disabled={showCreateForm}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Create User
</button>
</div>
{successMessage && (
<div className="admin-users-success">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<span>{successMessage}</span>
</div>
)}
{error && (
<div className="admin-users-error">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>{error}</span>
<button onClick={() => setError(null)} className="admin-users-error-dismiss">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
)}
{showCreateForm && (
<div className="admin-users-create-form-card">
<div className="admin-users-create-form-header">
<h2>Create New User</h2>
<button
className="admin-users-create-form-close"
onClick={() => {
setShowCreateForm(false);
setCreateUsername('');
setCreatePassword('');
setCreateEmail('');
setCreateIsAdmin(false);
setCreateError(null);
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{createError && (
<div className="admin-users-create-error">
{createError}
</div>
)}
<form onSubmit={handleCreate} className="admin-users-create-form">
<div className="admin-users-form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={createUsername}
onChange={(e) => setCreateUsername(e.target.value)}
placeholder="Enter username"
autoFocus
disabled={isCreating}
/>
</div>
<div className="admin-users-form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
placeholder="Enter password"
disabled={isCreating}
/>
</div>
<div className="admin-users-form-group">
<label htmlFor="email">Email (optional)</label>
<input
id="email"
type="email"
value={createEmail}
onChange={(e) => setCreateEmail(e.target.value)}
placeholder="user@example.com"
disabled={isCreating}
/>
</div>
<div className="admin-users-form-group admin-users-checkbox-group">
<label className="admin-users-checkbox-label">
<input
type="checkbox"
checked={createIsAdmin}
onChange={(e) => setCreateIsAdmin(e.target.checked)}
disabled={isCreating}
/>
<span className="admin-users-checkbox-custom"></span>
Grant admin privileges
</label>
</div>
<div className="admin-users-form-actions">
<button
type="button"
className="admin-users-cancel-button"
onClick={() => {
setShowCreateForm(false);
setCreateUsername('');
setCreatePassword('');
setCreateEmail('');
setCreateIsAdmin(false);
setCreateError(null);
}}
disabled={isCreating}
>
Cancel
</button>
<button
type="submit"
className="admin-users-submit-button"
disabled={isCreating || !createUsername.trim() || !createPassword.trim()}
>
{isCreating ? (
<>
<span className="admin-users-button-spinner"></span>
Creating...
</>
) : (
'Create User'
)}
</button>
</div>
</form>
</div>
)}
{resetPasswordUsername && (
<div className="admin-users-reset-password-card">
<div className="admin-users-reset-password-header">
<h2>Reset Password</h2>
<button
className="admin-users-create-form-close"
onClick={() => {
setResetPasswordUsername(null);
setNewPassword('');
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<p className="admin-users-reset-password-info">
Set a new password for <strong>{resetPasswordUsername}</strong>
</p>
<form onSubmit={handleResetPassword} className="admin-users-reset-password-form">
<div className="admin-users-form-group">
<label htmlFor="new-password">New Password</label>
<input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
autoFocus
disabled={isResetting}
/>
</div>
<div className="admin-users-form-actions">
<button
type="button"
className="admin-users-cancel-button"
onClick={() => {
setResetPasswordUsername(null);
setNewPassword('');
}}
disabled={isResetting}
>
Cancel
</button>
<button
type="submit"
className="admin-users-submit-button"
disabled={isResetting || !newPassword.trim()}
>
{isResetting ? (
<>
<span className="admin-users-button-spinner"></span>
Resetting...
</>
) : (
'Reset Password'
)}
</button>
</div>
</form>
</div>
)}
<div className="admin-users-list-container">
{loading ? (
<div className="admin-users-list-loading">
<div className="admin-users-spinner"></div>
<span>Loading users...</span>
</div>
) : users.length === 0 ? (
<div className="admin-users-empty">
<div className="admin-users-empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<h3>No Users</h3>
<p>Create a user to get started</p>
</div>
) : (
<div className="admin-users-list">
<div className="admin-users-list-header">
<span className="admin-users-col-user">User</span>
<span className="admin-users-col-status">Status</span>
<span className="admin-users-col-created">Created</span>
<span className="admin-users-col-login">Last Login</span>
<span className="admin-users-col-actions">Actions</span>
</div>
{users.map((u) => (
<div key={u.id} className={`admin-users-list-item ${!u.is_active ? 'admin-users-inactive' : ''}`}>
<div className="admin-users-col-user">
<div className="admin-users-item-avatar">
{u.username.charAt(0).toUpperCase()}
</div>
<div className="admin-users-item-info">
<div className="admin-users-item-username">
{u.username}
{u.is_admin && <span className="admin-users-admin-badge">Admin</span>}
</div>
{u.email && (
<div className="admin-users-item-email">{u.email}</div>
)}
</div>
</div>
<div className="admin-users-col-status">
<span className={`admin-users-status-badge ${u.is_active ? 'active' : 'inactive'}`}>
{u.is_active ? 'Active' : 'Disabled'}
</span>
</div>
<div className="admin-users-col-created">
{formatDate(u.created_at)}
</div>
<div className="admin-users-col-login">
{formatDate(u.last_login)}
</div>
<div className="admin-users-col-actions">
<div className="admin-users-actions-menu">
<button
className="admin-users-action-button"
onClick={() => handleToggleAdmin(u)}
disabled={togglingUser === u.username || u.username === user.username}
title={u.is_admin ? 'Remove admin' : 'Make admin'}
>
{togglingUser === u.username ? (
<span className="admin-users-action-spinner"></span>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
)}
{u.is_admin ? 'Revoke' : 'Admin'}
</button>
<button
className="admin-users-action-button"
onClick={() => handleToggleActive(u)}
disabled={togglingUser === u.username || u.username === user.username}
title={u.is_active ? 'Disable user' : 'Enable user'}
>
{togglingUser === u.username ? (
<span className="admin-users-action-spinner"></span>
) : u.is_active ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
)}
{u.is_active ? 'Disable' : 'Enable'}
</button>
<button
className="admin-users-action-button"
onClick={() => setResetPasswordUsername(u.username)}
disabled={togglingUser === u.username}
title="Reset password"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
Reset
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
export default AdminUsersPage;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,292 @@
/* Login Page - Full viewport centered layout */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
padding: 24px;
position: relative;
overflow: hidden;
}
/* Subtle background pattern */
.login-page::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 50%, rgba(16, 185, 129, 0.08) 0%, transparent 50%),
radial-gradient(circle at 80% 50%, rgba(16, 185, 129, 0.05) 0%, transparent 50%);
pointer-events: none;
}
.login-container {
width: 100%;
max-width: 400px;
position: relative;
z-index: 1;
}
/* Card styling */
.login-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
padding: 40px;
box-shadow: var(--shadow-lg);
}
/* Header section */
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
background: var(--accent-gradient);
border-radius: var(--radius-lg);
color: white;
margin-bottom: 24px;
box-shadow: var(--shadow-glow);
}
.login-header h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.login-subtitle {
color: var(--text-tertiary);
font-size: 0.875rem;
}
.login-subtitle.login-warning {
color: var(--warning);
font-weight: 500;
}
/* Error message */
.login-error {
display: flex;
align-items: center;
gap: 10px;
background: var(--error-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 0.875rem;
}
.login-error svg {
flex-shrink: 0;
}
/* Form styling */
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.login-form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.login-form-group label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
}
.login-form-group input {
width: 100%;
padding: 14px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.9375rem;
color: var(--text-primary);
transition: all var(--transition-fast);
}
.login-form-group input::placeholder {
color: var(--text-muted);
}
.login-form-group input:hover:not(:disabled) {
border-color: var(--border-secondary);
background: var(--bg-elevated);
}
.login-form-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
background: var(--bg-elevated);
}
.login-form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Submit button */
.login-submit {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px 20px;
background: var(--accent-gradient);
border: none;
border-radius: var(--radius-md);
font-size: 0.9375rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
margin-top: 8px;
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
}
.login-submit:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3);
}
.login-submit:active:not(:disabled) {
transform: translateY(0);
}
.login-submit:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
/* Loading spinner */
.login-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Loading state */
.login-loading {
text-align: center;
padding: 64px 32px;
color: var(--text-tertiary);
font-size: 0.9375rem;
}
/* Footer */
.login-footer {
text-align: center;
margin-top: 24px;
padding-top: 24px;
}
.login-footer p {
color: var(--text-muted);
font-size: 0.8125rem;
}
/* SSO Divider */
.login-divider {
display: flex;
align-items: center;
gap: 16px;
margin: 24px 0;
}
.login-divider::before,
.login-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-primary);
}
.login-divider span {
font-size: 0.8125rem;
color: var(--text-muted);
text-transform: lowercase;
}
/* SSO Button */
.login-sso-button {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px 20px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.9375rem;
font-weight: 500;
color: var(--text-primary);
text-decoration: none;
cursor: pointer;
transition: all var(--transition-fast);
}
.login-sso-button:hover {
background: var(--bg-hover);
border-color: var(--border-secondary);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.login-sso-button:active {
transform: translateY(0);
}
.login-sso-button svg {
color: var(--accent-primary);
}
/* Responsive adjustments */
@media (max-width: 480px) {
.login-card {
padding: 32px 24px;
}
.login-logo {
width: 64px;
height: 64px;
}
.login-logo svg {
width: 36px;
height: 36px;
}
.login-header h1 {
font-size: 1.25rem;
}
}

View File

@@ -0,0 +1,186 @@
import { useState, useEffect } from 'react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { getOIDCStatus, getOIDCLoginUrl } from '../api';
import { OIDCStatus } from '../types';
import './LoginPage.css';
function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [oidcStatus, setOidcStatus] = useState<OIDCStatus | null>(null);
const [searchParams] = useSearchParams();
const { user, login, loading: authLoading, refreshUser } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// Get the return URL from location state, default to home
const from = (location.state as { from?: string })?.from || '/';
// Load OIDC status on mount
useEffect(() => {
getOIDCStatus()
.then(setOidcStatus)
.catch(() => setOidcStatus({ enabled: false }));
}, []);
// Handle SSO callback - check for oidc_success or oidc_error params
useEffect(() => {
const oidcSuccess = searchParams.get('oidc_success');
const oidcError = searchParams.get('oidc_error');
if (oidcSuccess === 'true') {
refreshUser().then(() => {
navigate(from, { replace: true });
});
} else if (oidcError) {
setError(decodeURIComponent(oidcError));
}
}, [searchParams, refreshUser, navigate, from]);
// Redirect if already logged in
useEffect(() => {
if (user && !authLoading) {
navigate(from, { replace: true });
}
}, [user, authLoading, navigate, from]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!username.trim() || !password) {
setError('Please enter both username and password');
return;
}
setIsSubmitting(true);
setError(null);
try {
await login(username, password);
navigate(from, { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed. Please try again.');
} finally {
setIsSubmitting(false);
}
}
// Show loading while checking auth state
if (authLoading) {
return (
<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>
{oidcStatus?.enabled && (
<>
<div className="login-divider">
<span>or</span>
</div>
<a
href={getOIDCLoginUrl(from !== '/' ? from : undefined)}
className="login-sso-button"
>
<svg width="18" height="18" 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>
Sign in with SSO
</a>
</>
)}
</div>
<div className="login-footer">
<p>Artifact storage and management system</p>
</div>
</div>
</div>
);
}
export default LoginPage;

View File

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

View File

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

View File

@@ -1,3 +1,6 @@
// Access Control types (moved to top for use in Project interface)
export type AccessLevel = 'read' | 'write' | 'admin';
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
@@ -6,6 +9,9 @@ export interface Project {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
created_by: string; created_by: string;
// Access level info (populated when listing projects)
access_level?: AccessLevel | null;
is_owner?: boolean;
} }
export interface TagSummary { export interface TagSummary {
@@ -225,3 +231,127 @@ export interface CrossProjectStats {
bytes_saved_cross_project: number; bytes_saved_cross_project: number;
duplicates: CrossProjectDuplicate[]; duplicates: CrossProjectDuplicate[];
} }
// Auth types
export interface User {
id: string;
username: string;
display_name: string | null;
is_admin: boolean;
must_change_password?: boolean;
}
export interface LoginCredentials {
username: string;
password: string;
}
// API Key types
export interface APIKey {
id: string;
name: string;
description: string | null;
scopes: string[];
created_at: string;
expires_at: string | null;
last_used: string | null;
}
export interface APIKeyCreate {
name: string;
description?: string;
}
export interface APIKeyCreateResponse {
id: string;
name: string;
description: string | null;
scopes: string[];
key: string;
created_at: string;
expires_at: string | null;
}
// Admin User Management types
export interface AdminUser {
id: string;
username: string;
email: string | null;
display_name: string | null;
is_admin: boolean;
is_active: boolean;
created_at: string;
last_login: string | null;
}
export interface UserCreate {
username: string;
password: string;
email?: string;
is_admin?: boolean;
}
export interface UserUpdate {
email?: string;
is_admin?: boolean;
is_active?: boolean;
}
// Access Permission types
export interface AccessPermission {
id: string;
project_id: string;
user_id: string;
level: AccessLevel;
created_at: string;
expires_at: string | null;
}
export interface AccessPermissionCreate {
username: string;
level: AccessLevel;
expires_at?: string;
}
export interface AccessPermissionUpdate {
level?: AccessLevel;
expires_at?: string | null;
}
// Extended Project with user's access level
export interface ProjectWithAccess extends Project {
user_access_level?: AccessLevel;
}
// Current user with permissions context
export interface CurrentUser extends User {
permissions?: {
[projectId: string]: AccessLevel;
};
}
// OIDC types
export interface OIDCConfig {
enabled: boolean;
issuer_url: string;
client_id: string;
has_client_secret: boolean;
scopes: string[];
auto_create_users: boolean;
admin_group: string;
}
export interface OIDCConfigUpdate {
enabled?: boolean;
issuer_url?: string;
client_id?: string;
client_secret?: string;
scopes?: string[];
auto_create_users?: boolean;
admin_group?: string;
}
export interface OIDCStatus {
enabled: boolean;
issuer_url?: string;
}

View File

@@ -0,0 +1,86 @@
-- Authentication Tables Migration
-- Adds users table and updates api_keys with foreign key
-- Users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255),
email VARCHAR(255),
is_admin BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
must_change_password BOOLEAN DEFAULT FALSE,
oidc_subject VARCHAR(255),
oidc_issuer VARCHAR(512),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_login TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_oidc_subject ON users(oidc_subject) WHERE oidc_subject IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active) WHERE is_active = TRUE;
-- Sessions table for web login
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
last_accessed TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
user_agent VARCHAR(512),
ip_address VARCHAR(45)
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
-- Auth settings for OIDC configuration (future use)
CREATE TABLE IF NOT EXISTS auth_settings (
key VARCHAR(255) PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Add user_id foreign key to api_keys table
-- First add the column (nullable initially)
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS owner_id UUID REFERENCES users(id) ON DELETE CASCADE;
-- Add scopes column for API key permissions
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS scopes TEXT[] DEFAULT ARRAY['read', 'write'];
-- Add description column
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS description TEXT;
-- Create index for owner_id
CREATE INDEX IF NOT EXISTS idx_api_keys_owner_id ON api_keys(owner_id) WHERE owner_id IS NOT NULL;
-- Trigger to update users.updated_at
CREATE TRIGGER users_updated_at_trigger
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Trigger to update sessions.last_accessed on access
CREATE OR REPLACE FUNCTION update_session_last_accessed()
RETURNS TRIGGER AS $$
BEGIN
NEW.last_accessed = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Function to clean up expired sessions (can be called periodically)
CREATE OR REPLACE FUNCTION cleanup_expired_sessions()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM sessions WHERE expires_at < NOW();
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;