Add initial MVP documentation for DMARQ platform, detailing backend architecture, frontend implementation, and deployment structure
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install required system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements file
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 8080
|
||||
|
||||
# Command to run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.api_v1.endpoints import domains, health, reports, setup
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Include all endpoint routers
|
||||
api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(domains.router, prefix="/domains", tags=["domains"])
|
||||
api_router.include_router(reports.router, prefix="/reports", tags=["reports"])
|
||||
api_router.include_router(setup.router, prefix="/setup", tags=["setup"])
|
||||
@@ -0,0 +1,122 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.report_store import ReportStore
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class DomainBase(BaseModel):
|
||||
"""Base Domain schema"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
policy: Optional[str] = None
|
||||
|
||||
class DomainResponse(DomainBase):
|
||||
"""Domain response schema"""
|
||||
reports_count: int = 0
|
||||
emails_count: int = 0
|
||||
compliance_rate: float = 0.0
|
||||
|
||||
class DomainSummaryResponse(BaseModel):
|
||||
"""Domain summary for dashboard"""
|
||||
total_domains: int
|
||||
total_emails: int
|
||||
overall_pass_rate: float
|
||||
reports_processed: int
|
||||
domains: List[Dict[str, Any]]
|
||||
|
||||
@router.get("/summary", response_model=DomainSummaryResponse)
|
||||
async def get_domains_summary():
|
||||
"""
|
||||
Get summary statistics for all domains, formatted for the dashboard.
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
summaries = store.get_all_domain_summaries()
|
||||
|
||||
# Calculate overall statistics
|
||||
total_domains = len(domains)
|
||||
total_emails = 0
|
||||
total_passed = 0
|
||||
total_reports = 0
|
||||
|
||||
domains_list = []
|
||||
|
||||
for domain_name in domains:
|
||||
summary = summaries.get(domain_name, {})
|
||||
total_emails += summary.get("total_count", 0)
|
||||
total_passed += summary.get("passed_count", 0)
|
||||
total_reports += summary.get("reports_processed", 0)
|
||||
|
||||
# Format domain data for frontend
|
||||
domains_list.append({
|
||||
"id": domain_name, # Using the domain name as ID for now
|
||||
"domain_name": domain_name,
|
||||
"total_emails": summary.get("total_count", 0),
|
||||
"passed_count": summary.get("passed_count", 0),
|
||||
"failed_count": summary.get("failed_count", 0),
|
||||
"pass_rate": summary.get("compliance_rate", 0),
|
||||
"report_count": summary.get("reports_processed", 0)
|
||||
})
|
||||
|
||||
# Calculate overall pass rate
|
||||
overall_pass_rate = 0
|
||||
if total_emails > 0:
|
||||
overall_pass_rate = round((total_passed / total_emails) * 100, 1)
|
||||
|
||||
return DomainSummaryResponse(
|
||||
total_domains=total_domains,
|
||||
total_emails=total_emails,
|
||||
overall_pass_rate=overall_pass_rate,
|
||||
reports_processed=total_reports,
|
||||
domains=domains_list
|
||||
)
|
||||
|
||||
@router.get("/domains", response_model=List[DomainResponse])
|
||||
async def read_domains():
|
||||
"""
|
||||
Retrieve domains with their statistics.
|
||||
For Milestone 1, this simply returns domains from the in-memory store.
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
summaries = store.get_all_domain_summaries()
|
||||
|
||||
result = []
|
||||
for domain_name in domains:
|
||||
summary = summaries.get(domain_name, {})
|
||||
domain_response = DomainResponse(
|
||||
name=domain_name,
|
||||
policy=summary.get("policy", "unknown"),
|
||||
reports_count=summary.get("reports_processed", 0),
|
||||
emails_count=summary.get("total_count", 0),
|
||||
compliance_rate=summary.get("compliance_rate", 0.0)
|
||||
)
|
||||
result.append(domain_response)
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/domains/{domain_name}", response_model=DomainResponse)
|
||||
async def read_domain(domain_name: str):
|
||||
"""
|
||||
Get statistics for a specific domain.
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
|
||||
if domain_name not in domains:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
summary = store.get_domain_summary(domain_name)
|
||||
|
||||
return DomainResponse(
|
||||
name=domain_name,
|
||||
policy=summary.get("policy", "unknown"),
|
||||
reports_count=summary.get("reports_processed", 0),
|
||||
emails_count=summary.get("total_count", 0),
|
||||
compliance_rate=summary.get("compliance_rate", 0.0)
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.api_v1.endpoints.setup import setup_status
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/health", status_code=200)
|
||||
async def health_check():
|
||||
"""
|
||||
Health check endpoint to verify API status.
|
||||
For Milestone 1, this simply returns status information without checking a database.
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": "0.1.0",
|
||||
"service": "dmarq",
|
||||
"is_setup_complete": setup_status["is_setup_complete"]
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
from typing import Dict, List, Any
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.dmarc_parser import DMARCParser
|
||||
from app.services.report_store import ReportStore
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
"""Response model for report upload"""
|
||||
success: bool
|
||||
domain: str
|
||||
message: str
|
||||
processed_records: int = 0 # Added this field to track processed records
|
||||
|
||||
class DomainSummary(BaseModel):
|
||||
"""Domain summary response model"""
|
||||
domain: str
|
||||
total_count: int
|
||||
passed_count: int
|
||||
failed_count: int
|
||||
reports_processed: int
|
||||
compliance_rate: float
|
||||
|
||||
class ReportSummary(BaseModel):
|
||||
"""DMARC report summary model"""
|
||||
report_id: str
|
||||
org_name: str
|
||||
begin_date: str
|
||||
end_date: str
|
||||
total_count: int
|
||||
passed_count: int
|
||||
failed_count: int
|
||||
|
||||
@router.post("/upload", response_model=UploadResponse)
|
||||
async def upload_report(file: UploadFile = File(...)):
|
||||
"""
|
||||
Upload and process a DMARC aggregate report file (XML, ZIP, or GZIP)
|
||||
"""
|
||||
try:
|
||||
# Read the file content
|
||||
file_content = await file.read()
|
||||
filename = file.filename
|
||||
|
||||
# Parse the report
|
||||
parser = DMARCParser()
|
||||
report = parser.parse_file(file_content, filename)
|
||||
|
||||
# Store the report
|
||||
store = ReportStore.get_instance()
|
||||
store.add_report(report)
|
||||
|
||||
domain = report.get("domain", "unknown")
|
||||
processed_records = report.get("summary", {}).get("total_count", 0)
|
||||
|
||||
return UploadResponse(
|
||||
success=True,
|
||||
domain=domain,
|
||||
message=f"Report processed successfully for domain {domain}",
|
||||
processed_records=processed_records
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Error processing report: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/domains", response_model=List[str])
|
||||
async def get_domains():
|
||||
"""
|
||||
Get list of all domains with reports
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
return store.get_domains()
|
||||
|
||||
@router.get("/domain/{domain}/summary", response_model=DomainSummary)
|
||||
async def get_domain_summary(domain: str):
|
||||
"""
|
||||
Get summary statistics for a specific domain
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
summary = store.get_domain_summary(domain)
|
||||
|
||||
if not summary:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No reports found for domain {domain}"
|
||||
)
|
||||
|
||||
return DomainSummary(
|
||||
domain=domain,
|
||||
**summary
|
||||
)
|
||||
|
||||
@router.get("/summary", response_model=List[DomainSummary])
|
||||
async def get_all_summaries():
|
||||
"""
|
||||
Get summary statistics for all domains
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
all_summaries = store.get_all_domain_summaries()
|
||||
|
||||
return [
|
||||
DomainSummary(domain=domain, **summary)
|
||||
for domain, summary in all_summaries.items()
|
||||
]
|
||||
|
||||
@router.get("/domain/{domain}/reports", response_model=List[ReportSummary])
|
||||
async def get_domain_reports(domain: str):
|
||||
"""
|
||||
Get all reports for a specific domain
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
reports = store.get_domain_reports(domain)
|
||||
|
||||
if not reports:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No reports found for domain {domain}"
|
||||
)
|
||||
|
||||
return [
|
||||
ReportSummary(
|
||||
report_id=report.get("report_id", ""),
|
||||
org_name=report.get("org_name", ""),
|
||||
begin_date=report.get("begin_date", ""),
|
||||
end_date=report.get("end_date", ""),
|
||||
total_count=report.get("summary", {}).get("total_count", 0),
|
||||
passed_count=report.get("summary", {}).get("passed_count", 0),
|
||||
failed_count=report.get("summary", {}).get("failed_count", 0)
|
||||
)
|
||||
for report in reports
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Dict, Optional
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Simple in-memory storage for setup status (for Milestone 1)
|
||||
setup_status = {
|
||||
"is_setup_complete": False,
|
||||
"admin_email": None,
|
||||
"app_name": "DMARQ",
|
||||
}
|
||||
|
||||
class SetupStatusResponse(BaseModel):
|
||||
"""Setup status response"""
|
||||
is_setup_complete: bool
|
||||
app_name: str
|
||||
|
||||
class AdminSetupRequest(BaseModel):
|
||||
"""Admin user setup request body"""
|
||||
email: EmailStr
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class SystemConfigRequest(BaseModel):
|
||||
"""System configuration setup request body"""
|
||||
app_name: str
|
||||
base_url: str
|
||||
|
||||
@router.get("/status", response_model=SetupStatusResponse)
|
||||
async def get_setup_status():
|
||||
"""Get the current setup status"""
|
||||
return SetupStatusResponse(
|
||||
is_setup_complete=setup_status["is_setup_complete"],
|
||||
app_name=setup_status["app_name"]
|
||||
)
|
||||
|
||||
@router.post("/admin", status_code=201)
|
||||
async def setup_admin(request: AdminSetupRequest):
|
||||
"""
|
||||
Setup admin user during initial system configuration.
|
||||
For Milestone 1, this simply stores the admin email in memory.
|
||||
"""
|
||||
if setup_status["is_setup_complete"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Setup already completed"
|
||||
)
|
||||
|
||||
# Store admin email
|
||||
setup_status["admin_email"] = request.email
|
||||
|
||||
return {"message": "Admin user setup completed"}
|
||||
|
||||
@router.post("/system", status_code=200)
|
||||
async def setup_system(request: SystemConfigRequest):
|
||||
"""
|
||||
Setup system configuration.
|
||||
For Milestone 1, this simply stores the app name in memory.
|
||||
"""
|
||||
# Store app name
|
||||
setup_status["app_name"] = request.app_name
|
||||
setup_status["is_setup_complete"] = True
|
||||
|
||||
return {"message": "System settings saved successfully"}
|
||||
@@ -0,0 +1,63 @@
|
||||
from functools import lru_cache
|
||||
from typing import Optional, List, Union
|
||||
|
||||
# Try to import from pydantic_settings first (newer versions)
|
||||
try:
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import EmailStr, validator
|
||||
except ImportError:
|
||||
# Fall back to older pydantic version
|
||||
from pydantic import BaseSettings, EmailStr, validator
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings"""
|
||||
|
||||
# Base
|
||||
PROJECT_NAME: str = "DMARQ"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "sqlite:///./dmarq.db"
|
||||
|
||||
# JWT Authentication
|
||||
SECRET_KEY: str = "CHANGE_THIS_TO_A_RANDOM_SECRET_IN_PRODUCTION"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 # 1 hour
|
||||
|
||||
# CORS
|
||||
BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"]
|
||||
|
||||
# IMAP Settings
|
||||
IMAP_SERVER: Optional[str] = None
|
||||
IMAP_PORT: int = 993
|
||||
IMAP_USERNAME: Optional[str] = None
|
||||
IMAP_PASSWORD: Optional[str] = None
|
||||
|
||||
# Admin User
|
||||
FIRST_SUPERUSER: Optional[EmailStr] = None
|
||||
FIRST_SUPERUSER_PASSWORD: Optional[str] = None
|
||||
|
||||
# Optional Cloudflare Integration
|
||||
CLOUDFLARE_API_TOKEN: Optional[str] = None
|
||||
CLOUDFLARE_ZONE_ID: Optional[str] = None
|
||||
|
||||
@validator("BACKEND_CORS_ORIGINS", pre=True)
|
||||
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> List[str]:
|
||||
if isinstance(v, str) and not v.startswith("["):
|
||||
return [i.strip() for i in v.split(",")]
|
||||
elif isinstance(v, (list, str)):
|
||||
return v
|
||||
raise ValueError(v)
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""
|
||||
Get application settings from environment variables or .env file
|
||||
"""
|
||||
return Settings()
|
||||
@@ -0,0 +1,27 @@
|
||||
from typing import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Configure SQLAlchemy
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Create base class for SQLAlchemy models
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db() -> Generator:
|
||||
"""
|
||||
Dependency for getting DB sessions
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,40 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Union
|
||||
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def create_access_token(
|
||||
subject: Union[str, Any], expires_delta: timedelta = None
|
||||
) -> str:
|
||||
"""
|
||||
Create a JWT access token for authentication
|
||||
"""
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode = {"exp": expire, "sub": str(subject)}
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
Verify a password against its hash
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""
|
||||
Hash a password
|
||||
"""
|
||||
return pwd_context.hash(password)
|
||||
@@ -0,0 +1,78 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
import os
|
||||
|
||||
from app.api.api_v1.api import api_router
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application"""
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# Set all CORS enabled origins
|
||||
if settings.BACKEND_CORS_ORIGINS:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include API router
|
||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
||||
|
||||
# Mount static files directory
|
||||
app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
# Initialize Jinja2 templates
|
||||
templates_dir = os.path.join(os.path.dirname(__file__), "templates")
|
||||
templates = Jinja2Templates(directory=templates_dir)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root(request: Request):
|
||||
"""Root endpoint that serves the main HTML page"""
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
{"request": request, "app_name": settings.PROJECT_NAME}
|
||||
)
|
||||
|
||||
|
||||
# Frontend routes that should return the SPA template
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
@app.get("/setup", response_class=HTMLResponse)
|
||||
@app.get("/domains", response_class=HTMLResponse)
|
||||
@app.get("/reports", response_class=HTMLResponse)
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def serve_spa(request: Request):
|
||||
"""Serve the SPA for frontend routes"""
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
{"request": request, "app_name": settings.PROJECT_NAME}
|
||||
)
|
||||
|
||||
# Fallback for other routes (404 handling)
|
||||
@app.get("/{path:path}", response_class=HTMLResponse)
|
||||
async def catch_all(request: Request, path: str):
|
||||
"""Catch-all route that serves the main HTML page for client-side routing"""
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
{"request": request, "app_name": settings.PROJECT_NAME}
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Domain(Base):
|
||||
"""Domain model representing a monitored domain"""
|
||||
|
||||
__tablename__ = "domains"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, index=True, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
active = Column(Boolean, default=True)
|
||||
|
||||
# DMARC policy information
|
||||
dmarc_policy = Column(String, nullable=True)
|
||||
spf_record = Column(String, nullable=True)
|
||||
dkim_selectors = Column(String, nullable=True) # Comma-separated list of DKIM selectors
|
||||
|
||||
# DNS verification status
|
||||
verified = Column(Boolean, default=False)
|
||||
verification_token = Column(String, nullable=True)
|
||||
|
||||
# Date fields
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
reports = relationship("DMARCReport", back_populates="domain", cascade="all, delete-orphan")
|
||||
user_domains = relationship("UserDomain", back_populates="domain", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Domain {self.name}>"
|
||||
|
||||
|
||||
class UserDomain(Base):
|
||||
"""Association table for users and domains"""
|
||||
|
||||
__tablename__ = "user_domains"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
domain_id = Column(Integer, ForeignKey("domains.id"), nullable=False)
|
||||
|
||||
# Access level (admin, viewer, etc)
|
||||
role = Column(String, default="viewer", nullable=False)
|
||||
|
||||
# Date fields
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="user_domains")
|
||||
domain = relationship("Domain", back_populates="user_domains")
|
||||
@@ -0,0 +1,73 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class DMARCReport(Base):
|
||||
"""DMARC Aggregate Report model"""
|
||||
|
||||
__tablename__ = "dmarc_reports"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
domain_id = Column(Integer, ForeignKey("domains.id"), nullable=False)
|
||||
|
||||
# Report metadata
|
||||
report_id = Column(String, index=True, nullable=False)
|
||||
org_name = Column(String, nullable=False)
|
||||
begin_date = Column(Integer, nullable=False) # Unix timestamp
|
||||
end_date = Column(Integer, nullable=False) # Unix timestamp
|
||||
source_email = Column(String, nullable=True)
|
||||
|
||||
# Policy information
|
||||
policy = Column(String, nullable=True) # none, quarantine, reject
|
||||
subdomain_policy = Column(String, nullable=True)
|
||||
adkim = Column(String(1), nullable=True) # r (relaxed) or s (strict)
|
||||
aspf = Column(String(1), nullable=True) # r (relaxed) or s (strict)
|
||||
percentage = Column(Integer, nullable=True)
|
||||
|
||||
# Processing metadata
|
||||
processed_at = Column(DateTime, default=datetime.utcnow)
|
||||
raw_data = Column(Text, nullable=True) # Original XML content (optional)
|
||||
|
||||
# Relationships
|
||||
domain = relationship("Domain", back_populates="reports")
|
||||
records = relationship("ReportRecord", back_populates="report", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DMARCReport {self.report_id} for {self.domain_id}>"
|
||||
|
||||
|
||||
class ReportRecord(Base):
|
||||
"""Individual record within a DMARC report"""
|
||||
|
||||
__tablename__ = "report_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
report_id = Column(Integer, ForeignKey("dmarc_reports.id"), nullable=False)
|
||||
|
||||
# Source information
|
||||
source_ip = Column(String, nullable=False, index=True)
|
||||
count = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Policy evaluation
|
||||
disposition = Column(String, nullable=False) # none, quarantine, reject
|
||||
dkim = Column(String, nullable=True) # pass, fail
|
||||
spf = Column(String, nullable=True) # pass, fail
|
||||
|
||||
# Identifiers
|
||||
header_from = Column(String, nullable=True)
|
||||
envelope_from = Column(String, nullable=True)
|
||||
|
||||
# Authentication details (optional JSON fields)
|
||||
dkim_auth_details = Column(Text, nullable=True) # JSON array of DKIM results
|
||||
spf_auth_details = Column(Text, nullable=True) # JSON array of SPF results
|
||||
|
||||
# Relationships
|
||||
report = relationship("DMARCReport", back_populates="records")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ReportRecord {self.id} ({self.source_ip})>"
|
||||
@@ -0,0 +1,25 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import Boolean, Column, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_superuser = Column(Boolean, default=False)
|
||||
is_verified = Column(Boolean, default=False)
|
||||
|
||||
# Additional fields
|
||||
full_name = Column(String, nullable=True)
|
||||
organization = Column(String, nullable=True)
|
||||
|
||||
# Relationships
|
||||
user_domains = relationship("UserDomain", back_populates="user", cascade="all, delete-orphan")
|
||||
@@ -0,0 +1,183 @@
|
||||
import os
|
||||
import zipfile
|
||||
import gzip
|
||||
import io
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
import xml.etree.ElementTree as ET
|
||||
import logging
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DMARCParser:
|
||||
"""
|
||||
Parser for DMARC Aggregate Reports (XML format)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def parse_file(file_content: bytes, filename: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse a DMARC report file (XML, zip, or gzip) into a dictionary
|
||||
|
||||
Args:
|
||||
file_content: The binary content of the file
|
||||
filename: The name of the file (used to determine type)
|
||||
|
||||
Returns:
|
||||
Dict containing the parsed report data
|
||||
"""
|
||||
# Determine file type and extract XML content
|
||||
xml_content = DMARCParser._extract_xml_content(file_content, filename)
|
||||
if not xml_content:
|
||||
raise ValueError("Could not extract XML content from file")
|
||||
|
||||
# Parse the XML content
|
||||
return DMARCParser._parse_xml(xml_content)
|
||||
|
||||
@staticmethod
|
||||
def _extract_xml_content(file_content: bytes, filename: str) -> Optional[bytes]:
|
||||
"""
|
||||
Extract XML content from various file formats (ZIP, GZIP, or plain XML)
|
||||
"""
|
||||
# Try to handle as ZIP file
|
||||
if filename.lower().endswith('.zip'):
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_content)) as z:
|
||||
# Find the first XML file in the archive
|
||||
for file_info in z.infolist():
|
||||
if file_info.filename.lower().endswith('.xml'):
|
||||
return z.read(file_info.filename)
|
||||
except zipfile.BadZipFile:
|
||||
pass
|
||||
|
||||
# Try to handle as GZIP file
|
||||
if filename.lower().endswith('.gz') or filename.lower().endswith('.gzip'):
|
||||
try:
|
||||
return gzip.decompress(file_content)
|
||||
except gzip.BadGzipFile:
|
||||
pass
|
||||
|
||||
# Assume it's plain XML
|
||||
if filename.lower().endswith('.xml'):
|
||||
return file_content
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_xml(xml_content: bytes) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse DMARC XML content according to RFC 7489
|
||||
"""
|
||||
try:
|
||||
root = ET.fromstring(xml_content)
|
||||
report = {}
|
||||
|
||||
# Parse report metadata
|
||||
metadata = root.find("report_metadata")
|
||||
if metadata is not None:
|
||||
report["report_id"] = metadata.findtext("report_id", "")
|
||||
report["org_name"] = metadata.findtext("org_name", "")
|
||||
report["email"] = metadata.findtext("email", "")
|
||||
|
||||
# Parse date range
|
||||
date_range = metadata.find("date_range")
|
||||
if date_range is not None:
|
||||
begin_ts = int(date_range.findtext("begin", 0))
|
||||
end_ts = int(date_range.findtext("end", 0))
|
||||
report["begin_date"] = datetime.fromtimestamp(begin_ts).isoformat()
|
||||
report["end_date"] = datetime.fromtimestamp(end_ts).isoformat()
|
||||
report["begin_timestamp"] = begin_ts
|
||||
report["end_timestamp"] = end_ts
|
||||
|
||||
# Parse policy published
|
||||
policy = root.find("policy_published")
|
||||
if policy is not None:
|
||||
report["domain"] = policy.findtext("domain", "")
|
||||
report["policy"] = {
|
||||
"p": policy.findtext("p", "none"),
|
||||
"sp": policy.findtext("sp", ""),
|
||||
"pct": policy.findtext("pct", "100"),
|
||||
}
|
||||
|
||||
# Parse records
|
||||
records = []
|
||||
for record_elem in root.findall("record"):
|
||||
record = {}
|
||||
|
||||
# Parse row
|
||||
row = record_elem.find("row")
|
||||
if row is not None:
|
||||
record["source_ip"] = row.findtext("source_ip", "")
|
||||
record["count"] = int(row.findtext("count", 0))
|
||||
|
||||
policy_evaluated = row.find("policy_evaluated")
|
||||
if policy_evaluated is not None:
|
||||
record["disposition"] = policy_evaluated.findtext("disposition", "none")
|
||||
record["dkim_result"] = policy_evaluated.findtext("dkim", "").lower()
|
||||
record["spf_result"] = policy_evaluated.findtext("spf", "").lower()
|
||||
|
||||
# Parse identifiers
|
||||
identifiers = record_elem.find("identifiers")
|
||||
if identifiers is not None:
|
||||
record["header_from"] = identifiers.findtext("header_from", "")
|
||||
|
||||
# Parse auth results
|
||||
auth_results = record_elem.find("auth_results")
|
||||
if auth_results is not None:
|
||||
# SPF results
|
||||
spf_entries = []
|
||||
for spf in auth_results.findall("spf"):
|
||||
spf_entries.append({
|
||||
"domain": spf.findtext("domain", ""),
|
||||
"result": spf.findtext("result", "").lower()
|
||||
})
|
||||
if spf_entries:
|
||||
record["spf"] = spf_entries
|
||||
|
||||
# DKIM results
|
||||
dkim_entries = []
|
||||
for dkim in auth_results.findall("dkim"):
|
||||
dkim_entries.append({
|
||||
"domain": dkim.findtext("domain", ""),
|
||||
"result": dkim.findtext("result", "").lower(),
|
||||
"selector": dkim.findtext("selector", "")
|
||||
})
|
||||
if dkim_entries:
|
||||
record["dkim"] = dkim_entries
|
||||
|
||||
records.append(record)
|
||||
|
||||
report["records"] = records
|
||||
|
||||
# Calculate summary stats
|
||||
total_count = sum(r["count"] for r in records)
|
||||
|
||||
# Count records that pass either SPF or DKIM (or both)
|
||||
passed_count = sum(r["count"] for r in records
|
||||
if r.get("spf_result") == "pass" or r.get("dkim_result") == "pass")
|
||||
|
||||
failed_count = total_count - passed_count
|
||||
|
||||
# Log parse results for debugging
|
||||
logger.info(f"Parsed DMARC report for domain: {report.get('domain')}")
|
||||
logger.info(f"Found {len(records)} record entries with {total_count} total messages")
|
||||
logger.info(f"Messages passed: {passed_count}, failed: {failed_count}")
|
||||
|
||||
if len(records) > 0:
|
||||
# Log the first record for debugging
|
||||
logger.info(f"Sample record - SPF: {records[0].get('spf_result')}, DKIM: {records[0].get('dkim_result')}")
|
||||
|
||||
report["summary"] = {
|
||||
"total_count": total_count,
|
||||
"passed_count": passed_count,
|
||||
"failed_count": failed_count,
|
||||
"pass_rate": (passed_count / total_count * 100) if total_count > 0 else 0
|
||||
}
|
||||
|
||||
return report
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing DMARC XML: {str(e)}")
|
||||
raise ValueError(f"Error parsing DMARC XML: {str(e)}")
|
||||
@@ -0,0 +1,109 @@
|
||||
from typing import Dict, List, Any
|
||||
import threading
|
||||
|
||||
class ReportStore:
|
||||
"""
|
||||
In-memory store for DMARC reports
|
||||
(for Milestone 1, will be replaced with database in Milestone 3)
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> 'ReportStore':
|
||||
"""
|
||||
Get singleton instance of the report store
|
||||
"""
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = ReportStore()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize empty report store
|
||||
"""
|
||||
# Domain -> list of reports
|
||||
self.domain_reports: Dict[str, List[Dict[str, Any]]] = {}
|
||||
# Domain -> summary stats
|
||||
self.domain_summary: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def add_report(self, report: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Add a new report to the store
|
||||
|
||||
Args:
|
||||
report: Parsed DMARC report from DMARCParser
|
||||
"""
|
||||
domain = report.get("domain", "unknown")
|
||||
|
||||
# Initialize data structures if this is a new domain
|
||||
if domain not in self.domain_reports:
|
||||
self.domain_reports[domain] = []
|
||||
self.domain_summary[domain] = {
|
||||
"total_count": 0,
|
||||
"passed_count": 0,
|
||||
"failed_count": 0,
|
||||
"reports_processed": 0,
|
||||
}
|
||||
|
||||
# Add the new report
|
||||
self.domain_reports[domain].append(report)
|
||||
|
||||
# Update summary stats for this domain
|
||||
summary = report.get("summary", {})
|
||||
self.domain_summary[domain]["total_count"] += summary.get("total_count", 0)
|
||||
self.domain_summary[domain]["passed_count"] += summary.get("passed_count", 0)
|
||||
self.domain_summary[domain]["failed_count"] += summary.get("failed_count", 0)
|
||||
self.domain_summary[domain]["reports_processed"] += 1
|
||||
|
||||
# Calculate compliance rate (percentage of passing emails)
|
||||
if self.domain_summary[domain]["total_count"] > 0:
|
||||
pass_rate = (
|
||||
self.domain_summary[domain]["passed_count"] /
|
||||
self.domain_summary[domain]["total_count"] * 100
|
||||
)
|
||||
self.domain_summary[domain]["compliance_rate"] = round(pass_rate, 1)
|
||||
else:
|
||||
self.domain_summary[domain]["compliance_rate"] = 0
|
||||
|
||||
def get_domains(self) -> List[str]:
|
||||
"""
|
||||
Get list of all domains with reports
|
||||
"""
|
||||
return list(self.domain_reports.keys())
|
||||
|
||||
def get_domain_summary(self, domain: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get summary statistics for a domain
|
||||
|
||||
Args:
|
||||
domain: Domain name
|
||||
|
||||
Returns:
|
||||
Dictionary with summary stats or empty dict if domain not found
|
||||
"""
|
||||
return self.domain_summary.get(domain, {})
|
||||
|
||||
def get_all_domain_summaries(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get summary statistics for all domains
|
||||
|
||||
Returns:
|
||||
Dictionary mapping domain names to their summary stats
|
||||
"""
|
||||
return self.domain_summary
|
||||
|
||||
def get_domain_reports(self, domain: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all reports for a domain
|
||||
|
||||
Args:
|
||||
domain: Domain name
|
||||
|
||||
Returns:
|
||||
List of reports or empty list if domain not found
|
||||
"""
|
||||
return self.domain_reports.get(domain, [])
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* DMARQ Integrated Frontend Styles
|
||||
*/
|
||||
|
||||
/* Setup Wizard Styles */
|
||||
.setup-progress {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.setup-step {
|
||||
position: relative;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.setup-step.active {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.setup-step.completed {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.setup-progress:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: #e5e7eb;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Stats and Dashboard Styles */
|
||||
.stat {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.dashboard-stats .card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Additional Utility Classes */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Navigation Styles */
|
||||
.sidebar ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar ul li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar ul li a {
|
||||
display: block;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar ul li a:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.sidebar ul li a.active {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: static;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.dashboard-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* DMARQ Frontend Application
|
||||
* Vanilla JS implementation replacing the React frontend
|
||||
*/
|
||||
|
||||
// Global state management
|
||||
const appState = {
|
||||
isAuthenticated: false,
|
||||
isSetupComplete: null,
|
||||
currentPage: null,
|
||||
user: null,
|
||||
};
|
||||
|
||||
// API utility functions
|
||||
const api = {
|
||||
baseUrl: '/api/v1',
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const config = {
|
||||
...options,
|
||||
headers,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'An error occurred' }));
|
||||
throw new Error(error.detail || 'API request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Authentication endpoints
|
||||
auth: {
|
||||
async login(username, password) {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
|
||||
const response = await fetch(`${api.baseUrl}/auth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
localStorage.setItem('auth_token', data.access_token);
|
||||
return data;
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('auth_token');
|
||||
appState.isAuthenticated = false;
|
||||
appState.user = null;
|
||||
router.navigate('/login');
|
||||
}
|
||||
},
|
||||
|
||||
// System endpoints
|
||||
system: {
|
||||
async health() {
|
||||
return api.request('/health');
|
||||
}
|
||||
},
|
||||
|
||||
// Domain endpoints
|
||||
domains: {
|
||||
async getAll() {
|
||||
return api.request('/domains');
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
return api.request(`/domains/${id}`);
|
||||
}
|
||||
},
|
||||
|
||||
// Reports endpoints
|
||||
reports: {
|
||||
async getAll() {
|
||||
return api.request('/reports');
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
return api.request(`/reports/${id}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Simple router implementation
|
||||
const router = {
|
||||
routes: {
|
||||
'/': () => handleHome(),
|
||||
'/login': () => renderLogin(),
|
||||
'/dashboard': () => renderDashboard(),
|
||||
'/setup': () => renderSetup()
|
||||
},
|
||||
|
||||
init() {
|
||||
// Initial route handling
|
||||
window.addEventListener('popstate', () => this.handleRouteChange());
|
||||
|
||||
// Handle clicks on links to use client-side routing
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.matches('a[data-route]')) {
|
||||
e.preventDefault();
|
||||
this.navigate(e.target.getAttribute('href'));
|
||||
}
|
||||
});
|
||||
|
||||
// Initial route
|
||||
this.handleRouteChange();
|
||||
},
|
||||
|
||||
handleRouteChange() {
|
||||
const path = window.location.pathname;
|
||||
const route = this.routes[path];
|
||||
|
||||
if (route) {
|
||||
route();
|
||||
appState.currentPage = path;
|
||||
} else {
|
||||
this.navigate('/');
|
||||
}
|
||||
},
|
||||
|
||||
navigate(path) {
|
||||
window.history.pushState(null, null, path);
|
||||
this.handleRouteChange();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle initial app loading
|
||||
async function initApp() {
|
||||
try {
|
||||
// Check if user is authenticated
|
||||
const token = localStorage.getItem('auth_token');
|
||||
appState.isAuthenticated = !!token;
|
||||
|
||||
// Check system setup status
|
||||
const healthData = await api.system.health().catch(() => ({ is_setup_complete: false }));
|
||||
appState.isSetupComplete = healthData.is_setup_complete;
|
||||
|
||||
// Determine which page to show
|
||||
handleHome();
|
||||
} catch (error) {
|
||||
console.error('Error initializing app:', error);
|
||||
showError('Failed to initialize the application');
|
||||
} finally {
|
||||
// Hide loading indicator
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the home route based on app state
|
||||
function handleHome() {
|
||||
if (!appState.isSetupComplete) {
|
||||
router.navigate('/setup');
|
||||
} else if (!appState.isAuthenticated) {
|
||||
router.navigate('/login');
|
||||
} else {
|
||||
router.navigate('/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to show errors
|
||||
function showError(message) {
|
||||
const errorEl = document.createElement('div');
|
||||
errorEl.className = 'error-message';
|
||||
errorEl.textContent = message;
|
||||
errorEl.style.cssText = 'background-color: #fee2e2; color: #b91c1c; padding: 1rem; border-radius: 0.25rem; margin-bottom: 1rem;';
|
||||
|
||||
const app = document.getElementById('app');
|
||||
app.prepend(errorEl);
|
||||
|
||||
setTimeout(() => {
|
||||
errorEl.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Initialize the app when DOM is fully loaded
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
document.addEventListener('DOMContentLoaded', () => router.init());
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Dashboard page functionality
|
||||
*/
|
||||
|
||||
async function renderDashboard() {
|
||||
// Verify authentication
|
||||
if (!appState.isAuthenticated) {
|
||||
router.navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const appElement = document.getElementById('app');
|
||||
|
||||
// Create dashboard layout
|
||||
appElement.innerHTML = `
|
||||
<div class="navbar">
|
||||
<div class="logo">DMARQ</div>
|
||||
<div class="user-menu">
|
||||
<button id="logout-button">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<ul>
|
||||
<li><a href="/dashboard" data-route>Dashboard</a></li>
|
||||
<li><a href="/domains" data-route>Domains</a></li>
|
||||
<li><a href="/reports" data-route>Reports</a></li>
|
||||
<li><a href="/settings" data-route>Settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<div id="loading-dashboard">Loading dashboard data...</div>
|
||||
|
||||
<div id="dashboard-content" class="hidden">
|
||||
<div class="dashboard-stats">
|
||||
<div class="card">
|
||||
<h3>Total Domains</h3>
|
||||
<div id="total-domains" class="stat">-</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Total Reports</h3>
|
||||
<div id="total-reports" class="stat">-</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Compliance Rate</h3>
|
||||
<div id="compliance-rate" class="stat">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Compliance Overview</h2>
|
||||
<div class="chart-container">
|
||||
<canvas id="compliance-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Recent Reports</h2>
|
||||
<div id="recent-reports">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reports-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listener for logout
|
||||
document.getElementById('logout-button').addEventListener('click', () => {
|
||||
api.auth.logout();
|
||||
});
|
||||
|
||||
try {
|
||||
// Load dashboard data
|
||||
await loadDashboardData();
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard data:', error);
|
||||
showError('Failed to load dashboard data');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDashboardData() {
|
||||
try {
|
||||
// Fetch domains and reports data
|
||||
const [domainsResponse, reportsResponse] = await Promise.all([
|
||||
api.domains.getAll(),
|
||||
api.reports.getAll()
|
||||
]);
|
||||
|
||||
const domains = domainsResponse || [];
|
||||
const reports = reportsResponse || [];
|
||||
|
||||
// Update stats
|
||||
document.getElementById('total-domains').textContent = domains.length;
|
||||
document.getElementById('total-reports').textContent = reports.length;
|
||||
|
||||
// Calculate compliance rate
|
||||
const compliantReports = reports.filter(report => report.is_compliant);
|
||||
const complianceRate = reports.length > 0
|
||||
? Math.round((compliantReports.length / reports.length) * 100)
|
||||
: 0;
|
||||
document.getElementById('compliance-rate').textContent = `${complianceRate}%`;
|
||||
|
||||
// Render compliance chart
|
||||
renderComplianceChart(reports);
|
||||
|
||||
// Render recent reports table
|
||||
renderRecentReports(reports, domains);
|
||||
|
||||
// Hide loading, show content
|
||||
document.getElementById('loading-dashboard').classList.add('hidden');
|
||||
document.getElementById('dashboard-content').classList.remove('hidden');
|
||||
} catch (error) {
|
||||
throw new Error('Failed to load dashboard data');
|
||||
}
|
||||
}
|
||||
|
||||
function renderComplianceChart(reports) {
|
||||
if (!reports || reports.length === 0) return;
|
||||
|
||||
const canvas = document.getElementById('compliance-chart');
|
||||
if (!canvas) return;
|
||||
|
||||
// Prepare data
|
||||
const last6Months = [];
|
||||
const currentDate = new Date();
|
||||
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const date = new Date(currentDate);
|
||||
date.setMonth(currentDate.getMonth() - i);
|
||||
const monthName = date.toLocaleString('default', { month: 'short' });
|
||||
last6Months.push({
|
||||
month: monthName,
|
||||
year: date.getFullYear(),
|
||||
reports: [],
|
||||
startDate: new Date(date.getFullYear(), date.getMonth(), 1),
|
||||
endDate: new Date(date.getFullYear(), date.getMonth() + 1, 0)
|
||||
});
|
||||
}
|
||||
|
||||
// Group reports by month
|
||||
reports.forEach(report => {
|
||||
const reportDate = new Date(report.report_date);
|
||||
const monthData = last6Months.find(monthInfo =>
|
||||
reportDate >= monthInfo.startDate && reportDate <= monthInfo.endDate
|
||||
);
|
||||
|
||||
if (monthData) {
|
||||
monthData.reports.push(report);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate compliance rates by month
|
||||
const complianceData = last6Months.map(monthInfo => {
|
||||
if (monthInfo.reports.length === 0) return 0;
|
||||
const compliantCount = monthInfo.reports.filter(report => report.is_compliant).length;
|
||||
return Math.round((compliantCount / monthInfo.reports.length) * 100);
|
||||
});
|
||||
|
||||
const labels = last6Months.map(monthInfo => `${monthInfo.month} ${monthInfo.year}`);
|
||||
|
||||
// Create chart
|
||||
new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Compliance Rate (%)',
|
||||
data: complianceData,
|
||||
backgroundColor: '#3b82f6',
|
||||
borderColor: '#2563eb',
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Compliance Rate (%)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderRecentReports(reports, domains) {
|
||||
if (!reports || reports.length === 0) return;
|
||||
|
||||
const tableBody = document.getElementById('reports-table-body');
|
||||
if (!tableBody) return;
|
||||
|
||||
// Sort reports by date (newest first) and take last 10
|
||||
const sortedReports = [...reports]
|
||||
.sort((a, b) => new Date(b.report_date) - new Date(a.report_date))
|
||||
.slice(0, 10);
|
||||
|
||||
// Create a lookup map for domains
|
||||
const domainMap = new Map();
|
||||
domains.forEach(domain => {
|
||||
domainMap.set(domain.id, domain.domain_name);
|
||||
});
|
||||
|
||||
// Add rows to the table
|
||||
sortedReports.forEach(report => {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
// Format date
|
||||
const reportDate = new Date(report.report_date);
|
||||
const formattedDate = reportDate.toLocaleDateString();
|
||||
|
||||
// Get domain name
|
||||
const domainName = domainMap.get(report.domain_id) || 'Unknown';
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${domainName}</td>
|
||||
<td>${formattedDate}</td>
|
||||
<td>${report.is_compliant ?
|
||||
'<span style="color: green;">Compliant</span>' :
|
||||
'<span style="color: red;">Non-compliant</span>'
|
||||
}</td>
|
||||
`;
|
||||
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Login page functionality
|
||||
*/
|
||||
|
||||
function renderLogin() {
|
||||
const appElement = document.getElementById('app');
|
||||
|
||||
// Create login form HTML
|
||||
appElement.innerHTML = `
|
||||
<div class="auth-container">
|
||||
<div class="card">
|
||||
<h1>Login to DMARQ</h1>
|
||||
<form id="login-form">
|
||||
<div>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<div id="login-error" class="hidden" style="color: red; margin-top: 10px;"></div>
|
||||
<div>
|
||||
<button type="submit" id="login-button">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listener to the login form
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const loginButton = document.getElementById('login-button');
|
||||
const loginError = document.getElementById('login-error');
|
||||
|
||||
// Reset UI state
|
||||
loginError.classList.add('hidden');
|
||||
loginButton.disabled = true;
|
||||
loginButton.textContent = 'Logging in...';
|
||||
|
||||
try {
|
||||
await api.auth.login(username, password);
|
||||
appState.isAuthenticated = true;
|
||||
router.navigate('/dashboard');
|
||||
} catch (error) {
|
||||
loginError.textContent = 'Invalid username or password';
|
||||
loginError.classList.remove('hidden');
|
||||
} finally {
|
||||
loginButton.disabled = false;
|
||||
loginButton.textContent = 'Login';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Setup Wizard functionality
|
||||
*/
|
||||
|
||||
function renderSetup() {
|
||||
const appElement = document.getElementById('app');
|
||||
|
||||
// Create setup wizard layout
|
||||
appElement.innerHTML = `
|
||||
<div class="auth-container" style="max-width: 600px;">
|
||||
<div class="card">
|
||||
<h1>DMARQ Setup Wizard</h1>
|
||||
|
||||
<div class="setup-progress">
|
||||
<div class="setup-step active" id="step-1">1. Admin Account</div>
|
||||
<div class="setup-step" id="step-2">2. System Configuration</div>
|
||||
<div class="setup-step" id="step-3">3. Email Configuration</div>
|
||||
</div>
|
||||
|
||||
<div id="setup-content">
|
||||
<!-- Step 1: Admin Account -->
|
||||
<div id="setup-step-1" class="setup-form">
|
||||
<h2>Create Admin Account</h2>
|
||||
<form id="admin-setup-form">
|
||||
<div>
|
||||
<label for="admin-email">Email</label>
|
||||
<input type="email" id="admin-email" name="admin-email" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="admin-username">Username</label>
|
||||
<input type="text" id="admin-username" name="admin-username" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="admin-password">Password</label>
|
||||
<input type="password" id="admin-password" name="admin-password" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="admin-password-confirm">Confirm Password</label>
|
||||
<input type="password" id="admin-password-confirm" name="admin-password-confirm" required>
|
||||
</div>
|
||||
<div id="admin-error" class="hidden" style="color: red; margin-top: 10px;"></div>
|
||||
<div style="margin-top: 20px;">
|
||||
<button type="submit" id="admin-next-button">Next</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: System Configuration -->
|
||||
<div id="setup-step-2" class="setup-form hidden">
|
||||
<h2>System Configuration</h2>
|
||||
<form id="system-setup-form">
|
||||
<div>
|
||||
<label for="app-name">Application Name</label>
|
||||
<input type="text" id="app-name" name="app-name" value="DMARQ" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-url">Base URL</label>
|
||||
<input type="url" id="base-url" name="base-url" placeholder="https://your-dmarq-instance.com" required>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" id="enable-cloudflare" name="enable-cloudflare">
|
||||
Enable Cloudflare Integration
|
||||
</label>
|
||||
</div>
|
||||
<div id="cloudflare-settings" class="hidden">
|
||||
<div>
|
||||
<label for="cloudflare-token">Cloudflare API Token</label>
|
||||
<input type="password" id="cloudflare-token" name="cloudflare-token">
|
||||
</div>
|
||||
<div>
|
||||
<label for="cloudflare-zone">Cloudflare Zone ID</label>
|
||||
<input type="text" id="cloudflare-zone" name="cloudflare-zone">
|
||||
</div>
|
||||
</div>
|
||||
<div id="system-error" class="hidden" style="color: red; margin-top: 10px;"></div>
|
||||
<div style="margin-top: 20px; display: flex; justify-content: space-between;">
|
||||
<button type="button" id="system-prev-button">Previous</button>
|
||||
<button type="submit" id="system-next-button">Next</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Email Configuration -->
|
||||
<div id="setup-step-3" class="setup-form hidden">
|
||||
<h2>Email Configuration</h2>
|
||||
<form id="email-setup-form">
|
||||
<div>
|
||||
<label for="imap-server">IMAP Server</label>
|
||||
<input type="text" id="imap-server" name="imap-server" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="imap-port">IMAP Port</label>
|
||||
<input type="number" id="imap-port" name="imap-port" value="993" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="imap-username">IMAP Username</label>
|
||||
<input type="text" id="imap-username" name="imap-username" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="imap-password">IMAP Password</label>
|
||||
<input type="password" id="imap-password" name="imap-password" required>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" id="test-imap-button">Test Connection</button>
|
||||
<span id="test-imap-result"></span>
|
||||
</div>
|
||||
<div id="email-error" class="hidden" style="color: red; margin-top: 10px;"></div>
|
||||
<div style="margin-top: 20px; display: flex; justify-content: space-between;">
|
||||
<button type="button" id="email-prev-button">Previous</button>
|
||||
<button type="submit" id="email-finish-button">Finish Setup</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listeners and setup functionality for the wizard
|
||||
setupWizardEventListeners();
|
||||
}
|
||||
|
||||
function setupWizardEventListeners() {
|
||||
// Step 1: Admin Account setup
|
||||
const adminForm = document.getElementById('admin-setup-form');
|
||||
if (adminForm) {
|
||||
adminForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('admin-email').value;
|
||||
const username = document.getElementById('admin-username').value;
|
||||
const password = document.getElementById('admin-password').value;
|
||||
const confirmPassword = document.getElementById('admin-password-confirm').value;
|
||||
const errorElement = document.getElementById('admin-error');
|
||||
|
||||
// Simple validation
|
||||
if (password !== confirmPassword) {
|
||||
errorElement.textContent = 'Passwords do not match';
|
||||
errorElement.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store values (in a real app, you'd save these to the server)
|
||||
localStorage.setItem('setup_admin_email', email);
|
||||
localStorage.setItem('setup_admin_username', username);
|
||||
|
||||
// Move to step 2
|
||||
goToStep(2);
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: System Configuration
|
||||
const systemForm = document.getElementById('system-setup-form');
|
||||
if (systemForm) {
|
||||
// Toggle Cloudflare settings visibility
|
||||
const enableCloudflare = document.getElementById('enable-cloudflare');
|
||||
const cloudflareSettings = document.getElementById('cloudflare-settings');
|
||||
|
||||
enableCloudflare.addEventListener('change', () => {
|
||||
if (enableCloudflare.checked) {
|
||||
cloudflareSettings.classList.remove('hidden');
|
||||
} else {
|
||||
cloudflareSettings.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Previous button
|
||||
document.getElementById('system-prev-button').addEventListener('click', () => {
|
||||
goToStep(1);
|
||||
});
|
||||
|
||||
// Next button
|
||||
systemForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Store values
|
||||
const appName = document.getElementById('app-name').value;
|
||||
const baseUrl = document.getElementById('base-url').value;
|
||||
|
||||
localStorage.setItem('setup_app_name', appName);
|
||||
localStorage.setItem('setup_base_url', baseUrl);
|
||||
|
||||
if (enableCloudflare.checked) {
|
||||
const cloudflareToken = document.getElementById('cloudflare-token').value;
|
||||
const cloudflareZone = document.getElementById('cloudflare-zone').value;
|
||||
|
||||
localStorage.setItem('setup_cloudflare_enabled', 'true');
|
||||
localStorage.setItem('setup_cloudflare_token', cloudflareToken);
|
||||
localStorage.setItem('setup_cloudflare_zone', cloudflareZone);
|
||||
}
|
||||
|
||||
// Move to step 3
|
||||
goToStep(3);
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Email Configuration
|
||||
const emailForm = document.getElementById('email-setup-form');
|
||||
if (emailForm) {
|
||||
// Previous button
|
||||
document.getElementById('email-prev-button').addEventListener('click', () => {
|
||||
goToStep(2);
|
||||
});
|
||||
|
||||
// Test IMAP connection
|
||||
document.getElementById('test-imap-button').addEventListener('click', async () => {
|
||||
const testButton = document.getElementById('test-imap-button');
|
||||
const resultSpan = document.getElementById('test-imap-result');
|
||||
|
||||
testButton.disabled = true;
|
||||
testButton.textContent = 'Testing...';
|
||||
resultSpan.textContent = '';
|
||||
|
||||
try {
|
||||
// In a real app, you'd make an API call to test the connection
|
||||
// Here we'll just simulate it
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
resultSpan.textContent = '✓ Connection successful';
|
||||
resultSpan.style.color = 'green';
|
||||
} catch (error) {
|
||||
resultSpan.textContent = '✗ Connection failed';
|
||||
resultSpan.style.color = 'red';
|
||||
} finally {
|
||||
testButton.disabled = false;
|
||||
testButton.textContent = 'Test Connection';
|
||||
}
|
||||
});
|
||||
|
||||
// Finish setup
|
||||
emailForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const imapServer = document.getElementById('imap-server').value;
|
||||
const imapPort = document.getElementById('imap-port').value;
|
||||
const imapUsername = document.getElementById('imap-username').value;
|
||||
const imapPassword = document.getElementById('imap-password').value;
|
||||
|
||||
const finishButton = document.getElementById('email-finish-button');
|
||||
const errorElement = document.getElementById('email-error');
|
||||
|
||||
finishButton.disabled = true;
|
||||
finishButton.textContent = 'Completing Setup...';
|
||||
errorElement.classList.add('hidden');
|
||||
|
||||
try {
|
||||
// In a real app, you'd send all the setup data to the server
|
||||
// For this example, we'll simulate the API call
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Update app state
|
||||
appState.isSetupComplete = true;
|
||||
|
||||
// Redirect to login
|
||||
router.navigate('/login');
|
||||
} catch (error) {
|
||||
errorElement.textContent = 'Setup failed: ' + (error.message || 'Unknown error');
|
||||
errorElement.classList.remove('hidden');
|
||||
finishButton.disabled = false;
|
||||
finishButton.textContent = 'Finish Setup';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function goToStep(stepNumber) {
|
||||
// Hide all steps
|
||||
document.querySelectorAll('.setup-form').forEach(form => {
|
||||
form.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Show selected step
|
||||
document.getElementById(`setup-step-${stepNumber}`).classList.remove('hidden');
|
||||
|
||||
// Update step indicators
|
||||
document.querySelectorAll('.setup-step').forEach((step, index) => {
|
||||
if (index + 1 === stepNumber) {
|
||||
step.classList.add('active');
|
||||
} else if (index + 1 < stepNumber) {
|
||||
step.classList.add('completed');
|
||||
step.classList.remove('active');
|
||||
} else {
|
||||
step.classList.remove('active', 'completed');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,866 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DMARQ - DMARC Monitoring</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Font Awesome for icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css">
|
||||
|
||||
<!-- Inter font -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", "sans-serif"],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 232 47% 49%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 187 100% 42%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 232 47% 49%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 232 47% 49%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 187 100% 42%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 232 47% 49%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-background font-sans antialiased">
|
||||
<!-- Alpine.js for reactivity -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<div x-data="dashboardApp()">
|
||||
<!-- Sidebar -->
|
||||
<div class="fixed inset-y-0 left-0 z-20 w-64 bg-card border-r border-border shadow-sm transform transition-transform duration-300 lg:translate-x-0"
|
||||
:class="{'translate-x-0': sidebarOpen, '-translate-x-full': !sidebarOpen}">
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-border">
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg class="w-8 h-8 text-primary" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14h-2V9h2v8zm4 0h-2v-4h2v4zm0-6h-2V9h2v2zm-8 2H6v4h2v-4zm0-4H6v2h2V9z"/>
|
||||
</svg>
|
||||
<div class="text-xl font-bold text-foreground">DMARQ</div>
|
||||
</div>
|
||||
<button @click="sidebarOpen = false" type="button" class="p-1 text-muted-foreground rounded-md lg:hidden hover:bg-accent hover:text-accent-foreground">
|
||||
<span class="sr-only">Close sidebar</span>
|
||||
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 p-2 overflow-y-auto">
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<a href="#" @click.prevent="activeTab = 'dashboard'"
|
||||
class="flex items-center w-full px-3 py-2 transition-colors rounded-md text-sm font-medium"
|
||||
:class="activeTab === 'dashboard' ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent hover:text-accent-foreground'">
|
||||
<i class="fas fa-tachometer-alt w-4 h-4 mr-2"></i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" @click.prevent="activeTab = 'upload'"
|
||||
class="flex items-center w-full px-3 py-2 transition-colors rounded-md text-sm font-medium"
|
||||
:class="activeTab === 'upload' ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent hover:text-accent-foreground'">
|
||||
<i class="fas fa-upload w-4 h-4 mr-2"></i>
|
||||
<span>Upload Reports</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" @click.prevent="activeTab = 'domains'"
|
||||
class="flex items-center w-full px-3 py-2 transition-colors rounded-md text-sm font-medium"
|
||||
:class="activeTab === 'domains' ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent hover:text-accent-foreground'">
|
||||
<i class="fas fa-globe w-4 h-4 mr-2"></i>
|
||||
<span>Domains</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" @click.prevent="activeTab = 'reports'"
|
||||
class="flex items-center w-full px-3 py-2 transition-colors rounded-md text-sm font-medium"
|
||||
:class="activeTab === 'reports' ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent hover:text-accent-foreground'">
|
||||
<i class="fas fa-chart-bar w-4 h-4 mr-2"></i>
|
||||
<span>Reports</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" @click.prevent="activeTab = 'settings'"
|
||||
class="flex items-center w-full px-3 py-2 transition-colors rounded-md text-sm font-medium"
|
||||
:class="activeTab === 'settings' ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent hover:text-accent-foreground'">
|
||||
<i class="fas fa-cog w-4 h-4 mr-2"></i>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="p-4 text-xs text-center text-muted-foreground">
|
||||
<div>DMARQ v0.1.0</div>
|
||||
<div>DMARC Monitoring Platform</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="lg:pl-64">
|
||||
<!-- Top Navigation -->
|
||||
<header class="sticky top-0 z-10 w-full bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b border-border">
|
||||
<div class="flex items-center justify-between h-14 px-4">
|
||||
<!-- Mobile menu button -->
|
||||
<div class="flex items-center lg:hidden">
|
||||
<button type="button" @click="sidebarOpen = true" class="p-2 text-muted-foreground rounded-md hover:bg-accent hover:text-accent-foreground">
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Page title -->
|
||||
<div class="flex-1 px-2 mx-2">
|
||||
<h1 class="text-lg font-semibold" x-text="getActiveTabTitle()"></h1>
|
||||
</div>
|
||||
|
||||
<!-- Theme toggle and user profile -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="toggleTheme()" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring h-9 w-9 bg-transparent hover:bg-accent hover:text-accent-foreground">
|
||||
<svg x-show="!isDarkMode" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-moon">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
|
||||
</svg>
|
||||
<svg x-show="isDarkMode" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sun">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="m4.93 4.93 1.41 1.41"></path>
|
||||
<path d="m17.66 17.66 1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="m6.34 17.66-1.41 1.41"></path>
|
||||
<path d="m19.07 4.93-1.41 1.41"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button type="button" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-9 w-9">
|
||||
<i class="fas fa-user-circle text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main class="p-6">
|
||||
<!-- Dashboard Tab -->
|
||||
<div x-show="activeTab === 'dashboard'" x-transition>
|
||||
<!-- No Data Message -->
|
||||
<div x-show="!hasDomainData" class="flex flex-col items-center justify-center p-8 rounded-lg border border-border bg-card text-card-foreground shadow">
|
||||
<div class="flex flex-col items-center max-w-md text-center">
|
||||
<div class="rounded-full p-4 bg-muted mb-4">
|
||||
<i class="fas fa-chart-line text-4xl text-muted-foreground"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">No DMARC reports have been uploaded yet</h3>
|
||||
<p class="text-muted-foreground mb-4">Upload a report to see statistics and gain insights into your domain's email authentication.</p>
|
||||
<button @click="activeTab = 'upload'" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2">
|
||||
<i class="fas fa-upload mr-2"></i> Upload Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content (when data is available) -->
|
||||
<div x-show="hasDomainData">
|
||||
<!-- Stats Overview -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Total Domains -->
|
||||
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
|
||||
<div class="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<h3 class="tracking-tight text-sm font-medium">Total Domains</h3>
|
||||
<div class="p-2 bg-primary/10 rounded-full">
|
||||
<i class="fas fa-globe text-primary"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 pt-0">
|
||||
<div id="total-domains" class="text-2xl font-bold">0</div>
|
||||
<p class="text-xs text-muted-foreground">Active domains being monitored</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Emails -->
|
||||
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
|
||||
<div class="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<h3 class="tracking-tight text-sm font-medium">Emails Analyzed</h3>
|
||||
<div class="p-2 bg-blue-500/10 rounded-full">
|
||||
<i class="fas fa-envelope text-blue-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 pt-0">
|
||||
<div id="total-emails" class="text-2xl font-bold">0</div>
|
||||
<p class="text-xs text-muted-foreground">Total emails processed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pass Rate -->
|
||||
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
|
||||
<div class="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<h3 class="tracking-tight text-sm font-medium">Pass Rate</h3>
|
||||
<div class="p-2 bg-green-500/10 rounded-full">
|
||||
<i class="fas fa-check-circle text-green-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 pt-0">
|
||||
<div id="overall-pass-rate" class="text-2xl font-bold text-green-500">0%</div>
|
||||
<p class="text-xs text-muted-foreground">Overall DMARC compliance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reports Processed -->
|
||||
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
|
||||
<div class="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<h3 class="tracking-tight text-sm font-medium">Reports Processed</h3>
|
||||
<div class="p-2 bg-purple-500/10 rounded-full">
|
||||
<i class="fas fa-file-alt text-purple-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 pt-0">
|
||||
<div id="reports-processed" class="text-2xl font-bold">0</div>
|
||||
<p class="text-xs text-muted-foreground">DMARC reports received</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domain Compliance Table -->
|
||||
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold leading-none tracking-tight">Domain Compliance</h3>
|
||||
<button @click="fetchDomainSummary" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 px-3">
|
||||
<i class="fas fa-sync-alt mr-2"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-0">
|
||||
<div class="w-full overflow-auto">
|
||||
<table class="w-full caption-bottom text-sm">
|
||||
<thead class="[&_tr]:border-b">
|
||||
<tr class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
||||
<th class="h-10 px-4 text-left align-middle font-medium text-muted-foreground">Domain</th>
|
||||
<th class="h-10 px-4 text-left align-middle font-medium text-muted-foreground">Emails</th>
|
||||
<th class="h-10 px-4 text-left align-middle font-medium text-muted-foreground">Pass Rate</th>
|
||||
<th class="h-10 px-4 text-left align-middle font-medium text-muted-foreground">Failed</th>
|
||||
<th class="h-10 px-4 text-left align-middle font-medium text-muted-foreground">Reports</th>
|
||||
<th class="h-10 px-4 text-right align-middle font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="domains-table-body" class="[&_tr:last-child]:border-0">
|
||||
<!-- Domains will be added here dynamically -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Tab -->
|
||||
<div x-show="activeTab === 'upload'" x-transition>
|
||||
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
<h3 class="text-lg font-semibold leading-none tracking-tight">Upload DMARC Reports</h3>
|
||||
<p class="text-sm text-muted-foreground">Upload your DMARC aggregate report files (XML, ZIP, or GZIP)</p>
|
||||
</div>
|
||||
<div class="p-6 pt-0">
|
||||
<form id="upload-form" enctype="multipart/form-data">
|
||||
<div class="flex flex-col items-center justify-center border-2 border-dashed border-border rounded-md p-6 text-center hover:bg-muted/50 transition-colors cursor-pointer relative">
|
||||
<div class="mb-3 text-muted-foreground">
|
||||
<i class="fas fa-cloud-upload-alt text-4xl"></i>
|
||||
</div>
|
||||
<p class="mb-2 text-sm">
|
||||
<span class="font-semibold">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
XML, ZIP, or GZIP files only
|
||||
</p>
|
||||
<input id="report-file" type="file" name="file" accept=".xml,.zip,.gz,.gzip" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
</div>
|
||||
|
||||
<div id="file-selected" class="hidden mt-4 p-3 bg-muted rounded-md">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i class="fas fa-file-code text-primary"></i>
|
||||
<span id="file-name" class="text-sm font-medium"></span>
|
||||
</div>
|
||||
<button type="button" id="remove-file" class="text-muted-foreground hover:text-foreground">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button type="submit" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2">
|
||||
<i class="fas fa-upload mr-2"></i> Upload Report
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="upload-result" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domains Tab -->
|
||||
<div x-show="activeTab === 'domains'" x-transition>
|
||||
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold leading-none tracking-tight">Domain Management</h3>
|
||||
<button @click="fetchDomainSummary" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 px-3">
|
||||
<i class="fas fa-sync-alt mr-2"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">View and manage domains with DMARC reports</p>
|
||||
</div>
|
||||
<div class="p-6 pt-0">
|
||||
<p id="domains-message" class="text-center text-muted-foreground py-6">Loading domains...</p>
|
||||
<div id="domains-list" class="hidden">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Domain cards will be added here dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domain Details Container -->
|
||||
<div id="domain-details-container" x-show="activeTab === 'domain-details'" x-transition class="hidden">
|
||||
<div class="mb-4">
|
||||
<button id="back-to-summary" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 px-3">
|
||||
<i class="fas fa-arrow-left mr-2"></i> Back to Summary
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border bg-card text-card-foreground shadow mb-6">
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
<h3 id="domain-details-title" class="text-lg font-semibold leading-none tracking-tight">Domain Details</h3>
|
||||
</div>
|
||||
<div class="p-6 pt-0">
|
||||
<div id="domain-details-content">
|
||||
<!-- Domain details will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reports Tab -->
|
||||
<div x-show="activeTab === 'reports'" x-transition>
|
||||
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
<h3 class="text-lg font-semibold leading-none tracking-tight">Reports Summary</h3>
|
||||
<p class="text-sm text-muted-foreground">Advanced report visualization and analysis</p>
|
||||
</div>
|
||||
<div class="p-6 pt-0">
|
||||
<div class="flex flex-col items-center justify-center p-8">
|
||||
<div class="rounded-full p-3 bg-muted">
|
||||
<i class="fas fa-chart-line text-2xl text-muted-foreground"></i>
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-medium">Coming Soon</h3>
|
||||
<p class="text-sm text-center text-muted-foreground mt-2 max-w-md">
|
||||
Advanced report visualization and analysis features will be available in future milestones.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div x-show="activeTab === 'settings'" x-transition>
|
||||
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
<h3 class="text-lg font-semibold leading-none tracking-tight">Settings</h3>
|
||||
<p class="text-sm text-muted-foreground">User preferences and system configuration</p>
|
||||
</div>
|
||||
<div class="p-6 pt-0">
|
||||
<div class="flex flex-col items-center justify-center p-8">
|
||||
<div class="rounded-full p-3 bg-muted">
|
||||
<i class="fas fa-cog text-2xl text-muted-foreground"></i>
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-medium">Coming Soon</h3>
|
||||
<p class="text-sm text-center text-muted-foreground mt-2 max-w-md">
|
||||
User preferences and system configuration will be available in future milestones.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function dashboardApp() {
|
||||
return {
|
||||
activeTab: 'dashboard',
|
||||
sidebarOpen: false,
|
||||
isDarkMode: true,
|
||||
hasDomainData: false, // Will be set true when data is loaded
|
||||
|
||||
init() {
|
||||
// Check if we have any domains with data
|
||||
this.fetchDomainSummary();
|
||||
|
||||
// Check system preferences for dark mode
|
||||
const savedTheme = localStorage.getItem('dmarq-theme');
|
||||
if (savedTheme) {
|
||||
this.isDarkMode = savedTheme === 'dark';
|
||||
this.applyTheme();
|
||||
}
|
||||
|
||||
// Watch for tab changes to refresh data
|
||||
this.$watch('activeTab', (tab) => {
|
||||
if (tab === 'dashboard' || tab === 'domains') {
|
||||
this.fetchDomainSummary();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for custom event to refresh data
|
||||
document.addEventListener('dmarq:refresh-data', () => {
|
||||
this.fetchDomainSummary();
|
||||
});
|
||||
},
|
||||
|
||||
getActiveTabTitle() {
|
||||
switch (this.activeTab) {
|
||||
case 'dashboard': return 'Dashboard';
|
||||
case 'upload': return 'Upload Reports';
|
||||
case 'domains': return 'Domain Management';
|
||||
case 'reports': return 'Reports Analysis';
|
||||
case 'settings': return 'Settings';
|
||||
case 'domain-details': return 'Domain Details';
|
||||
default: return 'DMARQ';
|
||||
}
|
||||
},
|
||||
|
||||
toggleTheme() {
|
||||
this.isDarkMode = !this.isDarkMode;
|
||||
localStorage.setItem('dmarq-theme', this.isDarkMode ? 'dark' : 'light');
|
||||
this.applyTheme();
|
||||
},
|
||||
|
||||
applyTheme() {
|
||||
if (this.isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
},
|
||||
|
||||
async fetchDomainSummary() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/domains/summary');
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.domains && data.domains.length > 0) {
|
||||
this.hasDomainData = true;
|
||||
this.updateDashboardStats(data);
|
||||
this.populateDomainsTable(data.domains);
|
||||
this.populateDomainsList(data.domains);
|
||||
} else {
|
||||
this.hasDomainData = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching domain summary:', error);
|
||||
this.hasDomainData = false;
|
||||
}
|
||||
},
|
||||
|
||||
updateDashboardStats(data) {
|
||||
if (!data) return;
|
||||
|
||||
const totalDomains = document.getElementById('total-domains');
|
||||
const totalEmails = document.getElementById('total-emails');
|
||||
const passRate = document.getElementById('overall-pass-rate');
|
||||
const reportsProcessed = document.getElementById('reports-processed');
|
||||
|
||||
if (totalDomains) totalDomains.textContent = data.total_domains || 0;
|
||||
if (totalEmails) totalEmails.textContent = data.total_emails || 0;
|
||||
if (passRate) passRate.textContent = `${data.overall_pass_rate || 0}%`;
|
||||
if (reportsProcessed) reportsProcessed.textContent = data.reports_processed || 0;
|
||||
},
|
||||
|
||||
populateDomainsTable(domains) {
|
||||
if (!domains || !domains.length) return;
|
||||
|
||||
const tableBody = document.getElementById('domains-table-body');
|
||||
if (!tableBody) return;
|
||||
|
||||
tableBody.innerHTML = '';
|
||||
|
||||
domains.forEach(domain => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'border-b transition-colors hover:bg-muted/50';
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="p-4 align-middle">
|
||||
<div class="font-medium">${domain.domain_name}</div>
|
||||
</td>
|
||||
<td class="p-4 align-middle">${domain.total_emails || 0}</td>
|
||||
<td class="p-4 align-middle">
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 dark:bg-green-900/20 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">
|
||||
${domain.pass_rate || 0}%
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-4 align-middle">${domain.failed_count || 0}</td>
|
||||
<td class="p-4 align-middle">${domain.report_count || 0}</td>
|
||||
<td class="p-4 align-middle text-right">
|
||||
<button data-domain-id="${domain.id}" class="view-domain-details inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 px-3">
|
||||
<i class="fas fa-chart-bar mr-1"></i> Details
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
|
||||
// Add event listeners to the view details buttons
|
||||
document.querySelectorAll('.view-domain-details').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const domainId = button.getAttribute('data-domain-id');
|
||||
this.viewDomainDetails(domainId);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
populateDomainsList(domains) {
|
||||
if (!domains || !domains.length) return;
|
||||
|
||||
const domainsMessage = document.getElementById('domains-message');
|
||||
const domainsList = document.getElementById('domains-list');
|
||||
const domainsGrid = domainsList.querySelector('.grid');
|
||||
|
||||
if (domainsMessage && domainsList && domainsGrid) {
|
||||
// Hide loading message, show domain list
|
||||
domainsMessage.classList.add('hidden');
|
||||
domainsList.classList.remove('hidden');
|
||||
|
||||
domainsGrid.innerHTML = '';
|
||||
|
||||
domains.forEach(domain => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'rounded-lg border border-border bg-card text-card-foreground shadow';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="p-6">
|
||||
<h4 class="text-lg font-semibold mb-2">${domain.domain_name}</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-muted-foreground">Emails:</span>
|
||||
<span>${domain.total_emails || 0}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-muted-foreground">Pass Rate:</span>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 dark:bg-green-900/20 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">
|
||||
${domain.pass_rate || 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-muted-foreground">Failed:</span>
|
||||
<span>${domain.failed_count || 0}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-muted-foreground">Reports:</span>
|
||||
<span>${domain.report_count || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-border flex justify-end">
|
||||
<button data-domain-id="${domain.id}" class="view-domain-details inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 px-3">
|
||||
<i class="fas fa-chart-bar mr-1"></i> Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
domainsGrid.appendChild(card);
|
||||
});
|
||||
|
||||
// Add event listeners to the view details buttons
|
||||
domainsList.querySelectorAll('.view-domain-details').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const domainId = button.getAttribute('data-domain-id');
|
||||
this.viewDomainDetails(domainId);
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async viewDomainDetails(domainId) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/domains/${domainId}`);
|
||||
const domain = await response.json();
|
||||
|
||||
// Update the domain details section
|
||||
const detailsTitle = document.getElementById('domain-details-title');
|
||||
const detailsContent = document.getElementById('domain-details-content');
|
||||
|
||||
if (detailsTitle) {
|
||||
detailsTitle.textContent = `Domain: ${domain.domain_name}`;
|
||||
}
|
||||
|
||||
if (detailsContent) {
|
||||
// Populate the details content here
|
||||
detailsContent.innerHTML = `
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-2">Domain Information</h4>
|
||||
<dl class="grid grid-cols-[100px_1fr] gap-1">
|
||||
<dt class="text-xs text-muted-foreground">Name:</dt>
|
||||
<dd class="text-sm">${domain.domain_name}</dd>
|
||||
<dt class="text-xs text-muted-foreground">Reports:</dt>
|
||||
<dd class="text-sm">${domain.report_count || 0}</dd>
|
||||
<dt class="text-xs text-muted-foreground">Emails:</dt>
|
||||
<dd class="text-sm">${domain.total_emails || 0}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-2">Compliance Summary</h4>
|
||||
<dl class="grid grid-cols-[100px_1fr] gap-1">
|
||||
<dt class="text-xs text-muted-foreground">Pass Rate:</dt>
|
||||
<dd class="text-sm">${domain.pass_rate || 0}%</dd>
|
||||
<dt class="text-xs text-muted-foreground">Failed:</dt>
|
||||
<dd class="text-sm">${domain.failed_count || 0}</dd>
|
||||
<dt class="text-xs text-muted-foreground">DKIM:</dt>
|
||||
<dd class="text-sm">${domain.dkim_pass_count || 0} passed</dd>
|
||||
<dt class="text-xs text-muted-foreground">SPF:</dt>
|
||||
<dd class="text-sm">${domain.spf_pass_count || 0} passed</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-medium mb-2">Recent Reports</h4>
|
||||
<div class="border rounded-md overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted">
|
||||
<tr>
|
||||
<th class="p-2 text-left font-medium text-muted-foreground">Report ID</th>
|
||||
<th class="p-2 text-left font-medium text-muted-foreground">Date</th>
|
||||
<th class="p-2 text-left font-medium text-muted-foreground">Source</th>
|
||||
<th class="p-2 text-left font-medium text-muted-foreground">Emails</th>
|
||||
<th class="p-2 text-left font-medium text-muted-foreground">Pass Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-t">
|
||||
<td colspan="5" class="p-4 text-center text-muted-foreground">
|
||||
Report details will be available in future milestones.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Show the domain details container and hide others
|
||||
this.activeTab = 'domain-details';
|
||||
document.getElementById('domain-details-container').classList.remove('hidden');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching domain details:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file upload preview
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const fileInput = document.getElementById('report-file');
|
||||
const fileSelected = document.getElementById('file-selected');
|
||||
const fileName = document.getElementById('file-name');
|
||||
const removeFile = document.getElementById('remove-file');
|
||||
const uploadForm = document.getElementById('upload-form');
|
||||
const uploadResult = document.getElementById('upload-result');
|
||||
const backToSummary = document.getElementById('back-to-summary');
|
||||
|
||||
if (fileInput && fileSelected && fileName && removeFile) {
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
fileName.textContent = e.target.files[0].name;
|
||||
fileSelected.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
removeFile.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
fileSelected.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
if (uploadForm) {
|
||||
uploadForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!fileInput.files.length) {
|
||||
uploadResult.innerHTML = `
|
||||
<div class="p-3 bg-destructive/20 text-destructive rounded-md">
|
||||
Please select a file to upload
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
uploadResult.innerHTML = `
|
||||
<div class="p-3 bg-muted rounded-md flex items-center">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
<span>Uploading and processing report...</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/reports/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
uploadResult.innerHTML = `
|
||||
<div class="p-3 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded-md">
|
||||
<p><i class="fas fa-check-circle mr-2"></i> Report uploaded successfully</p>
|
||||
<p class="text-xs mt-1">Processed ${data.processed_records || 0} records for domain ${data.domain || ''}</p>
|
||||
</div>
|
||||
`;
|
||||
fileInput.value = '';
|
||||
fileSelected.classList.add('hidden');
|
||||
|
||||
// Refresh the dashboard data after successful upload
|
||||
// Use a custom event to communicate with the Alpine component
|
||||
document.dispatchEvent(new CustomEvent('dmarq:refresh-data'));
|
||||
} else {
|
||||
throw new Error(data.detail || 'Upload failed');
|
||||
}
|
||||
} catch (error) {
|
||||
uploadResult.innerHTML = `
|
||||
<div class="p-3 bg-destructive/20 text-destructive rounded-md">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
${error.message || 'An error occurred during upload'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Back button in domain details view
|
||||
if (backToSummary) {
|
||||
backToSummary.addEventListener('click', () => {
|
||||
document.getElementById('domain-details-container').classList.add('hidden');
|
||||
const app = document.querySelector('[x-data="dashboardApp()"]').__x.$data;
|
||||
app.activeTab = 'domains';
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,104 @@
|
||||
import asyncio
|
||||
import os
|
||||
from typing import AsyncGenerator, Generator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base, get_db
|
||||
from app.core.security import get_password_hash
|
||||
from app.models.user import User
|
||||
|
||||
# Use in-memory SQLite database for tests
|
||||
TEST_DATABASE_URL = "sqlite:///./test.db"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create an instance of the default event loop for each test case."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_app() -> FastAPI:
|
||||
# Avoid circular import
|
||||
from app.main import create_app
|
||||
|
||||
app = create_app()
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db_session():
|
||||
# Create the SQLite database engine
|
||||
engine = create_engine(TEST_DATABASE_URL)
|
||||
|
||||
# Create all tables
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
# Create a new session
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = TestingSessionLocal()
|
||||
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
# Drop all tables after the test
|
||||
Base.metadata.drop_all(engine)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client(test_app: FastAPI, db_session):
|
||||
# Override the get_db dependency to use the test database
|
||||
def override_get_db():
|
||||
try:
|
||||
yield db_session
|
||||
finally:
|
||||
pass
|
||||
|
||||
test_app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
# Use the FastAPI TestClient
|
||||
with TestClient(test_app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def async_client(test_app: FastAPI, db_session):
|
||||
# Override the get_db dependency to use the test database
|
||||
def override_get_db():
|
||||
try:
|
||||
yield db_session
|
||||
finally:
|
||||
pass
|
||||
|
||||
test_app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
async with AsyncClient(app=test_app, base_url="http://testserver") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def test_user(db_session):
|
||||
"""Create a test user in the database."""
|
||||
user = User(
|
||||
email="test@example.com",
|
||||
hashed_password=get_password_hash("password"),
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
is_verified=True
|
||||
)
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
db_session.refresh(user)
|
||||
return user
|
||||
@@ -0,0 +1,61 @@
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.domain import Domain
|
||||
|
||||
|
||||
def test_read_health(client: TestClient):
|
||||
"""Test health check endpoint"""
|
||||
response = client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "version" in data
|
||||
|
||||
|
||||
def test_read_domains_empty(client: TestClient):
|
||||
"""Test reading domains when none exist"""
|
||||
response = client.get("/api/v1/domains")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data == []
|
||||
|
||||
|
||||
def test_read_domains(client: TestClient, db_session: Session):
|
||||
"""Test reading domains"""
|
||||
# Create some test domains
|
||||
domain1 = Domain(name="example.com", description="Example Domain", active=True)
|
||||
domain2 = Domain(name="test.com", description="Test Domain", active=True)
|
||||
db_session.add_all([domain1, domain2])
|
||||
db_session.commit()
|
||||
|
||||
response = client.get("/api/v1/domains")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert len(data) == 2
|
||||
assert {"name": "example.com", "description": "Example Domain"}.items() <= data[0].items()
|
||||
assert {"name": "test.com", "description": "Test Domain"}.items() <= data[1].items()
|
||||
|
||||
|
||||
def test_create_domain(client: TestClient):
|
||||
"""Test creating a new domain"""
|
||||
response = client.post(
|
||||
"/api/v1/domains",
|
||||
json={"name": "newdomain.com", "description": "New Domain", "active": True}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "newdomain.com"
|
||||
assert data["description"] == "New Domain"
|
||||
assert data["active"] is True
|
||||
assert "id" in data
|
||||
|
||||
# Check that the domain was actually created
|
||||
response = client.get("/api/v1/domains")
|
||||
assert response.status_code == 200
|
||||
domains = response.json()
|
||||
assert any(d["name"] == "newdomain.com" for d in domains)
|
||||
@@ -0,0 +1,124 @@
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from app.services.dmarc_parser import (
|
||||
DMARCParser,
|
||||
parse_aggregate_report_xml,
|
||||
parse_aggregate_report_zip,
|
||||
)
|
||||
|
||||
|
||||
class TestDMARCParser:
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures"""
|
||||
self.parser = DMARCParser()
|
||||
|
||||
# Sample XML string for testing
|
||||
self.sample_xml = """<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>google.com</org_name>
|
||||
<email>noreply-dmarc-support@google.com</email>
|
||||
<report_id>123456789</report_id>
|
||||
<date_range>
|
||||
<begin>1597449600</begin>
|
||||
<end>1597535999</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<sp>none</sp>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>203.0.113.1</source_ip>
|
||||
<count>2</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>example.com</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>example.com</domain>
|
||||
<result>pass</result>
|
||||
<selector>default</selector>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>example.com</domain>
|
||||
<result>fail</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
"""
|
||||
|
||||
def test_parse_aggregate_report_xml(self):
|
||||
"""Test parsing an XML aggregate report"""
|
||||
result = parse_aggregate_report_xml(self.sample_xml)
|
||||
|
||||
# Verify report metadata
|
||||
assert result['report_metadata']['org_name'] == 'google.com'
|
||||
assert result['report_metadata']['email'] == 'noreply-dmarc-support@google.com'
|
||||
assert result['report_metadata']['report_id'] == '123456789'
|
||||
assert result['report_metadata']['begin_date'] == 1597449600
|
||||
assert result['report_metadata']['end_date'] == 1597535999
|
||||
|
||||
# Verify policy published
|
||||
assert result['policy_published']['domain'] == 'example.com'
|
||||
assert result['policy_published']['policy'] == 'none'
|
||||
|
||||
# Verify record data
|
||||
assert len(result['records']) == 1
|
||||
record = result['records'][0]
|
||||
assert record['source_ip'] == '203.0.113.1'
|
||||
assert record['count'] == 2
|
||||
assert record['policy_evaluated']['disposition'] == 'none'
|
||||
assert record['policy_evaluated']['dkim'] == 'pass'
|
||||
assert record['policy_evaluated']['spf'] == 'fail'
|
||||
assert record['identifiers']['header_from'] == 'example.com'
|
||||
|
||||
@patch('app.services.dmarc_parser.zipfile.ZipFile')
|
||||
def test_parse_aggregate_report_zip(self, mock_zipfile):
|
||||
"""Test parsing a zipped aggregate report"""
|
||||
# Setup mock zipfile extraction
|
||||
mock_zip_instance = MagicMock()
|
||||
mock_zipfile.return_value.__enter__.return_value = mock_zip_instance
|
||||
mock_zip_instance.namelist.return_value = ['report.xml']
|
||||
mock_zip_instance.read.return_value = self.sample_xml.encode('utf-8')
|
||||
|
||||
result = parse_aggregate_report_zip('/fake/path/report.zip')
|
||||
|
||||
# Assertions similar to test_parse_aggregate_report_xml
|
||||
assert result['report_metadata']['org_name'] == 'google.com'
|
||||
assert len(result['records']) == 1
|
||||
|
||||
def test_extract_authentication_results(self):
|
||||
"""Test extracting authentication results from report"""
|
||||
# Parse the sample XML
|
||||
root = ET.fromstring(self.sample_xml)
|
||||
record_elem = root.find('./record')
|
||||
|
||||
auth_results = self.parser._extract_authentication_results(record_elem)
|
||||
|
||||
# Verify DKIM results
|
||||
assert len(auth_results['dkim']) == 1
|
||||
assert auth_results['dkim'][0]['domain'] == 'example.com'
|
||||
assert auth_results['dkim'][0]['result'] == 'pass'
|
||||
assert auth_results['dkim'][0]['selector'] == 'default'
|
||||
|
||||
# Verify SPF results
|
||||
assert len(auth_results['spf']) == 1
|
||||
assert auth_results['spf'][0]['domain'] == 'example.com'
|
||||
assert auth_results['spf'][0]['result'] == 'fail'
|
||||
@@ -0,0 +1,151 @@
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.domain import Domain
|
||||
from app.models.report import DMARCReport, ReportRecord
|
||||
|
||||
|
||||
class TestDomainModel:
|
||||
"""Tests for the Domain model"""
|
||||
|
||||
def test_create_domain(self, db_session: Session):
|
||||
"""Test creating a domain in the database"""
|
||||
domain = Domain(
|
||||
name="example.com",
|
||||
description="Test domain",
|
||||
active=True,
|
||||
dmarc_policy="quarantine"
|
||||
)
|
||||
|
||||
db_session.add(domain)
|
||||
db_session.commit()
|
||||
db_session.refresh(domain)
|
||||
|
||||
assert domain.id is not None
|
||||
assert domain.name == "example.com"
|
||||
assert domain.description == "Test domain"
|
||||
assert domain.active is True
|
||||
assert domain.dmarc_policy == "quarantine"
|
||||
|
||||
def test_domain_reports_relationship(self, db_session: Session):
|
||||
"""Test the relationship between domains and DMARC reports"""
|
||||
# Create a domain
|
||||
domain = Domain(name="example.com", active=True)
|
||||
db_session.add(domain)
|
||||
db_session.commit()
|
||||
|
||||
# Create reports for the domain
|
||||
report1 = DMARCReport(
|
||||
domain_id=domain.id,
|
||||
report_id="report1",
|
||||
org_name="Google",
|
||||
begin_date=1597449600,
|
||||
end_date=1597535999,
|
||||
source_email="noreply-dmarc-support@google.com"
|
||||
)
|
||||
|
||||
report2 = DMARCReport(
|
||||
domain_id=domain.id,
|
||||
report_id="report2",
|
||||
org_name="Microsoft",
|
||||
begin_date=1597536000,
|
||||
end_date=1597622399,
|
||||
source_email="dmarc@microsoft.com"
|
||||
)
|
||||
|
||||
db_session.add_all([report1, report2])
|
||||
db_session.commit()
|
||||
|
||||
# Query the domain and check its reports
|
||||
domain = db_session.query(Domain).filter_by(name="example.com").first()
|
||||
assert domain is not None
|
||||
assert len(domain.reports) == 2
|
||||
assert domain.reports[0].report_id in ["report1", "report2"]
|
||||
assert domain.reports[1].report_id in ["report1", "report2"]
|
||||
|
||||
|
||||
class TestDMARCReportModel:
|
||||
"""Tests for the DMARCReport model"""
|
||||
|
||||
def test_create_report(self, db_session: Session):
|
||||
"""Test creating a DMARC report in the database"""
|
||||
# Create a domain first
|
||||
domain = Domain(name="example.com", active=True)
|
||||
db_session.add(domain)
|
||||
db_session.commit()
|
||||
|
||||
# Create a report
|
||||
report = DMARCReport(
|
||||
domain_id=domain.id,
|
||||
report_id="123456789",
|
||||
org_name="Google",
|
||||
begin_date=1597449600,
|
||||
end_date=1597535999,
|
||||
source_email="noreply-dmarc-support@google.com",
|
||||
policy="none",
|
||||
adkim="r",
|
||||
aspf="r",
|
||||
percentage=100
|
||||
)
|
||||
|
||||
db_session.add(report)
|
||||
db_session.commit()
|
||||
db_session.refresh(report)
|
||||
|
||||
assert report.id is not None
|
||||
assert report.domain_id == domain.id
|
||||
assert report.report_id == "123456789"
|
||||
assert report.org_name == "Google"
|
||||
assert report.begin_date == 1597449600
|
||||
assert report.policy == "none"
|
||||
|
||||
def test_report_records_relationship(self, db_session: Session):
|
||||
"""Test the relationship between reports and records"""
|
||||
# Create domain and report
|
||||
domain = Domain(name="example.com", active=True)
|
||||
db_session.add(domain)
|
||||
db_session.commit()
|
||||
|
||||
report = DMARCReport(
|
||||
domain_id=domain.id,
|
||||
report_id="123456789",
|
||||
org_name="Google",
|
||||
begin_date=1597449600,
|
||||
end_date=1597535999,
|
||||
source_email="noreply-dmarc-support@google.com"
|
||||
)
|
||||
db_session.add(report)
|
||||
db_session.commit()
|
||||
|
||||
# Create records for the report
|
||||
record1 = ReportRecord(
|
||||
report_id=report.id,
|
||||
source_ip="203.0.113.1",
|
||||
count=2,
|
||||
disposition="none",
|
||||
dkim="pass",
|
||||
spf="fail",
|
||||
header_from="example.com",
|
||||
envelope_from=None
|
||||
)
|
||||
|
||||
record2 = ReportRecord(
|
||||
report_id=report.id,
|
||||
source_ip="203.0.113.2",
|
||||
count=5,
|
||||
disposition="none",
|
||||
dkim="pass",
|
||||
spf="pass",
|
||||
header_from="example.com",
|
||||
envelope_from=None
|
||||
)
|
||||
|
||||
db_session.add_all([record1, record2])
|
||||
db_session.commit()
|
||||
|
||||
# Query the report and check its records
|
||||
report = db_session.query(DMARCReport).filter_by(report_id="123456789").first()
|
||||
assert report is not None
|
||||
assert len(report.records) == 2
|
||||
assert report.records[0].source_ip in ["203.0.113.1", "203.0.113.2"]
|
||||
assert report.records[1].source_ip in ["203.0.113.1", "203.0.113.2"]
|
||||
@@ -0,0 +1,166 @@
|
||||
import pytest
|
||||
import io
|
||||
import zipfile
|
||||
import os
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.domain import Domain
|
||||
|
||||
|
||||
def test_read_reports_empty(client: TestClient):
|
||||
"""Test reading reports when none exist"""
|
||||
response = client.get("/api/v1/reports")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data == []
|
||||
|
||||
|
||||
def test_upload_report_no_domain(client: TestClient):
|
||||
"""Test uploading a report when domain doesn't exist"""
|
||||
# Create a simple XML report
|
||||
xml_content = """<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>google.com</org_name>
|
||||
<email>noreply-dmarc-support@google.com</email>
|
||||
<report_id>123456789</report_id>
|
||||
<date_range>
|
||||
<begin>1597449600</begin>
|
||||
<end>1597535999</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>nonexistentdomain.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<sp>none</sp>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>203.0.113.1</source_ip>
|
||||
<count>2</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>nonexistentdomain.com</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>nonexistentdomain.com</domain>
|
||||
<result>pass</result>
|
||||
<selector>default</selector>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>nonexistentdomain.com</domain>
|
||||
<result>fail</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
"""
|
||||
|
||||
# Create an in-memory zip file
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, 'w') as zip_file:
|
||||
zip_file.writestr('report.xml', xml_content)
|
||||
zip_buffer.seek(0)
|
||||
|
||||
# Upload the zip file
|
||||
response = client.post(
|
||||
"/api/v1/reports/upload",
|
||||
files={"file": ("report.zip", zip_buffer, "application/zip")}
|
||||
)
|
||||
|
||||
# Should return an error since domain doesn't exist
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "domain not found" in data["detail"].lower()
|
||||
|
||||
|
||||
def test_upload_report_success(client: TestClient, db_session: Session):
|
||||
"""Test successfully uploading a report"""
|
||||
# Create a domain first
|
||||
domain = Domain(name="example.com", active=True)
|
||||
db_session.add(domain)
|
||||
db_session.commit()
|
||||
|
||||
# Create a simple XML report
|
||||
xml_content = """<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>google.com</org_name>
|
||||
<email>noreply-dmarc-support@google.com</email>
|
||||
<report_id>123456789</report_id>
|
||||
<date_range>
|
||||
<begin>1597449600</begin>
|
||||
<end>1597535999</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<sp>none</sp>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>203.0.113.1</source_ip>
|
||||
<count>2</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>example.com</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>example.com</domain>
|
||||
<result>pass</result>
|
||||
<selector>default</selector>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>example.com</domain>
|
||||
<result>fail</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
"""
|
||||
|
||||
# Create an in-memory zip file
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, 'w') as zip_file:
|
||||
zip_file.writestr('report.xml', xml_content)
|
||||
zip_buffer.seek(0)
|
||||
|
||||
# Upload the zip file
|
||||
response = client.post(
|
||||
"/api/v1/reports/upload",
|
||||
files={"file": ("report.zip", zip_buffer, "application/zip")}
|
||||
)
|
||||
|
||||
# Should be successful
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "report_id" in data
|
||||
|
||||
# Check that the report was actually created
|
||||
response = client.get("/api/v1/reports")
|
||||
assert response.status_code == 200
|
||||
reports = response.json()
|
||||
assert len(reports) == 1
|
||||
assert reports[0]["report_id"] == "123456789"
|
||||
assert reports[0]["org_name"] == "google.com"
|
||||
@@ -0,0 +1,27 @@
|
||||
fastapi>=0.96.0
|
||||
uvicorn>=0.22.0
|
||||
sqlalchemy>=2.0.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
python-multipart>=0.0.6
|
||||
fastapi-users[sqlalchemy]>=12.0.0
|
||||
alembic>=1.11.0
|
||||
pytest>=7.3.1
|
||||
pytest-asyncio>=0.21.0
|
||||
httpx>=0.24.1
|
||||
pytest-cov>=4.1.0
|
||||
psycopg2-binary>=2.9.6
|
||||
imap-tools>=1.0.0
|
||||
defusedxml>=0.7.1
|
||||
dynaconf>=3.1.12
|
||||
python-dotenv>=1.0.0
|
||||
tenacity>=8.2.2
|
||||
apprise>=1.4.5
|
||||
dnspython>=2.3.0
|
||||
email-validator>=2.0.0
|
||||
lxml>=4.9.2
|
||||
zipfile36>=0.1.3
|
||||
aiosmtplib>=2.0.2
|
||||
jinja2>=3.1.2
|
||||
Binary file not shown.
Reference in New Issue
Block a user