yo yo you should star the repo :) pls
|
|
@ -32,7 +32,7 @@ BACKEND_PORT=8000
|
|||
|
||||
# CORS Origins (comma-separated for multiple origins)
|
||||
# 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
|
||||
# When using Docker with nginx: http://localhost/api
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
name: Lint & Type Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ 'main' ]
|
||||
pull_request:
|
||||
branches: [ '*' ]
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
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):
|
||||
"""
|
||||
Application settings loaded from environment variables.
|
||||
Application settings loaded from environment variables
|
||||
|
||||
All magic numbers and configuration values are defined here to avoid
|
||||
hardcoding throughout the application.
|
||||
hardcoding throughout the application
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
|
|
@ -56,9 +56,9 @@ class Settings(BaseSettings):
|
|||
SCANNER_RATE_LIMIT_WINDOW_SECONDS: int = 60
|
||||
|
||||
# API endpoint rate limiting (incoming requests - slowapi format)
|
||||
API_RATE_LIMIT_LOGIN: str = "5/minute"
|
||||
API_RATE_LIMIT_REGISTER: str = "3/minute"
|
||||
API_RATE_LIMIT_SCAN: str = "10/minute"
|
||||
API_RATE_LIMIT_LOGIN: str = "20/minute"
|
||||
API_RATE_LIMIT_REGISTER: str = "15/minute"
|
||||
API_RATE_LIMIT_SCAN: str = "15/minute"
|
||||
API_RATE_LIMIT_DEFAULT: str = "100/minute"
|
||||
|
||||
# Pagination
|
||||
|
|
@ -73,8 +73,8 @@ class Settings(BaseSettings):
|
|||
|
||||
# Scanner timeouts and limits
|
||||
SCANNER_MAX_CONCURRENT_REQUESTS: int = 50
|
||||
SCANNER_CONNECTION_TIMEOUT: int = 30
|
||||
SCANNER_READ_TIMEOUT: int = 30
|
||||
SCANNER_CONNECTION_TIMEOUT: int = 180
|
||||
SCANNER_READ_TIMEOUT: int = 180
|
||||
|
||||
# Scanner request spacing and timing
|
||||
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.ext.declarative import declarative_base
|
||||
|
||||
from ..config import settings
|
||||
from config import settings
|
||||
|
||||
# Database engine
|
||||
engine = create_engine(
|
||||
|
|
@ -11,19 +11,22 @@ from fastapi.security import (
|
|||
HTTPAuthorizationCredentials,
|
||||
HTTPBearer,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
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()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> str:
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserResponse:
|
||||
"""
|
||||
FastAPI dependency to extract and
|
||||
verify the current authenticated user.
|
||||
FastAPI dependency to extract and verify the current authenticated user
|
||||
"""
|
||||
try:
|
||||
payload = decode_token(credentials.credentials)
|
||||
|
|
@ -36,7 +39,16 @@ async def get_current_user(
|
|||
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:
|
||||
raise HTTPException(
|
||||
|
|
@ -6,21 +6,20 @@ from datetime import (
|
|||
datetime,
|
||||
timedelta,
|
||||
)
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from config import settings
|
||||
|
||||
|
||||
# Password hashing
|
||||
pwd_context = CryptContext(schemes = ["bcrypt"], deprecated = "auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
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(
|
||||
|
|
@ -30,7 +29,9 @@ def verify_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(
|
||||
|
|
@ -13,9 +13,9 @@ from slowapi import (
|
|||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from .config import settings
|
||||
from .core.database import Base, engine
|
||||
from .routes import auth_router, scans_router
|
||||
from config import settings
|
||||
from core.database import Base, engine
|
||||
from routes import auth_router, scans_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
|
|
@ -27,8 +27,10 @@ def create_app() -> FastAPI:
|
|||
app = FastAPI(
|
||||
title = settings.APP_NAME,
|
||||
version = settings.VERSION,
|
||||
docs_url = "/api/docs",
|
||||
redoc_url = "/api/redoc",
|
||||
openapi_version = "3.1.0",
|
||||
docs_url = "/docs",
|
||||
redoc_url = "/redoc",
|
||||
openapi_url = "/openapi.json",
|
||||
debug = settings.DEBUG,
|
||||
)
|
||||
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
FastAPI application entry point
|
||||
ⒸCertGames.com | 2025
|
||||
ⒸAngelaMos | CarterPerez-dev
|
||||
----
|
||||
API Security Scanner FastAPI entry point
|
||||
"""
|
||||
|
||||
import uvicorn
|
||||
|
||||
from .config import settings
|
||||
from .factory import create_app
|
||||
from config import settings
|
||||
from factory import create_app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
|
@ -13,7 +13,7 @@ from sqlalchemy import (
|
|||
from datetime import datetime, UTC
|
||||
from sqlalchemy.ext.declarative import declared_attr
|
||||
|
||||
from ..core.database import Base
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class BaseModel(Base):
|
||||
|
|
@ -16,7 +16,7 @@ from sqlalchemy import (
|
|||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from ..config import settings
|
||||
from config import settings
|
||||
from .Base import BaseModel
|
||||
|
||||
|
||||
|
|
@ -6,14 +6,14 @@ TestResult model for storing individual security test results
|
|||
from sqlalchemy import (
|
||||
Column,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Text,
|
||||
ForeignKey,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
|
||||
from ..core.enums import (
|
||||
from core.enums import (
|
||||
ScanStatus,
|
||||
Severity,
|
||||
TestType,
|
||||
|
|
@ -9,7 +9,7 @@ from sqlalchemy import (
|
|||
String,
|
||||
)
|
||||
|
||||
from ..config import settings
|
||||
from config import settings
|
||||
from .Base import BaseModel
|
||||
|
||||
|
||||
|
|
@ -20,10 +20,10 @@ dependencies = [
|
|||
"alembic==1.17.1",
|
||||
# Security
|
||||
"slowapi==0.1.9",
|
||||
"passlib[bcrypt]==1.7.4",
|
||||
"python-jose[cryptography]==3.5.0",
|
||||
"bcrypt==4.3.0",
|
||||
"bcrypt==5.0.0",
|
||||
# HTTP client for scanners
|
||||
"requests==2.32.3",
|
||||
"httpx==0.28.1",
|
||||
"aiohttp==3.13.2",
|
||||
# Settings management
|
||||
|
|
@ -55,7 +55,22 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
|
||||
[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]
|
||||
target-version = "py311"
|
||||
|
|
@ -174,6 +189,7 @@ disable_error_code = [
|
|||
"union-attr",
|
||||
"call-arg",
|
||||
"dict-item",
|
||||
"abstract",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -181,11 +197,12 @@ disable_error_code = [
|
|||
py-version = "3.11"
|
||||
jobs = 4
|
||||
load-plugins = [
|
||||
"pylint_pydantic",
|
||||
"pylint_pydantic",
|
||||
"pylint_per_file_ignores"
|
||||
]
|
||||
persistent = true
|
||||
suggestion-mode = true
|
||||
init-hook = "import sys; import os; sys.path.insert(0, os.getcwd())"
|
||||
ignore = [
|
||||
"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 .scan_repository import ScanRepository
|
||||
|
|
@ -11,8 +11,8 @@ from sqlalchemy.orm import (
|
|||
joinedload,
|
||||
)
|
||||
|
||||
from ..config import settings
|
||||
from ..models.Scan import Scan
|
||||
from config import settings
|
||||
from models.Scan import Scan
|
||||
|
||||
|
||||
class ScanRepository:
|
||||
|
|
@ -9,12 +9,12 @@ from typing import Any
|
|||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..core.enums import (
|
||||
from core.enums import (
|
||||
ScanStatus,
|
||||
Severity,
|
||||
TestType,
|
||||
)
|
||||
from ..models.TestResult import TestResult
|
||||
from models.TestResult import TestResult
|
||||
|
||||
|
||||
class TestResultRepository:
|
||||
|
|
@ -7,8 +7,8 @@ from __future__ import annotations
|
|||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import settings
|
||||
from ..models.User import User
|
||||
from config import settings
|
||||
from models.User import User
|
||||
|
||||
|
||||
class UserRepository:
|
||||
|
|
@ -11,11 +11,12 @@ psycopg2-binary>=2.9.9
|
|||
alembic>=1.13.0
|
||||
|
||||
# Security
|
||||
passlib[bcrypt]>=1.7.4
|
||||
slowapi>=0.1.9
|
||||
python-jose[cryptography]>=3.3.0
|
||||
bcrypt>=4.1.2
|
||||
bcrypt==5.0.0
|
||||
|
||||
# HTTP client for scanners
|
||||
requests>=2.31.0
|
||||
httpx>=0.26.0
|
||||
aiohttp>=3.9.0
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""
|
||||
©AngelaMos | 2025
|
||||
Authentication routes - registration and login
|
||||
Authentication routes
|
||||
"""
|
||||
|
||||
from fastapi import (
|
||||
|
|
@ -13,15 +13,15 @@ from slowapi import Limiter
|
|||
from slowapi.util import get_remote_address
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import settings
|
||||
from ..core.database import get_db
|
||||
from ..schemas.user_schemas import (
|
||||
from config import settings
|
||||
from core.database import get_db
|
||||
from schemas.user_schemas import (
|
||||
TokenResponse,
|
||||
UserCreate,
|
||||
UserLogin,
|
||||
UserResponse,
|
||||
)
|
||||
from ..services.auth_service import AuthService
|
||||
from services.auth_service import AuthService
|
||||
|
||||
|
||||
router = APIRouter(prefix = "/auth", tags = ["authentication"])
|
||||
|
|
@ -13,15 +13,15 @@ from slowapi import Limiter
|
|||
from slowapi.util import get_remote_address
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import settings
|
||||
from ..core.database import get_db
|
||||
from ..core.dependencies import get_current_user
|
||||
from ..schemas.scan_schemas import (
|
||||
from config import settings
|
||||
from core.database import get_db
|
||||
from core.dependencies import get_current_user
|
||||
from schemas.scan_schemas import (
|
||||
ScanRequest,
|
||||
ScanResponse,
|
||||
)
|
||||
from ..schemas.user_schemas import UserResponse
|
||||
from ..services.scan_service import ScanService
|
||||
from schemas.user_schemas import UserResponse
|
||||
from services.scan_service import ScanService
|
||||
|
||||
|
||||
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 .rate_limit_scanner import RateLimitScanner
|
||||
|
|
@ -11,13 +11,13 @@ import base64
|
|||
import json
|
||||
from typing import Any
|
||||
|
||||
from ..config import settings
|
||||
from ..core.enums import (
|
||||
from config import settings
|
||||
from core.enums import (
|
||||
ScanStatus,
|
||||
Severity,
|
||||
TestType,
|
||||
)
|
||||
from ..schemas.test_result_schemas import TestResultCreate
|
||||
from schemas.test_result_schemas import TestResultCreate
|
||||
|
||||
from .payloads import AuthPayloads
|
||||
from .base_scanner import BaseScanner
|
||||
|
|
@ -14,8 +14,8 @@ from abc import ABC, abstractmethod
|
|||
|
||||
import requests
|
||||
|
||||
from ..config import settings
|
||||
from ..schemas.test_result_schemas import TestResultCreate
|
||||
from config import settings
|
||||
from schemas.test_result_schemas import TestResultCreate
|
||||
|
||||
|
||||
class BaseScanner(ABC):
|
||||
|
|
@ -10,8 +10,8 @@ from __future__ import annotations
|
|||
import re
|
||||
from typing import Any
|
||||
|
||||
from ..core.enums import ScanStatus, Severity, TestType
|
||||
from ..schemas.test_result_schemas import TestResultCreate
|
||||
from core.enums import ScanStatus, Severity, TestType
|
||||
from schemas.test_result_schemas import TestResultCreate
|
||||
from .base_scanner import BaseScanner
|
||||
from .payloads import IDORPayloads
|
||||
|
||||
|
|
@ -8,7 +8,6 @@ class SQLiPayloads:
|
|||
"""
|
||||
SQL Injection test payloads covering various database types and techniques
|
||||
"""
|
||||
|
||||
ERROR_SIGNATURES = {
|
||||
"mysql": [
|
||||
"sql syntax",
|
||||
|
|
@ -222,7 +221,6 @@ class IDORPayloads:
|
|||
"""
|
||||
Insecure Direct Object Reference (IDOR) test patterns
|
||||
"""
|
||||
|
||||
NUMERIC_ID_MANIPULATIONS = [
|
||||
0,
|
||||
-1,
|
||||
|
|
@ -275,7 +273,6 @@ class RateLimitBypassPayloads:
|
|||
"""
|
||||
Rate limiting bypass techniques and patterns
|
||||
"""
|
||||
|
||||
HEADER_PATTERNS = {
|
||||
"limit":
|
||||
r"x-ratelimit-limit|x-rate-limit-limit|ratelimit-limit",
|
||||
|
|
@ -11,12 +11,12 @@ import re
|
|||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..core.enums import (
|
||||
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 .payloads import RateLimitBypassPayloads
|
||||
|
|
@ -9,12 +9,12 @@ import time
|
|||
import statistics
|
||||
from typing import Any
|
||||
|
||||
from ..core.enums import (
|
||||
from core.enums import (
|
||||
ScanStatus,
|
||||
Severity,
|
||||
TestType,
|
||||
)
|
||||
from ..schemas.test_result_schemas import TestResultCreate
|
||||
from schemas.test_result_schemas import TestResultCreate
|
||||
|
||||
from .payloads import SQLiPayloads
|
||||
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 (
|
||||
TokenResponse,
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
Scan model API validation and serialization.
|
||||
Scan model API validation and serialization
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -13,8 +13,9 @@ from pydantic import (
|
|||
)
|
||||
from datetime import datetime
|
||||
|
||||
from ..config import settings
|
||||
from ..core.enums import TestType
|
||||
from config import settings
|
||||
from core.enums import TestType
|
||||
from .test_result_schemas import TestResultResponse
|
||||
|
||||
|
||||
class ScanRequest(BaseModel):
|
||||
|
|
@ -35,8 +36,6 @@ class ScanResponse(BaseModel):
|
|||
"""
|
||||
Schema for scan data in API responses
|
||||
"""
|
||||
# Circular to avoid circular imports
|
||||
from .test_result_schemas import TestResultResponse
|
||||
model_config = ConfigDict(from_attributes = True)
|
||||
|
||||
id: int
|
||||
|
|
@ -11,7 +11,7 @@ from pydantic import (
|
|||
Field,
|
||||
)
|
||||
|
||||
from ..core.enums import (
|
||||
from core.enums import (
|
||||
ScanStatus,
|
||||
Severity,
|
||||
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
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
from ..config import settings
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
EmailStr,
|
||||
Field,
|
||||
field_validator
|
||||
)
|
||||
from config import settings
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""
|
||||
Schema for user registration request.
|
||||
"""
|
||||
|
||||
email: EmailStr
|
||||
password: str = Field(
|
||||
min_length = settings.PASSWORD_MIN_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):
|
||||
"""
|
||||
Schema for user login request.
|
||||
"""
|
||||
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
|
@ -37,7 +56,6 @@ class UserResponse(BaseModel):
|
|||
Schema for user data in API responses.
|
||||
Excludes sensitive fields like hashed_password.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes = True)
|
||||
|
||||
id: int
|
||||
|
|
@ -50,6 +68,5 @@ class TokenResponse(BaseModel):
|
|||
"""
|
||||
Schema for JWT token response.
|
||||
"""
|
||||
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
|
@ -9,19 +9,19 @@ from datetime import timedelta
|
|||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from ..config import settings
|
||||
from ..core.security import (
|
||||
from config import settings
|
||||
from core.security import (
|
||||
create_access_token,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from ..schemas.user_schemas import (
|
||||
from schemas.user_schemas import (
|
||||
TokenResponse,
|
||||
UserCreate,
|
||||
UserLogin,
|
||||
UserResponse,
|
||||
)
|
||||
from ..repositories.user_repository import UserRepository
|
||||
from repositories.user_repository import UserRepository
|
||||
|
||||
|
||||
class AuthService:
|
||||
|
|
@ -5,19 +5,22 @@ Coordinates scanners and saves results
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Type
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from ..core.enums import TestType
|
||||
from ..repositories.scan_repository import ScanRepository
|
||||
from ..repositories.test_result_repository import TestResultRepository
|
||||
from core.enums import TestType
|
||||
from repositories.scan_repository import ScanRepository
|
||||
from repositories.test_result_repository import TestResultRepository
|
||||
|
||||
from ..scanners.auth_scanner import AuthScanner
|
||||
from ..scanners.idor_scanner import IDORScanner
|
||||
from ..scanners.sqli_scanner import SQLiScanner
|
||||
from ..scanners.rate_limit_scanner import RateLimitScanner
|
||||
from ..schemas.test_result_schemas import TestResultCreate
|
||||
from ..schemas.scan_schemas import ScanRequest, ScanResponse
|
||||
from scanners.base_scanner import BaseScanner
|
||||
from scanners.auth_scanner import AuthScanner
|
||||
from scanners.idor_scanner import IDORScanner
|
||||
from scanners.sqli_scanner import SQLiScanner
|
||||
from scanners.rate_limit_scanner import RateLimitScanner
|
||||
from schemas.test_result_schemas import TestResultCreate
|
||||
from schemas.scan_schemas import ScanRequest, ScanResponse
|
||||
|
||||
|
||||
class ScanService:
|
||||
|
|
@ -47,7 +50,7 @@ class ScanService:
|
|||
target_url = str(scan_request.target_url),
|
||||
)
|
||||
|
||||
scanner_mapping = {
|
||||
scanner_mapping: dict[TestType, Type[BaseScanner]] = {
|
||||
TestType.RATE_LIMIT: RateLimitScanner,
|
||||
TestType.AUTH: AuthScanner,
|
||||
TestType.SQLI: SQLiScanner,
|
||||
|
|
@ -57,7 +60,7 @@ class ScanService:
|
|||
results: list[TestResultCreate] = []
|
||||
|
||||
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:
|
||||
continue
|
||||
|
|
@ -3,9 +3,8 @@
|
|||
# Stage 1: Build the React app with Vite
|
||||
# Stage 2: Serve static files with Nginx
|
||||
|
||||
# ===================================
|
||||
|
||||
# Stage 1: Build
|
||||
# ===================================
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
|
@ -22,16 +21,17 @@ COPY frontend/ .
|
|||
# Build the app (creates dist/ folder with static files)
|
||||
RUN npm run build
|
||||
|
||||
# ===================================
|
||||
|
||||
|
||||
# Stage 2: Serve with Nginx
|
||||
# ===================================
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built static files from builder stage
|
||||
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/http.conf /etc/nginx/http.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
|
@ -10,8 +10,10 @@ events {
|
|||
}
|
||||
|
||||
http {
|
||||
# Include shared config
|
||||
# Include shared config and MIME types
|
||||
include /etc/nginx/http.conf;
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Disable access logs in dev (less noise)
|
||||
access_log off;
|
||||
|
|
@ -19,16 +21,16 @@ http {
|
|||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name localhost;
|
||||
|
||||
# Client max body size (for file uploads in scans)
|
||||
# Client upload limits
|
||||
client_max_body_size 10M;
|
||||
client_body_buffer_size 128k;
|
||||
|
||||
# Backend API routes
|
||||
# Backend API
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
|
||||
# Apply common proxy settings
|
||||
proxy_pass http://backend/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
|
|
@ -39,11 +41,9 @@ http {
|
|||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Frontend (Vite dev server with HMR)
|
||||
# Frontend
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
|
||||
# WebSocket support for Vite HMR
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
|
|
@ -51,7 +51,6 @@ http {
|
|||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
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
|
||||
# - Caching, gzip compression, security headers
|
||||
# - SSL/HTTPS ready (commented out, uncomment when you have certificates)
|
||||
# - Use Cloudfare tls with dns proxy if no tls in nginx
|
||||
|
||||
events {
|
||||
worker_connections 2048;
|
||||
|
|
@ -46,16 +47,18 @@ http {
|
|||
application/vnd.ms-fontobject
|
||||
image/svg+xml;
|
||||
|
||||
# HTTP server (will redirect to HTTPS in production)
|
||||
# HTTP server
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
# Uncomment below to redirect HTTP to HTTPS in production
|
||||
# return 301 https://$server_name$request_uri;
|
||||
|
||||
# Client max body size
|
||||
# Client upload limits
|
||||
client_max_body_size 10M;
|
||||
client_body_buffer_size 128k;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
|
|
@ -100,7 +103,6 @@ http {
|
|||
}
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
|
|
@ -111,6 +113,7 @@ http {
|
|||
# HTTPS server (uncomment and configure when you have SSL certificates)
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# listen [::]:443 ssl http2;
|
||||
# server_name yourdomain.com;
|
||||
#
|
||||
# # SSL certificates (use Let's Encrypt or similar)
|
||||
|
|
@ -14,7 +14,7 @@ services:
|
|||
volumes:
|
||||
- postgres_data_dev:/var/lib/postgresql/data
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
|
@ -45,7 +45,7 @@ services:
|
|||
- apisec_network
|
||||
restart: unless-stopped
|
||||
|
||||
# React Frontend (Development with Vite HMR)
|
||||
# React Frontend - Vite HMR
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
|
|
@ -23,7 +23,7 @@ services:
|
|||
- apisec_network
|
||||
restart: always
|
||||
|
||||
# FastAPI Backend (Production with Gunicorn)
|
||||
# FastAPI Backend
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
|
|
@ -51,7 +51,7 @@ services:
|
|||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Frontend (Production - Nginx serving static files)
|
||||
# Nginx serving static files
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
|
|
@ -59,14 +59,15 @@ services:
|
|||
container_name: apisec_frontend_prod
|
||||
ports:
|
||||
- "${HOST_NGINX_PORT:-80}:80"
|
||||
# Uncomment for HTTPS:
|
||||
# Uncomment for HTTPS - or dns proxy with Cloudfare:
|
||||
# - "${HOST_NGINX_HTTPS_PORT:-443}:443"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- apisec_network
|
||||
restart: always
|
||||
# Optional: Mount SSL certificates for HTTPS
|
||||
# Optional: Mount SSL certificates for HTTPS -
|
||||
# or dns proxy with Cloudfare (easier)
|
||||
# volumes:
|
||||
# - ./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}\"",
|
||||
"lint": "npm run lint:eslint && npm run lint:scss && npm run lint:types",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -52,6 +54,9 @@
|
|||
"globals": "^16.3.0",
|
||||
"husky": "^8.0.0",
|
||||
"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-eslint": "^8.39.1",
|
||||
"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 { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './styles/variables.css';
|
||||
import './styles/index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<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>
|
||||
);
|
||||
};
|
||||