yo yo you should star the repo :) pls
|
|
@ -32,7 +32,7 @@ BACKEND_PORT=8000
|
||||||
|
|
||||||
# CORS Origins (comma-separated for multiple origins)
|
# CORS Origins (comma-separated for multiple origins)
|
||||||
# Include both direct Vite dev server (5173) and nginx proxy (80)
|
# Include both direct Vite dev server (5173) and nginx proxy (80)
|
||||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost,http://localhost:8000
|
||||||
|
|
||||||
# Frontend API URL
|
# Frontend API URL
|
||||||
# When using Docker with nginx: http://localhost/api
|
# When using Docker with nginx: http://localhost/api
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
name: Lint & Type Check
|
name: Lint & Type Check
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ 'main' ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ '*' ]
|
branches: [ '*' ]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
name: Run Linters
|
name: Run Linters
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
"""
|
||||||
|
ⒸAngelaMos | CarterPerez-dev
|
||||||
|
ⒸCertGames.com | 2025
|
||||||
|
----
|
||||||
|
API Security Scanner
|
||||||
|
|
||||||
|
⡋⣡⣴⣶⣶⡀⠄⠄⠙⢿⣿⣿⣿⣿⣿⣴⣿⣿⣿⢃⣤⣄⣀⣥⣿
|
||||||
|
⢸⣇⠻⣿⣿⣿⣧⣀⢀⣠⡌⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣿
|
||||||
|
⢸⣿⣷⣤⣤⣤⣬⣙⣛⢿⣿⣿⣿⣿⣿⣿⡿⣿⣿⡍⠄⠄⢀⣤⣄⠉
|
||||||
|
⣖⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⣿⣿⣿⣿⣿⢇⣿⣿⡷⠶⠶⢿⣿⣿⠇⢀
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣿⣿⣿⡇⣿⣿⣿⣿⣿⣿⣷⣶⣥⣴
|
||||||
|
⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣦⣌⣛⣻⣿⣿⣧⠙⠛⠛⡭⠅⠒⠦⠭⣭⡻⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃⠄
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⡆⠄⠄⠄⠄⠄⠄⠄⠄⠹⠈⢋⣽⣿⣿⣿⣿⣵⣾
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⠄⣴⣿⣶⣄⠄⣴⣶⠄⢀⣾⣿⣿⣿⣿⣿⣿⠃⠄⠄
|
||||||
|
⠈⠻⣿⣿⣿⣿⣿⣿⡄⢻⣿⣿⣿⠄⣿⣿⡀⣾⣿⣿⣿⣿⣛⠛⠁
|
||||||
|
⠄⠄⠈⠛⢿⣿⣿⣿⠁⠞⢿⣿⣿⡄⢿⣿⡇⣸⣿⣿⠿⠛⠁⠄
|
||||||
|
⠄⠄⠄⠄⠄⠉⠻⣿⣿⣾⣦⡙⠻⣷⣾⣿⠃⠿⠋⠁⠄
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
@ -9,10 +9,10 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""
|
"""
|
||||||
Application settings loaded from environment variables.
|
Application settings loaded from environment variables
|
||||||
|
|
||||||
All magic numbers and configuration values are defined here to avoid
|
All magic numbers and configuration values are defined here to avoid
|
||||||
hardcoding throughout the application.
|
hardcoding throughout the application
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
|
|
@ -56,9 +56,9 @@ class Settings(BaseSettings):
|
||||||
SCANNER_RATE_LIMIT_WINDOW_SECONDS: int = 60
|
SCANNER_RATE_LIMIT_WINDOW_SECONDS: int = 60
|
||||||
|
|
||||||
# API endpoint rate limiting (incoming requests - slowapi format)
|
# API endpoint rate limiting (incoming requests - slowapi format)
|
||||||
API_RATE_LIMIT_LOGIN: str = "5/minute"
|
API_RATE_LIMIT_LOGIN: str = "20/minute"
|
||||||
API_RATE_LIMIT_REGISTER: str = "3/minute"
|
API_RATE_LIMIT_REGISTER: str = "15/minute"
|
||||||
API_RATE_LIMIT_SCAN: str = "10/minute"
|
API_RATE_LIMIT_SCAN: str = "15/minute"
|
||||||
API_RATE_LIMIT_DEFAULT: str = "100/minute"
|
API_RATE_LIMIT_DEFAULT: str = "100/minute"
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
|
|
@ -73,8 +73,8 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
# Scanner timeouts and limits
|
# Scanner timeouts and limits
|
||||||
SCANNER_MAX_CONCURRENT_REQUESTS: int = 50
|
SCANNER_MAX_CONCURRENT_REQUESTS: int = 50
|
||||||
SCANNER_CONNECTION_TIMEOUT: int = 30
|
SCANNER_CONNECTION_TIMEOUT: int = 180
|
||||||
SCANNER_READ_TIMEOUT: int = 30
|
SCANNER_READ_TIMEOUT: int = 180
|
||||||
|
|
||||||
# Scanner request spacing and timing
|
# Scanner request spacing and timing
|
||||||
DEFAULT_JITTER_MS: int = 100
|
DEFAULT_JITTER_MS: int = 100
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
"""
|
||||||
|
ⒸAngelaMos | 2025
|
||||||
|
Core modules for application infrastructure
|
||||||
|
"""
|
||||||
|
|
@ -8,7 +8,7 @@ from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
from ..config import settings
|
from config import settings
|
||||||
|
|
||||||
# Database engine
|
# Database engine
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
|
|
@ -11,19 +11,22 @@ from fastapi.security import (
|
||||||
HTTPAuthorizationCredentials,
|
HTTPAuthorizationCredentials,
|
||||||
HTTPBearer,
|
HTTPBearer,
|
||||||
)
|
)
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
from .security import decode_token
|
from .security import decode_token
|
||||||
|
from .database import get_db
|
||||||
|
from repositories.user_repository import UserRepository
|
||||||
|
from schemas.user_schemas import UserResponse
|
||||||
|
|
||||||
|
|
||||||
# HTTP Bearer token authentication
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
) -> str:
|
db: Session = Depends(get_db),
|
||||||
|
) -> UserResponse:
|
||||||
"""
|
"""
|
||||||
FastAPI dependency to extract and
|
FastAPI dependency to extract and verify the current authenticated user
|
||||||
verify the current authenticated user.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
payload = decode_token(credentials.credentials)
|
payload = decode_token(credentials.credentials)
|
||||||
|
|
@ -36,7 +39,16 @@ async def get_current_user(
|
||||||
headers = {"WWW-Authenticate": "Bearer"},
|
headers = {"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
return email
|
user = UserRepository.get_by_email(db, email)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail = "User not found",
|
||||||
|
headers = {"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -6,21 +6,20 @@ from datetime import (
|
||||||
datetime,
|
datetime,
|
||||||
timedelta,
|
timedelta,
|
||||||
)
|
)
|
||||||
|
import bcrypt
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
|
|
||||||
# Password hashing
|
|
||||||
pwd_context = CryptContext(schemes = ["bcrypt"], deprecated = "auto")
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
"""
|
"""
|
||||||
Hash a plain text password using bcrypt
|
Hash a plain text password using bcrypt
|
||||||
"""
|
"""
|
||||||
return pwd_context.hash(password)
|
password_bytes = password.encode('utf-8')
|
||||||
|
salt = bcrypt.gensalt()
|
||||||
|
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||||
|
return hashed.decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
def verify_password(
|
def verify_password(
|
||||||
|
|
@ -30,7 +29,9 @@ def verify_password(
|
||||||
"""
|
"""
|
||||||
Verify a plain text password against a hashed password
|
Verify a plain text password against a hashed password
|
||||||
"""
|
"""
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
password_bytes = plain_password.encode('utf-8')
|
||||||
|
hashed_bytes = hashed_password.encode('utf-8')
|
||||||
|
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(
|
def create_access_token(
|
||||||
|
|
@ -13,9 +13,9 @@ from slowapi import (
|
||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
from .config import settings
|
from config import settings
|
||||||
from .core.database import Base, engine
|
from core.database import Base, engine
|
||||||
from .routes import auth_router, scans_router
|
from routes import auth_router, scans_router
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
|
@ -27,8 +27,10 @@ def create_app() -> FastAPI:
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title = settings.APP_NAME,
|
title = settings.APP_NAME,
|
||||||
version = settings.VERSION,
|
version = settings.VERSION,
|
||||||
docs_url = "/api/docs",
|
openapi_version = "3.1.0",
|
||||||
redoc_url = "/api/redoc",
|
docs_url = "/docs",
|
||||||
|
redoc_url = "/redoc",
|
||||||
|
openapi_url = "/openapi.json",
|
||||||
debug = settings.DEBUG,
|
debug = settings.DEBUG,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸCertGames.com | 2025
|
||||||
FastAPI application entry point
|
ⒸAngelaMos | CarterPerez-dev
|
||||||
|
----
|
||||||
|
API Security Scanner FastAPI entry point
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from .config import settings
|
from config import settings
|
||||||
from .factory import create_app
|
from factory import create_app
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
@ -13,7 +13,7 @@ from sqlalchemy import (
|
||||||
from datetime import datetime, UTC
|
from datetime import datetime, UTC
|
||||||
from sqlalchemy.ext.declarative import declared_attr
|
from sqlalchemy.ext.declarative import declared_attr
|
||||||
|
|
||||||
from ..core.database import Base
|
from core.database import Base
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(Base):
|
class BaseModel(Base):
|
||||||
|
|
@ -16,7 +16,7 @@ from sqlalchemy import (
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from ..config import settings
|
from config import settings
|
||||||
from .Base import BaseModel
|
from .Base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -6,14 +6,14 @@ TestResult model for storing individual security test results
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column,
|
Column,
|
||||||
Enum,
|
Enum,
|
||||||
ForeignKey,
|
|
||||||
Integer,
|
Integer,
|
||||||
Text,
|
Text,
|
||||||
|
ForeignKey,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.dialects.postgresql import JSON
|
from sqlalchemy.dialects.postgresql import JSON
|
||||||
|
|
||||||
from ..core.enums import (
|
from core.enums import (
|
||||||
ScanStatus,
|
ScanStatus,
|
||||||
Severity,
|
Severity,
|
||||||
TestType,
|
TestType,
|
||||||
|
|
@ -9,7 +9,7 @@ from sqlalchemy import (
|
||||||
String,
|
String,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..config import settings
|
from config import settings
|
||||||
from .Base import BaseModel
|
from .Base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -20,10 +20,10 @@ dependencies = [
|
||||||
"alembic==1.17.1",
|
"alembic==1.17.1",
|
||||||
# Security
|
# Security
|
||||||
"slowapi==0.1.9",
|
"slowapi==0.1.9",
|
||||||
"passlib[bcrypt]==1.7.4",
|
|
||||||
"python-jose[cryptography]==3.5.0",
|
"python-jose[cryptography]==3.5.0",
|
||||||
"bcrypt==4.3.0",
|
"bcrypt==5.0.0",
|
||||||
# HTTP client for scanners
|
# HTTP client for scanners
|
||||||
|
"requests==2.32.3",
|
||||||
"httpx==0.28.1",
|
"httpx==0.28.1",
|
||||||
"aiohttp==3.13.2",
|
"aiohttp==3.13.2",
|
||||||
# Settings management
|
# Settings management
|
||||||
|
|
@ -55,7 +55,22 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
py-modules = ["main", "config", "factory"]
|
py-modules = [
|
||||||
|
"main",
|
||||||
|
"config",
|
||||||
|
"factory"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = [
|
||||||
|
"core",
|
||||||
|
"routes",
|
||||||
|
"models",
|
||||||
|
"schemas",
|
||||||
|
"services",
|
||||||
|
"scanners",
|
||||||
|
"repositories"
|
||||||
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
|
|
@ -174,6 +189,7 @@ disable_error_code = [
|
||||||
"union-attr",
|
"union-attr",
|
||||||
"call-arg",
|
"call-arg",
|
||||||
"dict-item",
|
"dict-item",
|
||||||
|
"abstract",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -181,11 +197,12 @@ disable_error_code = [
|
||||||
py-version = "3.11"
|
py-version = "3.11"
|
||||||
jobs = 4
|
jobs = 4
|
||||||
load-plugins = [
|
load-plugins = [
|
||||||
"pylint_pydantic",
|
"pylint_pydantic",
|
||||||
"pylint_per_file_ignores"
|
"pylint_per_file_ignores"
|
||||||
]
|
]
|
||||||
persistent = true
|
persistent = true
|
||||||
suggestion-mode = true
|
suggestion-mode = true
|
||||||
|
init-hook = "import sys; import os; sys.path.insert(0, os.getcwd())"
|
||||||
ignore = [
|
ignore = [
|
||||||
"venv",
|
"venv",
|
||||||
".venv",
|
".venv",
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
"""Database repository layer for data access operations"""
|
"""
|
||||||
|
ⒸAngelaMos | 2025
|
||||||
|
Database repository layer for data access operations
|
||||||
|
"""
|
||||||
|
|
||||||
from .user_repository import UserRepository
|
from .user_repository import UserRepository
|
||||||
from .scan_repository import ScanRepository
|
from .scan_repository import ScanRepository
|
||||||
|
|
@ -11,8 +11,8 @@ from sqlalchemy.orm import (
|
||||||
joinedload,
|
joinedload,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..config import settings
|
from config import settings
|
||||||
from ..models.Scan import Scan
|
from models.Scan import Scan
|
||||||
|
|
||||||
|
|
||||||
class ScanRepository:
|
class ScanRepository:
|
||||||
|
|
@ -9,12 +9,12 @@ from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..core.enums import (
|
from core.enums import (
|
||||||
ScanStatus,
|
ScanStatus,
|
||||||
Severity,
|
Severity,
|
||||||
TestType,
|
TestType,
|
||||||
)
|
)
|
||||||
from ..models.TestResult import TestResult
|
from models.TestResult import TestResult
|
||||||
|
|
||||||
|
|
||||||
class TestResultRepository:
|
class TestResultRepository:
|
||||||
|
|
@ -7,8 +7,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..config import settings
|
from config import settings
|
||||||
from ..models.User import User
|
from models.User import User
|
||||||
|
|
||||||
|
|
||||||
class UserRepository:
|
class UserRepository:
|
||||||
|
|
@ -11,11 +11,12 @@ psycopg2-binary>=2.9.9
|
||||||
alembic>=1.13.0
|
alembic>=1.13.0
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
passlib[bcrypt]>=1.7.4
|
slowapi>=0.1.9
|
||||||
python-jose[cryptography]>=3.3.0
|
python-jose[cryptography]>=3.3.0
|
||||||
bcrypt>=4.1.2
|
bcrypt==5.0.0
|
||||||
|
|
||||||
# HTTP client for scanners
|
# HTTP client for scanners
|
||||||
|
requests>=2.31.0
|
||||||
httpx>=0.26.0
|
httpx>=0.26.0
|
||||||
aiohttp>=3.9.0
|
aiohttp>=3.9.0
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""
|
"""
|
||||||
©AngelaMos | 2025
|
©AngelaMos | 2025
|
||||||
Authentication routes - registration and login
|
Authentication routes
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
|
|
@ -13,15 +13,15 @@ from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..config import settings
|
from config import settings
|
||||||
from ..core.database import get_db
|
from core.database import get_db
|
||||||
from ..schemas.user_schemas import (
|
from schemas.user_schemas import (
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserLogin,
|
UserLogin,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
)
|
)
|
||||||
from ..services.auth_service import AuthService
|
from services.auth_service import AuthService
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix = "/auth", tags = ["authentication"])
|
router = APIRouter(prefix = "/auth", tags = ["authentication"])
|
||||||
|
|
@ -13,15 +13,15 @@ from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..config import settings
|
from config import settings
|
||||||
from ..core.database import get_db
|
from core.database import get_db
|
||||||
from ..core.dependencies import get_current_user
|
from core.dependencies import get_current_user
|
||||||
from ..schemas.scan_schemas import (
|
from schemas.scan_schemas import (
|
||||||
ScanRequest,
|
ScanRequest,
|
||||||
ScanResponse,
|
ScanResponse,
|
||||||
)
|
)
|
||||||
from ..schemas.user_schemas import UserResponse
|
from schemas.user_schemas import UserResponse
|
||||||
from ..services.scan_service import ScanService
|
from services.scan_service import ScanService
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix = "/scans", tags = ["scans"])
|
router = APIRouter(prefix = "/scans", tags = ["scans"])
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
"""Security scanner modules for API vulnerability testing"""
|
"""
|
||||||
|
ⒸAngelaMos | 2025
|
||||||
|
Security scanner modules for API vulnerability testing
|
||||||
|
"""
|
||||||
|
|
||||||
from .base_scanner import BaseScanner
|
from .base_scanner import BaseScanner
|
||||||
from .rate_limit_scanner import RateLimitScanner
|
from .rate_limit_scanner import RateLimitScanner
|
||||||
|
|
@ -11,13 +11,13 @@ import base64
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..config import settings
|
from config import settings
|
||||||
from ..core.enums import (
|
from core.enums import (
|
||||||
ScanStatus,
|
ScanStatus,
|
||||||
Severity,
|
Severity,
|
||||||
TestType,
|
TestType,
|
||||||
)
|
)
|
||||||
from ..schemas.test_result_schemas import TestResultCreate
|
from schemas.test_result_schemas import TestResultCreate
|
||||||
|
|
||||||
from .payloads import AuthPayloads
|
from .payloads import AuthPayloads
|
||||||
from .base_scanner import BaseScanner
|
from .base_scanner import BaseScanner
|
||||||
|
|
@ -14,8 +14,8 @@ from abc import ABC, abstractmethod
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from ..config import settings
|
from config import settings
|
||||||
from ..schemas.test_result_schemas import TestResultCreate
|
from schemas.test_result_schemas import TestResultCreate
|
||||||
|
|
||||||
|
|
||||||
class BaseScanner(ABC):
|
class BaseScanner(ABC):
|
||||||
|
|
@ -10,8 +10,8 @@ from __future__ import annotations
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..core.enums import ScanStatus, Severity, TestType
|
from core.enums import ScanStatus, Severity, TestType
|
||||||
from ..schemas.test_result_schemas import TestResultCreate
|
from schemas.test_result_schemas import TestResultCreate
|
||||||
from .base_scanner import BaseScanner
|
from .base_scanner import BaseScanner
|
||||||
from .payloads import IDORPayloads
|
from .payloads import IDORPayloads
|
||||||
|
|
||||||
|
|
@ -8,7 +8,6 @@ class SQLiPayloads:
|
||||||
"""
|
"""
|
||||||
SQL Injection test payloads covering various database types and techniques
|
SQL Injection test payloads covering various database types and techniques
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ERROR_SIGNATURES = {
|
ERROR_SIGNATURES = {
|
||||||
"mysql": [
|
"mysql": [
|
||||||
"sql syntax",
|
"sql syntax",
|
||||||
|
|
@ -222,7 +221,6 @@ class IDORPayloads:
|
||||||
"""
|
"""
|
||||||
Insecure Direct Object Reference (IDOR) test patterns
|
Insecure Direct Object Reference (IDOR) test patterns
|
||||||
"""
|
"""
|
||||||
|
|
||||||
NUMERIC_ID_MANIPULATIONS = [
|
NUMERIC_ID_MANIPULATIONS = [
|
||||||
0,
|
0,
|
||||||
-1,
|
-1,
|
||||||
|
|
@ -275,7 +273,6 @@ class RateLimitBypassPayloads:
|
||||||
"""
|
"""
|
||||||
Rate limiting bypass techniques and patterns
|
Rate limiting bypass techniques and patterns
|
||||||
"""
|
"""
|
||||||
|
|
||||||
HEADER_PATTERNS = {
|
HEADER_PATTERNS = {
|
||||||
"limit":
|
"limit":
|
||||||
r"x-ratelimit-limit|x-rate-limit-limit|ratelimit-limit",
|
r"x-ratelimit-limit|x-rate-limit-limit|ratelimit-limit",
|
||||||
|
|
@ -11,12 +11,12 @@ import re
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..core.enums import (
|
from core.enums import (
|
||||||
ScanStatus,
|
ScanStatus,
|
||||||
Severity,
|
Severity,
|
||||||
TestType,
|
TestType,
|
||||||
)
|
)
|
||||||
from ..schemas.test_result_schemas import TestResultCreate
|
from schemas.test_result_schemas import TestResultCreate
|
||||||
|
|
||||||
from .base_scanner import BaseScanner
|
from .base_scanner import BaseScanner
|
||||||
from .payloads import RateLimitBypassPayloads
|
from .payloads import RateLimitBypassPayloads
|
||||||
|
|
@ -9,12 +9,12 @@ import time
|
||||||
import statistics
|
import statistics
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..core.enums import (
|
from core.enums import (
|
||||||
ScanStatus,
|
ScanStatus,
|
||||||
Severity,
|
Severity,
|
||||||
TestType,
|
TestType,
|
||||||
)
|
)
|
||||||
from ..schemas.test_result_schemas import TestResultCreate
|
from schemas.test_result_schemas import TestResultCreate
|
||||||
|
|
||||||
from .payloads import SQLiPayloads
|
from .payloads import SQLiPayloads
|
||||||
from .base_scanner import BaseScanner
|
from .base_scanner import BaseScanner
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
"""Pydantic schemas for API validation and serialization"""
|
"""
|
||||||
|
ⒸAngelaMos | 2025
|
||||||
|
Pydantic schemas for API validation and serialization
|
||||||
|
"""
|
||||||
|
|
||||||
from .user_schemas import (
|
from .user_schemas import (
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
Scan model API validation and serialization.
|
Scan model API validation and serialization
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -13,8 +13,9 @@ from pydantic import (
|
||||||
)
|
)
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from ..config import settings
|
from config import settings
|
||||||
from ..core.enums import TestType
|
from core.enums import TestType
|
||||||
|
from .test_result_schemas import TestResultResponse
|
||||||
|
|
||||||
|
|
||||||
class ScanRequest(BaseModel):
|
class ScanRequest(BaseModel):
|
||||||
|
|
@ -35,8 +36,6 @@ class ScanResponse(BaseModel):
|
||||||
"""
|
"""
|
||||||
Schema for scan data in API responses
|
Schema for scan data in API responses
|
||||||
"""
|
"""
|
||||||
# Circular to avoid circular imports
|
|
||||||
from .test_result_schemas import TestResultResponse
|
|
||||||
model_config = ConfigDict(from_attributes = True)
|
model_config = ConfigDict(from_attributes = True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
|
@ -11,7 +11,7 @@ from pydantic import (
|
||||||
Field,
|
Field,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..core.enums import (
|
from core.enums import (
|
||||||
ScanStatus,
|
ScanStatus,
|
||||||
Severity,
|
Severity,
|
||||||
TestType,
|
TestType,
|
||||||
|
|
@ -1,33 +1,52 @@
|
||||||
"""
|
"""
|
||||||
Pydantic schemas for User model - API validation and serialization.
|
ⒸAngelaMos | 2025
|
||||||
|
User model API validation and serialization
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
from ..config import settings
|
ConfigDict,
|
||||||
|
EmailStr,
|
||||||
|
Field,
|
||||||
|
field_validator
|
||||||
|
)
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
class UserCreate(BaseModel):
|
||||||
"""
|
"""
|
||||||
Schema for user registration request.
|
Schema for user registration request.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str = Field(
|
password: str = Field(
|
||||||
min_length = settings.PASSWORD_MIN_LENGTH,
|
min_length = settings.PASSWORD_MIN_LENGTH,
|
||||||
max_length = settings.PASSWORD_MAX_LENGTH,
|
max_length = settings.PASSWORD_MAX_LENGTH,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator('password')
|
||||||
|
@classmethod
|
||||||
|
def validate_password_strength(cls, v: str) -> str:
|
||||||
|
"""
|
||||||
|
Validate password meets security requirements
|
||||||
|
"""
|
||||||
|
if not re.search(r'[A-Z]', v):
|
||||||
|
raise ValueError('Password must contain at least one uppercase letter')
|
||||||
|
if not re.search(r'[a-z]', v):
|
||||||
|
raise ValueError('Password must contain at least one lowercase letter')
|
||||||
|
if not re.search(r'[0-9]', v):
|
||||||
|
raise ValueError('Password must contain at least one number')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class UserLogin(BaseModel):
|
class UserLogin(BaseModel):
|
||||||
"""
|
"""
|
||||||
Schema for user login request.
|
Schema for user login request.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
@ -37,7 +56,6 @@ class UserResponse(BaseModel):
|
||||||
Schema for user data in API responses.
|
Schema for user data in API responses.
|
||||||
Excludes sensitive fields like hashed_password.
|
Excludes sensitive fields like hashed_password.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes = True)
|
model_config = ConfigDict(from_attributes = True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
|
@ -50,6 +68,5 @@ class TokenResponse(BaseModel):
|
||||||
"""
|
"""
|
||||||
Schema for JWT token response.
|
Schema for JWT token response.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
|
@ -9,19 +9,19 @@ from datetime import timedelta
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
from ..config import settings
|
from config import settings
|
||||||
from ..core.security import (
|
from core.security import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
hash_password,
|
hash_password,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from ..schemas.user_schemas import (
|
from schemas.user_schemas import (
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserLogin,
|
UserLogin,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
)
|
)
|
||||||
from ..repositories.user_repository import UserRepository
|
from repositories.user_repository import UserRepository
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
|
|
@ -5,19 +5,22 @@ Coordinates scanners and saves results
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Type
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
from ..core.enums import TestType
|
from core.enums import TestType
|
||||||
from ..repositories.scan_repository import ScanRepository
|
from repositories.scan_repository import ScanRepository
|
||||||
from ..repositories.test_result_repository import TestResultRepository
|
from repositories.test_result_repository import TestResultRepository
|
||||||
|
|
||||||
from ..scanners.auth_scanner import AuthScanner
|
from scanners.base_scanner import BaseScanner
|
||||||
from ..scanners.idor_scanner import IDORScanner
|
from scanners.auth_scanner import AuthScanner
|
||||||
from ..scanners.sqli_scanner import SQLiScanner
|
from scanners.idor_scanner import IDORScanner
|
||||||
from ..scanners.rate_limit_scanner import RateLimitScanner
|
from scanners.sqli_scanner import SQLiScanner
|
||||||
from ..schemas.test_result_schemas import TestResultCreate
|
from scanners.rate_limit_scanner import RateLimitScanner
|
||||||
from ..schemas.scan_schemas import ScanRequest, ScanResponse
|
from schemas.test_result_schemas import TestResultCreate
|
||||||
|
from schemas.scan_schemas import ScanRequest, ScanResponse
|
||||||
|
|
||||||
|
|
||||||
class ScanService:
|
class ScanService:
|
||||||
|
|
@ -47,7 +50,7 @@ class ScanService:
|
||||||
target_url = str(scan_request.target_url),
|
target_url = str(scan_request.target_url),
|
||||||
)
|
)
|
||||||
|
|
||||||
scanner_mapping = {
|
scanner_mapping: dict[TestType, Type[BaseScanner]] = {
|
||||||
TestType.RATE_LIMIT: RateLimitScanner,
|
TestType.RATE_LIMIT: RateLimitScanner,
|
||||||
TestType.AUTH: AuthScanner,
|
TestType.AUTH: AuthScanner,
|
||||||
TestType.SQLI: SQLiScanner,
|
TestType.SQLI: SQLiScanner,
|
||||||
|
|
@ -57,7 +60,7 @@ class ScanService:
|
||||||
results: list[TestResultCreate] = []
|
results: list[TestResultCreate] = []
|
||||||
|
|
||||||
for test_type in scan_request.tests_to_run:
|
for test_type in scan_request.tests_to_run:
|
||||||
scanner_class = scanner_mapping.get(test_type)
|
scanner_class: Type[BaseScanner] | None = scanner_mapping.get(test_type)
|
||||||
|
|
||||||
if not scanner_class:
|
if not scanner_class:
|
||||||
continue
|
continue
|
||||||
|
|
@ -3,9 +3,8 @@
|
||||||
# Stage 1: Build the React app with Vite
|
# Stage 1: Build the React app with Vite
|
||||||
# Stage 2: Serve static files with Nginx
|
# Stage 2: Serve static files with Nginx
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# Stage 1: Build
|
# Stage 1: Build
|
||||||
# ===================================
|
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
@ -22,16 +21,17 @@ COPY frontend/ .
|
||||||
# Build the app (creates dist/ folder with static files)
|
# Build the app (creates dist/ folder with static files)
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ===================================
|
|
||||||
|
|
||||||
# Stage 2: Serve with Nginx
|
# Stage 2: Serve with Nginx
|
||||||
# ===================================
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
# Copy built static files from builder stage
|
# Copy built static files from builder stage
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Copy custom nginx configuration
|
# Copy nginx configurations
|
||||||
COPY conf/nginx/prod.nginx /etc/nginx/nginx.conf
|
COPY conf/nginx/prod.nginx /etc/nginx/nginx.conf
|
||||||
|
COPY conf/nginx/http.conf /etc/nginx/http.conf
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
@ -10,8 +10,10 @@ events {
|
||||||
}
|
}
|
||||||
|
|
||||||
http {
|
http {
|
||||||
# Include shared config
|
# Include shared config and MIME types
|
||||||
include /etc/nginx/http.conf;
|
include /etc/nginx/http.conf;
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
# Disable access logs in dev (less noise)
|
# Disable access logs in dev (less noise)
|
||||||
access_log off;
|
access_log off;
|
||||||
|
|
@ -19,16 +21,16 @@ http {
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
|
||||||
# Client max body size (for file uploads in scans)
|
# Client upload limits
|
||||||
client_max_body_size 10M;
|
client_max_body_size 10M;
|
||||||
|
client_body_buffer_size 128k;
|
||||||
|
|
||||||
# Backend API routes
|
# Backend API
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000/;
|
proxy_pass http://backend/;
|
||||||
|
|
||||||
# Apply common proxy settings
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
|
|
@ -39,11 +41,9 @@ http {
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Frontend (Vite dev server with HMR)
|
# Frontend
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend;
|
proxy_pass http://frontend;
|
||||||
|
|
||||||
# WebSocket support for Vite HMR
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
|
|
@ -51,7 +51,6 @@ http {
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Health check endpoint
|
|
||||||
location /health {
|
location /health {
|
||||||
access_log off;
|
access_log off;
|
||||||
return 200 "healthy\n";
|
return 200 "healthy\n";
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# ⒸAngelaMos | 2025
|
||||||
|
# Shared HTTP configuration for both dev and prod
|
||||||
|
# This file contains common upstream definitions
|
||||||
|
|
||||||
|
# Upstream backend (FastAPI) - used in both dev and prod
|
||||||
|
upstream backend {
|
||||||
|
server backend:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upstream frontend (Vite dev server) - only used in dev
|
||||||
|
upstream frontend {
|
||||||
|
server frontend:5173;
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
# - Proxies /api to FastAPI backend
|
# - Proxies /api to FastAPI backend
|
||||||
# - Caching, gzip compression, security headers
|
# - Caching, gzip compression, security headers
|
||||||
# - SSL/HTTPS ready (commented out, uncomment when you have certificates)
|
# - SSL/HTTPS ready (commented out, uncomment when you have certificates)
|
||||||
|
# - Use Cloudfare tls with dns proxy if no tls in nginx
|
||||||
|
|
||||||
events {
|
events {
|
||||||
worker_connections 2048;
|
worker_connections 2048;
|
||||||
|
|
@ -46,16 +47,18 @@ http {
|
||||||
application/vnd.ms-fontobject
|
application/vnd.ms-fontobject
|
||||||
image/svg+xml;
|
image/svg+xml;
|
||||||
|
|
||||||
# HTTP server (will redirect to HTTPS in production)
|
# HTTP server
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
listen [::]:80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
# Uncomment below to redirect HTTP to HTTPS in production
|
# Uncomment below to redirect HTTP to HTTPS in production
|
||||||
# return 301 https://$server_name$request_uri;
|
# return 301 https://$server_name$request_uri;
|
||||||
|
|
||||||
# Client max body size
|
# Client upload limits
|
||||||
client_max_body_size 10M;
|
client_max_body_size 10M;
|
||||||
|
client_body_buffer_size 128k;
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
|
@ -100,7 +103,6 @@ http {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Health check endpoint
|
|
||||||
location /health {
|
location /health {
|
||||||
access_log off;
|
access_log off;
|
||||||
return 200 "healthy\n";
|
return 200 "healthy\n";
|
||||||
|
|
@ -111,6 +113,7 @@ http {
|
||||||
# HTTPS server (uncomment and configure when you have SSL certificates)
|
# HTTPS server (uncomment and configure when you have SSL certificates)
|
||||||
# server {
|
# server {
|
||||||
# listen 443 ssl http2;
|
# listen 443 ssl http2;
|
||||||
|
# listen [::]:443 ssl http2;
|
||||||
# server_name yourdomain.com;
|
# server_name yourdomain.com;
|
||||||
#
|
#
|
||||||
# # SSL certificates (use Let's Encrypt or similar)
|
# # SSL certificates (use Let's Encrypt or similar)
|
||||||
|
|
@ -14,7 +14,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data_dev:/var/lib/postgresql/data
|
- postgres_data_dev:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-apiuser}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-apiuser} -d ${POSTGRES_DB:-apisecurity}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
@ -45,7 +45,7 @@ services:
|
||||||
- apisec_network
|
- apisec_network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# React Frontend (Development with Vite HMR)
|
# React Frontend - Vite HMR
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -23,7 +23,7 @@ services:
|
||||||
- apisec_network
|
- apisec_network
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
# FastAPI Backend (Production with Gunicorn)
|
# FastAPI Backend
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -51,7 +51,7 @@ services:
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# Frontend (Production - Nginx serving static files)
|
# Nginx serving static files
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -59,14 +59,15 @@ services:
|
||||||
container_name: apisec_frontend_prod
|
container_name: apisec_frontend_prod
|
||||||
ports:
|
ports:
|
||||||
- "${HOST_NGINX_PORT:-80}:80"
|
- "${HOST_NGINX_PORT:-80}:80"
|
||||||
# Uncomment for HTTPS:
|
# Uncomment for HTTPS - or dns proxy with Cloudfare:
|
||||||
# - "${HOST_NGINX_HTTPS_PORT:-443}:443"
|
# - "${HOST_NGINX_HTTPS_PORT:-443}:443"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
- apisec_network
|
- apisec_network
|
||||||
restart: always
|
restart: always
|
||||||
# Optional: Mount SSL certificates for HTTPS
|
# Optional: Mount SSL certificates for HTTPS -
|
||||||
|
# or dns proxy with Cloudfare (easier)
|
||||||
# volumes:
|
# volumes:
|
||||||
# - ./ssl:/etc/nginx/ssl:ro
|
# - ./ssl:/etc/nginx/ssl:ro
|
||||||
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"stylelint-config-standard-scss",
|
||||||
|
"stylelint-config-prettier-scss"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"scss/at-rule-no-unknown": null,
|
||||||
|
"selector-class-pattern": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,scss}\"",
|
"format:check": "prettier --check \"**/*.{ts,tsx,scss}\"",
|
||||||
"lint": "npm run lint:eslint && npm run lint:scss && npm run lint:types",
|
"lint": "npm run lint:eslint && npm run lint:scss && npm run lint:types",
|
||||||
"lint:eslint": "eslint . --ext .ts,.tsx --max-warnings 0",
|
"lint:eslint": "eslint . --ext .ts,.tsx --max-warnings 0",
|
||||||
|
"lint:scss": "stylelint \"src/**/*.css\" --max-warnings 0",
|
||||||
|
"lint:scss:fix": "stylelint \"src/**/*.css\" --fix",
|
||||||
"lint:types": "tsc --project tsconfig.app.json --noEmit"
|
"lint:types": "tsc --project tsconfig.app.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -52,6 +54,9 @@
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"husky": "^8.0.0",
|
"husky": "^8.0.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
|
"stylelint": "^16.25.0",
|
||||||
|
"stylelint-config-prettier-scss": "^1.0.0",
|
||||||
|
"stylelint-config-standard-scss": "^16.0.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.39.1",
|
"typescript-eslint": "^8.39.1",
|
||||||
"vite": "^7.1.2",
|
"vite": "^7.1.2",
|
||||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 378 B After Width: | Height: | Size: 378 B |
|
Before Width: | Height: | Size: 878 B After Width: | Height: | Size: 878 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* ©AngelaMos | 2025
|
||||||
|
* Main application component
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
|
import { queryClient } from '@/lib/queryClient';
|
||||||
|
import { router } from '@/router';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
|
||||||
|
const AuthInitializer = (): null => {
|
||||||
|
const loadUserFromStorage = useAuthStore((state) => state.loadUserFromStorage);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUserFromStorage();
|
||||||
|
}, [loadUserFromStorage]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function App(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthInitializer />
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
<Toaster position="top-right" closeButton duration={4500} theme="dark" />
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* ©AngelaMos | 2025
|
||||||
|
* Auth form styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 40px;
|
||||||
|
background: rgb(255 255 255 / 5%);
|
||||||
|
border: 1px solid rgb(255 255 255 / 10%);
|
||||||
|
border-radius: 16px;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 8px 32px 0 rgb(0 0 0 / 37%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form__title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form__subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgb(255 255 255 / 60%);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form__fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form__link {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgb(255 255 255 / 60%);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form__link-text {
|
||||||
|
color: #3b82f6;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form__link-text:hover {
|
||||||
|
color: #60a5fa;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form__link-text:focus-visible {
|
||||||
|
outline: 2px solid #60a5fa;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form__error-message {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgb(220 38 38 / 10%);
|
||||||
|
border: 1px solid rgb(220 38 38 / 30%);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
// ===========================
|
||||||
|
// LoginForm.tsx
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { isAxiosError } from 'axios';
|
||||||
|
import { Button } from '@/components/common/Button';
|
||||||
|
import { Input } from '@/components/common/Input';
|
||||||
|
import { useLogin } from '@/hooks/useAuth';
|
||||||
|
import { useUIStore } from '@/store/uiStore';
|
||||||
|
import { loginSchema } from '@/lib/validation';
|
||||||
|
import './AuthForm.css';
|
||||||
|
|
||||||
|
export const LoginForm = (): React.ReactElement => {
|
||||||
|
const loginFormState = useUIStore((state) => state.loginForm);
|
||||||
|
const setLoginFormField = useUIStore((state) => state.setLoginFormField);
|
||||||
|
const clearLoginForm = useUIStore((state) => state.clearLoginForm);
|
||||||
|
const clearExpiredData = useUIStore((state) => state.clearExpiredData);
|
||||||
|
|
||||||
|
const [email, setEmail] = useState<string>('');
|
||||||
|
const [password, setPassword] = useState<string>('');
|
||||||
|
const [errors, setErrors] = useState<{ email?: string; password?: string }>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutate: login, isPending, error } = useLogin();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearExpiredData();
|
||||||
|
setEmail(loginFormState.email);
|
||||||
|
setPassword(loginFormState.password);
|
||||||
|
}, [clearExpiredData, loginFormState.email, loginFormState.password]);
|
||||||
|
|
||||||
|
const handleEmailChange = (value: string): void => {
|
||||||
|
setEmail(value);
|
||||||
|
setLoginFormField('email', value);
|
||||||
|
if (errors.email !== null && errors.email !== undefined) {
|
||||||
|
validateField('email', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordChange = (value: string): void => {
|
||||||
|
setPassword(value);
|
||||||
|
setLoginFormField('password', value);
|
||||||
|
if (errors.password !== null && errors.password !== undefined) {
|
||||||
|
validateField('password', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateField = (field: 'email' | 'password', value: string): void => {
|
||||||
|
const result = loginSchema.safeParse({
|
||||||
|
email: field === 'email' ? value : email,
|
||||||
|
password: field === 'password' ? value : password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const fieldError = result.error.issues.find((err) => err.path[0] === field);
|
||||||
|
if (fieldError !== null && fieldError !== undefined) {
|
||||||
|
setErrors((prev) => ({ ...prev, [field]: fieldError.message }));
|
||||||
|
} else {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const { [field]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const { [field]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (field: 'email' | 'password'): void => {
|
||||||
|
const value = field === 'email' ? email : password;
|
||||||
|
validateField(field, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const result = loginSchema.safeParse({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const newErrors: { email?: string; password?: string } = {};
|
||||||
|
|
||||||
|
result.error.issues.forEach((err) => {
|
||||||
|
const field = err.path[0] as keyof typeof newErrors;
|
||||||
|
if (field !== null && field !== undefined) {
|
||||||
|
newErrors[field] = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors({});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
login(
|
||||||
|
{ email, password },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
clearLoginForm();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
|
<div className="auth-form__header">
|
||||||
|
<h1 className="auth-form__title">Welcome Back</h1>
|
||||||
|
<p className="auth-form__subtitle">Sign in to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-form__fields">
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => handleEmailChange(e.target.value)}
|
||||||
|
onBlur={() => handleBlur('email')}
|
||||||
|
error={errors.email}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => handlePasswordChange(e.target.value)}
|
||||||
|
onBlur={() => handleBlur('password')}
|
||||||
|
error={errors.password}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error !== null && error !== undefined && isAxiosError(error) ? (
|
||||||
|
<div className="auth-form__error-message" role="alert">
|
||||||
|
{(error.response?.data as { detail?: string } | undefined)?.detail ?? 'Login failed. Please try again.'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button type="submit" isLoading={isPending} disabled={isPending}>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="auth-form__link">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/register" className="auth-form__link-text">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
// ===========================
|
||||||
|
// RegisterForm.tsx
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { isAxiosError } from 'axios';
|
||||||
|
import { Button } from '@/components/common/Button';
|
||||||
|
import { Input } from '@/components/common/Input';
|
||||||
|
import { useRegister } from '@/hooks/useAuth';
|
||||||
|
import { useUIStore } from '@/store/uiStore';
|
||||||
|
import { registerSchema } from '@/lib/validation';
|
||||||
|
import './AuthForm.css';
|
||||||
|
|
||||||
|
export const RegisterForm = (): React.ReactElement => {
|
||||||
|
const registerFormState = useUIStore((state) => state.registerForm);
|
||||||
|
const setRegisterFormField = useUIStore((state) => state.setRegisterFormField);
|
||||||
|
const clearRegisterForm = useUIStore((state) => state.clearRegisterForm);
|
||||||
|
const clearExpiredData = useUIStore((state) => state.clearExpiredData);
|
||||||
|
|
||||||
|
const [email, setEmail] = useState<string>('');
|
||||||
|
const [password, setPassword] = useState<string>('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState<string>('');
|
||||||
|
const [errors, setErrors] = useState<{
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
confirmPassword?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
const { mutate: register, isPending, error } = useRegister();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearExpiredData();
|
||||||
|
setEmail(registerFormState.email);
|
||||||
|
setPassword(registerFormState.password);
|
||||||
|
setConfirmPassword(registerFormState.confirmPassword);
|
||||||
|
}, [
|
||||||
|
clearExpiredData,
|
||||||
|
registerFormState.email,
|
||||||
|
registerFormState.password,
|
||||||
|
registerFormState.confirmPassword,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleEmailChange = (value: string): void => {
|
||||||
|
setEmail(value);
|
||||||
|
setRegisterFormField('email', value);
|
||||||
|
if (errors.email !== null && errors.email !== undefined) {
|
||||||
|
validateField('email', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordChange = (value: string): void => {
|
||||||
|
setPassword(value);
|
||||||
|
setRegisterFormField('password', value);
|
||||||
|
if (errors.password !== null && errors.password !== undefined) {
|
||||||
|
validateField('password', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmPasswordChange = (value: string): void => {
|
||||||
|
setConfirmPassword(value);
|
||||||
|
setRegisterFormField('confirmPassword', value);
|
||||||
|
if (errors.confirmPassword !== null && errors.confirmPassword !== undefined) {
|
||||||
|
validateField('confirmPassword', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateField = (
|
||||||
|
field: 'email' | 'password' | 'confirmPassword',
|
||||||
|
value: string,
|
||||||
|
): void => {
|
||||||
|
const result = registerSchema.safeParse({
|
||||||
|
email: field === 'email' ? value : email,
|
||||||
|
password: field === 'password' ? value : password,
|
||||||
|
confirmPassword: field === 'confirmPassword' ? value : confirmPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const fieldError = result.error.issues.find((err) => err.path[0] === field);
|
||||||
|
if (fieldError !== null && fieldError !== undefined) {
|
||||||
|
setErrors((prev) => ({ ...prev, [field]: fieldError.message }));
|
||||||
|
} else {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const { [field]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const { [field]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (field: 'email' | 'password' | 'confirmPassword'): void => {
|
||||||
|
let value: string;
|
||||||
|
if (field === 'email') {
|
||||||
|
value = email;
|
||||||
|
} else if (field === 'password') {
|
||||||
|
value = password;
|
||||||
|
} else {
|
||||||
|
value = confirmPassword;
|
||||||
|
}
|
||||||
|
validateField(field, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const result = registerSchema.safeParse({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
confirmPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const newErrors: {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
confirmPassword?: string;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
result.error.issues.forEach((err) => {
|
||||||
|
const field = err.path[0] as keyof typeof newErrors;
|
||||||
|
if (field !== null && field !== undefined) {
|
||||||
|
newErrors[field] = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors({});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
register(
|
||||||
|
{ email, password },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
clearRegisterForm();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
|
<div className="auth-form__header">
|
||||||
|
<h1 className="auth-form__title">Create Account</h1>
|
||||||
|
<p className="auth-form__subtitle">Get started with API Security Scanner</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-form__fields">
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => handleEmailChange(e.target.value)}
|
||||||
|
onBlur={() => handleBlur('email')}
|
||||||
|
error={errors.email}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => handlePasswordChange(e.target.value)}
|
||||||
|
onBlur={() => handleBlur('password')}
|
||||||
|
error={errors.password}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Confirm Password"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => handleConfirmPasswordChange(e.target.value)}
|
||||||
|
onBlur={() => handleBlur('confirmPassword')}
|
||||||
|
error={errors.confirmPassword}
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error !== null && error !== undefined && isAxiosError(error) ? (
|
||||||
|
<div className="auth-form__error-message" role="alert">
|
||||||
|
{(error.response?.data as { detail?: string } | undefined)?.detail ?? 'Registration failed. Please try again.'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button type="submit" isLoading={isPending} disabled={isPending}>
|
||||||
|
Create Account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="auth-form__link">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="auth-form__link-text">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
/**
|
||||||
|
* ©AngelaMos | 2025
|
||||||
|
* Button component styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
.button {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:focus-visible {
|
||||||
|
outline: 2px solid #60a5fa;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--sm {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--md {
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--lg {
|
||||||
|
padding: 16px 32px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--primary {
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgb(255 255 255 / 20%);
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 30%),
|
||||||
|
0 2px 4px -1px rgb(0 0 0 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--primary:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 30%),
|
||||||
|
0 4px 6px -2px rgb(0 0 0 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--primary:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 4px -1px rgb(0 0 0 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--secondary {
|
||||||
|
background: rgb(255 255 255 / 10%);
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgb(255 255 255 / 20%);
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--secondary:hover:not(:disabled) {
|
||||||
|
background: rgb(255 255 255 / 15%);
|
||||||
|
border-color: rgb(255 255 255 / 30%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--ghost:hover:not(:disabled) {
|
||||||
|
background: rgb(255 255 255 / 10%);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
// ===========================
|
||||||
|
// Button.tsx
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { type ButtonHTMLAttributes } from 'react';
|
||||||
|
import './Button.css';
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
isLoading?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = ({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
isLoading = false,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ButtonProps): React.ReactElement => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`button button--${variant} button--${size}`}
|
||||||
|
disabled={disabled ?? isLoading}
|
||||||
|
aria-busy={isLoading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Loading...' : children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* ©AngelaMos | 2025
|
||||||
|
* Input component styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #fff;
|
||||||
|
background: rgb(255 255 255 / 5%);
|
||||||
|
border: 1px solid rgb(255 255 255 / 20%);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: rgb(255 255 255 / 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: rgb(255 255 255 / 10%);
|
||||||
|
box-shadow: 0 0 0 3px rgb(59 130 246 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:hover:not(:disabled) {
|
||||||
|
border-color: rgb(255 255 255 / 30%);
|
||||||
|
background: rgb(255 255 255 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--error {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--error:focus {
|
||||||
|
border-color: #ef4444;
|
||||||
|
box-shadow: 0 0 0 3px rgb(239 68 68 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-error {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #ef4444;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
// ===========================
|
||||||
|
// Input.tsx
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { type InputHTMLAttributes, forwardRef } from 'react';
|
||||||
|
import './Input.css';
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label: string;
|
||||||
|
error?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ label, error, id, ...props }, ref) => {
|
||||||
|
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||||
|
const errorId = `${inputId}-error`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="input-wrapper">
|
||||||
|
<label htmlFor={inputId} className="input-label">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
id={inputId}
|
||||||
|
className={`input ${error !== null && error !== undefined ? 'input--error' : ''}`}
|
||||||
|
aria-invalid={error !== null && error !== undefined ? true : false}
|
||||||
|
aria-describedby={error !== null && error !== undefined ? errorId : undefined}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error !== null && error !== undefined ? (
|
||||||
|
<p id={errorId} className="input-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(15 23 42 / 95%);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
animation: fade-in var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
padding: var(--spacing-2xl);
|
||||||
|
max-width: 500px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay__spinner {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 4px solid var(--glass-border);
|
||||||
|
border-top-color: var(--color-accent-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay__title {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay__subtitle {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay__tests {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay__test {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-md);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
// ===========================
|
||||||
|
// LoadingOverlay.tsx
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { TEST_TYPE_LABELS, type ScanTestType } from '@/config/constants';
|
||||||
|
import './LoadingOverlay.css';
|
||||||
|
|
||||||
|
interface LoadingOverlayProps {
|
||||||
|
tests: ScanTestType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadingOverlay = ({
|
||||||
|
tests,
|
||||||
|
}: LoadingOverlayProps): React.ReactElement => {
|
||||||
|
return (
|
||||||
|
<div className="loading-overlay" role="dialog" aria-label="Scan in progress">
|
||||||
|
<div className="loading-overlay__content">
|
||||||
|
<div className="loading-overlay__spinner">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
</div>
|
||||||
|
<h2 className="loading-overlay__title">Running Security Scan</h2>
|
||||||
|
<p className="loading-overlay__subtitle">
|
||||||
|
Testing {tests.length} {tests.length === 1 ? 'vulnerability' : 'vulnerabilities'}
|
||||||
|
</p>
|
||||||
|
<div className="loading-overlay__tests">
|
||||||
|
{tests.map((test) => (
|
||||||
|
<div key={test} className="loading-overlay__test">
|
||||||
|
{TEST_TYPE_LABELS[test]}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
// ===========================
|
||||||
|
// ProtectedRoute.tsx
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProtectedRoute = ({
|
||||||
|
children,
|
||||||
|
}: ProtectedRouteProps): React.ReactElement => {
|
||||||
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children as React.ReactElement;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
// ===========================
|
||||||
|
// NewScanForm.tsx
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/common/Button';
|
||||||
|
import { Input } from '@/components/common/Input';
|
||||||
|
import { LoadingOverlay } from '@/components/common/LoadingOverlay';
|
||||||
|
import { useCreateScan } from '@/hooks/useScan';
|
||||||
|
import { useUIStore } from '@/store/uiStore';
|
||||||
|
import {
|
||||||
|
SCAN_TEST_TYPES,
|
||||||
|
TEST_TYPE_LABELS,
|
||||||
|
type ScanTestType,
|
||||||
|
} from '@/config/constants';
|
||||||
|
import { scanSchema } from '@/lib/validation';
|
||||||
|
import './ScanForm.css';
|
||||||
|
|
||||||
|
export const NewScanForm = (): React.ReactElement => {
|
||||||
|
const scanFormState = useUIStore((state) => state.scanForm);
|
||||||
|
const setScanFormField = useUIStore((state) => state.setScanFormField);
|
||||||
|
const clearScanForm = useUIStore((state) => state.clearScanForm);
|
||||||
|
const clearExpiredData = useUIStore((state) => state.clearExpiredData);
|
||||||
|
|
||||||
|
const [targetUrl, setTargetUrl] = useState<string>('');
|
||||||
|
const [authToken, setAuthToken] = useState<string>('');
|
||||||
|
const [selectedTests, setSelectedTests] = useState<ScanTestType[]>([]);
|
||||||
|
const [maxRequests, setMaxRequests] = useState<string>('50');
|
||||||
|
const [errors, setErrors] = useState<{
|
||||||
|
targetUrl?: string;
|
||||||
|
authToken?: string;
|
||||||
|
testsToRun?: string;
|
||||||
|
maxRequests?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
const { mutate: createScan, isPending } = useCreateScan();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearExpiredData();
|
||||||
|
setTargetUrl(scanFormState.targetUrl);
|
||||||
|
setAuthToken(scanFormState.authToken);
|
||||||
|
setSelectedTests(
|
||||||
|
scanFormState.selectedTests.length > 0
|
||||||
|
? scanFormState.selectedTests
|
||||||
|
: [SCAN_TEST_TYPES.RATE_LIMIT],
|
||||||
|
);
|
||||||
|
setMaxRequests(scanFormState.maxRequests);
|
||||||
|
}, [
|
||||||
|
clearExpiredData,
|
||||||
|
scanFormState.targetUrl,
|
||||||
|
scanFormState.authToken,
|
||||||
|
scanFormState.selectedTests,
|
||||||
|
scanFormState.maxRequests,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const maxReq = parseInt(maxRequests);
|
||||||
|
|
||||||
|
const result = scanSchema.safeParse({
|
||||||
|
targetUrl: targetUrl.trim(),
|
||||||
|
authToken: authToken.trim().length > 0 ? authToken.trim() : undefined,
|
||||||
|
testsToRun: selectedTests,
|
||||||
|
maxRequests: maxReq,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const newErrors: {
|
||||||
|
targetUrl?: string;
|
||||||
|
authToken?: string;
|
||||||
|
testsToRun?: string;
|
||||||
|
maxRequests?: string;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
result.error.issues.forEach((err) => {
|
||||||
|
const field = err.path[0] as keyof typeof newErrors;
|
||||||
|
if (field !== null && field !== undefined) {
|
||||||
|
newErrors[field] = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors({});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTargetUrlChange = (value: string): void => {
|
||||||
|
setTargetUrl(value);
|
||||||
|
setScanFormField('targetUrl', value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthTokenChange = (value: string): void => {
|
||||||
|
setAuthToken(value);
|
||||||
|
setScanFormField('authToken', value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaxRequestsChange = (value: string): void => {
|
||||||
|
setMaxRequests(value);
|
||||||
|
setScanFormField('maxRequests', value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestToggle = (test: ScanTestType): void => {
|
||||||
|
const newTests = selectedTests.includes(test)
|
||||||
|
? selectedTests.filter((t) => t !== test)
|
||||||
|
: [...selectedTests, test];
|
||||||
|
|
||||||
|
setSelectedTests(newTests);
|
||||||
|
setScanFormField('selectedTests', newTests);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxReq = parseInt(maxRequests);
|
||||||
|
|
||||||
|
createScan(
|
||||||
|
{
|
||||||
|
target_url: targetUrl.trim(),
|
||||||
|
auth_token: authToken.trim().length > 0 ? authToken.trim() : null,
|
||||||
|
tests_to_run: selectedTests,
|
||||||
|
max_requests: maxReq,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
clearScanForm();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isPending ? <LoadingOverlay tests={selectedTests} /> : null}
|
||||||
|
<form className="scan-form" onSubmit={handleSubmit}>
|
||||||
|
<div className="scan-form__fields">
|
||||||
|
<Input
|
||||||
|
label="Target URL"
|
||||||
|
type="url"
|
||||||
|
value={targetUrl}
|
||||||
|
onChange={(e) => handleTargetUrlChange(e.target.value)}
|
||||||
|
error={errors.targetUrl}
|
||||||
|
placeholder="https://api.example.com/endpoint"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Auth Token (Optional)"
|
||||||
|
type="text"
|
||||||
|
value={authToken}
|
||||||
|
onChange={(e) => handleAuthTokenChange(e.target.value)}
|
||||||
|
error={errors.authToken}
|
||||||
|
placeholder="Bearer token or API key"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="scan-form__field">
|
||||||
|
<label className="scan-form__label">
|
||||||
|
Select Tests
|
||||||
|
{errors.testsToRun !== null && errors.testsToRun !== undefined ? (
|
||||||
|
<span className="scan-form__error" role="alert">
|
||||||
|
{errors.testsToRun}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
<div className="scan-form__checkboxes">
|
||||||
|
{Object.values(SCAN_TEST_TYPES).map((test) => (
|
||||||
|
<label key={test} className="scan-form__checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedTests.includes(test)}
|
||||||
|
onChange={() => handleTestToggle(test)}
|
||||||
|
className="scan-form__checkbox"
|
||||||
|
/>
|
||||||
|
<span>{TEST_TYPE_LABELS[test]}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Max Requests"
|
||||||
|
type="number"
|
||||||
|
value={maxRequests}
|
||||||
|
onChange={(e) => handleMaxRequestsChange(e.target.value)}
|
||||||
|
error={errors.maxRequests}
|
||||||
|
placeholder="50"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" isLoading={isPending} disabled={isPending}>
|
||||||
|
{isPending ? 'Running Scan...' : 'Start Scan'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
.scan-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-form__fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-form__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-form__label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-form__error {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-weight: var(--font-weight-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-form__checkboxes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-form__checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-form__checkbox-label:hover {
|
||||||
|
background: var(--glass-bg-hover);
|
||||||
|
border-color: var(--glass-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-form__checkbox {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--color-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.scan-form__checkboxes {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
.scans-list__loading,
|
||||||
|
.scans-list__error {
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__empty {
|
||||||
|
padding: var(--spacing-2xl) var(--spacing-xl);
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__empty-title {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__empty-text {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__header-cell {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__row:hover {
|
||||||
|
background: var(--glass-bg-hover);
|
||||||
|
border-color: var(--glass-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__url {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__vuln-badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__vuln-badge--danger {
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__vuln-badge--safe {
|
||||||
|
background: var(--color-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__view-link {
|
||||||
|
color: var(--color-accent-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__view-link:hover {
|
||||||
|
color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__view-link:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 968px) {
|
||||||
|
.scans-list__header,
|
||||||
|
.scans-list__row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__cell {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scans-list__cell::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
// ===========================
|
||||||
|
// ScansList.tsx
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { GiMagnifyingGlass } from 'react-icons/gi';
|
||||||
|
import { useGetScans } from '@/hooks/useScan';
|
||||||
|
import { formatDate } from '@/lib/utils';
|
||||||
|
import './ScansList.css';
|
||||||
|
|
||||||
|
export const ScansList = (): React.ReactElement => {
|
||||||
|
const { data: scans, isLoading, error } = useGetScans();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="scans-list__loading">
|
||||||
|
<p>Loading scans...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error !== null && error !== undefined) {
|
||||||
|
return (
|
||||||
|
<div className="scans-list__error">
|
||||||
|
<p>Failed to load scans. Please try again.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
scans === null ||
|
||||||
|
scans === undefined ||
|
||||||
|
!Array.isArray(scans) ||
|
||||||
|
scans.length === 0
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="scans-list__empty">
|
||||||
|
<GiMagnifyingGlass className="scans-list__empty-icon" />
|
||||||
|
<h3 className="scans-list__empty-title">No Scans Yet</h3>
|
||||||
|
<p className="scans-list__empty-text">
|
||||||
|
Get started by running your first security scan above
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scans-list">
|
||||||
|
<div className="scans-list__table">
|
||||||
|
<div className="scans-list__header">
|
||||||
|
<div className="scans-list__header-cell">Target URL</div>
|
||||||
|
<div className="scans-list__header-cell">Date</div>
|
||||||
|
<div className="scans-list__header-cell">Tests</div>
|
||||||
|
<div className="scans-list__header-cell">Vulnerabilities</div>
|
||||||
|
<div className="scans-list__header-cell">Actions</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scans-list__body">
|
||||||
|
{scans.map((scan) => {
|
||||||
|
const vulnerableCount = scan.test_results.filter(
|
||||||
|
(r) => r.status === 'vulnerable',
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const scanDate = formatDate(scan.scan_date);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={scan.id} className="scans-list__row">
|
||||||
|
<div className="scans-list__cell">
|
||||||
|
<span className="scans-list__url">{scan.target_url}</span>
|
||||||
|
</div>
|
||||||
|
<div className="scans-list__cell">{scanDate}</div>
|
||||||
|
<div className="scans-list__cell">
|
||||||
|
{scan.test_results.length}
|
||||||
|
</div>
|
||||||
|
<div className="scans-list__cell">
|
||||||
|
<span
|
||||||
|
className={`scans-list__vuln-badge ${
|
||||||
|
vulnerableCount > 0
|
||||||
|
? 'scans-list__vuln-badge--danger'
|
||||||
|
: 'scans-list__vuln-badge--safe'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{vulnerableCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="scans-list__cell">
|
||||||
|
<Link
|
||||||
|
to={`/scans/${scan.id.toString()}`}
|
||||||
|
className="scans-list__view-link"
|
||||||
|
>
|
||||||
|
View Results
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
.test-result-card {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card:hover {
|
||||||
|
border-color: var(--glass-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__header {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__title-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__title {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__badges {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__section-title {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__details {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__recommendations {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__recommendation {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border-left: 3px solid var(--color-accent-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__evidence-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
color: var(--color-accent-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__evidence-toggle:hover {
|
||||||
|
color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__evidence-toggle:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__evidence {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.test-result-card {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-card__title-section {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
// ===========================
|
||||||
|
// TestResultCard.tsx
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { useUIStore } from '@/store/uiStore';
|
||||||
|
import type { TestResult } from '@/types/scan.types';
|
||||||
|
import {
|
||||||
|
TEST_TYPE_LABELS,
|
||||||
|
SEVERITY_COLORS,
|
||||||
|
STATUS_COLORS,
|
||||||
|
} from '@/config/constants';
|
||||||
|
import './TestResultCard.css';
|
||||||
|
|
||||||
|
interface TestResultCardProps {
|
||||||
|
result: TestResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TestResultCard = ({
|
||||||
|
result,
|
||||||
|
}: TestResultCardProps): React.ReactElement => {
|
||||||
|
const showEvidence = useUIStore(
|
||||||
|
(state) => state.testResults.expandedTests[result.id] ?? false,
|
||||||
|
);
|
||||||
|
const toggleTestExpanded = useUIStore((state) => state.toggleTestExpanded);
|
||||||
|
|
||||||
|
const statusColor = STATUS_COLORS[result.status];
|
||||||
|
const severityColor = SEVERITY_COLORS[result.severity];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="test-result-card">
|
||||||
|
<div className="test-result-card__header">
|
||||||
|
<div className="test-result-card__title-section">
|
||||||
|
<h3 className="test-result-card__title">
|
||||||
|
{TEST_TYPE_LABELS[result.test_name]}
|
||||||
|
</h3>
|
||||||
|
<div className="test-result-card__badges">
|
||||||
|
<span
|
||||||
|
className="test-result-card__badge test-result-card__badge--status"
|
||||||
|
style={{ backgroundColor: statusColor }}
|
||||||
|
>
|
||||||
|
{result.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="test-result-card__badge test-result-card__badge--severity"
|
||||||
|
style={{ backgroundColor: severityColor }}
|
||||||
|
>
|
||||||
|
{result.severity.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="test-result-card__content">
|
||||||
|
<div className="test-result-card__section">
|
||||||
|
<h4 className="test-result-card__section-title">Details</h4>
|
||||||
|
<p className="test-result-card__details">{result.details}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.recommendations_json.length > 0 ? (
|
||||||
|
<div className="test-result-card__section">
|
||||||
|
<h4 className="test-result-card__section-title">Recommendations</h4>
|
||||||
|
<ul className="test-result-card__recommendations">
|
||||||
|
{result.recommendations_json.map((rec) => (
|
||||||
|
<li key={`${result.id.toString()}-rec-${rec}`} className="test-result-card__recommendation">
|
||||||
|
{rec}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{Object.keys(result.evidence_json).length > 0 ? (
|
||||||
|
<div className="test-result-card__section">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleTestExpanded(result.id)}
|
||||||
|
className="test-result-card__evidence-toggle"
|
||||||
|
aria-expanded={showEvidence}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{showEvidence ? '▼' : '▶'} Technical Evidence
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{showEvidence ? (
|
||||||
|
<pre className="test-result-card__evidence">
|
||||||
|
{JSON.stringify(result.evidence_json, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
/**
|
||||||
|
* ©AngelaMos | 2025
|
||||||
|
* All hardcoded values, API endpoints, and configuration constants
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Configuration
|
||||||
|
*/
|
||||||
|
export const API_BASE_URL =
|
||||||
|
import.meta.env.VITE_API_URL !== undefined &&
|
||||||
|
import.meta.env.VITE_API_URL !== null &&
|
||||||
|
import.meta.env.VITE_API_URL !== ''
|
||||||
|
? import.meta.env.VITE_API_URL
|
||||||
|
: '/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth API Endpoints
|
||||||
|
*/
|
||||||
|
export const AUTH_ENDPOINTS = {
|
||||||
|
REGISTER: '/auth/register',
|
||||||
|
LOGIN: '/auth/login',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AuthEndpoint = (typeof AUTH_ENDPOINTS)[keyof typeof AUTH_ENDPOINTS];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth Error Messages
|
||||||
|
*/
|
||||||
|
export const AUTH_ERROR_MESSAGES = {
|
||||||
|
INVALID_LOGIN_RESPONSE: 'Invalid login response from server',
|
||||||
|
INVALID_REGISTER_RESPONSE: 'Invalid register response from server',
|
||||||
|
LOGIN_FAILED: 'Failed to login',
|
||||||
|
REGISTER_FAILED: 'Failed to register',
|
||||||
|
LOGOUT_FAILED: 'Failed to logout',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AuthErrorMessage =
|
||||||
|
(typeof AUTH_ERROR_MESSAGES)[keyof typeof AUTH_ERROR_MESSAGES];
|
||||||
|
|
||||||
|
export const AUTH_ERROR_CONTEXTS = {
|
||||||
|
LOGIN: 'auth.login',
|
||||||
|
REGISTER: 'auth.register',
|
||||||
|
LOGOUT: 'auth.logout',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AuthErrorContext =
|
||||||
|
(typeof AUTH_ERROR_CONTEXTS)[keyof typeof AUTH_ERROR_CONTEXTS];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocalStorage Keys
|
||||||
|
*/
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
AUTH_TOKEN: 'auth_token',
|
||||||
|
USER: 'user',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application Constants
|
||||||
|
*/
|
||||||
|
export const APP_NAME = 'API Security Scanner';
|
||||||
|
export const APP_VERSION = '1.0.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan Test Types
|
||||||
|
*/
|
||||||
|
export const SCAN_TEST_TYPES = {
|
||||||
|
RATE_LIMIT: 'rate_limit',
|
||||||
|
AUTH: 'auth',
|
||||||
|
SQLI: 'sqli',
|
||||||
|
IDOR: 'idor',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ScanTestType =
|
||||||
|
(typeof SCAN_TEST_TYPES)[keyof typeof SCAN_TEST_TYPES];
|
||||||
|
|
||||||
|
export const TEST_TYPE_LABELS: Record<ScanTestType, string> = {
|
||||||
|
[SCAN_TEST_TYPES.RATE_LIMIT]: 'Rate Limiting',
|
||||||
|
[SCAN_TEST_TYPES.AUTH]: 'Authentication',
|
||||||
|
[SCAN_TEST_TYPES.SQLI]: 'SQL Injection',
|
||||||
|
[SCAN_TEST_TYPES.IDOR]: 'IDOR/BOLA',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Result Status
|
||||||
|
*/
|
||||||
|
export const SCAN_STATUS = {
|
||||||
|
VULNERABLE: 'vulnerable',
|
||||||
|
SAFE: 'safe',
|
||||||
|
ERROR: 'error',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ScanStatus = (typeof SCAN_STATUS)[keyof typeof SCAN_STATUS];
|
||||||
|
|
||||||
|
export const STATUS_COLORS: Record<ScanStatus, string> = {
|
||||||
|
[SCAN_STATUS.VULNERABLE]: '#dc2626',
|
||||||
|
[SCAN_STATUS.SAFE]: '#16a34a',
|
||||||
|
[SCAN_STATUS.ERROR]: '#6b7280',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Severity Levels
|
||||||
|
*/
|
||||||
|
export const SEVERITY = {
|
||||||
|
CRITICAL: 'critical',
|
||||||
|
HIGH: 'high',
|
||||||
|
MEDIUM: 'medium',
|
||||||
|
LOW: 'low',
|
||||||
|
INFO: 'info',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Severity = (typeof SEVERITY)[keyof typeof SEVERITY];
|
||||||
|
|
||||||
|
export const SEVERITY_COLORS: Record<Severity, string> = {
|
||||||
|
[SEVERITY.CRITICAL]: '#dc2626',
|
||||||
|
[SEVERITY.HIGH]: '#ea580c',
|
||||||
|
[SEVERITY.MEDIUM]: '#f59e0b',
|
||||||
|
[SEVERITY.LOW]: '#3b82f6',
|
||||||
|
[SEVERITY.INFO]: '#6b7280',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan API Endpoints
|
||||||
|
*/
|
||||||
|
export const SCAN_ENDPOINTS = {
|
||||||
|
CREATE: '/scans/',
|
||||||
|
LIST: '/scans/',
|
||||||
|
GET: (id: number) => `/scans/${id.toString()}`,
|
||||||
|
DELETE: (id: number) => `/scans/${id.toString()}`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan Error Messages
|
||||||
|
*/
|
||||||
|
export const SCAN_ERROR_MESSAGES = {
|
||||||
|
INVALID_CREATE_SCAN_RESPONSE: 'Invalid create scan response from server',
|
||||||
|
INVALID_GET_SCANS_RESPONSE: 'Invalid get scans response from server',
|
||||||
|
INVALID_GET_SCAN_RESPONSE: 'Invalid get scan response from server',
|
||||||
|
CREATE_SCAN_FAILED: 'Failed to create scan',
|
||||||
|
GET_SCANS_FAILED: 'Failed to fetch scans',
|
||||||
|
GET_SCAN_FAILED: 'Failed to fetch scan details',
|
||||||
|
DELETE_SCAN_FAILED: 'Failed to delete scan',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ScanErrorMessage =
|
||||||
|
(typeof SCAN_ERROR_MESSAGES)[keyof typeof SCAN_ERROR_MESSAGES];
|
||||||
|
|
||||||
|
export const SCAN_ERROR_CONTEXTS = {
|
||||||
|
CREATE_SCAN: 'scan.createScan',
|
||||||
|
GET_SCANS: 'scan.getScans',
|
||||||
|
GET_SCAN: 'scan.getScan',
|
||||||
|
DELETE_SCAN: 'scan.deleteScan',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ScanErrorContext =
|
||||||
|
(typeof SCAN_ERROR_CONTEXTS)[keyof typeof SCAN_ERROR_CONTEXTS];
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
// ===========================
|
||||||
|
// useAuth.ts
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
type UseMutationResult,
|
||||||
|
} from '@tanstack/react-query';
|
||||||
|
import { isAxiosError, type AxiosError } from 'axios';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { authMutations } from '@/services/authService';
|
||||||
|
import {
|
||||||
|
isValidLoginResponse,
|
||||||
|
isValidRegisterResponse,
|
||||||
|
} from '@/types/guards';
|
||||||
|
import type {
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
RegisterRequest,
|
||||||
|
RegisterResponse,
|
||||||
|
} from '@/types/auth.types';
|
||||||
|
import {
|
||||||
|
AUTH_ERROR_MESSAGES,
|
||||||
|
AUTH_ERROR_CONTEXTS,
|
||||||
|
} from '@/config/constants';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
|
||||||
|
const createAuthErrorHandler = (context: string) => {
|
||||||
|
return (error: unknown): void => {
|
||||||
|
if (isAxiosError(error) && error.response?.data !== null && error.response?.data !== undefined) {
|
||||||
|
const errorData: unknown = error.response.data;
|
||||||
|
|
||||||
|
if (typeof errorData === 'object' && errorData !== null && 'detail' in errorData) {
|
||||||
|
const apiError = errorData as { detail: unknown };
|
||||||
|
if (typeof apiError.detail === 'string' && apiError.detail.length > 0) {
|
||||||
|
toast.error(apiError.detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackMessage =
|
||||||
|
error instanceof Error ? error.message : `${context} failed`;
|
||||||
|
toast.error(fallbackMessage);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRegister = (): UseMutationResult<
|
||||||
|
RegisterResponse,
|
||||||
|
AxiosError,
|
||||||
|
RegisterRequest
|
||||||
|
> => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return useMutation<RegisterResponse, AxiosError, RegisterRequest>({
|
||||||
|
mutationFn: async (data: RegisterRequest): Promise<RegisterResponse> => {
|
||||||
|
const response = await authMutations.register(data);
|
||||||
|
if (!isValidRegisterResponse(response)) {
|
||||||
|
throw new Error(AUTH_ERROR_MESSAGES.INVALID_REGISTER_RESPONSE);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
onSuccess: (_data: RegisterResponse): void => {
|
||||||
|
toast.success('Account created! Please login.');
|
||||||
|
void navigate('/login');
|
||||||
|
},
|
||||||
|
onError: createAuthErrorHandler(AUTH_ERROR_CONTEXTS.REGISTER),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLogin = (): UseMutationResult<
|
||||||
|
LoginResponse,
|
||||||
|
AxiosError,
|
||||||
|
LoginRequest
|
||||||
|
> => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setAuth } = useAuthStore();
|
||||||
|
|
||||||
|
return useMutation<LoginResponse, AxiosError, LoginRequest>({
|
||||||
|
mutationFn: async (data: LoginRequest): Promise<LoginResponse> => {
|
||||||
|
const response = await authMutations.login(data);
|
||||||
|
if (!isValidLoginResponse(response)) {
|
||||||
|
throw new Error(AUTH_ERROR_MESSAGES.INVALID_LOGIN_RESPONSE);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
onSuccess: (data: LoginResponse, variables: LoginRequest): void => {
|
||||||
|
const user = {
|
||||||
|
id: 0,
|
||||||
|
email: variables.email,
|
||||||
|
is_active: true,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setAuth(user, data.access_token);
|
||||||
|
toast.success('Login successful!');
|
||||||
|
void navigate('/');
|
||||||
|
},
|
||||||
|
onError: createAuthErrorHandler(AUTH_ERROR_CONTEXTS.LOGIN),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLogout = (): (() => void) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { clearAuth } = useAuthStore();
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
clearAuth();
|
||||||
|
toast.success('Logged out successfully');
|
||||||
|
void navigate('/login');
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
// ===========================
|
||||||
|
// useScan.ts
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import {
|
||||||
|
useQuery,
|
||||||
|
useMutation,
|
||||||
|
useQueryClient,
|
||||||
|
type UseQueryResult,
|
||||||
|
type UseMutationResult,
|
||||||
|
} from '@tanstack/react-query';
|
||||||
|
import { type AxiosError } from 'axios';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
scanQueryKeys,
|
||||||
|
scanQueries,
|
||||||
|
scanMutations,
|
||||||
|
} from '@/services/scanService';
|
||||||
|
import {
|
||||||
|
isValidGetScansResponse,
|
||||||
|
isValidGetScanResponse,
|
||||||
|
isValidCreateScanResponse,
|
||||||
|
} from '@/types/guards';
|
||||||
|
import type {
|
||||||
|
GetScansResponse,
|
||||||
|
GetScanResponse,
|
||||||
|
CreateScanRequest,
|
||||||
|
CreateScanResponse,
|
||||||
|
} from '@/types/scan.types';
|
||||||
|
import {
|
||||||
|
SCAN_ERROR_MESSAGES,
|
||||||
|
SCAN_ERROR_CONTEXTS,
|
||||||
|
} from '@/config/constants';
|
||||||
|
import { createApiErrorHandler } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const useGetScans = (): UseQueryResult<
|
||||||
|
GetScansResponse,
|
||||||
|
AxiosError
|
||||||
|
> => {
|
||||||
|
return useQuery<GetScansResponse, AxiosError>({
|
||||||
|
queryKey: scanQueryKeys.list(),
|
||||||
|
queryFn: async (): Promise<GetScansResponse> => {
|
||||||
|
const response = await scanQueries.getScans();
|
||||||
|
if (!isValidGetScansResponse(response)) {
|
||||||
|
throw new Error(SCAN_ERROR_MESSAGES.INVALID_GET_SCANS_RESPONSE);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
gcTime: 1000 * 60 * 10,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetScan = (
|
||||||
|
id: number,
|
||||||
|
): UseQueryResult<GetScanResponse, AxiosError> => {
|
||||||
|
return useQuery<GetScanResponse, AxiosError>({
|
||||||
|
queryKey: scanQueryKeys.detail(id),
|
||||||
|
queryFn: async (): Promise<GetScanResponse> => {
|
||||||
|
const response = await scanQueries.getScan(id);
|
||||||
|
if (!isValidGetScanResponse(response)) {
|
||||||
|
throw new Error(SCAN_ERROR_MESSAGES.INVALID_GET_SCAN_RESPONSE);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
gcTime: 1000 * 60 * 10,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateScan = (): UseMutationResult<
|
||||||
|
CreateScanResponse,
|
||||||
|
AxiosError,
|
||||||
|
CreateScanRequest
|
||||||
|
> => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return useMutation<CreateScanResponse, AxiosError, CreateScanRequest>({
|
||||||
|
mutationFn: async (
|
||||||
|
data: CreateScanRequest,
|
||||||
|
): Promise<CreateScanResponse> => {
|
||||||
|
const response = await scanMutations.createScan(data);
|
||||||
|
if (!isValidCreateScanResponse(response)) {
|
||||||
|
throw new Error(SCAN_ERROR_MESSAGES.INVALID_CREATE_SCAN_RESPONSE);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
onSuccess: (data: CreateScanResponse): void => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: scanQueryKeys.lists(),
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Scan completed successfully!');
|
||||||
|
void navigate(`/scans/${data.id.toString()}`);
|
||||||
|
},
|
||||||
|
onError: createApiErrorHandler(SCAN_ERROR_CONTEXTS.CREATE_SCAN),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteScan = (): UseMutationResult<
|
||||||
|
undefined,
|
||||||
|
AxiosError,
|
||||||
|
number
|
||||||
|
> => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<undefined, AxiosError, number>({
|
||||||
|
mutationFn: async (id: number): Promise<undefined> => {
|
||||||
|
await scanMutations.deleteScan(id);
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
onSuccess: (): void => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: scanQueryKeys.lists(),
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Scan deleted successfully!');
|
||||||
|
},
|
||||||
|
onError: createApiErrorHandler(SCAN_ERROR_CONTEXTS.DELETE_SCAN),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* ©AngelaMos | 2025
|
||||||
|
* Axios instance with request/response interceptors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios, { type AxiosError, type AxiosResponse } from 'axios';
|
||||||
|
import { API_BASE_URL, STORAGE_KEYS } from '@/config/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Axios Base Config
|
||||||
|
*/
|
||||||
|
const axiosInstance = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 180000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request interceptor
|
||||||
|
*/
|
||||||
|
axiosInstance.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response interceptor
|
||||||
|
*/
|
||||||
|
axiosInstance.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const requestUrl = error.config?.url ?? '';
|
||||||
|
const isAuthEndpoint = requestUrl.includes('/auth/login') || requestUrl.includes('/auth/register');
|
||||||
|
|
||||||
|
if (!isAuthEndpoint) {
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.USER);
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API wrapper
|
||||||
|
*/
|
||||||
|
export const api = {
|
||||||
|
get: async <T>(url: string): Promise<T> => {
|
||||||
|
const response = await axiosInstance.get<T>(url);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
post: async <T>(url: string, data?: unknown): Promise<T> => {
|
||||||
|
const response = await axiosInstance.post<T>(url, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
put: async <T>(url: string, data?: unknown): Promise<T> => {
|
||||||
|
const response = await axiosInstance.put<T>(url, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async <T>(url: string): Promise<T> => {
|
||||||
|
const response = await axiosInstance.delete<T>(url);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
// ===========================
|
||||||
|
// errors.ts
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { isAxiosError } from 'axios';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export const createApiErrorHandler = (context: string) => {
|
||||||
|
return (error: unknown): void => {
|
||||||
|
if (isAxiosError(error) && error.response?.data !== null && error.response?.data !== undefined) {
|
||||||
|
const errorData: unknown = error.response.data;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof errorData === 'object' &&
|
||||||
|
errorData !== null &&
|
||||||
|
'detail' in errorData &&
|
||||||
|
typeof errorData.detail === 'string' &&
|
||||||
|
errorData.detail.length > 0
|
||||||
|
) {
|
||||||
|
toast.error(errorData.detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackMessage =
|
||||||
|
error instanceof Error ? error.message : `Operation failed: ${context}`;
|
||||||
|
toast.error(fallbackMessage);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
// ===========================
|
||||||
|
// utils.ts
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
export const formatDate = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDateTime = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatRelativeTime = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'just now';
|
||||||
|
if (diffMins < 60) return `${diffMins.toString()}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours.toString()}h ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays.toString()}d ago`;
|
||||||
|
|
||||||
|
return formatDate(dateString);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
// ===========================
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// Zod Validation Schemas
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.email('Invalid email format')
|
||||||
|
.min(1, 'Email is required')
|
||||||
|
.max(255, 'Email too long'),
|
||||||
|
password: z.string().min(1, 'Password is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registerSchema = z
|
||||||
|
.object({
|
||||||
|
email: z
|
||||||
|
.email('Invalid email format')
|
||||||
|
.min(1, 'Email is required')
|
||||||
|
.max(255, 'Email too long'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.max(100, 'Password must be less than 100 characters')
|
||||||
|
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||||
|
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||||
|
.regex(/[0-9]/, 'Password must contain at least one number'),
|
||||||
|
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: 'Passwords do not match',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const scanSchema = z.object({
|
||||||
|
targetUrl: z
|
||||||
|
.url('Invalid URL format')
|
||||||
|
.min(1, 'Target URL is required')
|
||||||
|
.max(2048, 'URL too long'),
|
||||||
|
authToken: z.string().max(1000, 'Token too long').optional(),
|
||||||
|
testsToRun: z
|
||||||
|
.array(z.enum(['rate_limit', 'auth', 'sqli', 'idor']))
|
||||||
|
.min(1, 'Select at least one test'),
|
||||||
|
maxRequests: z
|
||||||
|
.number()
|
||||||
|
.int('Must be a whole number')
|
||||||
|
.min(1, 'Must be at least 1')
|
||||||
|
.max(50, 'Maximum 50 requests allowed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LoginFormData = z.infer<typeof loginSchema>;
|
||||||
|
export type RegisterFormData = z.infer<typeof registerSchema>;
|
||||||
|
export type ScanFormData = z.infer<typeof scanSchema>;
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
|
import './styles/variables.css';
|
||||||
|
import './styles/index.css';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* ©AngelaMos | 2025
|
||||||
|
* Auth page styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
.auth-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page__container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
.dashboard {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, var(--color-bg-primary) 0%, var(--color-bg-secondary) 100%);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__header {
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__header-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__title {
|
||||||
|
font-size: var(--font-size-4xl);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 var(--spacing-xs) 0;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__subtitle {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__user-email {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__section {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__section-title {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 var(--spacing-md) 0;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.dashboard {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__title {
|
||||||
|
font-size: var(--font-size-3xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__header-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__section {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
// ===========================
|
||||||
|
// DashboardPage.tsx
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { NewScanForm } from '@/components/scan/NewScanForm';
|
||||||
|
import { ScansList } from '@/components/scan/ScansList';
|
||||||
|
import { Button } from '@/components/common/Button';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import './DashboardPage.css';
|
||||||
|
|
||||||
|
export const DashboardPage = (): React.ReactElement => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const clearAuth = useAuthStore((state) => state.clearAuth);
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
|
||||||
|
const handleLogout = (): void => {
|
||||||
|
clearAuth();
|
||||||
|
void navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<div className="dashboard__container">
|
||||||
|
<header className="dashboard__header">
|
||||||
|
<div className="dashboard__header-content">
|
||||||
|
<div className="dashboard__header-text">
|
||||||
|
<h1 className="dashboard__title">API Security Scanner</h1>
|
||||||
|
<p className="dashboard__subtitle">
|
||||||
|
Test your APIs for security vulnerabilities
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard__header-actions">
|
||||||
|
<span className="dashboard__user-email">{user?.email}</span>
|
||||||
|
<Button onClick={handleLogout} variant="secondary">
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="dashboard__content">
|
||||||
|
<section className="dashboard__section">
|
||||||
|
<h2 className="dashboard__section-title">New Scan</h2>
|
||||||
|
<NewScanForm />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="dashboard__section">
|
||||||
|
<h2 className="dashboard__section-title">Recent Scans</h2>
|
||||||
|
<ScansList />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
// ===========================
|
||||||
|
// LoginPage.tsx
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { LoginForm } from '@/components/auth/LoginForm';
|
||||||
|
import './AuthPage.css';
|
||||||
|
|
||||||
|
export const LoginPage = (): React.ReactElement => {
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<div className="auth-page__container">
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
// ===========================
|
||||||
|
// RegisterPage.tsx
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { RegisterForm } from '@/components/auth/RegisterForm';
|
||||||
|
import './AuthPage.css';
|
||||||
|
|
||||||
|
export const RegisterPage = (): React.ReactElement => {
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<div className="auth-page__container">
|
||||||
|
<RegisterForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
.scan-results {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, var(--color-bg-primary) 0%, var(--color-bg-secondary) 100%);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__loading,
|
||||||
|
.scan-results__error {
|
||||||
|
min-height: 60vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__header {
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
color: var(--color-accent-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__back-link:hover {
|
||||||
|
color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__back-link:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__title {
|
||||||
|
font-size: var(--font-size-3xl);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 var(--spacing-md) 0;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__metadata {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__meta-label {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__meta-value {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__vuln-count {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__vuln-count--danger {
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__vuln-count--safe {
|
||||||
|
background: var(--color-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__tests {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.scan-results {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__title {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results__metadata {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
// ===========================
|
||||||
|
// ScanResultsPage.tsx
|
||||||
|
// ©AngelaMos | 2025
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useGetScan } from '@/hooks/useScan';
|
||||||
|
import { TestResultCard } from '@/components/scan/TestResultCard';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
import './ScanResultsPage.css';
|
||||||
|
|
||||||
|
export const ScanResultsPage = (): React.ReactElement => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const scanId = id !== null && id !== undefined ? parseInt(id) : 0;
|
||||||
|
|
||||||
|
const { data: scan, isLoading, error } = useGetScan(scanId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="scan-results__loading">
|
||||||
|
<p>Loading scan results...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error !== null && error !== undefined) {
|
||||||
|
return (
|
||||||
|
<div className="scan-results__error">
|
||||||
|
<p>Failed to load scan results. Please try again.</p>
|
||||||
|
<Link to="/" className="scan-results__back-link">
|
||||||
|
Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scan === null || scan === undefined) {
|
||||||
|
return (
|
||||||
|
<div className="scan-results__error">
|
||||||
|
<p>Scan not found.</p>
|
||||||
|
<Link to="/" className="scan-results__back-link">
|
||||||
|
Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanDate = formatDateTime(scan.scan_date);
|
||||||
|
|
||||||
|
const vulnerableCount = scan.test_results.filter(
|
||||||
|
(r) => r.status === 'vulnerable',
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scan-results">
|
||||||
|
<div className="scan-results__container">
|
||||||
|
<header className="scan-results__header">
|
||||||
|
<Link to="/" className="scan-results__back-link">
|
||||||
|
← Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
<h1 className="scan-results__title">Scan Results</h1>
|
||||||
|
<div className="scan-results__metadata">
|
||||||
|
<div className="scan-results__meta-item">
|
||||||
|
<span className="scan-results__meta-label">Target:</span>
|
||||||
|
<span className="scan-results__meta-value">
|
||||||
|
{scan.target_url}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="scan-results__meta-item">
|
||||||
|
<span className="scan-results__meta-label">Date:</span>
|
||||||
|
<span className="scan-results__meta-value">{scanDate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="scan-results__meta-item">
|
||||||
|
<span className="scan-results__meta-label">Tests Run:</span>
|
||||||
|
<span className="scan-results__meta-value">
|
||||||
|
{scan.test_results.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="scan-results__meta-item">
|
||||||
|
<span className="scan-results__meta-label">Vulnerabilities:</span>
|
||||||
|
<span
|
||||||
|
className={`scan-results__vuln-count ${
|
||||||
|
vulnerableCount > 0
|
||||||
|
? 'scan-results__vuln-count--danger'
|
||||||
|
: 'scan-results__vuln-count--safe'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{vulnerableCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="scan-results__tests">
|
||||||
|
{scan.test_results.map((result) => (
|
||||||
|
<TestResultCard key={result.id} result={result} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||