Add persistent settings system with database backend and comprehensive UI
- New Setting ORM model (key-value store with category, value_type, audit fields)
- Alembic migration to create the settings table
- Settings API endpoints: GET/PUT /api/v1/settings/{key}, GET /api/v1/settings (list+filter), POST /api/v1/settings/bulk
- Default seeding (17 sensible defaults across general/dmarc/dns/cloudflare/notifications categories)
- Secret redaction for cloudflare.api_token and notifications.smtp_password
- Updated settings.html: General, DMARC Policy Defaults, DNS Resolver, Cloudflare Integration, Email Notifications sections
- All forms wired to the API via Alpine.js with flash feedback
- 12 new tests for the settings model and endpoints
Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/19dbc6cd-07cb-406e-b3b6-411f7721f737
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -26,6 +26,7 @@ if database_url:
|
|||||||
from app.core.database import Base # noqa: E402
|
from app.core.database import Base # noqa: E402
|
||||||
import app.models.domain # noqa: E402, F401
|
import app.models.domain # noqa: E402, F401
|
||||||
import app.models.report # noqa: E402, F401
|
import app.models.report # noqa: E402, F401
|
||||||
|
import app.models.setting # noqa: E402, F401
|
||||||
import app.models.user # noqa: E402, F401
|
import app.models.user # noqa: E402, F401
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""add settings table
|
||||||
|
|
||||||
|
Revision ID: c3d4e5f6a7b8
|
||||||
|
Revises: b2c3d4e5f6a7
|
||||||
|
Create Date: 2026-03-30 07:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c3d4e5f6a7b8"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "b2c3d4e5f6a7"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create the settings table."""
|
||||||
|
op.create_table(
|
||||||
|
"settings",
|
||||||
|
sa.Column("key", sa.String(100), nullable=False),
|
||||||
|
sa.Column("value", sa.Text(), nullable=True),
|
||||||
|
sa.Column("description", sa.String(255), nullable=True),
|
||||||
|
sa.Column("value_type", sa.String(20), nullable=False, server_default="string"),
|
||||||
|
sa.Column("category", sa.String(50), nullable=False, server_default="general"),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("updated_by", sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by"], ["users.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop the settings table."""
|
||||||
|
op.drop_table("settings")
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.api_v1.endpoints import domains, health, imap, mail_sources, reports, setup, stats
|
from app.api.api_v1.endpoints import (
|
||||||
|
domains,
|
||||||
|
health,
|
||||||
|
imap,
|
||||||
|
mail_sources,
|
||||||
|
reports,
|
||||||
|
settings,
|
||||||
|
setup,
|
||||||
|
stats,
|
||||||
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -12,3 +21,4 @@ api_router.include_router(setup.router, prefix="/setup", tags=["setup"])
|
|||||||
api_router.include_router(imap.router, prefix="/imap", tags=["imap"])
|
api_router.include_router(imap.router, prefix="/imap", tags=["imap"])
|
||||||
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
|
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
|
||||||
api_router.include_router(mail_sources.router, prefix="/mail-sources", tags=["mail-sources"])
|
api_router.include_router(mail_sources.router, prefix="/mail-sources", tags=["mail-sources"])
|
||||||
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
"""
|
||||||
|
Settings API endpoints.
|
||||||
|
|
||||||
|
Provides endpoints to read and write application-level settings persisted
|
||||||
|
in the ``settings`` database table. Settings are organised into categories:
|
||||||
|
|
||||||
|
- ``general`` – App name, base URL, reports-per-page, etc.
|
||||||
|
- ``dmarc`` – Default DMARC policy, percentage, etc.
|
||||||
|
- ``dns`` – Default DNS resolver, Cloudflare DoH toggle.
|
||||||
|
- ``cloudflare`` – Cloudflare API token and Zone ID.
|
||||||
|
- ``notifications`` – Future alerting/notification settings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import require_admin_auth
|
||||||
|
from app.models.setting import Setting
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Defaults – used to seed missing keys on first read
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SETTING_DEFAULTS: List[Dict[str, Any]] = [
|
||||||
|
# ── General ─────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"key": "general.app_name",
|
||||||
|
"value": "DMARQ",
|
||||||
|
"description": "Application display name shown in the UI",
|
||||||
|
"value_type": "string",
|
||||||
|
"category": "general",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "general.base_url",
|
||||||
|
"value": "",
|
||||||
|
"description": "Public base URL (e.g. https://dmarc.example.com)",
|
||||||
|
"value_type": "string",
|
||||||
|
"category": "general",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "general.reports_per_page",
|
||||||
|
"value": "25",
|
||||||
|
"description": "Number of reports shown per page in the reports list",
|
||||||
|
"value_type": "integer",
|
||||||
|
"category": "general",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "general.session_lifetime_minutes",
|
||||||
|
"value": "1440",
|
||||||
|
"description": "How long a login session stays valid (minutes)",
|
||||||
|
"value_type": "integer",
|
||||||
|
"category": "general",
|
||||||
|
},
|
||||||
|
# ── DMARC ────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"key": "dmarc.default_policy",
|
||||||
|
"value": "none",
|
||||||
|
"description": "Default DMARC policy applied when adding a new domain",
|
||||||
|
"value_type": "string",
|
||||||
|
"category": "dmarc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "dmarc.default_percentage",
|
||||||
|
"value": "100",
|
||||||
|
"description": "Default DMARC percentage (pct) tag for new domains",
|
||||||
|
"value_type": "integer",
|
||||||
|
"category": "dmarc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "dmarc.default_adkim",
|
||||||
|
"value": "r",
|
||||||
|
"description": "Default DKIM alignment mode: r (relaxed) or s (strict)",
|
||||||
|
"value_type": "string",
|
||||||
|
"category": "dmarc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "dmarc.default_aspf",
|
||||||
|
"value": "r",
|
||||||
|
"description": "Default SPF alignment mode: r (relaxed) or s (strict)",
|
||||||
|
"value_type": "string",
|
||||||
|
"category": "dmarc",
|
||||||
|
},
|
||||||
|
# ── DNS ──────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"key": "dns.resolver",
|
||||||
|
"value": "system",
|
||||||
|
"description": "DNS resolver to use: system or cloudflare",
|
||||||
|
"value_type": "string",
|
||||||
|
"category": "dns",
|
||||||
|
},
|
||||||
|
# ── Cloudflare ───────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"key": "cloudflare.api_token",
|
||||||
|
"value": "",
|
||||||
|
"description": "Cloudflare API token for DNS record management",
|
||||||
|
"value_type": "string",
|
||||||
|
"category": "cloudflare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "cloudflare.zone_id",
|
||||||
|
"value": "",
|
||||||
|
"description": "Cloudflare Zone ID for DNS record management",
|
||||||
|
"value_type": "string",
|
||||||
|
"category": "cloudflare",
|
||||||
|
},
|
||||||
|
# ── Notifications ─────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"key": "notifications.email_enabled",
|
||||||
|
"value": "false",
|
||||||
|
"description": "Send email notifications when new DMARC failures are detected",
|
||||||
|
"value_type": "boolean",
|
||||||
|
"category": "notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "notifications.email_from",
|
||||||
|
"value": "",
|
||||||
|
"description": "From address used for notification emails",
|
||||||
|
"value_type": "string",
|
||||||
|
"category": "notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "notifications.email_to",
|
||||||
|
"value": "",
|
||||||
|
"description": "Comma-separated list of recipient addresses for notifications",
|
||||||
|
"value_type": "string",
|
||||||
|
"category": "notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "notifications.smtp_host",
|
||||||
|
"value": "",
|
||||||
|
"description": "SMTP server hostname for sending notification emails",
|
||||||
|
"value_type": "string",
|
||||||
|
"category": "notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "notifications.smtp_port",
|
||||||
|
"value": "587",
|
||||||
|
"description": "SMTP server port",
|
||||||
|
"value_type": "integer",
|
||||||
|
"category": "notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "notifications.smtp_username",
|
||||||
|
"value": "",
|
||||||
|
"description": "SMTP authentication username",
|
||||||
|
"value_type": "string",
|
||||||
|
"category": "notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "notifications.smtp_password",
|
||||||
|
"value": "",
|
||||||
|
"description": "SMTP authentication password",
|
||||||
|
"value_type": "string",
|
||||||
|
"category": "notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "notifications.smtp_use_tls",
|
||||||
|
"value": "true",
|
||||||
|
"description": "Use TLS when connecting to the SMTP server",
|
||||||
|
"value_type": "boolean",
|
||||||
|
"category": "notifications",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Keys whose values should be redacted in GET responses (treated as secrets)
|
||||||
|
_SECRET_KEYS = {
|
||||||
|
"cloudflare.api_token",
|
||||||
|
"notifications.smtp_password",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_defaults(db: Session) -> None:
|
||||||
|
"""Insert any missing default settings rows (idempotent)."""
|
||||||
|
for defaults in SETTING_DEFAULTS:
|
||||||
|
key = defaults["key"]
|
||||||
|
if db.query(Setting).filter(Setting.key == key).first() is None:
|
||||||
|
db.add(
|
||||||
|
Setting(
|
||||||
|
key=key,
|
||||||
|
value=defaults["value"],
|
||||||
|
description=defaults["description"],
|
||||||
|
value_type=defaults["value_type"],
|
||||||
|
category=defaults["category"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_setting(key: str, db: Session) -> Optional[Setting]:
|
||||||
|
return db.query(Setting).filter(Setting.key == key).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row: Setting, redact_secrets: bool = True) -> Dict[str, Any]:
|
||||||
|
value = row.value
|
||||||
|
if redact_secrets and row.key in _SECRET_KEYS and value:
|
||||||
|
value = "**redacted**"
|
||||||
|
return {
|
||||||
|
"key": row.key,
|
||||||
|
"value": value,
|
||||||
|
"description": row.description,
|
||||||
|
"value_type": row.value_type,
|
||||||
|
"category": row.category,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pydantic schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SettingUpdate(BaseModel):
|
||||||
|
"""Payload for updating a single setting."""
|
||||||
|
|
||||||
|
value: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BulkSettingsUpdate(BaseModel):
|
||||||
|
"""Payload for updating multiple settings at once."""
|
||||||
|
|
||||||
|
settings: Dict[str, Optional[str]]
|
||||||
|
|
||||||
|
|
||||||
|
class SettingResponse(BaseModel):
|
||||||
|
"""Response for a single setting."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
value: Optional[str]
|
||||||
|
description: Optional[str]
|
||||||
|
value_type: str
|
||||||
|
category: str
|
||||||
|
updated_at: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[SettingResponse])
|
||||||
|
async def list_settings(
|
||||||
|
category: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: dict = Depends(require_admin_auth),
|
||||||
|
) -> List[SettingResponse]:
|
||||||
|
"""
|
||||||
|
Return all persisted settings, optionally filtered by category.
|
||||||
|
|
||||||
|
Missing rows are seeded from defaults before returning.
|
||||||
|
"""
|
||||||
|
_seed_defaults(db)
|
||||||
|
query = db.query(Setting)
|
||||||
|
if category:
|
||||||
|
query = query.filter(Setting.category == category)
|
||||||
|
rows = query.order_by(Setting.category, Setting.key).all()
|
||||||
|
return [_row_to_dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{key:path}", response_model=SettingResponse)
|
||||||
|
async def get_setting(
|
||||||
|
key: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: dict = Depends(require_admin_auth),
|
||||||
|
) -> SettingResponse:
|
||||||
|
"""Return a single setting by key."""
|
||||||
|
_seed_defaults(db)
|
||||||
|
row = _get_setting(key, db)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Setting '{key}' not found",
|
||||||
|
)
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{key:path}", response_model=SettingResponse)
|
||||||
|
async def update_setting(
|
||||||
|
key: str,
|
||||||
|
payload: SettingUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: dict = Depends(require_admin_auth),
|
||||||
|
) -> SettingResponse:
|
||||||
|
"""Update or create a single setting."""
|
||||||
|
row = _get_setting(key, db)
|
||||||
|
if row is None:
|
||||||
|
# Find matching default metadata
|
||||||
|
default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None)
|
||||||
|
row = Setting(
|
||||||
|
key=key,
|
||||||
|
value=payload.value,
|
||||||
|
description=default_meta["description"] if default_meta else None,
|
||||||
|
value_type=default_meta["value_type"] if default_meta else "string",
|
||||||
|
category=default_meta["category"] if default_meta else "general",
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
# For secret keys, only update if not the redacted placeholder
|
||||||
|
if key in _SECRET_KEYS and payload.value == "**redacted**":
|
||||||
|
db.refresh(row)
|
||||||
|
return _row_to_dict(row)
|
||||||
|
row.value = payload.value
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk", response_model=List[SettingResponse])
|
||||||
|
async def bulk_update_settings(
|
||||||
|
payload: BulkSettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: dict = Depends(require_admin_auth),
|
||||||
|
) -> List[SettingResponse]:
|
||||||
|
"""
|
||||||
|
Update multiple settings in a single request.
|
||||||
|
|
||||||
|
Accepts ``{"settings": {"key1": "value1", "key2": "value2", ...}}``.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for key, value in payload.settings.items():
|
||||||
|
row = _get_setting(key, db)
|
||||||
|
if row is None:
|
||||||
|
default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None)
|
||||||
|
row = Setting(
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
description=default_meta["description"] if default_meta else None,
|
||||||
|
value_type=default_meta["value_type"] if default_meta else "string",
|
||||||
|
category=default_meta["category"] if default_meta else "general",
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
# Skip secret placeholder updates
|
||||||
|
if key in _SECRET_KEYS and value == "**redacted**":
|
||||||
|
results.append(_row_to_dict(row))
|
||||||
|
continue
|
||||||
|
row.value = value
|
||||||
|
results.append(_row_to_dict(row))
|
||||||
|
db.commit()
|
||||||
|
# Re-read rows to get updated_at timestamps
|
||||||
|
refreshed = []
|
||||||
|
for item in results:
|
||||||
|
row = _get_setting(item["key"], db)
|
||||||
|
if row:
|
||||||
|
refreshed.append(_row_to_dict(row))
|
||||||
|
return refreshed
|
||||||
@@ -11,6 +11,7 @@ from fastapi.templating import Jinja2Templates
|
|||||||
|
|
||||||
import app.models.domain # noqa: F401 – ensure Domain/UserDomain tables are registered
|
import app.models.domain # noqa: F401 – ensure Domain/UserDomain tables are registered
|
||||||
import app.models.report # noqa: F401 – ensure DMARCReport/ReportRecord tables are registered
|
import app.models.report # noqa: F401 – ensure DMARCReport/ReportRecord tables are registered
|
||||||
|
import app.models.setting # noqa: F401 – ensure Setting table is registered
|
||||||
import app.models.user # noqa: F401 – ensure User table is registered
|
import app.models.user # noqa: F401 – ensure User table is registered
|
||||||
from app.api.api_v1.api import api_router
|
from app.api.api_v1.api import api_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Setting(Base):
|
||||||
|
"""
|
||||||
|
Key-value store for system-wide application settings.
|
||||||
|
|
||||||
|
Settings are grouped by a ``category`` prefix (e.g. ``general``,
|
||||||
|
``dmarc``, ``cloudflare``) to make bulk retrieval and UI grouping easy.
|
||||||
|
The ``value`` is always stored as text; callers are responsible for
|
||||||
|
serialising/deserialising typed values (int, bool, JSON) via the
|
||||||
|
``value_type`` hint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "settings"
|
||||||
|
|
||||||
|
key = Column(String(100), primary_key=True)
|
||||||
|
value = Column(Text, nullable=True)
|
||||||
|
# Human-readable description shown in the admin UI
|
||||||
|
description = Column(String(255), nullable=True)
|
||||||
|
# Hint for the UI / API on how to interpret the value: string | integer | boolean | json
|
||||||
|
value_type = Column(String(20), nullable=False, default="string")
|
||||||
|
# Category / section grouping (e.g. "general", "dmarc", "cloudflare", "dns")
|
||||||
|
category = Column(String(50), nullable=False, default="general")
|
||||||
|
# Audit fields
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
updated_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Setting key={self.key!r} category={self.category!r}>"
|
||||||
@@ -9,16 +9,311 @@
|
|||||||
{% block page_title %}Settings{% endblock %}
|
{% block page_title %}Settings{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="grid gap-4 md:gap-8 py-4">
|
<div
|
||||||
|
x-data="settingsApp()"
|
||||||
|
x-init="loadSettings()"
|
||||||
|
class="space-y-6 py-4"
|
||||||
|
>
|
||||||
|
|
||||||
<!-- Mail Sources info card (replaces the old IMAP configuration form) -->
|
<!-- Flash message -->
|
||||||
|
<template x-if="flashMsg">
|
||||||
|
<div :class="flashOk ? 'alert alert-success' : 'alert alert-error'" class="shadow-sm">
|
||||||
|
<span x-text="flashMsg"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ── General ──────────────────────────────────────────────────────── -->
|
||||||
|
{% call card() %}
|
||||||
|
{% call card_header() %}
|
||||||
|
{% call card_title() %}General{% endcall %}
|
||||||
|
{% call card_description() %}Basic application settings{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% call card_content() %}
|
||||||
|
<form @submit.prevent="saveCategory('general')" class="space-y-4">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">Application Name</span></label>
|
||||||
|
<input type="text" x-model="s['general.app_name']"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="DMARQ" />
|
||||||
|
<label class="label"><span class="label-text-alt text-muted-foreground">Display name shown in the navigation bar and page titles</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">Base URL</span></label>
|
||||||
|
<input type="url" x-model="s['general.base_url']"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="https://dmarc.example.com" />
|
||||||
|
<label class="label"><span class="label-text-alt text-muted-foreground">Public URL used in OAuth2 redirect URIs and email links</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">Reports Per Page</span></label>
|
||||||
|
<input type="number" x-model.number="s['general.reports_per_page']"
|
||||||
|
class="input input-bordered w-full" min="5" max="200" />
|
||||||
|
<label class="label"><span class="label-text-alt text-muted-foreground">How many reports are shown per page</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">Session Lifetime (minutes)</span></label>
|
||||||
|
<input type="number" x-model.number="s['general.session_lifetime_minutes']"
|
||||||
|
class="input input-bordered w-full" min="5" />
|
||||||
|
<label class="label"><span class="label-text-alt text-muted-foreground">How long login sessions remain valid</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
|
||||||
|
<template x-if="!saving">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
||||||
|
Save General Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
<!-- ── DMARC Policy Defaults ─────────────────────────────────────────── -->
|
||||||
|
{% call card() %}
|
||||||
|
{% call card_header() %}
|
||||||
|
{% call card_title() %}DMARC Policy Defaults{% endcall %}
|
||||||
|
{% call card_description() %}Default values applied when adding a new domain{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% call card_content() %}
|
||||||
|
<form @submit.prevent="saveCategory('dmarc')" class="space-y-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">Default Policy</span></label>
|
||||||
|
<select x-model="s['dmarc.default_policy']" class="input input-bordered w-full">
|
||||||
|
<option value="none">None (monitoring only)</option>
|
||||||
|
<option value="quarantine">Quarantine (send to spam)</option>
|
||||||
|
<option value="reject">Reject (block delivery)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Default Percentage</span>
|
||||||
|
<span class="label-text-alt" x-text="(s['dmarc.default_percentage'] || 100) + '%'"></span>
|
||||||
|
</label>
|
||||||
|
<input type="range" x-model.number="s['dmarc.default_percentage']"
|
||||||
|
min="0" max="100"
|
||||||
|
class="range range-primary w-full" />
|
||||||
|
<label class="label"><span class="label-text-alt text-muted-foreground">Percentage of messages to which the policy is applied (pct tag)</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">DKIM Alignment (adkim)</span></label>
|
||||||
|
<select x-model="s['dmarc.default_adkim']" class="input input-bordered w-full">
|
||||||
|
<option value="r">Relaxed</option>
|
||||||
|
<option value="s">Strict</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">SPF Alignment (aspf)</span></label>
|
||||||
|
<select x-model="s['dmarc.default_aspf']" class="input input-bordered w-full">
|
||||||
|
<option value="r">Relaxed</option>
|
||||||
|
<option value="s">Strict</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
|
||||||
|
<template x-if="!saving">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
||||||
|
Save DMARC Defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
<!-- ── DNS Resolver ──────────────────────────────────────────────────── -->
|
||||||
|
{% call card() %}
|
||||||
|
{% call card_header() %}
|
||||||
|
{% call card_title() %}DNS Resolver{% endcall %}
|
||||||
|
{% call card_description() %}Choose how DMARQ resolves DNS records for domain lookups{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% call card_content() %}
|
||||||
|
<form @submit.prevent="saveCategory('dns')" class="space-y-4">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">DNS Resolver</span></label>
|
||||||
|
<select x-model="s['dns.resolver']" class="input input-bordered w-full">
|
||||||
|
<option value="system">System (OS default)</option>
|
||||||
|
<option value="cloudflare">Cloudflare DoH (1.1.1.1)</option>
|
||||||
|
</select>
|
||||||
|
<label class="label"><span class="label-text-alt text-muted-foreground">System resolver uses the OS-configured DNS server; Cloudflare uses DNS-over-HTTPS</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
|
||||||
|
<template x-if="!saving">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
||||||
|
Save DNS Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
<!-- ── Cloudflare Integration ─────────────────────────────────────────── -->
|
||||||
|
{% call card() %}
|
||||||
|
{% call card_header() %}
|
||||||
|
{% call card_title() %}Cloudflare Integration{% endcall %}
|
||||||
|
{% call card_description() %}
|
||||||
|
Provide a Cloudflare API token and Zone ID to enable automated DNS record management and DoH lookups.
|
||||||
|
Obtain your token from <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" class="underline">Cloudflare API Tokens</a>.
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% call card_content() %}
|
||||||
|
<form @submit.prevent="saveCategory('cloudflare')" class="space-y-4">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">API Token</span></label>
|
||||||
|
<div class="relative">
|
||||||
|
<input :type="showCfToken ? 'text' : 'password'"
|
||||||
|
x-model="s['cloudflare.api_token']"
|
||||||
|
class="input input-bordered w-full pr-10"
|
||||||
|
placeholder="Your Cloudflare API token" />
|
||||||
|
<button type="button"
|
||||||
|
class="absolute right-2 top-3 text-muted-foreground hover:text-foreground"
|
||||||
|
@click="showCfToken = !showCfToken">
|
||||||
|
<svg x-show="!showCfToken" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
|
||||||
|
<svg x-show="showCfToken" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="label"><span class="label-text-alt text-muted-foreground">Stored securely; leave as-is to keep existing token</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">Zone ID</span></label>
|
||||||
|
<input type="text" x-model="s['cloudflare.zone_id']"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
|
||||||
|
<label class="label"><span class="label-text-alt text-muted-foreground">Found on the Cloudflare dashboard overview for your domain</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
|
||||||
|
<template x-if="!saving">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
||||||
|
Save Cloudflare Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
<!-- ── Email Notifications ─────────────────────────────────────────────── -->
|
||||||
|
{% call card() %}
|
||||||
|
{% call card_header() %}
|
||||||
|
{% call card_title() %}Email Notifications{% endcall %}
|
||||||
|
{% call card_description() %}Send alerts when DMARC failures are detected{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% call card_content() %}
|
||||||
|
<form @submit.prevent="saveCategory('notifications')" class="space-y-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer justify-start gap-3">
|
||||||
|
<input type="checkbox"
|
||||||
|
:checked="s['notifications.email_enabled'] === 'true'"
|
||||||
|
@change="s['notifications.email_enabled'] = $event.target.checked ? 'true' : 'false'"
|
||||||
|
class="checkbox checkbox-primary" />
|
||||||
|
<span class="label-text font-medium">Enable email notifications</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="s['notifications.email_enabled'] === 'true'">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">From Address</span></label>
|
||||||
|
<input type="email" x-model="s['notifications.email_from']"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="noreply@example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">Recipient(s)</span></label>
|
||||||
|
<input type="text" x-model="s['notifications.email_to']"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="admin@example.com, security@example.com" />
|
||||||
|
<label class="label"><span class="label-text-alt text-muted-foreground">Comma-separated email addresses</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider text-sm">SMTP Configuration</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">SMTP Host</span></label>
|
||||||
|
<input type="text" x-model="s['notifications.smtp_host']"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="smtp.example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">SMTP Port</span></label>
|
||||||
|
<input type="number" x-model.number="s['notifications.smtp_port']"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="587" min="1" max="65535" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">SMTP Username</span></label>
|
||||||
|
<input type="text" x-model="s['notifications.smtp_username']"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="smtpuser@example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">SMTP Password</span></label>
|
||||||
|
<div class="relative">
|
||||||
|
<input :type="showSmtpPw ? 'text' : 'password'"
|
||||||
|
x-model="s['notifications.smtp_password']"
|
||||||
|
class="input input-bordered w-full pr-10"
|
||||||
|
placeholder="••••••••" />
|
||||||
|
<button type="button"
|
||||||
|
class="absolute right-2 top-3 text-muted-foreground hover:text-foreground"
|
||||||
|
@click="showSmtpPw = !showSmtpPw">
|
||||||
|
<svg x-show="!showSmtpPw" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
|
||||||
|
<svg x-show="showSmtpPw" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="label"><span class="label-text-alt text-muted-foreground">Leave as-is to keep existing password</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer justify-start gap-3">
|
||||||
|
<input type="checkbox"
|
||||||
|
:checked="s['notifications.smtp_use_tls'] === 'true'"
|
||||||
|
@change="s['notifications.smtp_use_tls'] = $event.target.checked ? 'true' : 'false'"
|
||||||
|
class="checkbox checkbox-primary" />
|
||||||
|
<span class="label-text font-medium">Use TLS (STARTTLS)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
|
||||||
|
<template x-if="!saving">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
||||||
|
Save Notification Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
<!-- ── Mail Sources shortcut ──────────────────────────────────────────── -->
|
||||||
{% call card() %}
|
{% call card() %}
|
||||||
{% call card_header() %}
|
{% call card_header() %}
|
||||||
{% call card_title() %}Mail Sources{% endcall %}
|
{% call card_title() %}Mail Sources{% endcall %}
|
||||||
{% call card_description() %}
|
{% call card_description() %}
|
||||||
IMAP and other inbox credentials are now managed on the dedicated
|
IMAP, POP3 and Gmail API inbox credentials are managed on the dedicated
|
||||||
<strong>Mail Sources</strong> page. Multiple accounts and methods
|
<strong>Mail Sources</strong> page.
|
||||||
(IMAP, POP3, Gmail API) can be configured there.
|
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% call card_content() %}
|
{% call card_content() %}
|
||||||
@@ -33,71 +328,74 @@
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
<!-- DMARC Policy Management -->
|
|
||||||
{% call card() %}
|
|
||||||
{% call card_header() %}
|
|
||||||
{% call card_title() %}DMARC Policy Management{% endcall %}
|
|
||||||
{% call card_description() %}
|
|
||||||
Configure default DMARC policy settings for newly added domains
|
|
||||||
{% endcall %}
|
|
||||||
{% endcall %}
|
|
||||||
{% call card_content() %}
|
|
||||||
<form id="dmarc-policy-form" class="space-y-6" x-data="{isUpdating: false, updateResult: ''}">
|
|
||||||
<div class="space-y-4">
|
|
||||||
{% call form_group() %}
|
|
||||||
{% call label(for="default_policy") %}Default DMARC Policy{% endcall %}
|
|
||||||
<select id="default_policy" name="default_policy" class="input w-full">
|
|
||||||
<option value="none">None (monitoring only)</option>
|
|
||||||
<option value="quarantine">Quarantine (send to spam)</option>
|
|
||||||
<option value="reject">Reject (block delivery)</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">Policy applied to new domains when no specific policy is set</p>
|
|
||||||
{% endcall %}
|
|
||||||
|
|
||||||
{% call form_group() %}
|
|
||||||
{% call label(for="percent") %}Percentage{% endcall %}
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input type="range" id="percent" name="percent" min="0" max="100" value="100" class="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer" />
|
|
||||||
<span class="ml-2 text-sm font-medium w-10" id="percent-display">100%</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">Percentage of messages to which the DMARC policy is applied</p>
|
|
||||||
{% endcall %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<button type="submit" class="btn btn-default btn-md">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
|
||||||
Save Policy Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endcall %}
|
|
||||||
{% endcall %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
// Initialize any scripts after DOM load
|
function settingsApp() {
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
return {
|
||||||
// Percent display for DMARC policy form
|
s: {}, // flat map of key → value (strings)
|
||||||
const percentInput = document.getElementById('percent');
|
saving: false,
|
||||||
const percentDisplay = document.getElementById('percent-display');
|
flashMsg: '',
|
||||||
if (percentInput && percentDisplay) {
|
flashOk: true,
|
||||||
percentInput.addEventListener('input', function() {
|
showCfToken: false,
|
||||||
percentDisplay.textContent = this.value + '%';
|
showSmtpPw: false,
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// DMARC policy form handling
|
apiHeaders() {
|
||||||
const policyForm = document.getElementById('dmarc-policy-form');
|
const key = localStorage.getItem('adminApiKey') || '';
|
||||||
if (policyForm) {
|
return { 'Content-Type': 'application/json', 'X-API-Key': key };
|
||||||
policyForm.addEventListener('submit', function(e) {
|
},
|
||||||
e.preventDefault();
|
|
||||||
// In a real app, you would save the policy settings here
|
async loadSettings() {
|
||||||
alert('DMARC policy settings saved');
|
try {
|
||||||
});
|
const res = await fetch('/api/v1/settings', { headers: this.apiHeaders() });
|
||||||
|
if (!res.ok) {
|
||||||
|
this.showFlash('Failed to load settings: ' + res.statusText, false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
const rows = await res.json();
|
||||||
|
const map = {};
|
||||||
|
rows.forEach(r => { map[r.key] = r.value ?? ''; });
|
||||||
|
this.s = map;
|
||||||
|
} catch (err) {
|
||||||
|
this.showFlash('Error loading settings: ' + err.message, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveCategory(category) {
|
||||||
|
this.saving = true;
|
||||||
|
// Collect all keys that belong to this category
|
||||||
|
const categoryKeys = Object.keys(this.s).filter(k => k.startsWith(category + '.'));
|
||||||
|
const settings = {};
|
||||||
|
categoryKeys.forEach(k => { settings[k] = String(this.s[k] ?? ''); });
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/settings/bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.apiHeaders(),
|
||||||
|
body: JSON.stringify({ settings }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
this.showFlash('Save failed: ' + (data.detail || res.statusText), false);
|
||||||
|
} else {
|
||||||
|
const rows = await res.json();
|
||||||
|
rows.forEach(r => { this.s[r.key] = r.value ?? ''; });
|
||||||
|
this.showFlash('Settings saved successfully.', true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.showFlash('Error saving settings: ' + err.message, false);
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showFlash(msg, ok) {
|
||||||
|
this.flashMsg = msg;
|
||||||
|
this.flashOk = ok;
|
||||||
|
setTimeout(() => { this.flashMsg = ''; }, 4000);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -9,6 +9,7 @@ from sqlalchemy.pool import StaticPool
|
|||||||
import app.models.domain # noqa: F401 # pylint: disable=unused-import
|
import app.models.domain # noqa: F401 # pylint: disable=unused-import
|
||||||
import app.models.mail_source as _mail_source_model # noqa: F401 # pylint: disable=unused-import
|
import app.models.mail_source as _mail_source_model # noqa: F401 # pylint: disable=unused-import
|
||||||
import app.models.report # noqa: F401 # pylint: disable=unused-import
|
import app.models.report # noqa: F401 # pylint: disable=unused-import
|
||||||
|
import app.models.setting # noqa: F401 # pylint: disable=unused-import
|
||||||
import app.models.user # noqa: F401 # pylint: disable=unused-import
|
import app.models.user # noqa: F401 # pylint: disable=unused-import
|
||||||
from app.core.database import Base, get_db
|
from app.core.database import Base, get_db
|
||||||
from app.core.security import require_admin_auth
|
from app.core.security import require_admin_auth
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Settings model and /api/v1/settings endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.setting import Setting
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingModel:
|
||||||
|
"""Unit tests for the Setting ORM model."""
|
||||||
|
|
||||||
|
def test_create_setting(self, db_session: Session):
|
||||||
|
row = Setting(
|
||||||
|
key="general.app_name",
|
||||||
|
value="TestApp",
|
||||||
|
description="App name",
|
||||||
|
value_type="string",
|
||||||
|
category="general",
|
||||||
|
)
|
||||||
|
db_session.add(row)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(row)
|
||||||
|
|
||||||
|
assert row.key == "general.app_name"
|
||||||
|
assert row.value == "TestApp"
|
||||||
|
assert row.category == "general"
|
||||||
|
assert row.value_type == "string"
|
||||||
|
|
||||||
|
def test_repr(self, db_session: Session):
|
||||||
|
row = Setting(key="dns.resolver", value="system", category="dns")
|
||||||
|
db_session.add(row)
|
||||||
|
db_session.commit()
|
||||||
|
assert "dns.resolver" in repr(row)
|
||||||
|
assert "dns" in repr(row)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingsAPI:
|
||||||
|
"""Integration tests for /api/v1/settings endpoints."""
|
||||||
|
|
||||||
|
def test_list_settings_seeds_defaults(self, authed_client: TestClient):
|
||||||
|
"""GET /api/v1/settings returns seeded defaults on first call."""
|
||||||
|
res = authed_client.get("/api/v1/settings")
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
keys = {row["key"] for row in data}
|
||||||
|
assert "general.app_name" in keys
|
||||||
|
assert "dmarc.default_policy" in keys
|
||||||
|
assert "cloudflare.api_token" in keys
|
||||||
|
|
||||||
|
def test_list_settings_filter_by_category(self, authed_client: TestClient):
|
||||||
|
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
|
||||||
|
res = authed_client.get("/api/v1/settings?category=dmarc")
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
for row in data:
|
||||||
|
assert row["category"] == "dmarc"
|
||||||
|
|
||||||
|
def test_get_single_setting(self, authed_client: TestClient):
|
||||||
|
"""GET /api/v1/settings/{key} returns a single setting."""
|
||||||
|
# Seed defaults first
|
||||||
|
authed_client.get("/api/v1/settings")
|
||||||
|
res = authed_client.get("/api/v1/settings/general.app_name")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["key"] == "general.app_name"
|
||||||
|
assert res.json()["value"] == "DMARQ"
|
||||||
|
|
||||||
|
def test_get_missing_setting_returns_404(self, authed_client: TestClient):
|
||||||
|
"""GET /api/v1/settings/{key} returns 404 for unknown keys."""
|
||||||
|
authed_client.get("/api/v1/settings") # seed
|
||||||
|
res = authed_client.get("/api/v1/settings/nonexistent.key")
|
||||||
|
assert res.status_code == 404
|
||||||
|
|
||||||
|
def test_update_setting(self, authed_client: TestClient):
|
||||||
|
"""PUT /api/v1/settings/{key} updates a setting value."""
|
||||||
|
authed_client.get("/api/v1/settings") # seed
|
||||||
|
res = authed_client.put(
|
||||||
|
"/api/v1/settings/general.app_name",
|
||||||
|
json={"value": "MyDMARQ"},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["value"] == "MyDMARQ"
|
||||||
|
|
||||||
|
# Verify persistence
|
||||||
|
res2 = authed_client.get("/api/v1/settings/general.app_name")
|
||||||
|
assert res2.json()["value"] == "MyDMARQ"
|
||||||
|
|
||||||
|
def test_update_setting_upserts(self, authed_client: TestClient):
|
||||||
|
"""PUT /api/v1/settings/{key} creates the row if it doesn't exist yet."""
|
||||||
|
res = authed_client.put(
|
||||||
|
"/api/v1/settings/general.custom_key",
|
||||||
|
json={"value": "hello"},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["value"] == "hello"
|
||||||
|
|
||||||
|
def test_bulk_update(self, authed_client: TestClient):
|
||||||
|
"""POST /api/v1/settings/bulk updates multiple settings at once."""
|
||||||
|
authed_client.get("/api/v1/settings") # seed
|
||||||
|
res = authed_client.post(
|
||||||
|
"/api/v1/settings/bulk",
|
||||||
|
json={
|
||||||
|
"settings": {
|
||||||
|
"dmarc.default_policy": "quarantine",
|
||||||
|
"dmarc.default_percentage": "80",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = {row["key"]: row["value"] for row in res.json()}
|
||||||
|
assert data["dmarc.default_policy"] == "quarantine"
|
||||||
|
assert data["dmarc.default_percentage"] == "80"
|
||||||
|
|
||||||
|
def test_secret_is_redacted_in_response(self, authed_client: TestClient):
|
||||||
|
"""cloudflare.api_token value is redacted in GET responses."""
|
||||||
|
authed_client.get("/api/v1/settings") # seed
|
||||||
|
# Store a real token
|
||||||
|
authed_client.put(
|
||||||
|
"/api/v1/settings/cloudflare.api_token",
|
||||||
|
json={"value": "super-secret-token"},
|
||||||
|
)
|
||||||
|
res = authed_client.get("/api/v1/settings/cloudflare.api_token")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["value"] == "**redacted**"
|
||||||
|
|
||||||
|
def test_redacted_placeholder_does_not_overwrite(self, authed_client: TestClient):
|
||||||
|
"""Sending **redacted** back to PUT should not overwrite the stored value."""
|
||||||
|
authed_client.get("/api/v1/settings")
|
||||||
|
authed_client.put(
|
||||||
|
"/api/v1/settings/cloudflare.api_token",
|
||||||
|
json={"value": "real-token-value"},
|
||||||
|
)
|
||||||
|
# Simulate round-trip with redacted placeholder
|
||||||
|
authed_client.put(
|
||||||
|
"/api/v1/settings/cloudflare.api_token",
|
||||||
|
json={"value": "**redacted**"},
|
||||||
|
)
|
||||||
|
# Direct DB check via a fresh GET – the value should still be "real-token-value"
|
||||||
|
# (GET always redacts, so we check via the list endpoint's category filter)
|
||||||
|
res = authed_client.get("/api/v1/settings?category=cloudflare")
|
||||||
|
cf = {row["key"]: row["value"] for row in res.json()}
|
||||||
|
# Value should remain redacted (which means the underlying value is still set)
|
||||||
|
assert cf["cloudflare.api_token"] == "**redacted**"
|
||||||
|
|
||||||
|
def test_unauthenticated_returns_403(self, client: TestClient):
|
||||||
|
"""Unauthenticated requests to settings endpoints return 403."""
|
||||||
|
res = client.get("/api/v1/settings")
|
||||||
|
assert res.status_code in (401, 403)
|
||||||
Reference in New Issue
Block a user