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:
30
CHANGELOG.md
30
CHANGELOG.md
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
1208
backend/app/auth.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
16
backend/app/rate_limit.py
Normal 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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
760
backend/tests/integration/test_auth_api.py
Normal file
760
backend/tests/integration/test_auth_api.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}` : ''}`;
|
||||||
|
}
|
||||||
|
|||||||
116
frontend/src/components/AccessManagement.css
Normal file
116
frontend/src/components/AccessManagement.css
Normal 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);
|
||||||
|
}
|
||||||
296
frontend/src/components/AccessManagement.tsx
Normal file
296
frontend/src/components/AccessManagement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
166
frontend/src/contexts/AuthContext.tsx
Normal file
166
frontend/src/contexts/AuthContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
580
frontend/src/pages/APIKeysPage.css
Normal file
580
frontend/src/pages/APIKeysPage.css
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
.api-keys-page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-header-content h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-subtitle {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-create-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-create-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-create-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--error-bg);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
color: var(--error);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-error svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-error span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-error-dismiss {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--error);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-error-dismiss:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-new-key-banner {
|
||||||
|
background: linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.08) 100%);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-new-key-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-new-key-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-new-key-warning {
|
||||||
|
background: var(--warning-bg);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
color: var(--warning);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-new-key-value-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-new-key-value {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-copy-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-copy-button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-done-button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-done-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-create-form-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-create-form-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-create-form-header h2 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-create-form-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-create-form-close:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-create-error {
|
||||||
|
background: var(--error-bg);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
color: var(--error);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-form-group label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-form-group input {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-form-group input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-form-group input:hover:not(:disabled) {
|
||||||
|
border-color: var(--border-secondary);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-form-group input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-cancel-button {
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-cancel-button:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-cancel-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-submit-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-submit-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-submit-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-button-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: api-keys-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes api-keys-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-list-container {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-list-loading,
|
||||||
|
.api-keys-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 64px 24px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--border-secondary);
|
||||||
|
border-top-color: var(--accent-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: api-keys-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 64px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-empty-icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-empty h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-empty p {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-list-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 160px 160px 140px;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-list-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 160px 160px 140px;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-list-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-list-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-item-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-item-description {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-col-created,
|
||||||
|
.api-keys-col-used {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-col-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-revoke-button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--error);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-revoke-button:hover {
|
||||||
|
background: var(--error-bg);
|
||||||
|
border-color: rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-delete-confirm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-confirm-yes {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--error);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-confirm-yes:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-confirm-yes:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-confirm-no {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-confirm-no:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-confirm-no:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.api-keys-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-create-button {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-list-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-list-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-col-name {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-col-created,
|
||||||
|
.api-keys-col-used {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-col-created::before {
|
||||||
|
content: 'Created: ';
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-col-used::before {
|
||||||
|
content: 'Last used: ';
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-col-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-new-key-value-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-copy-button {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
371
frontend/src/pages/APIKeysPage.tsx
Normal file
371
frontend/src/pages/APIKeysPage.tsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { listAPIKeys, createAPIKey, deleteAPIKey } from '../api';
|
||||||
|
import { APIKey, APIKeyCreateResponse } from '../types';
|
||||||
|
import './APIKeysPage.css';
|
||||||
|
|
||||||
|
function APIKeysPage() {
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [keys, setKeys] = useState<APIKey[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [createName, setCreateName] = useState('');
|
||||||
|
const [createDescription, setCreateDescription] = useState('');
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [newlyCreatedKey, setNewlyCreatedKey] = useState<APIKeyCreateResponse | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !user) {
|
||||||
|
navigate('/login', { state: { from: '/settings/api-keys' } });
|
||||||
|
}
|
||||||
|
}, [user, authLoading, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
loadKeys();
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
async function loadKeys() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await listAPIKeys();
|
||||||
|
setKeys(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load API keys');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!createName.trim()) {
|
||||||
|
setCreateError('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
const response = await createAPIKey({
|
||||||
|
name: createName.trim(),
|
||||||
|
description: createDescription.trim() || undefined,
|
||||||
|
});
|
||||||
|
setNewlyCreatedKey(response);
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setCreateName('');
|
||||||
|
setCreateDescription('');
|
||||||
|
await loadKeys();
|
||||||
|
} catch (err) {
|
||||||
|
setCreateError(err instanceof Error ? err.message : 'Failed to create API key');
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteAPIKey(id);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
await loadKeys();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to revoke API key');
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyKey() {
|
||||||
|
if (newlyCreatedKey) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(newlyCreatedKey.key);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
setError('Failed to copy to clipboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDismissNewKey() {
|
||||||
|
setNewlyCreatedKey(null);
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string | null): string {
|
||||||
|
if (!dateString) return 'Never';
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="api-keys-page">
|
||||||
|
<div className="api-keys-loading">
|
||||||
|
<div className="api-keys-spinner"></div>
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="api-keys-page">
|
||||||
|
<div className="api-keys-header">
|
||||||
|
<div className="api-keys-header-content">
|
||||||
|
<h1>API Keys</h1>
|
||||||
|
<p className="api-keys-subtitle">
|
||||||
|
Manage API keys for programmatic access to Orchard
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="api-keys-create-button"
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
disabled={showCreateForm}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Create New Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="api-keys-error">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => setError(null)} className="api-keys-error-dismiss">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{newlyCreatedKey && (
|
||||||
|
<div className="api-keys-new-key-banner">
|
||||||
|
<div className="api-keys-new-key-header">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
</svg>
|
||||||
|
<span className="api-keys-new-key-title">New API Key Created</span>
|
||||||
|
</div>
|
||||||
|
<div className="api-keys-new-key-warning">
|
||||||
|
Copy this key now! It won't be shown again.
|
||||||
|
</div>
|
||||||
|
<div className="api-keys-new-key-value-container">
|
||||||
|
<code className="api-keys-new-key-value">{newlyCreatedKey.key}</code>
|
||||||
|
<button
|
||||||
|
className="api-keys-copy-button"
|
||||||
|
onClick={handleCopyKey}
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button className="api-keys-done-button" onClick={handleDismissNewKey}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="api-keys-create-form-card">
|
||||||
|
<div className="api-keys-create-form-header">
|
||||||
|
<h2>Create New API Key</h2>
|
||||||
|
<button
|
||||||
|
className="api-keys-create-form-close"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setCreateName('');
|
||||||
|
setCreateDescription('');
|
||||||
|
setCreateError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createError && (
|
||||||
|
<div className="api-keys-create-error">
|
||||||
|
{createError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleCreate} className="api-keys-create-form">
|
||||||
|
<div className="api-keys-form-group">
|
||||||
|
<label htmlFor="key-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="key-name"
|
||||||
|
type="text"
|
||||||
|
value={createName}
|
||||||
|
onChange={(e) => setCreateName(e.target.value)}
|
||||||
|
placeholder="e.g., CI/CD Pipeline, Local Development"
|
||||||
|
autoFocus
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="api-keys-form-group">
|
||||||
|
<label htmlFor="key-description">Description (optional)</label>
|
||||||
|
<input
|
||||||
|
id="key-description"
|
||||||
|
type="text"
|
||||||
|
value={createDescription}
|
||||||
|
onChange={(e) => setCreateDescription(e.target.value)}
|
||||||
|
placeholder="What will this key be used for?"
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="api-keys-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="api-keys-cancel-button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setCreateName('');
|
||||||
|
setCreateDescription('');
|
||||||
|
setCreateError(null);
|
||||||
|
}}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="api-keys-submit-button"
|
||||||
|
disabled={isCreating || !createName.trim()}
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<>
|
||||||
|
<span className="api-keys-button-spinner"></span>
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Create Key'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="api-keys-list-container">
|
||||||
|
{loading ? (
|
||||||
|
<div className="api-keys-list-loading">
|
||||||
|
<div className="api-keys-spinner"></div>
|
||||||
|
<span>Loading API keys...</span>
|
||||||
|
</div>
|
||||||
|
) : keys.length === 0 ? (
|
||||||
|
<div className="api-keys-empty">
|
||||||
|
<div className="api-keys-empty-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>No API Keys</h3>
|
||||||
|
<p>Create an API key to access Orchard programmatically</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="api-keys-list">
|
||||||
|
<div className="api-keys-list-header">
|
||||||
|
<span className="api-keys-col-name">Name</span>
|
||||||
|
<span className="api-keys-col-created">Created</span>
|
||||||
|
<span className="api-keys-col-used">Last Used</span>
|
||||||
|
<span className="api-keys-col-actions">Actions</span>
|
||||||
|
</div>
|
||||||
|
{keys.map((key) => (
|
||||||
|
<div key={key.id} className="api-keys-list-item">
|
||||||
|
<div className="api-keys-col-name">
|
||||||
|
<div className="api-keys-item-name">{key.name}</div>
|
||||||
|
{key.description && (
|
||||||
|
<div className="api-keys-item-description">{key.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="api-keys-col-created">
|
||||||
|
{formatDate(key.created_at)}
|
||||||
|
</div>
|
||||||
|
<div className="api-keys-col-used">
|
||||||
|
{formatDate(key.last_used)}
|
||||||
|
</div>
|
||||||
|
<div className="api-keys-col-actions">
|
||||||
|
{deleteConfirmId === key.id ? (
|
||||||
|
<div className="api-keys-delete-confirm">
|
||||||
|
<span>Revoke?</span>
|
||||||
|
<button
|
||||||
|
className="api-keys-confirm-yes"
|
||||||
|
onClick={() => handleDelete(key.id)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Revoking...' : 'Yes'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="api-keys-confirm-no"
|
||||||
|
onClick={() => setDeleteConfirmId(null)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="api-keys-revoke-button"
|
||||||
|
onClick={() => setDeleteConfirmId(key.id)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default APIKeysPage;
|
||||||
405
frontend/src/pages/AdminOIDCPage.css
Normal file
405
frontend/src/pages/AdminOIDCPage.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
342
frontend/src/pages/AdminOIDCPage.tsx
Normal file
342
frontend/src/pages/AdminOIDCPage.tsx
Normal 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;
|
||||||
667
frontend/src/pages/AdminUsersPage.css
Normal file
667
frontend/src/pages/AdminUsersPage.css
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
.admin-users-page {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-header-content h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-subtitle {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-create-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-create-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-create-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-success {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--success-bg);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
|
color: var(--success);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
animation: admin-users-fade-in 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes admin-users-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--error-bg);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
color: var(--error);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-error svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-error span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-error-dismiss {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--error);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-error-dismiss:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-access-denied {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-access-denied-icon {
|
||||||
|
color: var(--error);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-access-denied h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-access-denied p {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-create-form-card,
|
||||||
|
.admin-users-reset-password-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-create-form-header,
|
||||||
|
.admin-users-reset-password-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-create-form-header h2,
|
||||||
|
.admin-users-reset-password-header h2 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-create-form-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-create-form-close:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-reset-password-info {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-reset-password-info strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-create-error {
|
||||||
|
background: var(--error-bg);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
color: var(--error);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-create-form,
|
||||||
|
.admin-users-reset-password-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-form-group label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-form-group input[type="text"],
|
||||||
|
.admin-users-form-group input[type="password"],
|
||||||
|
.admin-users-form-group input[type="email"] {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-form-group input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-form-group input:hover:not(:disabled) {
|
||||||
|
border-color: var(--border-secondary);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-form-group input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-checkbox-group {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-checkbox-label input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-checkbox-custom {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-checkbox-label input[type="checkbox"]:checked + .admin-users-checkbox-custom {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-checkbox-label input[type="checkbox"]:checked + .admin-users-checkbox-custom::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 5px;
|
||||||
|
top: 2px;
|
||||||
|
width: 5px;
|
||||||
|
height: 9px;
|
||||||
|
border: solid white;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-checkbox-label input[type="checkbox"]:focus + .admin-users-checkbox-custom {
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-checkbox-label:hover .admin-users-checkbox-custom {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-cancel-button {
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-cancel-button:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-cancel-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-submit-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-submit-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-submit-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-button-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: admin-users-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes admin-users-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-list-container {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-list-loading,
|
||||||
|
.admin-users-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 64px 24px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--border-secondary);
|
||||||
|
border-top-color: var(--accent-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: admin-users-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 64px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-empty-icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-empty h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-empty p {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-list-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 100px 140px 140px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-list-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 100px 140px 140px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-list-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-list-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-list-item.admin-users-inactive {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-col-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-item-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-item-username {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-admin-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.1) 100%);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-item-email {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-col-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-status-badge.active {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-status-badge.inactive {
|
||||||
|
background: var(--error-bg);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-col-created,
|
||||||
|
.admin-users-col-login {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-col-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-actions-menu {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-action-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-action-button:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-action-button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-action-spinner {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid var(--border-secondary);
|
||||||
|
border-top-color: var(--accent-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: admin-users-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.admin-users-list-header {
|
||||||
|
grid-template-columns: 2fr 100px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-list-item {
|
||||||
|
grid-template-columns: 2fr 100px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-col-created,
|
||||||
|
.admin-users-col-login {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-list-header .admin-users-col-created,
|
||||||
|
.admin-users-list-header .admin-users-col-login {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-users-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-create-button {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-list-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-list-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-col-user {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-col-status {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-col-actions {
|
||||||
|
order: 3;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-actions-menu {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
529
frontend/src/pages/AdminUsersPage.tsx
Normal file
529
frontend/src/pages/AdminUsersPage.tsx
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { listUsers, createUser, updateUser, resetUserPassword } from '../api';
|
||||||
|
import { AdminUser } from '../types';
|
||||||
|
import './AdminUsersPage.css';
|
||||||
|
|
||||||
|
function AdminUsersPage() {
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [createUsername, setCreateUsername] = useState('');
|
||||||
|
const [createPassword, setCreatePassword] = useState('');
|
||||||
|
const [createEmail, setCreateEmail] = useState('');
|
||||||
|
const [createIsAdmin, setCreateIsAdmin] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [resetPasswordUsername, setResetPasswordUsername] = useState<string | null>(null);
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
|
const [togglingUser, setTogglingUser] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !user) {
|
||||||
|
navigate('/login', { state: { from: '/admin/users' } });
|
||||||
|
}
|
||||||
|
}, [user, authLoading, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && user.is_admin) {
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (successMessage) {
|
||||||
|
const timer = setTimeout(() => setSuccessMessage(null), 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [successMessage]);
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await listUsers();
|
||||||
|
setUsers(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load users');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!createUsername.trim()) {
|
||||||
|
setCreateError('Username is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!createPassword.trim()) {
|
||||||
|
setCreateError('Password is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
await createUser({
|
||||||
|
username: createUsername.trim(),
|
||||||
|
password: createPassword,
|
||||||
|
email: createEmail.trim() || undefined,
|
||||||
|
is_admin: createIsAdmin,
|
||||||
|
});
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setCreateUsername('');
|
||||||
|
setCreatePassword('');
|
||||||
|
setCreateEmail('');
|
||||||
|
setCreateIsAdmin(false);
|
||||||
|
setSuccessMessage('User created successfully');
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
setCreateError(err instanceof Error ? err.message : 'Failed to create user');
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleAdmin(targetUser: AdminUser) {
|
||||||
|
setTogglingUser(targetUser.username);
|
||||||
|
try {
|
||||||
|
await updateUser(targetUser.username, { is_admin: !targetUser.is_admin });
|
||||||
|
setSuccessMessage(`${targetUser.username} is ${!targetUser.is_admin ? 'now' : 'no longer'} an admin`);
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to update user');
|
||||||
|
} finally {
|
||||||
|
setTogglingUser(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleActive(targetUser: AdminUser) {
|
||||||
|
setTogglingUser(targetUser.username);
|
||||||
|
try {
|
||||||
|
await updateUser(targetUser.username, { is_active: !targetUser.is_active });
|
||||||
|
setSuccessMessage(`${targetUser.username} has been ${!targetUser.is_active ? 'enabled' : 'disabled'}`);
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to update user');
|
||||||
|
} finally {
|
||||||
|
setTogglingUser(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetPassword(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!resetPasswordUsername || !newPassword.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsResetting(true);
|
||||||
|
try {
|
||||||
|
await resetUserPassword(resetPasswordUsername, newPassword);
|
||||||
|
setResetPasswordUsername(null);
|
||||||
|
setNewPassword('');
|
||||||
|
setSuccessMessage(`Password reset for ${resetPasswordUsername}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to reset password');
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string | null): string {
|
||||||
|
if (!dateString) return 'Never';
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="admin-users-page">
|
||||||
|
<div className="admin-users-loading">
|
||||||
|
<div className="admin-users-spinner"></div>
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.is_admin) {
|
||||||
|
return (
|
||||||
|
<div className="admin-users-page">
|
||||||
|
<div className="admin-users-access-denied">
|
||||||
|
<div className="admin-users-access-denied-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2>Access Denied</h2>
|
||||||
|
<p>You do not have permission to access this page. Admin privileges are required.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-users-page">
|
||||||
|
<div className="admin-users-header">
|
||||||
|
<div className="admin-users-header-content">
|
||||||
|
<h1>User Management</h1>
|
||||||
|
<p className="admin-users-subtitle">
|
||||||
|
Manage user accounts and permissions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="admin-users-create-button"
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
disabled={showCreateForm}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Create User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{successMessage && (
|
||||||
|
<div className="admin-users-success">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||||
|
</svg>
|
||||||
|
<span>{successMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="admin-users-error">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => setError(null)} className="admin-users-error-dismiss">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="admin-users-create-form-card">
|
||||||
|
<div className="admin-users-create-form-header">
|
||||||
|
<h2>Create New User</h2>
|
||||||
|
<button
|
||||||
|
className="admin-users-create-form-close"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setCreateUsername('');
|
||||||
|
setCreatePassword('');
|
||||||
|
setCreateEmail('');
|
||||||
|
setCreateIsAdmin(false);
|
||||||
|
setCreateError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createError && (
|
||||||
|
<div className="admin-users-create-error">
|
||||||
|
{createError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleCreate} className="admin-users-create-form">
|
||||||
|
<div className="admin-users-form-group">
|
||||||
|
<label htmlFor="username">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={createUsername}
|
||||||
|
onChange={(e) => setCreateUsername(e.target.value)}
|
||||||
|
placeholder="Enter username"
|
||||||
|
autoFocus
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-users-form-group">
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={createPassword}
|
||||||
|
onChange={(e) => setCreatePassword(e.target.value)}
|
||||||
|
placeholder="Enter password"
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-users-form-group">
|
||||||
|
<label htmlFor="email">Email (optional)</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={createEmail}
|
||||||
|
onChange={(e) => setCreateEmail(e.target.value)}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-users-form-group admin-users-checkbox-group">
|
||||||
|
<label className="admin-users-checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createIsAdmin}
|
||||||
|
onChange={(e) => setCreateIsAdmin(e.target.checked)}
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
<span className="admin-users-checkbox-custom"></span>
|
||||||
|
Grant admin privileges
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-users-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-users-cancel-button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setCreateUsername('');
|
||||||
|
setCreatePassword('');
|
||||||
|
setCreateEmail('');
|
||||||
|
setCreateIsAdmin(false);
|
||||||
|
setCreateError(null);
|
||||||
|
}}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="admin-users-submit-button"
|
||||||
|
disabled={isCreating || !createUsername.trim() || !createPassword.trim()}
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<>
|
||||||
|
<span className="admin-users-button-spinner"></span>
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Create User'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resetPasswordUsername && (
|
||||||
|
<div className="admin-users-reset-password-card">
|
||||||
|
<div className="admin-users-reset-password-header">
|
||||||
|
<h2>Reset Password</h2>
|
||||||
|
<button
|
||||||
|
className="admin-users-create-form-close"
|
||||||
|
onClick={() => {
|
||||||
|
setResetPasswordUsername(null);
|
||||||
|
setNewPassword('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="admin-users-reset-password-info">
|
||||||
|
Set a new password for <strong>{resetPasswordUsername}</strong>
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleResetPassword} className="admin-users-reset-password-form">
|
||||||
|
<div className="admin-users-form-group">
|
||||||
|
<label htmlFor="new-password">New Password</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Enter new password"
|
||||||
|
autoFocus
|
||||||
|
disabled={isResetting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-users-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-users-cancel-button"
|
||||||
|
onClick={() => {
|
||||||
|
setResetPasswordUsername(null);
|
||||||
|
setNewPassword('');
|
||||||
|
}}
|
||||||
|
disabled={isResetting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="admin-users-submit-button"
|
||||||
|
disabled={isResetting || !newPassword.trim()}
|
||||||
|
>
|
||||||
|
{isResetting ? (
|
||||||
|
<>
|
||||||
|
<span className="admin-users-button-spinner"></span>
|
||||||
|
Resetting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Reset Password'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="admin-users-list-container">
|
||||||
|
{loading ? (
|
||||||
|
<div className="admin-users-list-loading">
|
||||||
|
<div className="admin-users-spinner"></div>
|
||||||
|
<span>Loading users...</span>
|
||||||
|
</div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<div className="admin-users-empty">
|
||||||
|
<div className="admin-users-empty-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="9" cy="7" r="4"/>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>No Users</h3>
|
||||||
|
<p>Create a user to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-users-list">
|
||||||
|
<div className="admin-users-list-header">
|
||||||
|
<span className="admin-users-col-user">User</span>
|
||||||
|
<span className="admin-users-col-status">Status</span>
|
||||||
|
<span className="admin-users-col-created">Created</span>
|
||||||
|
<span className="admin-users-col-login">Last Login</span>
|
||||||
|
<span className="admin-users-col-actions">Actions</span>
|
||||||
|
</div>
|
||||||
|
{users.map((u) => (
|
||||||
|
<div key={u.id} className={`admin-users-list-item ${!u.is_active ? 'admin-users-inactive' : ''}`}>
|
||||||
|
<div className="admin-users-col-user">
|
||||||
|
<div className="admin-users-item-avatar">
|
||||||
|
{u.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="admin-users-item-info">
|
||||||
|
<div className="admin-users-item-username">
|
||||||
|
{u.username}
|
||||||
|
{u.is_admin && <span className="admin-users-admin-badge">Admin</span>}
|
||||||
|
</div>
|
||||||
|
{u.email && (
|
||||||
|
<div className="admin-users-item-email">{u.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-users-col-status">
|
||||||
|
<span className={`admin-users-status-badge ${u.is_active ? 'active' : 'inactive'}`}>
|
||||||
|
{u.is_active ? 'Active' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-users-col-created">
|
||||||
|
{formatDate(u.created_at)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-users-col-login">
|
||||||
|
{formatDate(u.last_login)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-users-col-actions">
|
||||||
|
<div className="admin-users-actions-menu">
|
||||||
|
<button
|
||||||
|
className="admin-users-action-button"
|
||||||
|
onClick={() => handleToggleAdmin(u)}
|
||||||
|
disabled={togglingUser === u.username || u.username === user.username}
|
||||||
|
title={u.is_admin ? 'Remove admin' : 'Make admin'}
|
||||||
|
>
|
||||||
|
{togglingUser === u.username ? (
|
||||||
|
<span className="admin-users-action-spinner"></span>
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{u.is_admin ? 'Revoke' : 'Admin'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="admin-users-action-button"
|
||||||
|
onClick={() => handleToggleActive(u)}
|
||||||
|
disabled={togglingUser === u.username || u.username === user.username}
|
||||||
|
title={u.is_active ? 'Disable user' : 'Enable user'}
|
||||||
|
>
|
||||||
|
{togglingUser === u.username ? (
|
||||||
|
<span className="admin-users-action-spinner"></span>
|
||||||
|
) : u.is_active ? (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{u.is_active ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="admin-users-action-button"
|
||||||
|
onClick={() => setResetPasswordUsername(u.username)}
|
||||||
|
disabled={togglingUser === u.username}
|
||||||
|
title="Reset password"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminUsersPage;
|
||||||
156
frontend/src/pages/ChangePasswordPage.tsx
Normal file
156
frontend/src/pages/ChangePasswordPage.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
292
frontend/src/pages/LoginPage.css
Normal file
292
frontend/src/pages/LoginPage.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
frontend/src/pages/LoginPage.tsx
Normal file
186
frontend/src/pages/LoginPage.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
86
migrations/006_auth_tables.sql
Normal file
86
migrations/006_auth_tables.sql
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
-- Authentication Tables Migration
|
||||||
|
-- Adds users table and updates api_keys with foreign key
|
||||||
|
|
||||||
|
-- Users table
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255),
|
||||||
|
email VARCHAR(255),
|
||||||
|
is_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
must_change_password BOOLEAN DEFAULT FALSE,
|
||||||
|
oidc_subject VARCHAR(255),
|
||||||
|
oidc_issuer VARCHAR(512),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
last_login TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_oidc_subject ON users(oidc_subject) WHERE oidc_subject IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- Sessions table for web login
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
last_accessed TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
user_agent VARCHAR(512),
|
||||||
|
ip_address VARCHAR(45)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||||
|
|
||||||
|
-- Auth settings for OIDC configuration (future use)
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_settings (
|
||||||
|
key VARCHAR(255) PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add user_id foreign key to api_keys table
|
||||||
|
-- First add the column (nullable initially)
|
||||||
|
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS owner_id UUID REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Add scopes column for API key permissions
|
||||||
|
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS scopes TEXT[] DEFAULT ARRAY['read', 'write'];
|
||||||
|
|
||||||
|
-- Add description column
|
||||||
|
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS description TEXT;
|
||||||
|
|
||||||
|
-- Create index for owner_id
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_owner_id ON api_keys(owner_id) WHERE owner_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Trigger to update users.updated_at
|
||||||
|
CREATE TRIGGER users_updated_at_trigger
|
||||||
|
BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Trigger to update sessions.last_accessed on access
|
||||||
|
CREATE OR REPLACE FUNCTION update_session_last_accessed()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.last_accessed = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Function to clean up expired sessions (can be called periodically)
|
||||||
|
CREATE OR REPLACE FUNCTION cleanup_expired_sessions()
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM sessions WHERE expires_at < NOW();
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
Reference in New Issue
Block a user