feat(phase-9): implement MVP polishing and closure

T-032: User management admin panel - backend users router with CRUD, frontend UsersPage with modals

T-033: Audit log viewer - backend audit router with filters/pagination, frontend AuditLogPage

T-034: Global error handling - ErrorBoundary, LoadingSpinner, ErrorMessage, Toast components

T-035: Backend tests - pytest setup with SQLite, tests for health/auth/techniques/tests

T-036: Documentation - Updated README with testing section, created docs/API.md
This commit is contained in:
2026-02-06 16:30:35 +01:00
parent cb447f3803
commit 174919da4e
27 changed files with 2539 additions and 17 deletions

View File

@@ -147,6 +147,21 @@ Once the backend is running, access the interactive API documentation at:
| GET | `/api/v1/metrics/summary` | Authenticated | Global coverage summary (counts + percentage) | | GET | `/api/v1/metrics/summary` | Authenticated | Global coverage summary (counts + percentage) |
| GET | `/api/v1/metrics/by-tactic` | Authenticated | Coverage breakdown per MITRE tactic | | GET | `/api/v1/metrics/by-tactic` | Authenticated | Coverage breakdown per MITRE tactic |
### Users (Admin)
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| GET | `/api/v1/users` | Admin | List all users |
| POST | `/api/v1/users` | Admin | Create new user |
| GET | `/api/v1/users/{id}` | Admin | Get user by ID |
| PATCH | `/api/v1/users/{id}` | Admin | Update user (role, email, active status) |
### Audit Logs (Admin)
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| GET | `/api/v1/audit-logs` | Admin | List audit logs (filters: `?action=`, `?entity_type=`, `?start_date=`, `?end_date=`) |
| GET | `/api/v1/audit-logs/actions` | Admin | List distinct action types |
| GET | `/api/v1/audit-logs/entity-types` | Admin | List distinct entity types |
## Project Structure ## Project Structure
``` ```
@@ -187,7 +202,9 @@ Aegis/
│ │ ├── tests.py # CRUD tests (create, detail, update, validate, reject) │ │ ├── tests.py # CRUD tests (create, detail, update, validate, reject)
│ │ ├── evidence.py # Upload evidence, presigned download │ │ ├── evidence.py # Upload evidence, presigned download
│ │ ├── system.py # MITRE sync trigger, scheduler status │ │ ├── system.py # MITRE sync trigger, scheduler status
│ │ ── metrics.py # Coverage summary & per-tactic breakdown │ │ ── metrics.py # Coverage summary & per-tactic breakdown
│ │ ├── users.py # User management (admin only)
│ │ └── audit.py # Audit log viewer (admin only)
│ ├── dependencies/ # FastAPI dependencies (DI) │ ├── dependencies/ # FastAPI dependencies (DI)
│ │ └── auth.py # get_current_user, require_role, require_any_role │ │ └── auth.py # get_current_user, require_role, require_any_role
│ ├── jobs/ # Background scheduled jobs │ ├── jobs/ # Background scheduled jobs
@@ -213,7 +230,9 @@ Aegis/
│ ├── techniques.ts # getTechniques(), getTechniqueByMitreId() │ ├── techniques.ts # getTechniques(), getTechniqueByMitreId()
│ ├── tests.ts # createTest(), validateTest(), rejectTest() │ ├── tests.ts # createTest(), validateTest(), rejectTest()
│ ├── evidence.ts # uploadEvidence(), getEvidence() │ ├── evidence.ts # uploadEvidence(), getEvidence()
── system.ts # triggerMitreSync(), triggerIntelScan() ── system.ts # triggerMitreSync(), triggerIntelScan()
│ ├── users.ts # getUsers(), createUser(), updateUser()
│ └── audit.ts # getAuditLogs(), getAuditActions()
├── context/ ├── context/
│ └── AuthContext.tsx # Auth state: user, login, logout, isLoading │ └── AuthContext.tsx # Auth state: user, login, logout, isLoading
├── components/ ├── components/
@@ -226,7 +245,11 @@ Aegis/
│ ├── TechniqueCell.tsx # Individual technique cell in matrix │ ├── TechniqueCell.tsx # Individual technique cell in matrix
│ ├── TestForm.tsx # Reusable test creation/edit form │ ├── TestForm.tsx # Reusable test creation/edit form
│ ├── EvidenceUpload.tsx # Drag & drop file upload │ ├── EvidenceUpload.tsx # Drag & drop file upload
── EvidenceList.tsx # Evidence file listing ── EvidenceList.tsx # Evidence file listing
│ ├── ErrorBoundary.tsx # Global error boundary
│ ├── ErrorMessage.tsx # Reusable error display
│ ├── LoadingSpinner.tsx # Reusable loading indicator
│ └── Toast.tsx # Toast notification system
├── pages/ ├── pages/
│ ├── LoginPage.tsx # User authentication form │ ├── LoginPage.tsx # User authentication form
│ ├── DashboardPage.tsx # Coverage metrics dashboard with summary cards │ ├── DashboardPage.tsx # Coverage metrics dashboard with summary cards
@@ -235,7 +258,9 @@ Aegis/
│ ├── TestsPage.tsx # Tests overview and navigation │ ├── TestsPage.tsx # Tests overview and navigation
│ ├── TestCreatePage.tsx # Test creation form │ ├── TestCreatePage.tsx # Test creation form
│ ├── TestDetailPage.tsx # Test details with evidence upload │ ├── TestDetailPage.tsx # Test details with evidence upload
── SystemPage.tsx # Admin panel for MITRE sync & intel scan ── SystemPage.tsx # Admin panel for MITRE sync & intel scan
│ ├── UsersPage.tsx # User management (admin only)
│ └── AuditLogPage.tsx # Audit log viewer (admin only)
├── types/ ├── types/
│ └── models.ts # TS interfaces matching backend schemas │ └── models.ts # TS interfaces matching backend schemas
├── hooks/ ├── hooks/
@@ -293,6 +318,33 @@ docker exec -w /app aegis-backend-1 alembic current
- **MinIO Console**: http://localhost:9001 (login: `minioadmin` / `minioadmin`) - **MinIO Console**: http://localhost:9001 (login: `minioadmin` / `minioadmin`)
- **PostgreSQL**: `psql -h localhost -p 5433 -U postgres -d attackdb` - **PostgreSQL**: `psql -h localhost -p 5433 -U postgres -d attackdb`
### Running Tests
The backend includes a test suite using pytest:
```bash
# Install test dependencies (if running locally)
pip install pytest pytest-asyncio httpx
# Run all tests
docker exec -w /app aegis-backend-1 pytest
# Run tests with verbose output
docker exec -w /app aegis-backend-1 pytest -v
# Run specific test file
docker exec -w /app aegis-backend-1 pytest tests/test_auth.py
# Run locally (requires SQLite)
cd backend && pytest
```
Test files:
- `test_health.py` - Health endpoint tests
- `test_auth.py` - Authentication and authorization tests
- `test_techniques.py` - Technique CRUD tests
- `test_tests.py` - Security test CRUD and validation tests
## User Roles ## User Roles
| Role | Description | | Role | Description |

View File

