Merge pull request #1 from CarterPerez-dev/dev

yo yo you should star the repo :) pls
This commit is contained in:
Carter Perez 2025-11-12 08:07:28 -05:00 committed by GitHub
commit 276b736a96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
191 changed files with 9428 additions and 396 deletions

View File

@ -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

View File

@ -1,10 +1,13 @@
name: Lint & Type Check
on:
push:
branches: [ 'main' ]
pull_request:
branches: [ '*' ]
workflow_dispatch:
jobs:
lint:
name: Run Linters

View File

@ -0,0 +1,20 @@
"""
AngelaMos | CarterPerez-dev
CertGames.com | 2025
----
API Security Scanner
"""

View File

@ -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

View File

@ -0,0 +1,4 @@
"""
AngelaMos | 2025
Core modules for application infrastructure
"""

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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,
)

View File

@ -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()

View File

@ -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):

View File

@ -16,7 +16,7 @@ from sqlalchemy import (
)
from sqlalchemy.orm import relationship
from ..config import settings
from config import settings
from .Base import BaseModel

View File

@ -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,

View File

@ -9,7 +9,7 @@ from sqlalchemy import (
String,
)
from ..config import settings
from config import settings
from .Base import BaseModel

View File

@ -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",

View File

@ -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

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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"])

View File

@ -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"])

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -11,7 +11,7 @@ from pydantic import (
Field,
)
from ..core.enums import (
from core.enums import (
ScanStatus,
Severity,
TestType,

View File

@ -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"

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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";

View File

@ -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;
}

View File

@ -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)

View File

@ -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: .

View File

@ -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

View File

@ -0,0 +1,10 @@
{
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-prettier-scss"
],
"rules": {
"scss/at-rule-no-unknown": null,
"selector-class-pattern": null
}
}

View File

@ -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",

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 378 B

View File

Before

Width:  |  Height:  |  Size: 878 B

After

Width:  |  Height:  |  Size: 878 B

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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;

View File

@ -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;
}

View File

@ -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&apos;t have an account?{' '}
<Link to="/register" className="auth-form__link-text">
Sign up
</Link>
</p>
</form>
);
};

View File

@ -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>
);
};

View File

@ -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%);
}

View File

@ -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>
);
};

View File

@ -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;
}

View File

@ -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';

View File

@ -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);
}

View File

@ -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>
);
};

View File

@ -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;
};

View File

@ -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>
</>
);
};

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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>
);
};

View File

@ -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;
}
}

View File

@ -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>
);
};

View File

@ -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];

View File

@ -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');
};
};

View File

@ -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),
});
};

View File

@ -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;
},
};

View File

@ -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);
};
};

View File

@ -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);
};

View File

@ -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>;

View File

@ -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>

View File

@ -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%;
}

View File

@ -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);
}
}

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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);
}
}

View File

@ -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>
);
};

Some files were not shown because too many files have changed in this diff Show More