@@ -1,8 +1,11 @@
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import SQLAlchemyError
from app.routers import auth as auth_router from app.routers import auth as auth_router
from app.routers import techniques as techniques_router from app.routers import techniques as techniques_router
@@ -10,6 +13,8 @@ from app.routers import tests as tests_router
from app.routers import evidence as evidence_router from app.routers import evidence as evidence_router
from app.routers import system as system_router from app.routers import system as system_router
from app.routers import metrics as metrics_router from app.routers import metrics as metrics_router
from app.routers import users as users_router
from app.routers import audit as audit_router
from app.storage import ensure_bucket_exists from app.storage import ensure_bucket_exists
from app.jobs.mitre_sync_job import start_scheduler, scheduler from app.jobs.mitre_sync_job import start_scheduler, scheduler
@@ -47,8 +52,52 @@ app.include_router(tests_router.router, prefix="/api/v1")
app.include_router(evidence_router.router, prefix="/api/v1") app.include_router(evidence_router.router, prefix="/api/v1")
app.include_router(system_router.router, prefix="/api/v1") app.include_router(system_router.router, prefix="/api/v1")
app.include_router(metrics_router.router, prefix="/api/v1") app.include_router(metrics_router.router, prefix="/api/v1")
app.include_router(users_router.router, prefix="/api/v1")
app.include_router(audit_router.router, prefix="/api/v1")
@app.get("/health") @app.get("/health")
def health(): def health():
return {"status": "ok"} return {"status": "ok"}
# ── Exception Handlers ────────────────────────────────────────────────────
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Handle validation errors with consistent format."""
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
"detail": "Validation error",
"code": "VALIDATION_ERROR",
"errors": exc.errors(),
},
)
@app.exception_handler(SQLAlchemyError)
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
"""Handle database errors."""
logging.error(f"Database error: {exc}")
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"detail": "Database error occurred",
"code": "DATABASE_ERROR",
},
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""Handle all unhandled exceptions."""
logging.error(f"Unhandled exception: {exc}")
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"detail": "An internal server error occurred",
"code": "INTERNAL_ERROR",
},
)

View File

@@ -0,0 +1,118 @@
"""Audit log viewer router (admin only)."""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.dependencies.auth import require_role
from app.models.audit import AuditLog
from app.models.user import User
from app.schemas.audit import AuditLogOut, AuditLogPage
router = APIRouter(prefix="/audit-logs", tags=["audit"])
@router.get("", response_model=AuditLogPage)
def list_audit_logs(
user_id: Optional[str] = Query(None, description="Filter by user ID"),
action: Optional[str] = Query(None, description="Filter by action type"),
entity_type: Optional[str] = Query(None, description="Filter by entity type"),
start_date: Optional[datetime] = Query(None, description="Filter by start date"),
end_date: Optional[datetime] = Query(None, description="Filter by end date"),
offset: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(50, ge=1, le=100, description="Max records to return"),
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return paginated audit logs with optional filters.
**Requires admin role.**
"""
query = db.query(AuditLog).options(joinedload(AuditLog.user))
# Apply filters
if user_id:
query = query.filter(AuditLog.user_id == user_id)
if action:
query = query.filter(AuditLog.action == action)
if entity_type:
query = query.filter(AuditLog.entity_type == entity_type)
if start_date:
query = query.filter(AuditLog.timestamp >= start_date)
if end_date:
query = query.filter(AuditLog.timestamp <= end_date)
# Get total count
total = query.count()
# Get paginated results
logs = (
query
.order_by(AuditLog.timestamp.desc())
.offset(offset)
.limit(limit)
.all()
)
# Convert to response format with username
items = []
for log in logs:
item = AuditLogOut(
id=log.id,
user_id=log.user_id,
username=log.user.username if log.user else None,
action=log.action,
entity_type=log.entity_type,
entity_id=log.entity_id,
timestamp=log.timestamp,
details=log.details,
)
items.append(item)
return AuditLogPage(
items=items,
total=total,
offset=offset,
limit=limit,
)
@router.get("/actions", response_model=list[str])
def list_actions(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return a list of distinct action types in the audit log.
**Requires admin role.**
"""
actions = (
db.query(AuditLog.action)
.distinct()
.order_by(AuditLog.action)
.all()
)
return [a[0] for a in actions]
@router.get("/entity-types", response_model=list[str])
def list_entity_types(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return a list of distinct entity types in the audit log.
**Requires admin role.**
"""
types = (
db.query(AuditLog.entity_type)
.filter(AuditLog.entity_type.isnot(None))
.distinct()
.order_by(AuditLog.entity_type)
.all()
)
return [t[0] for t in types]

View File

@@ -0,0 +1,153 @@
"""User management router (admin only)."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import require_role
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate, UserOut
from app.auth import hash_password
from app.services.audit_service import log_action
router = APIRouter(prefix="/users", tags=["users"])
VALID_ROLES = {"admin", "red_tech", "blue_tech", "red_lead", "blue_lead", "viewer"}
# ---------------------------------------------------------------------------
# GET /users — list all users
# ---------------------------------------------------------------------------
@router.get("", response_model=list[UserOut])
def list_users(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return a list of all users. **Requires admin role.**"""
return db.query(User).order_by(User.username).all()
# ---------------------------------------------------------------------------
# POST /users — create a new user
# ---------------------------------------------------------------------------
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def create_user(
payload: UserCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Create a new user. **Requires admin role.**"""
# Check if username already exists
existing = db.query(User).filter(User.username == payload.username).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Username '{payload.username}' already exists",
)
# Validate role
if payload.role not in VALID_ROLES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid role '{payload.role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}",
)
user = User(
username=payload.username,
email=payload.email,
hashed_password=hash_password(payload.password),
role=payload.role,
)
db.add(user)
db.commit()
db.refresh(user)
log_action(
db,
user_id=current_user.id,
action="create_user",
entity_type="user",
entity_id=user.id,
details={"username": user.username, "role": user.role},
)
return user
# ---------------------------------------------------------------------------
# GET /users/{id} — get a single user
# ---------------------------------------------------------------------------
@router.get("/{user_id}", response_model=UserOut)
def get_user(
user_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return a single user by ID. **Requires admin role.**"""
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return user
# ---------------------------------------------------------------------------
# PATCH /users/{id} — update a user
# ---------------------------------------------------------------------------
@router.patch("/{user_id}", response_model=UserOut)
def update_user(
user_id: uuid.UUID,
payload: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Update one or more fields of an existing user. **Requires admin role.**"""
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
update_data = payload.model_dump(exclude_unset=True)
# Validate role if being updated
if "role" in update_data and update_data["role"] not in VALID_ROLES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid role '{update_data['role']}'. Must be one of: {', '.join(sorted(VALID_ROLES))}",
)
# Hash password if being updated
if "password" in update_data:
update_data["hashed_password"] = hash_password(update_data.pop("password"))
for field, value in update_data.items():
setattr(user, field, value)
db.commit()
db.refresh(user)
log_action(
db,
user_id=current_user.id,
action="update_user",
entity_type="user",
entity_id=user.id,
details={"updated_fields": list(payload.model_dump(exclude_unset=True).keys())},
)
return user

View File

@@ -0,0 +1,31 @@
"""Pydantic schemas for Audit Log endpoints."""
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict
class AuditLogOut(BaseModel):
"""Complete representation of an audit log entry."""
id: uuid.UUID
user_id: uuid.UUID | None = None
username: str | None = None # Populated from user relationship
action: str
entity_type: str | None = None
entity_id: str | None = None
timestamp: datetime
details: dict[str, Any] | None = None
model_config = ConfigDict(from_attributes=True)
class AuditLogPage(BaseModel):
"""Paginated response for audit logs."""
items: list[AuditLogOut]
total: int
offset: int
limit: int

View File

@@ -0,0 +1,45 @@
"""Pydantic schemas for User management endpoints."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr
# ── Create ──────────────────────────────────────────────────────────
class UserCreate(BaseModel):
"""Payload for creating a new user."""
username: str
email: str | None = None
password: str
role: str = "viewer"
# ── Update ──────────────────────────────────────────────────────────
class UserUpdate(BaseModel):
"""Payload for partially updating an existing user.
Every field is optional so callers send only what changed."""
email: str | None = None
role: str | None = None
is_active: bool | None = None
password: str | None = None
# ── Read (full) ─────────────────────────────────────────────────────
class UserOut(BaseModel):
"""Complete representation returned by the API."""
id: uuid.UUID
username: str
email: str | None = None
role: str
is_active: bool
created_at: datetime | None = None
last_login: datetime | None = None
model_config = ConfigDict(from_attributes=True)

5
backend/pytest.ini Normal file
View File

@@ -0,0 +1,5 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
addopts = -v --tb=short

View File

@@ -12,3 +12,8 @@ requests
taxii2-client taxii2-client
python-multipart python-multipart
pydantic-settings pydantic-settings
# Testing
pytest
pytest-asyncio
httpx

View File

@@ -0,0 +1 @@
# Tests package

118
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,118 @@
"""Pytest fixtures and configuration for backend tests."""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.main import app
from app.database import Base, get_db
from app.auth import hash_password
from app.models.user import User
# Use in-memory SQLite for tests
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def override_get_db():
"""Override the database dependency for testing."""
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
@pytest.fixture(scope="function")
def db():
"""Create a fresh database for each test."""
Base.metadata.create_all(bind=engine)
db = TestingSessionLocal()
yield db
db.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def client(db):
"""Create a test client with database override."""
app.dependency_overrides[get_db] = override_get_db
Base.metadata.create_all(bind=engine)
with TestClient(app) as test_client:
yield test_client
Base.metadata.drop_all(bind=engine)
app.dependency_overrides.clear()
@pytest.fixture(scope="function")
def admin_user(db):
"""Create an admin user for testing."""
user = User(
username="admin",
email="admin@test.com",
hashed_password=hash_password("admin123"),
role="admin",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture(scope="function")
def red_tech_user(db):
"""Create a red_tech user for testing."""
user = User(
username="redtech",
email="redtech@test.com",
hashed_password=hash_password("redtech123"),
role="red_tech",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture(scope="function")
def admin_token(client, admin_user):
"""Get an auth token for the admin user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "admin", "password": "admin123"},
)
return response.json()["access_token"]
@pytest.fixture(scope="function")
def red_tech_token(client, red_tech_user):
"""Get an auth token for the red_tech user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "redtech", "password": "redtech123"},
)
return response.json()["access_token"]
@pytest.fixture(scope="function")
def auth_headers(admin_token):
"""Return authorization headers for admin user."""
return {"Authorization": f"Bearer {admin_token}"}
@pytest.fixture(scope="function")
def red_tech_headers(red_tech_token):
"""Return authorization headers for red_tech user."""
return {"Authorization": f"Bearer {red_tech_token}"}

View File

@@ -0,0 +1,81 @@
"""Tests for authentication endpoints."""
import pytest
def test_login_success(client, admin_user):
"""Test successful login returns a token."""
response = client.post(
"/api/v1/auth/login",
data={"username": "admin", "password": "admin123"},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
def test_login_wrong_password(client, admin_user):
"""Test login with wrong password returns 400."""
response = client.post(
"/api/v1/auth/login",
data={"username": "admin", "password": "wrongpassword"},
)
assert response.status_code == 400
def test_login_nonexistent_user(client):
"""Test login with non-existent user returns 400."""
response = client.post(
"/api/v1/auth/login",
data={"username": "nobody", "password": "password"},
)
assert response.status_code == 400
def test_login_inactive_user(client, db):
"""Test login with inactive user returns 400."""
from app.auth import hash_password
from app.models.user import User
user = User(
username="inactive",
hashed_password=hash_password("password"),
role="viewer",
is_active=False,
)
db.add(user)
db.commit()
response = client.post(
"/api/v1/auth/login",
data={"username": "inactive", "password": "password"},
)
assert response.status_code == 400
def test_get_me_with_token(client, admin_user, admin_token):
"""Test /auth/me returns current user with valid token."""
response = client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["username"] == "admin"
assert data["role"] == "admin"
def test_get_me_without_token(client):
"""Test /auth/me returns 401 without token."""
response = client.get("/api/v1/auth/me")
assert response.status_code == 401
def test_get_me_invalid_token(client):
"""Test /auth/me returns 401 with invalid token."""
response = client.get(
"/api/v1/auth/me",
headers={"Authorization": "Bearer invalidtoken"},
)
assert response.status_code == 401

View File

@@ -0,0 +1,8 @@
"""Tests for the health endpoint."""
def test_health_endpoint(client):
"""Test that the health endpoint returns 200 OK."""
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

View File

@@ -0,0 +1,128 @@
"""Tests for technique endpoints."""
import pytest
def test_list_techniques_requires_auth(client):
"""Test that listing techniques requires authentication."""
response = client.get("/api/v1/techniques")
assert response.status_code == 401
def test_list_techniques_empty(client, auth_headers):
"""Test listing techniques when none exist."""
response = client.get("/api/v1/techniques", headers=auth_headers)
assert response.status_code == 200
assert response.json() == []
def test_create_technique_requires_admin(client, red_tech_headers):
"""Test that creating techniques requires admin role."""
response = client.post(
"/api/v1/techniques",
json={"mitre_id": "T1001", "name": "Test Technique"},
headers=red_tech_headers,
)
assert response.status_code == 403
def test_create_technique_success(client, auth_headers):
"""Test successful technique creation."""
response = client.post(
"/api/v1/techniques",
json={
"mitre_id": "T1059",
"name": "Command and Scripting Interpreter",
"description": "Test description",
"tactic": "execution",
},
headers=auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["mitre_id"] == "T1059"
assert data["name"] == "Command and Scripting Interpreter"
assert data["status_global"] == "not_evaluated"
def test_create_duplicate_technique(client, auth_headers):
"""Test creating a technique with duplicate mitre_id fails."""
# Create first technique
client.post(
"/api/v1/techniques",
json={"mitre_id": "T1001", "name": "First"},
headers=auth_headers,
)
# Try to create duplicate
response = client.post(
"/api/v1/techniques",
json={"mitre_id": "T1001", "name": "Second"},
headers=auth_headers,
)
assert response.status_code == 409
def test_get_technique_by_mitre_id(client, auth_headers):
"""Test getting a single technique by mitre_id."""
# Create a technique first
client.post(
"/api/v1/techniques",
json={"mitre_id": "T1059", "name": "Test Technique"},
headers=auth_headers,
)
# Get it by mitre_id
response = client.get("/api/v1/techniques/T1059", headers=auth_headers)
assert response.status_code == 200
assert response.json()["mitre_id"] == "T1059"
def test_get_nonexistent_technique(client, auth_headers):
"""Test getting a non-existent technique returns 404."""
response = client.get("/api/v1/techniques/T9999", headers=auth_headers)
assert response.status_code == 404
def test_update_technique(client, auth_headers):
"""Test updating a technique."""
# Create technique
client.post(
"/api/v1/techniques",
json={"mitre_id": "T1059", "name": "Original Name"},
headers=auth_headers,
)
# Update it
response = client.patch(
"/api/v1/techniques/T1059",
json={"name": "Updated Name", "description": "New description"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["name"] == "Updated Name"
def test_filter_techniques_by_tactic(client, auth_headers):
"""Test filtering techniques by tactic."""
# Create techniques in different tactics
client.post(
"/api/v1/techniques",
json={"mitre_id": "T1001", "name": "Exec", "tactic": "execution"},
headers=auth_headers,
)
client.post(
"/api/v1/techniques",
json={"mitre_id": "T1002", "name": "Persist", "tactic": "persistence"},
headers=auth_headers,
)
# Filter by execution
response = client.get(
"/api/v1/techniques?tactic=execution",
headers=auth_headers,
)
assert response.status_code == 200
techniques = response.json()
assert len(techniques) == 1
assert techniques[0]["mitre_id"] == "T1001"

165
backend/tests/test_tests.py Normal file
View File

@@ -0,0 +1,165 @@
"""Tests for security test endpoints."""
import pytest
@pytest.fixture
def technique(client, auth_headers):
"""Create a technique for test association."""
response = client.post(
"/api/v1/techniques",
json={"mitre_id": "T1059", "name": "Test Technique"},
headers=auth_headers,
)
return response.json()
def test_create_test_requires_auth(client, technique):
"""Test that creating a test requires authentication."""
response = client.post(
"/api/v1/tests",
json={
"technique_id": technique["id"],
"name": "Test Name",
},
)
assert response.status_code == 401
def test_create_test_success(client, red_tech_headers, technique):
"""Test successful test creation."""
response = client.post(
"/api/v1/tests",
json={
"technique_id": technique["id"],
"name": "My Security Test",
"description": "Test description",
"platform": "windows",
},
headers=red_tech_headers,
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "My Security Test"
assert data["state"] == "draft"
assert data["technique_id"] == technique["id"]
def test_create_test_nonexistent_technique(client, red_tech_headers):
"""Test creating a test with non-existent technique fails."""
response = client.post(
"/api/v1/tests",
json={
"technique_id": "00000000-0000-0000-0000-000000000000",
"name": "Test",
},
headers=red_tech_headers,
)
assert response.status_code == 404
def test_get_test_by_id(client, red_tech_headers, technique):
"""Test getting a test by ID."""
# Create a test
create_response = client.post(
"/api/v1/tests",
json={"technique_id": technique["id"], "name": "Test"},
headers=red_tech_headers,
)
test_id = create_response.json()["id"]
# Get it
response = client.get(f"/api/v1/tests/{test_id}", headers=red_tech_headers)
assert response.status_code == 200
assert response.json()["id"] == test_id
def test_validate_test(client, auth_headers, red_tech_headers, technique):
"""Test validating a test updates status correctly."""
# Create a test
create_response = client.post(
"/api/v1/tests",
json={"technique_id": technique["id"], "name": "Test"},
headers=red_tech_headers,
)
test_id = create_response.json()["id"]
# Validate it (requires lead/admin)
response = client.post(
f"/api/v1/tests/{test_id}/validate",
json={"result": "detected"},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["state"] == "validated"
assert data["result"] == "detected"
assert data["validated_by"] is not None
def test_validate_test_updates_technique_status(client, auth_headers, red_tech_headers, technique):
"""Test that validating a test recalculates technique status."""
# Create and validate a test
create_response = client.post(
"/api/v1/tests",
json={"technique_id": technique["id"], "name": "Test"},
headers=red_tech_headers,
)
test_id = create_response.json()["id"]
client.post(
f"/api/v1/tests/{test_id}/validate",
json={"result": "detected"},
headers=auth_headers,
)
# Check technique status was updated
response = client.get(
f"/api/v1/techniques/{technique['mitre_id']}",
headers=auth_headers,
)
assert response.json()["status_global"] == "validated"
def test_reject_test(client, auth_headers, red_tech_headers, technique):
"""Test rejecting a test."""
# Create a test
create_response = client.post(
"/api/v1/tests",
json={"technique_id": technique["id"], "name": "Test"},
headers=red_tech_headers,
)
test_id = create_response.json()["id"]
# Reject it
response = client.post(
f"/api/v1/tests/{test_id}/reject",
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["state"] == "rejected"
def test_update_test_only_in_draft(client, auth_headers, red_tech_headers, technique):
"""Test that tests can only be updated when in draft/rejected state."""
# Create and validate a test
create_response = client.post(
"/api/v1/tests",
json={"technique_id": technique["id"], "name": "Test"},
headers=red_tech_headers,
)
test_id = create_response.json()["id"]
client.post(
f"/api/v1/tests/{test_id}/validate",
json={"result": "detected"},
headers=auth_headers,
)
# Try to update validated test
response = client.patch(
f"/api/v1/tests/{test_id}",
json={"name": "New Name"},
headers=red_tech_headers,
)
assert response.status_code == 400

385
docs/API.md Normal file
View File

@@ -0,0 +1,385 @@
# Aegis API Documentation
This document provides an overview of the Aegis REST API. For interactive documentation with request/response examples, visit:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
## Base URL
All API endpoints are prefixed with `/api/v1`.
## Authentication
The API uses JWT (JSON Web Token) for authentication. Include the token in the `Authorization` header:
```
Authorization: Bearer <your-token>
```
### Obtaining a Token
```bash
curl -X POST http://localhost:8000/api/v1/auth/login \
-d "username=admin&password=admin123"
```
Response:
```json
{
"access_token": "eyJ...",
"token_type": "bearer"
}
```
## Endpoints
### Authentication
#### POST /auth/login
Authenticate and receive a JWT token.
**Request** (form data):
- `username`: string
- `password`: string
**Response** (200):
```json
{
"access_token": "string",
"token_type": "bearer"
}
```
#### GET /auth/me
Get current authenticated user.
**Headers**: `Authorization: Bearer <token>`
**Response** (200):
```json
{
"id": "uuid",
"username": "string",
"email": "string|null",
"role": "string",
"is_active": true
}
```
---
### Techniques
#### GET /techniques
List all techniques with optional filters.
**Query Parameters**:
- `tactic` (optional): Filter by tactic name
- `status` (optional): Filter by status (`not_evaluated`, `in_progress`, `validated`, `partial`, `not_covered`)
- `review_required` (optional): Filter by review flag (`true`/`false`)
**Response** (200):
```json
[
{
"id": "uuid",
"mitre_id": "T1059",
"name": "Command and Scripting Interpreter",
"tactic": "execution",
"status_global": "validated"
}
]
```
#### GET /techniques/{mitre_id}
Get a single technique with associated tests.
**Response** (200):
```json
{
"id": "uuid",
"mitre_id": "T1059",
"name": "Command and Scripting Interpreter",
"description": "...",
"tactic": "execution",
"platforms": ["windows", "linux"],
"status_global": "validated",
"review_required": false,
"tests": [...]
}
```
#### POST /techniques
Create a new technique. **Admin only.**
**Request**:
```json
{
"mitre_id": "T1059",
"name": "Command and Scripting Interpreter",
"description": "...",
"tactic": "execution",
"platforms": ["windows", "linux"]
}
```
#### PATCH /techniques/{mitre_id}
Update a technique. **Admin only.**
**Request**:
```json
{
"name": "Updated Name",
"status_global": "validated"
}
```
#### PATCH /techniques/{mitre_id}/review
Mark a technique as reviewed. **Leads/Admin only.**
---
### Tests
#### POST /tests
Create a new test. **Red Tech/Admin only.**
**Request**:
```json
{
"technique_id": "uuid",
"name": "My Test",
"description": "Test description",
"platform": "windows",
"procedure_text": "Step by step...",
"tool_used": "Atomic Red Team"
}
```
#### GET /tests/{id}
Get a test with associated evidences.
#### PATCH /tests/{id}
Update a test. **Creator/Admin only.** Only allowed when state is `draft` or `rejected`.
#### POST /tests/{id}/validate
Validate a test. **Leads/Admin only.**
**Request**:
```json
{
"result": "detected",
"comments": "Optional comment"
}
```
Valid results: `detected`, `not_detected`, `partially_detected`
#### POST /tests/{id}/reject
Reject a test. **Leads/Admin only.**
---
### Evidence
#### POST /tests/{test_id}/evidence
Upload an evidence file.
**Request**: `multipart/form-data` with `file` field.
**Response** (201):
```json
{
"id": "uuid",
"test_id": "uuid",
"file_name": "screenshot.png",
"sha256_hash": "abc123...",
"uploaded_at": "2024-01-01T00:00:00Z",
"download_url": "https://..."
}
```
#### GET /evidence/{id}
Get evidence metadata with presigned download URL.
---
### Metrics
#### GET /metrics/summary
Get global coverage summary.
**Response** (200):
```json
{
"total_techniques": 200,
"validated": 50,
"partial": 20,
"not_covered": 30,
"in_progress": 10,
"not_evaluated": 90,
"coverage_percentage": 35.0
}
```
#### GET /metrics/by-tactic
Get coverage breakdown by tactic.
**Response** (200):
```json
[
{
"tactic": "execution",
"total": 15,
"validated": 5,
"partial": 2,
"not_covered": 3,
"not_evaluated": 5,
"in_progress": 0
}
]
```
---
### Users (Admin Only)
#### GET /users
List all users.
#### POST /users
Create a new user.
**Request**:
```json
{
"username": "newuser",
"email": "user@example.com",
"password": "securepassword",
"role": "red_tech"
}
```
Valid roles: `admin`, `red_tech`, `blue_tech`, `red_lead`, `blue_lead`, `viewer`
#### PATCH /users/{id}
Update a user.
**Request**:
```json
{
"email": "new@email.com",
"role": "blue_tech",
"is_active": false,
"password": "newpassword"
}
```
---
### Audit Logs (Admin Only)
#### GET /audit-logs
List audit logs with pagination and filters.
**Query Parameters**:
- `user_id` (optional): Filter by user UUID
- `action` (optional): Filter by action type
- `entity_type` (optional): Filter by entity type
- `start_date` (optional): Filter from date (ISO format)
- `end_date` (optional): Filter to date (ISO format)
- `offset` (optional): Pagination offset (default: 0)
- `limit` (optional): Page size (default: 50, max: 100)
**Response** (200):
```json
{
"items": [
{
"id": "uuid",
"user_id": "uuid",
"username": "admin",
"action": "create_technique",
"entity_type": "technique",
"entity_id": "uuid",
"timestamp": "2024-01-01T00:00:00Z",
"details": {"mitre_id": "T1059"}
}
],
"total": 100,
"offset": 0,
"limit": 50
}
```
#### GET /audit-logs/actions
List distinct action types.
#### GET /audit-logs/entity-types
List distinct entity types.
---
### System (Admin Only)
#### POST /system/sync-mitre
Trigger MITRE ATT&CK synchronization.
**Response** (200):
```json
{
"message": "MITRE sync completed",
"new": 5,
"updated": 10
}
```
#### POST /system/run-intel-scan
Trigger threat intelligence scan.
**Response** (200):
```json
{
"message": "Intel scan completed",
"new_items": 3
}
```
#### GET /system/scheduler-status
Get background scheduler status.
**Response** (200):
```json
{
"running": true,
"jobs": [
{
"id": "mitre_sync",
"name": "MITRE ATT&CK Sync",
"next_run_time": "2024-01-02T00:00:00Z"
}
]
}
```
---
## Error Responses
All errors follow a consistent format:
```json
{
"detail": "Error message",
"code": "ERROR_CODE"
}
```
Common HTTP status codes:
- `400` - Bad Request (validation error, invalid input)
- `401` - Unauthorized (missing or invalid token)
- `403` - Forbidden (insufficient permissions)
- `404` - Not Found (resource doesn't exist)
- `409` - Conflict (duplicate resource)
- `500` - Internal Server Error

View File

@@ -7,6 +7,8 @@ import TestsPage from "./pages/TestsPage";
import TestCreatePage from "./pages/TestCreatePage"; import TestCreatePage from "./pages/TestCreatePage";
import TestDetailPage from "./pages/TestDetailPage"; import TestDetailPage from "./pages/TestDetailPage";
import SystemPage from "./pages/SystemPage"; import SystemPage from "./pages/SystemPage";
import UsersPage from "./pages/UsersPage";
import AuditLogPage from "./pages/AuditLogPage";
import Layout from "./components/Layout"; import Layout from "./components/Layout";
import ProtectedRoute from "./components/ProtectedRoute"; import ProtectedRoute from "./components/ProtectedRoute";
@@ -38,6 +40,22 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/users"
element={
<ProtectedRoute roles={["admin"]}>
<UsersPage />
</ProtectedRoute>
}
/>
<Route
path="/audit"
element={
<ProtectedRoute roles={["admin"]}>
<AuditLogPage />
</ProtectedRoute>
}
/>
</Route> </Route>
{/* Catch-all → dashboard */} {/* Catch-all → dashboard */}

58
frontend/src/api/audit.ts Normal file
View File

@@ -0,0 +1,58 @@
import client from "./client";
export interface AuditLogOut {
id: string;
user_id: string | null;
username: string | null;
action: string;
entity_type: string | null;
entity_id: string | null;
timestamp: string;
details: Record<string, unknown> | null;
}
export interface AuditLogPage {
items: AuditLogOut[];
total: number;
offset: number;
limit: number;
}
export interface AuditLogFilters {
user_id?: string;
action?: string;
entity_type?: string;
start_date?: string;
end_date?: string;
offset?: number;
limit?: number;
}
/** Fetch paginated audit logs with filters. */
export async function getAuditLogs(filters?: AuditLogFilters): Promise<AuditLogPage> {
const params = new URLSearchParams();
if (filters?.user_id) params.append("user_id", filters.user_id);
if (filters?.action) params.append("action", filters.action);
if (filters?.entity_type) params.append("entity_type", filters.entity_type);
if (filters?.start_date) params.append("start_date", filters.start_date);
if (filters?.end_date) params.append("end_date", filters.end_date);
if (filters?.offset !== undefined) params.append("offset", String(filters.offset));
if (filters?.limit !== undefined) params.append("limit", String(filters.limit));
const { data } = await client.get<AuditLogPage>(
`/audit-logs${params.toString() ? `?${params}` : ""}`
);
return data;
}
/** Fetch list of distinct actions. */
export async function getAuditActions(): Promise<string[]> {
const { data } = await client.get<string[]>("/audit-logs/actions");
return data;
}
/** Fetch list of distinct entity types. */
export async function getAuditEntityTypes(): Promise<string[]> {
const { data } = await client.get<string[]>("/audit-logs/entity-types");
return data;
}

View File

@@ -1,4 +1,4 @@
import axios from "axios"; import axios, { type AxiosError } from "axios";
const client = axios.create({ const client = axios.create({
baseURL: "http://localhost:8000/api/v1", baseURL: "http://localhost:8000/api/v1",
@@ -14,14 +14,33 @@ client.interceptors.request.use((config) => {
return config; return config;
}); });
// On 401, clear token so the UI can redirect to login // Response interceptor for error handling
client.interceptors.response.use( client.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error: AxiosError<{ detail?: string; code?: string }>) => {
if (error.response?.status === 401) { const status = error.response?.status;
// On 401, clear token and redirect to login
if (status === 401) {
localStorage.removeItem("token"); localStorage.removeItem("token");
// Only redirect if not already on login page
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
} }
return Promise.reject(error);
// Extract error message from response
const message =
error.response?.data?.detail ||
error.message ||
"An unexpected error occurred";
// Create a more descriptive error
const enhancedError = new Error(message);
(enhancedError as Error & { status?: number; code?: string }).status = status;
(enhancedError as Error & { code?: string }).code = error.response?.data?.code;
return Promise.reject(enhancedError);
}, },
); );

43
frontend/src/api/users.ts Normal file
View File

@@ -0,0 +1,43 @@
import client from "./client";
export interface UserOut {
id: string;
username: string;
email: string | null;
role: string;
is_active: boolean;
created_at: string | null;
last_login: string | null;
}
export interface UserCreatePayload {
username: string;
email?: string;
password: string;
role: string;
}
export interface UserUpdatePayload {
email?: string;
role?: string;
is_active?: boolean;
password?: string;
}
/** Fetch all users (admin only). */
export async function getUsers(): Promise<UserOut[]> {
const { data } = await client.get<UserOut[]>("/users");
return data;
}
/** Create a new user (admin only). */
export async function createUser(payload: UserCreatePayload): Promise<UserOut> {
const { data } = await client.post<UserOut>("/users", payload);
return data;
}
/** Update a user (admin only). */
export async function updateUser(userId: string, payload: UserUpdatePayload): Promise<UserOut> {
const { data } = await client.patch<UserOut>(`/users/${userId}`, payload);
return data;
}

View File

@@ -0,0 +1,101 @@
import { Component, type ReactNode, type ErrorInfo } from "react";
import { AlertTriangle, RefreshCw, Home } from "lucide-react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
this.setState({ errorInfo });
}
handleReload = () => {
window.location.reload();
};
handleGoHome = () => {
window.location.href = "/dashboard";
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-950 p-6">
<div className="w-full max-w-md rounded-xl border border-red-500/30 bg-gray-900 p-8">
<div className="flex flex-col items-center text-center">
<div className="rounded-full bg-red-900/30 p-4">
<AlertTriangle className="h-12 w-12 text-red-400" />
</div>
<h1 className="mt-6 text-xl font-bold text-white">
Something went wrong
</h1>
<p className="mt-2 text-sm text-gray-400">
An unexpected error occurred. Please try refreshing the page or
return to the dashboard.
</p>
{process.env.NODE_ENV === "development" && this.state.error && (
<details className="mt-4 w-full text-left">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-300">
Error details
</summary>
<pre className="mt-2 max-h-40 overflow-auto rounded bg-gray-800 p-3 text-xs text-red-400">
{this.state.error.toString()}
{this.state.errorInfo?.componentStack}
</pre>
</details>
)}
<div className="mt-6 flex gap-3">
<button
onClick={this.handleReload}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
>
<RefreshCw className="h-4 w-4" />
Refresh Page
</button>
<button
onClick={this.handleGoHome}
className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm text-gray-300 hover:border-gray-600 hover:text-white transition-colors"
>
<Home className="h-4 w-4" />
Go to Dashboard
</button>
</div>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,40 @@
import { AlertCircle, RefreshCw } from "lucide-react";
interface ErrorMessageProps {
title?: string;
message?: string;
onRetry?: () => void;
fullHeight?: boolean;
}
export default function ErrorMessage({
title = "Something went wrong",
message = "An error occurred while loading this content.",
onRetry,
fullHeight = true,
}: ErrorMessageProps) {
return (
<div
className={`flex flex-col items-center justify-center gap-4 ${
fullHeight ? "h-64" : "py-8"
}`}
>
<div className="rounded-full bg-red-900/30 p-4">
<AlertCircle className="h-10 w-10 text-red-400" />
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<p className="mt-1 text-sm text-gray-400">{message}</p>
</div>
{onRetry && (
<button
onClick={onRetry}
className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm text-gray-300 hover:border-cyan-500/50 hover:text-cyan-400 transition-colors"
>
<RefreshCw className="h-4 w-4" />
Try Again
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Loader2 } from "lucide-react";
interface LoadingSpinnerProps {
size?: "sm" | "md" | "lg";
text?: string;
fullScreen?: boolean;
}
const sizeClasses = {
sm: "h-4 w-4",
md: "h-8 w-8",
lg: "h-12 w-12",
};
export default function LoadingSpinner({
size = "md",
text,
fullScreen = false,
}: LoadingSpinnerProps) {
const content = (
<div className="flex flex-col items-center justify-center gap-3">
<Loader2 className={`animate-spin text-cyan-400 ${sizeClasses[size]}`} />
{text && <p className="text-sm text-gray-400">{text}</p>}
</div>
);
if (fullScreen) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-gray-950/80 backdrop-blur-sm z-50">
{content}
</div>
);
}
return <div className="flex h-64 items-center justify-center">{content}</div>;
}

View File

@@ -4,6 +4,8 @@ import {
Shield, Shield,
FlaskConical, FlaskConical,
Settings, Settings,
Users,
FileText,
} from "lucide-react"; } from "lucide-react";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
@@ -14,6 +16,8 @@ const baseLinks = [
]; ];
const adminLinks = [ const adminLinks = [
{ to: "/users", label: "Users", icon: Users },
{ to: "/audit", label: "Audit Log", icon: FileText },
{ to: "/system", label: "System", icon: Settings }, { to: "/system", label: "System", icon: Settings },
]; ];

View File

@@ -0,0 +1,88 @@
import { useState, useEffect, createContext, useContext, useCallback, type ReactNode } from "react";
import { CheckCircle, AlertCircle, Info, X } from "lucide-react";
type ToastType = "success" | "error" | "info";
interface Toast {
id: string;
type: ToastType;
message: string;
}
interface ToastContextValue {
showToast: (type: ToastType, message: string) => void;
}
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within a ToastProvider");
}
return context;
}
const icons = {
success: CheckCircle,
error: AlertCircle,
info: Info,
};
const colors = {
success: "border-green-500/30 bg-green-900/30 text-green-400",
error: "border-red-500/30 bg-red-900/30 text-red-400",
info: "border-cyan-500/30 bg-cyan-900/30 text-cyan-400",
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((type: ToastType, message: string) => {
const id = Math.random().toString(36).substr(2, 9);
setToasts((prev) => [...prev, { id, type, message }]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<ToastItem
key={toast.id}
toast={toast}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
</ToastContext.Provider>
);
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
const Icon = icons[toast.type];
useEffect(() => {
const timer = setTimeout(onClose, 5000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div
className={`flex items-center gap-3 rounded-lg border px-4 py-3 shadow-lg animate-in slide-in-from-right ${colors[toast.type]}`}
>
<Icon className="h-5 w-5 flex-shrink-0" />
<p className="text-sm">{toast.message}</p>
<button
onClick={onClose}
className="ml-2 rounded p-1 hover:bg-white/10"
>
<X className="h-4 w-4" />
</button>
</div>
);
}

View File

@@ -3,6 +3,8 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "./context/AuthContext"; import { AuthProvider } from "./context/AuthContext";
import { ToastProvider } from "./components/Toast";
import ErrorBoundary from "./components/ErrorBoundary";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
@@ -14,12 +16,16 @@ const queryClient = new QueryClient({
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <ErrorBoundary>
<BrowserRouter> <QueryClientProvider client={queryClient}>
<AuthProvider> <BrowserRouter>
<App /> <AuthProvider>
</AuthProvider> <ToastProvider>
</BrowserRouter> <App />
</QueryClientProvider> </ToastProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>, </StrictMode>,
); );

View File

@@ -0,0 +1,293 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Loader2,
AlertCircle,
FileText,
Filter,
ChevronLeft,
ChevronRight,
X,
} from "lucide-react";
import {
getAuditLogs,
getAuditActions,
getAuditEntityTypes,
type AuditLogFilters,
} from "../api/audit";
const PAGE_SIZE = 25;
export default function AuditLogPage() {
const [filters, setFilters] = useState<AuditLogFilters>({
offset: 0,
limit: PAGE_SIZE,
});
const {
data: logsData,
isLoading: logsLoading,
error: logsError,
} = useQuery({
queryKey: ["audit-logs", filters],
queryFn: () => getAuditLogs(filters),
});
const { data: actions } = useQuery({
queryKey: ["audit-actions"],
queryFn: getAuditActions,
});
const { data: entityTypes } = useQuery({
queryKey: ["audit-entity-types"],
queryFn: getAuditEntityTypes,
});
const handleFilterChange = (key: keyof AuditLogFilters, value: string) => {
setFilters((prev) => ({
...prev,
[key]: value || undefined,
offset: 0, // Reset pagination on filter change
}));
};
const clearFilters = () => {
setFilters({ offset: 0, limit: PAGE_SIZE });
};
const goToPage = (newOffset: number) => {
setFilters((prev) => ({ ...prev, offset: newOffset }));
};
const hasActiveFilters = filters.action || filters.entity_type || filters.start_date || filters.end_date;
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
const formatDetails = (details: Record<string, unknown> | null) => {
if (!details) return null;
return JSON.stringify(details, null, 2);
};
const totalPages = logsData ? Math.ceil(logsData.total / PAGE_SIZE) : 0;
const currentPage = Math.floor((filters.offset || 0) / PAGE_SIZE) + 1;
if (logsLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
);
}
if (logsError) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load audit logs</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white">Audit Log</h1>
<p className="mt-1 text-sm text-gray-400">
System activity and change history
</p>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-gray-800 bg-gray-900 p-4">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-gray-400" />
<span className="text-sm font-medium text-gray-400">Filters:</span>
</div>
{/* Action filter */}
<select
value={filters.action || ""}
onChange={(e) => handleFilterChange("action", e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="">All Actions</option>
{actions?.map((action) => (
<option key={action} value={action}>
{action.replace(/_/g, " ")}
</option>
))}
</select>
{/* Entity type filter */}
<select
value={filters.entity_type || ""}
onChange={(e) => handleFilterChange("entity_type", e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="">All Entity Types</option>
{entityTypes?.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
{/* Date range */}
<input
type="date"
value={filters.start_date?.split("T")[0] || ""}
onChange={(e) =>
handleFilterChange(
"start_date",
e.target.value ? `${e.target.value}T00:00:00` : ""
)
}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
placeholder="Start date"
/>
<span className="text-gray-500">to</span>
<input
type="date"
value={filters.end_date?.split("T")[0] || ""}
onChange={(e) =>
handleFilterChange(
"end_date",
e.target.value ? `${e.target.value}T23:59:59` : ""
)
}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
placeholder="End date"
/>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-400 hover:border-red-500/50 hover:text-red-400"
>
<X className="h-3.5 w-3.5" />
Clear
</button>
)}
<div className="ml-auto text-sm text-gray-500">
{logsData?.total || 0} total records
</div>
</div>
{/* Logs Table */}
<div className="rounded-xl border border-gray-800 bg-gray-900">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="px-6 py-4 font-medium text-gray-400">Timestamp</th>
<th className="px-6 py-4 font-medium text-gray-400">User</th>
<th className="px-6 py-4 font-medium text-gray-400">Action</th>
<th className="px-6 py-4 font-medium text-gray-400">Entity</th>
<th className="px-6 py-4 font-medium text-gray-400">Details</th>
</tr>
</thead>
<tbody>
{logsData?.items.map((log) => (
<tr
key={log.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
>
<td className="px-6 py-4">
<span className="font-mono text-xs text-gray-400">
{formatDate(log.timestamp)}
</span>
</td>
<td className="px-6 py-4">
<span className="text-gray-200">
{log.username || "System"}
</span>
</td>
<td className="px-6 py-4">
<span className="inline-flex rounded-full border border-cyan-500/30 bg-cyan-900/30 px-2 py-0.5 text-xs font-medium text-cyan-400">
{log.action.replace(/_/g, " ")}
</span>
</td>
<td className="px-6 py-4">
{log.entity_type && (
<div>
<span className="text-gray-300">{log.entity_type}</span>
{log.entity_id && (
<span className="ml-1 font-mono text-xs text-gray-500">
({log.entity_id.slice(0, 8)}...)
</span>
)}
</div>
)}
{!log.entity_type && <span className="text-gray-600"></span>}
</td>
<td className="px-6 py-4">
{log.details ? (
<details className="cursor-pointer">
<summary className="text-xs text-gray-400 hover:text-gray-200">
View details
</summary>
<pre className="mt-2 max-w-xs overflow-auto rounded bg-gray-800 p-2 text-xs text-gray-300">
{formatDetails(log.details)}
</pre>
</details>
) : (
<span className="text-gray-600"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
{logsData?.items.length === 0 && (
<div className="py-12 text-center text-gray-400">
No audit logs found
</div>
)}
</div>
{/* Pagination */}
{logsData && logsData.total > PAGE_SIZE && (
<div className="flex items-center justify-between border-t border-gray-800 px-6 py-4">
<div className="text-sm text-gray-400">
Showing {(filters.offset || 0) + 1} to{" "}
{Math.min((filters.offset || 0) + PAGE_SIZE, logsData.total)} of{" "}
{logsData.total}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => goToPage((filters.offset || 0) - PAGE_SIZE)}
disabled={(filters.offset || 0) === 0}
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="h-4 w-4" />
Previous
</button>
<span className="text-sm text-gray-400">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => goToPage((filters.offset || 0) + PAGE_SIZE)}
disabled={(filters.offset || 0) + PAGE_SIZE >= logsData.total}
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,472 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Loader2,
AlertCircle,
Users,
Plus,
X,
Check,
UserX,
UserCheck,
Edit,
} from "lucide-react";
import { getUsers, createUser, updateUser, type UserOut, type UserCreatePayload } from "../api/users";
const ROLES = [
{ value: "viewer", label: "Viewer" },
{ value: "red_tech", label: "Red Tech" },
{ value: "blue_tech", label: "Blue Tech" },
{ value: "red_lead", label: "Red Lead" },
{ value: "blue_lead", label: "Blue Lead" },
{ value: "admin", label: "Admin" },
];
const roleBadgeColors: Record<string, string> = {
admin: "bg-purple-900/50 text-purple-400 border-purple-500/30",
red_tech: "bg-red-900/50 text-red-400 border-red-500/30",
blue_tech: "bg-blue-900/50 text-blue-400 border-blue-500/30",
red_lead: "bg-orange-900/50 text-orange-400 border-orange-500/30",
blue_lead: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
viewer: "bg-gray-800/50 text-gray-400 border-gray-600/30",
};
export default function UsersPage() {
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingUser, setEditingUser] = useState<UserOut | null>(null);
const {
data: users,
isLoading,
error,
} = useQuery({
queryKey: ["users"],
queryFn: getUsers,
});
const createMutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
setShowCreateModal(false);
},
});
const updateMutation = useMutation({
mutationFn: ({ userId, payload }: { userId: string; payload: Parameters<typeof updateUser>[1] }) =>
updateUser(userId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
setEditingUser(null);
},
});
const toggleUserActive = (user: UserOut) => {
updateMutation.mutate({
userId: user.id,
payload: { is_active: !user.is_active },
});
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return "Never";
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
);
}
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load users</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">User Management</h1>
<p className="mt-1 text-sm text-gray-400">
Manage user accounts and permissions
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
>
<Plus className="h-4 w-4" />
New User
</button>
</div>
{/* Users Table */}
<div className="rounded-xl border border-gray-800 bg-gray-900">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="px-6 py-4 font-medium text-gray-400">Username</th>
<th className="px-6 py-4 font-medium text-gray-400">Email</th>
<th className="px-6 py-4 font-medium text-gray-400">Role</th>
<th className="px-6 py-4 font-medium text-gray-400">Status</th>
<th className="px-6 py-4 font-medium text-gray-400">Created</th>
<th className="px-6 py-4 font-medium text-gray-400">Last Login</th>
<th className="px-6 py-4 font-medium text-gray-400">Actions</th>
</tr>
</thead>
<tbody>
{users?.map((user) => (
<tr
key={user.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
>
<td className="px-6 py-4">
<span className="font-medium text-white">{user.username}</span>
</td>
<td className="px-6 py-4 text-gray-400">
{user.email || "—"}
</td>
<td className="px-6 py-4">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
roleBadgeColors[user.role] || roleBadgeColors.viewer
}`}
>
{user.role.replace(/_/g, " ")}
</span>
</td>
<td className="px-6 py-4">
{user.is_active ? (
<span className="inline-flex items-center gap-1 text-green-400">
<Check className="h-3.5 w-3.5" />
Active
</span>
) : (
<span className="inline-flex items-center gap-1 text-red-400">
<X className="h-3.5 w-3.5" />
Inactive
</span>
)}
</td>
<td className="px-6 py-4 text-gray-400">
{formatDate(user.created_at)}
</td>
<td className="px-6 py-4 text-gray-400">
{formatDate(user.last_login)}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<button
onClick={() => setEditingUser(user)}
className="rounded p-1.5 text-gray-400 hover:bg-gray-800 hover:text-cyan-400"
title="Edit user"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => toggleUserActive(user)}
disabled={updateMutation.isPending}
className={`rounded p-1.5 ${
user.is_active
? "text-gray-400 hover:bg-red-900/50 hover:text-red-400"
: "text-gray-400 hover:bg-green-900/50 hover:text-green-400"
}`}
title={user.is_active ? "Deactivate" : "Activate"}
>
{user.is_active ? (
<UserX className="h-4 w-4" />
) : (
<UserCheck className="h-4 w-4" />
)}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{users?.length === 0 && (
<div className="py-12 text-center text-gray-400">
No users found
</div>
)}
</div>
</div>
{/* Create User Modal */}
{showCreateModal && (
<CreateUserModal
onClose={() => setShowCreateModal(false)}
onSubmit={(data) => createMutation.mutate(data)}
isSubmitting={createMutation.isPending}
error={createMutation.error as Error | null}
/>
)}
{/* Edit User Modal */}
{editingUser && (
<EditUserModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSubmit={(payload) =>
updateMutation.mutate({ userId: editingUser.id, payload })
}
isSubmitting={updateMutation.isPending}
error={updateMutation.error as Error | null}
/>
)}
</div>
);
}
// ── Create User Modal ──────────────────────────────────────────────
interface CreateUserModalProps {
onClose: () => void;
onSubmit: (data: UserCreatePayload) => void;
isSubmitting: boolean;
error: Error | null;
}
function CreateUserModal({ onClose, onSubmit, isSubmitting, error }: CreateUserModalProps) {
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
role: "viewer",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = () => {
const newErrors: Record<string, string> = {};
if (!formData.username.trim()) newErrors.username = "Username is required";
if (!formData.password) newErrors.password = "Password is required";
if (formData.password.length < 6) newErrors.password = "Password must be at least 6 characters";
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validate()) {
onSubmit({
username: formData.username,
email: formData.email || undefined,
password: formData.password,
role: formData.role,
});
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Create New User</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white">
<X className="h-5 w-5" />
</button>
</div>
{error && (
<div className="mb-4 rounded-lg border border-red-500/30 bg-red-900/20 p-3">
<p className="text-sm text-red-400">{error.message}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300">Username *</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className={`mt-1 w-full rounded-lg border bg-gray-800 px-3 py-2 text-gray-200 ${
errors.username ? "border-red-500" : "border-gray-700"
}`}
/>
{errors.username && <p className="mt-1 text-sm text-red-400">{errors.username}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-300">Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300">Password *</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className={`mt-1 w-full rounded-lg border bg-gray-800 px-3 py-2 text-gray-200 ${
errors.password ? "border-red-500" : "border-gray-700"
}`}
/>
{errors.password && <p className="mt-1 text-sm text-red-400">{errors.password}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-300">Role</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200"
>
{ROLES.map((role) => (
<option key={role.value} value={role.value}>
{role.label}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50"
>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
Create User
</button>
</div>
</form>
</div>
</div>
);
}
// ── Edit User Modal ──────────────────────────────────────────────
interface EditUserModalProps {
user: UserOut;
onClose: () => void;
onSubmit: (payload: Parameters<typeof updateUser>[1]) => void;
isSubmitting: boolean;
error: Error | null;
}
function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUserModalProps) {
const [formData, setFormData] = useState({
email: user.email || "",
role: user.role,
password: "",
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const payload: Parameters<typeof updateUser>[1] = {
email: formData.email || undefined,
role: formData.role,
};
if (formData.password) {
payload.password = formData.password;
}
onSubmit(payload);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Edit User: {user.username}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white">
<X className="h-5 w-5" />
</button>
</div>
{error && (
<div className="mb-4 rounded-lg border border-red-500/30 bg-red-900/20 p-3">
<p className="text-sm text-red-400">{error.message}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300">Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300">Role</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200"
>
{ROLES.map((role) => (
<option key={role.value} value={role.value}>
{role.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300">
New Password <span className="text-gray-500">(leave blank to keep current)</span>
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200"
placeholder="••••••••"
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50"
>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
Save Changes
</button>
</div>
</form>
</div>
</div>
);
}