diff --git a/backend/alembic/versions/b033_system_configs.py b/backend/alembic/versions/b033_system_configs.py new file mode 100644 index 0000000..75db08d --- /dev/null +++ b/backend/alembic/versions/b033_system_configs.py @@ -0,0 +1,43 @@ +"""Phase 8: system_configs table for runtime configuration. + +Revision ID: b033syscfg +Revises: b032phase7 +""" +from typing import Sequence, Union +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from alembic import op + +revision: str = "b033syscfg" +down_revision: Union[str, None] = "b032phase7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _table_exists(name: str) -> bool: + bind = op.get_bind() + insp = sa.inspect(bind) + return name in insp.get_table_names() + + +def upgrade() -> None: + if not _table_exists("system_configs"): + op.create_table( + "system_configs", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("key", sa.String(200), unique=True, nullable=False), + sa.Column("value", sa.Text, nullable=True), + sa.Column("description", sa.String(500), nullable=True), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + ), + ) + op.create_index("ix_system_configs_key", "system_configs", ["key"]) + + +def downgrade() -> None: + if _table_exists("system_configs"): + op.drop_index("ix_system_configs_key", table_name="system_configs") + op.drop_table("system_configs") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2780d6d..af6a30d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -21,6 +21,8 @@ from app.models.worklog import Worklog from app.models.osint_item import OsintItem from app.models.scoring_config import ScoringConfig from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide +from app.models.webhook_config import WebhookConfig +from app.models.system_config import SystemConfig __all__ = [ "User", "Technique", "Test", "TestTemplate", "Evidence", @@ -34,4 +36,5 @@ __all__ = [ "JiraLink", "JiraLinkEntityType", "JiraSyncDirection", "Worklog", "OsintItem", "ScoringConfig", "TechniqueStatus", "TestState", "TestResult", "TeamSide", + "WebhookConfig", "SystemConfig", ] diff --git a/backend/app/models/system_config.py b/backend/app/models/system_config.py new file mode 100644 index 0000000..5e0416b --- /dev/null +++ b/backend/app/models/system_config.py @@ -0,0 +1,26 @@ +"""SystemConfig model — runtime key-value configuration store.""" + +import uuid + +from sqlalchemy import Column, String, Text, DateTime, func +from sqlalchemy.dialects.postgresql import UUID + +from app.database import Base + + +class SystemConfig(Base): + """Generic key-value store for runtime system configuration. + + Currently used for: + - SMTP email settings (overrides .env values when present) + + Keys are namespaced by convention: ``smtp.host``, ``smtp.port``, etc. + """ + + __tablename__ = "system_configs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + key = Column(String(200), unique=True, nullable=False, index=True) + value = Column(Text, nullable=True) + description = Column(String(500), nullable=True) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index 96ee96d..f61b73d 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -3,11 +3,16 @@ Provides manual triggers for background operations such as the MITRE ATT&CK synchronisation, intel scanning, Atomic Red Team import, and scheduler health introspection. + +Also exposes email configuration CRUD (admin only) that writes to the +system_configs table so settings survive container restarts. """ import logging +from typing import Optional -from fastapi import APIRouter, BackgroundTasks, Depends, Request +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status +from pydantic import BaseModel from sqlalchemy.orm import Session from app.database import SessionLocal, get_db @@ -24,6 +29,68 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/system", tags=["system"]) +# --------------------------------------------------------------------------- +# Pydantic schemas for email config +# --------------------------------------------------------------------------- + + +class EmailConfigOut(BaseModel): + enabled: bool + host: str + port: int + username: str + from_email: str + use_tls: bool + # password is never returned + + +class EmailConfigUpdate(BaseModel): + enabled: Optional[bool] = None + host: Optional[str] = None + port: Optional[int] = None + username: Optional[str] = None + password: Optional[str] = None + from_email: Optional[str] = None + use_tls: Optional[bool] = None + + +class EmailTestRequest(BaseModel): + to: str + + +# --------------------------------------------------------------------------- +# Helpers for system_configs CRUD +# --------------------------------------------------------------------------- + +_SMTP_KEYS = { + "enabled": "smtp.enabled", + "host": "smtp.host", + "port": "smtp.port", + "username": "smtp.username", + "password": "smtp.password", + "from_email": "smtp.from_email", + "use_tls": "smtp.use_tls", +} + + +def _upsert_config(db: Session, key: str, value: str) -> None: + from app.models.system_config import SystemConfig # lazy import avoids circular + + row = db.query(SystemConfig).filter(SystemConfig.key == key).first() + if row: + row.value = value + else: + row = SystemConfig(key=key, value=value) + db.add(row) + + +def _read_email_config_from_db(db: Session) -> dict: + """Return a dict with resolved email settings (DB overrides env).""" + from app.services.email_service import _get_smtp_config + + return _get_smtp_config(db) + + def _bg_mitre_sync() -> None: """Run MITRE sync in a background task with its own DB session.""" logger.info("Background MITRE sync task starting...") @@ -132,3 +199,89 @@ def scheduler_status( for job in jobs ], } + + +# --------------------------------------------------------------------------- +# GET /system/email-config +# --------------------------------------------------------------------------- + + +@router.get("/email-config", response_model=EmailConfigOut) +def get_email_config( + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Return current SMTP email configuration (merged DB + env). + + **Requires** the ``admin`` role. Password is never returned. + """ + cfg = _read_email_config_from_db(db) + return EmailConfigOut( + enabled=cfg["enabled"], + host=cfg["host"], + port=cfg["port"], + username=cfg["username"], + from_email=cfg["from_email"], + use_tls=cfg["use_tls"], + ) + + +# --------------------------------------------------------------------------- +# PATCH /system/email-config +# --------------------------------------------------------------------------- + + +@router.patch("/email-config", response_model=EmailConfigOut) +def update_email_config( + payload: EmailConfigUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Update SMTP email configuration and persist to DB. + + **Requires** the ``admin`` role. + Only provided fields are updated (partial update). + """ + update_data = payload.model_dump(exclude_unset=True) + for field, val in update_data.items(): + db_key = _SMTP_KEYS.get(field) + if db_key: + _upsert_config(db, db_key, str(val)) + db.commit() + + cfg = _read_email_config_from_db(db) + return EmailConfigOut( + enabled=cfg["enabled"], + host=cfg["host"], + port=cfg["port"], + username=cfg["username"], + from_email=cfg["from_email"], + use_tls=cfg["use_tls"], + ) + + +# --------------------------------------------------------------------------- +# POST /system/email-test +# --------------------------------------------------------------------------- + + +@router.post("/email-test") +def send_test_email( + payload: EmailTestRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Send a test email to verify SMTP configuration. + + **Requires** the ``admin`` role. + Returns 200 on success, 502 if sending fails. + """ + from app.services.email_service import send_test_email as _send_test + + ok = _send_test(payload.to, db=db) + if not ok: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to send test email. Check SMTP configuration and server logs.", + ) + return {"detail": f"Test email sent to {payload.to}"} diff --git a/backend/app/routers/webhooks.py b/backend/app/routers/webhooks.py index 5a9e39c..11ce303 100644 --- a/backend/app/routers/webhooks.py +++ b/backend/app/routers/webhooks.py @@ -16,7 +16,7 @@ from fastapi import APIRouter, Depends, status from sqlalchemy.orm import Session from app.database import get_db -from app.dependencies.auth import require_role +from app.dependencies.auth import require_any_role from app.domain.unit_of_work import UnitOfWork from app.models.user import User from app.schemas.webhook import WebhookConfigCreate, WebhookConfigOut, WebhookConfigUpdate @@ -52,7 +52,7 @@ def list_webhooks_route( offset: int = 0, limit: int = 50, db: Session = Depends(get_db), - current_user: User = Depends(require_role("admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Return all webhook configurations. **Requires admin role.**""" webhooks = list_webhooks(db, offset=offset, limit=limit) @@ -68,7 +68,7 @@ def list_webhooks_route( def create_webhook_route( payload: WebhookConfigCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_role("admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Create a new webhook configuration. **Requires admin role.**""" with UnitOfWork(db) as uow: @@ -87,7 +87,7 @@ def create_webhook_route( def get_webhook_route( webhook_id: uuid.UUID, db: Session = Depends(get_db), - current_user: User = Depends(require_role("admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Return a single webhook configuration. **Requires admin role.**""" wh = get_webhook_or_raise(db, webhook_id) @@ -104,7 +104,7 @@ def update_webhook_route( webhook_id: uuid.UUID, payload: WebhookConfigUpdate, db: Session = Depends(get_db), - current_user: User = Depends(require_role("admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Update one or more fields of a webhook configuration. **Requires admin role.**""" with UnitOfWork(db) as uow: @@ -123,7 +123,7 @@ def update_webhook_route( def delete_webhook_route( webhook_id: uuid.UUID, db: Session = Depends(get_db), - current_user: User = Depends(require_role("admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Hard-delete a webhook configuration. **Requires admin role.**""" with UnitOfWork(db) as uow: @@ -140,7 +140,7 @@ def delete_webhook_route( def test_webhook_route( webhook_id: uuid.UUID, db: Session = Depends(get_db), - current_user: User = Depends(require_role("admin")), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Send a test ping to the webhook endpoint. **Requires admin role.**""" # Verify the webhook exists before dispatching diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 44c68f8..a9e1e6f 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -1,21 +1,94 @@ """Email notification service using SMTP. -Sending is silently skipped when SMTP_ENABLED=False (default). -All errors are caught and logged — email failures never crash the caller. +Sending is silently skipped when SMTP_ENABLED=False (default) and no +DB config overrides it. All errors are caught and logged — email +failures never crash the caller. + +Config priority: +1. system_configs table (key ``smtp.*``) — managed via the Settings UI +2. .env / environment variables (app.config.settings) """ import logging import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from typing import Optional from app.config import settings logger = logging.getLogger(__name__) -def send_email(to: str, subject: str, html_body: str) -> bool: - """Send an HTML email. Returns True on success, False on skip/error.""" - if not settings.SMTP_ENABLED: +# --------------------------------------------------------------------------- +# Helpers — read effective SMTP config (DB first, env fallback) +# --------------------------------------------------------------------------- + + +def _get_smtp_config(db=None) -> dict: + """Return a dict with resolved SMTP settings. + + When *db* is provided the function looks up ``system_configs`` rows + whose key starts with ``smtp.`` and overrides the .env values. + """ + cfg = { + "enabled": settings.SMTP_ENABLED, + "host": settings.SMTP_HOST, + "port": settings.SMTP_PORT, + "username": settings.SMTP_USERNAME, + "password": settings.SMTP_PASSWORD, + "from_email": settings.SMTP_FROM_EMAIL, + "use_tls": settings.SMTP_USE_TLS, + } + + if db is not None: + try: + from app.models.system_config import SystemConfig # avoid circular + + rows = db.query(SystemConfig).filter( + SystemConfig.key.like("smtp.%") + ).all() + for row in rows: + k = row.key # e.g. "smtp.host" + v = row.value + if v is None: + continue + short = k[len("smtp."):] # "host" + if short == "enabled": + cfg["enabled"] = v.lower() in ("true", "1", "yes") + elif short == "host": + cfg["host"] = v + elif short == "port": + try: + cfg["port"] = int(v) + except ValueError: + pass + elif short == "username": + cfg["username"] = v + elif short == "password": + cfg["password"] = v + elif short == "from_email": + cfg["from_email"] = v + elif short == "use_tls": + cfg["use_tls"] = v.lower() in ("true", "1", "yes") + except Exception: + logger.exception("Failed to read SMTP config from DB — falling back to env") + + return cfg + + +# --------------------------------------------------------------------------- +# Core send +# --------------------------------------------------------------------------- + + +def send_email(to: str, subject: str, html_body: str, db=None) -> bool: + """Send an HTML email. Returns True on success, False on skip/error. + + Pass *db* to allow runtime config override from system_configs table. + """ + cfg = _get_smtp_config(db) + + if not cfg["enabled"]: logger.debug("SMTP disabled — skipping email to %s: %s", to, subject) return False if not to: @@ -23,14 +96,14 @@ def send_email(to: str, subject: str, html_body: str) -> bool: try: msg = MIMEMultipart("alternative") msg["Subject"] = f"[Aegis] {subject}" - msg["From"] = settings.SMTP_FROM_EMAIL + msg["From"] = cfg["from_email"] msg["To"] = to msg.attach(MIMEText(html_body, "html")) - with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=10) as server: - if settings.SMTP_USE_TLS: + with smtplib.SMTP(cfg["host"], cfg["port"], timeout=10) as server: + if cfg["use_tls"]: server.starttls() - if settings.SMTP_USERNAME: - server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD) + if cfg["username"]: + server.login(cfg["username"], cfg["password"]) server.send_message(msg) logger.info("Email sent to %s: %s", to, subject) return True @@ -39,7 +112,12 @@ def send_email(to: str, subject: str, html_body: str) -> bool: return False -def send_test_validated_email(to: str, test_name: str, technique_id: str, test_id: str) -> bool: +# --------------------------------------------------------------------------- +# Typed senders +# --------------------------------------------------------------------------- + + +def send_test_validated_email(to: str, test_name: str, technique_id: str, test_id: str, db=None) -> bool: """Notify that a test was validated.""" url = f"{settings.PLATFORM_URL}/tests/{test_id}" html = f""" @@ -49,10 +127,10 @@ def send_test_validated_email(to: str, test_name: str, technique_id: str, test_i

View Test

Aegis ATT&CK Coverage Platform

""" - return send_email(to, f"Test Validated: {test_name}", html) + return send_email(to, f"Test Validated: {test_name}", html, db=db) -def send_campaign_completed_email(to: str, campaign_name: str, campaign_id: str) -> bool: +def send_campaign_completed_email(to: str, campaign_name: str, campaign_id: str, db=None) -> bool: """Notify that a campaign was completed.""" url = f"{settings.PLATFORM_URL}/campaigns/{campaign_id}" html = f""" @@ -62,10 +140,10 @@ def send_campaign_completed_email(to: str, campaign_name: str, campaign_id: str)

View Campaign

Aegis ATT&CK Coverage Platform

""" - return send_email(to, f"Campaign Completed: {campaign_name}", html) + return send_email(to, f"Campaign Completed: {campaign_name}", html, db=db) -def send_new_mitre_techniques_email(to: str, created: int, updated: int) -> bool: +def send_new_mitre_techniques_email(to: str, created: int, updated: int, db=None) -> bool: """Notify of new MITRE techniques after sync.""" if created == 0: return False @@ -76,4 +154,15 @@ def send_new_mitre_techniques_email(to: str, created: int, updated: int) -> bool

View Techniques

Aegis ATT&CK Coverage Platform

""" - return send_email(to, f"MITRE ATT&CK Updated: {created} new techniques", html) + return send_email(to, f"MITRE ATT&CK Updated: {created} new techniques", html, db=db) + + +def send_test_email(to: str, db=None) -> bool: + """Send a test/ping email to verify SMTP config.""" + html = """ + +

✅ Email Configuration Test

+

This is a test email from Aegis. If you received this, your SMTP configuration is working correctly.

+

Aegis ATT&CK Coverage Platform

+ """ + return send_email(to, "Email Configuration Test", html, db=db) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d72f77..8de2a4d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,7 @@ const ThreatActorDetailPage = React.lazy(() => import("./pages/ThreatActorDetail const CampaignsPage = React.lazy(() => import("./pages/CampaignsPage")); const CampaignDetailPage = React.lazy(() => import("./pages/CampaignDetailPage")); const ComparisonPage = React.lazy(() => import("./pages/ComparisonPage")); +const SettingsPage = React.lazy(() => import("./pages/SettingsPage")); export default function App() { return ( @@ -92,6 +93,9 @@ export default function App() { {/* ── Reports ──────────────────────────────────────────── */} }>} /> + {/* ── Settings (all authenticated users) ───────────────── */} + }>} /> + {/* ── System (admin only) ──────────────────────────────── */} { + const { data } = await client.get("/system/email-config"); + return data; +} + +export async function updateEmailConfig(payload: EmailConfigUpdate): Promise { + const { data } = await client.patch("/system/email-config", payload); + return data; +} + +export async function sendTestEmail(to: string): Promise<{ detail: string }> { + const { data } = await client.post<{ detail: string }>("/system/email-test", { to }); + return data; +} + +// --------------------------------------------------------------------------- +// Webhook config (admin + leads) +// --------------------------------------------------------------------------- + +export interface WebhookOut { + id: string; + name: string; + url: string; + secret: string | null; + events: string[]; + is_active: boolean; + created_at: string | null; + last_triggered_at: string | null; + failure_count: number; +} + +export interface WebhookCreate { + name: string; + url: string; + secret?: string; + events: string[]; + is_active?: boolean; +} + +export interface WebhookUpdate { + name?: string; + url?: string; + secret?: string; + events?: string[]; + is_active?: boolean; +} + +export async function getWebhooks(): Promise { + const { data } = await client.get("/webhooks"); + return data; +} + +export async function createWebhook(payload: WebhookCreate): Promise { + const { data } = await client.post("/webhooks", payload); + return data; +} + +export async function updateWebhook(id: string, payload: WebhookUpdate): Promise { + const { data } = await client.patch(`/webhooks/${id}`, payload); + return data; +} + +export async function deleteWebhook(id: string): Promise { + await client.delete(`/webhooks/${id}`); +} + +export async function testWebhook(id: string): Promise<{ detail: string }> { + const { data } = await client.post<{ detail: string }>(`/webhooks/${id}/test`); + return data; +} + +// --------------------------------------------------------------------------- +// User preferences (all users) +// --------------------------------------------------------------------------- + +export interface NotificationPreferences { + // Universal + email_on_test_validated: boolean; + email_on_test_rejected: boolean; + email_on_campaign_completed: boolean; + email_on_new_mitre_techniques: boolean; + email_on_stale_coverage: boolean; + in_app_all: boolean; + // Tech + leads + email_on_assigned_to_campaign?: boolean; + email_on_test_state_change?: boolean; + // Leads only + email_on_all_team_validations?: boolean; + email_on_webhook_failures?: boolean; + // Admin only + email_on_new_users?: boolean; + email_on_system_errors?: boolean; +} + +export interface UserPreferencesUpdate { + notification_preferences?: Partial; + jira_account_id?: string | null; +} + +export interface UserMeOut { + id: string; + username: string; + email: string | null; + role: string; + is_active: boolean; + must_change_password: boolean; + created_at: string | null; + last_login: string | null; + notification_preferences: NotificationPreferences | null; + jira_account_id: string | null; +} + +export async function getMe(): Promise { + const { data } = await client.get("/users/me"); + return data; +} + +export async function updateMyPreferences(payload: UserPreferencesUpdate): Promise { + const { data } = await client.patch("/users/me/preferences", payload); + return data; +} + diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index b002e03..6e38e7f 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -50,6 +50,7 @@ const mainLinks: NavItem[] = [ { to: "/compliance", label: "Compliance", icon: ShieldCheck }, { to: "/comparison", label: "Comparison", icon: GitCompareArrows, roles: ["admin", "red_lead", "blue_lead", "viewer"] }, { to: "/reports", label: "Reports", icon: BarChart3 }, + { to: "/settings", label: "Settings", icon: Settings }, ]; const systemLinks: NavItem[] = [ diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..547533e --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,1003 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Settings, + Mail, + Webhook, + Bell, + User, + Plus, + Trash2, + TestTube, + Save, + Eye, + EyeOff, + CheckCircle, + XCircle, + AlertCircle, + Loader2, + Edit2, + X, +} from "lucide-react"; +import { useAuth } from "../context/AuthContext"; +import { + getEmailConfig, + updateEmailConfig, + sendTestEmail, + getWebhooks, + createWebhook, + updateWebhook, + deleteWebhook, + testWebhook, + getMe, + updateMyPreferences, + type EmailConfigUpdate, + type WebhookCreate, + type WebhookOut, + type NotificationPreferences, +} from "../api/settings"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function Section({ + title, + icon: Icon, + children, +}: { + title: string; + icon: React.FC<{ className?: string }>; + children: React.ReactNode; +}) { + return ( +
+

+ + {title} +

+ {children} +
+ ); +} + +function ToggleRow({ + label, + description, + checked, + onChange, +}: { + label: string; + description?: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( +
+
+

{label}

+ {description &&

{description}

} +
+ +
+ ); +} + +function Toast({ + message, + type, + onClose, +}: { + message: string; + type: "success" | "error"; + onClose: () => void; +}) { + return ( +
+ {type === "success" ? ( + + ) : ( + + )} + {message} + +
+ ); +} + +// --------------------------------------------------------------------------- +// SMTP Email Section (admin only) +// --------------------------------------------------------------------------- + +const AVAILABLE_EVENTS = [ + "test.validated", + "test.rejected", + "campaign.completed", + "campaign.started", + "mitre.synced", + "webhook.test", +]; + +function EmailSection() { + const qc = useQueryClient(); + const [showPw, setShowPw] = useState(false); + const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null); + const [testEmail, setTestEmail] = useState(""); + + const { data: cfg, isLoading } = useQuery({ + queryKey: ["email-config"], + queryFn: getEmailConfig, + }); + + const [form, setForm] = useState({}); + const effective = { ...cfg, ...form }; + + const saveMutation = useMutation({ + mutationFn: updateEmailConfig, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["email-config"] }); + setForm({}); + setToast({ msg: "Email configuration saved", type: "success" }); + }, + onError: () => setToast({ msg: "Failed to save email configuration", type: "error" }), + }); + + const testMutation = useMutation({ + mutationFn: () => sendTestEmail(testEmail), + onSuccess: () => setToast({ msg: `Test email sent to ${testEmail}`, type: "success" }), + onError: () => + setToast({ msg: "Failed to send test email. Check SMTP settings and logs.", type: "error" }), + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + const field = ( + key: keyof EmailConfigUpdate, + label: string, + type = "text", + placeholder = "" + ) => ( +
+ + + setForm((prev) => ({ ...prev, [key]: e.target.value })) + } + placeholder={placeholder} + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" + /> +
+ ); + + return ( + <> + {toast && ( + setToast(null)} /> + )} +
+ {/* Enable toggle */} + setForm((prev) => ({ ...prev, enabled: v }))} + /> + +
+ {field("host", "SMTP Host", "text", "smtp.gmail.com")} +
+ + + setForm((prev) => ({ ...prev, port: parseInt(e.target.value) || 587 })) + } + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none" + /> +
+ {field("username", "Username / Email", "text", "you@company.com")} +
+ +
+ setForm((prev) => ({ ...prev, password: e.target.value }))} + placeholder="Leave blank to keep current" + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 pr-10 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" + /> + +
+
+ {field("from_email", "From Address", "email", "aegis@company.com")} +
+ + setForm((prev) => ({ ...prev, use_tls: v }))} + /> + +
+ +
+ + {/* Test email */} +
+

Send Test Email

+
+ setTestEmail(e.target.value)} + placeholder="recipient@example.com" + className="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" + /> + +
+
+
+ + ); +} + +// --------------------------------------------------------------------------- +// Webhooks Section (admin + leads) +// --------------------------------------------------------------------------- + +function WebhookForm({ + initial, + onSave, + onCancel, + isSaving, +}: { + initial?: Partial; + onSave: (data: WebhookCreate) => void; + onCancel: () => void; + isSaving: boolean; +}) { + const [form, setForm] = useState({ + name: initial?.name ?? "", + url: initial?.url ?? "", + secret: initial?.secret ?? "", + events: initial?.events ?? [], + is_active: initial?.is_active ?? true, + }); + + const toggleEvent = (ev: string) => { + setForm((prev) => ({ + ...prev, + events: prev.events.includes(ev) + ? prev.events.filter((e) => e !== ev) + : [...prev.events, ev], + })); + }; + + return ( +
+
+
+ + setForm((p) => ({ ...p, name: e.target.value }))} + placeholder="My Webhook" + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" + /> +
+
+ + setForm((p) => ({ ...p, url: e.target.value }))} + placeholder="https://hooks.example.com/..." + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" + /> +
+
+ + setForm((p) => ({ ...p, secret: e.target.value }))} + placeholder="supersecretkey" + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" + /> +
+
+
+ +
+ {AVAILABLE_EVENTS.map((ev) => ( + + ))} +
+
+ setForm((p) => ({ ...p, is_active: v }))} + /> +
+ + +
+
+ ); +} + +function WebhooksSection() { + const qc = useQueryClient(); + const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null); + const [creating, setCreating] = useState(false); + const [editingId, setEditingId] = useState(null); + + const { data: webhooks = [], isLoading } = useQuery({ + queryKey: ["webhooks"], + queryFn: getWebhooks, + }); + + const createMut = useMutation({ + mutationFn: createWebhook, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["webhooks"] }); + setCreating(false); + setToast({ msg: "Webhook created", type: "success" }); + }, + onError: () => setToast({ msg: "Failed to create webhook", type: "error" }), + }); + + const updateMut = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial }) => + updateWebhook(id, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["webhooks"] }); + setEditingId(null); + setToast({ msg: "Webhook updated", type: "success" }); + }, + onError: () => setToast({ msg: "Failed to update webhook", type: "error" }), + }); + + const deleteMut = useMutation({ + mutationFn: deleteWebhook, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["webhooks"] }); + setToast({ msg: "Webhook deleted", type: "success" }); + }, + onError: () => setToast({ msg: "Failed to delete webhook", type: "error" }), + }); + + const testMut = useMutation({ + mutationFn: testWebhook, + onSuccess: () => setToast({ msg: "Test ping dispatched", type: "success" }), + onError: () => setToast({ msg: "Ping failed", type: "error" }), + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + <> + {toast && ( + setToast(null)} /> + )} +
+ {webhooks.length === 0 && !creating && ( +

No webhooks configured yet.

+ )} + + {webhooks.map((wh) => + editingId === wh.id ? ( + updateMut.mutate({ id: wh.id, data })} + onCancel={() => setEditingId(null)} + isSaving={updateMut.isPending} + /> + ) : ( + setEditingId(wh.id)} + onDelete={() => deleteMut.mutate(wh.id)} + onTest={() => testMut.mutate(wh.id)} + isDeleting={deleteMut.isPending} + isTesting={testMut.isPending} + /> + ) + )} + + {creating && ( + createMut.mutate(data)} + onCancel={() => setCreating(false)} + isSaving={createMut.isPending} + /> + )} + + {!creating && ( + + )} +
+ + ); +} + +function WebhookRow({ + wh, + onEdit, + onDelete, + onTest, + isDeleting, + isTesting, +}: { + wh: WebhookOut; + onEdit: () => void; + onDelete: () => void; + onTest: () => void; + isDeleting: boolean; + isTesting: boolean; +}) { + return ( +
+
+
+
+ {wh.name} + + {wh.is_active ? "active" : "inactive"} + + {wh.failure_count > 0 && ( + + + {wh.failure_count} failures + + )} +
+

{wh.url}

+
+ {wh.events.map((ev) => ( + + {ev} + + ))} +
+
+
+ + + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Notification Preferences Section (all users) +// --------------------------------------------------------------------------- + +const PREF_DEFS: { + key: keyof NotificationPreferences; + label: string; + description?: string; + roles: string[]; // roles that see this pref +}[] = [ + { + key: "email_on_test_validated", + label: "Email on test validated", + description: "Receive an email when a test you're involved with gets validated", + roles: ["admin", "red_lead", "blue_lead", "red_tech", "blue_tech", "viewer"], + }, + { + key: "email_on_test_rejected", + label: "Email on test rejected", + description: "Receive an email when a test you submitted gets rejected", + roles: ["admin", "red_lead", "blue_lead", "red_tech", "blue_tech"], + }, + { + key: "email_on_campaign_completed", + label: "Email on campaign completed", + roles: ["admin", "red_lead", "blue_lead", "red_tech", "blue_tech"], + }, + { + key: "email_on_new_mitre_techniques", + label: "Email on new MITRE techniques", + description: "Get notified when the MITRE ATT&CK sync adds new techniques", + roles: ["admin", "red_lead", "blue_lead"], + }, + { + key: "email_on_stale_coverage", + label: "Email on stale coverage alert", + description: "Notified when techniques haven't been tested in a long time", + roles: ["admin", "red_lead", "blue_lead"], + }, + { + key: "email_on_assigned_to_campaign", + label: "Email when assigned to a campaign", + roles: ["admin", "red_lead", "blue_lead", "red_tech", "blue_tech"], + }, + { + key: "email_on_test_state_change", + label: "Email on test state change", + description: "Notified when tests move through the validation workflow", + roles: ["admin", "red_lead", "blue_lead", "red_tech", "blue_tech"], + }, + { + key: "email_on_all_team_validations", + label: "Email on all team validations", + description: "Lead-level: receive email for every validation in your team", + roles: ["admin", "red_lead", "blue_lead"], + }, + { + key: "email_on_webhook_failures", + label: "Email on webhook delivery failures", + roles: ["admin", "red_lead", "blue_lead"], + }, + { + key: "email_on_new_users", + label: "Email on new user registration", + roles: ["admin"], + }, + { + key: "email_on_system_errors", + label: "Email on system errors", + description: "Critical errors in background jobs and integrations", + roles: ["admin"], + }, + { + key: "in_app_all", + label: "In-app notifications", + description: "Show notification bell alerts inside Aegis", + roles: ["admin", "red_lead", "blue_lead", "red_tech", "blue_tech", "viewer"], + }, +]; + +const DEFAULT_PREFS: NotificationPreferences = { + email_on_test_validated: true, + email_on_test_rejected: true, + email_on_campaign_completed: true, + email_on_new_mitre_techniques: false, + email_on_stale_coverage: false, + email_on_assigned_to_campaign: true, + email_on_test_state_change: true, + email_on_all_team_validations: false, + email_on_webhook_failures: true, + email_on_new_users: false, + email_on_system_errors: true, + in_app_all: true, +}; + +function NotificationSection() { + const qc = useQueryClient(); + const { user } = useAuth(); + const role = user?.role ?? "viewer"; + + const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null); + + const { data: me, isLoading } = useQuery({ + queryKey: ["me-prefs"], + queryFn: getMe, + }); + + const [localPrefs, setLocalPrefs] = useState>({}); + const [jiraAccountId, setJiraAccountId] = useState(""); + const [jiraIdDirty, setJiraIdDirty] = useState(false); + + const effectivePrefs: NotificationPreferences = { + ...DEFAULT_PREFS, + ...(me?.notification_preferences ?? {}), + ...localPrefs, + }; + + const saveMut = useMutation({ + mutationFn: updateMyPreferences, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["me-prefs"] }); + setLocalPrefs({}); + setJiraIdDirty(false); + setToast({ msg: "Preferences saved", type: "success" }); + }, + onError: () => setToast({ msg: "Failed to save preferences", type: "error" }), + }); + + const handleSave = () => { + const payload: Parameters[0] = {}; + if (Object.keys(localPrefs).length > 0) { + payload.notification_preferences = { + ...(me?.notification_preferences ?? {}), + ...localPrefs, + }; + } + if (jiraIdDirty) { + payload.jira_account_id = jiraAccountId || null; + } + if (Object.keys(payload).length > 0) { + saveMut.mutate(payload); + } + }; + + const isDirty = Object.keys(localPrefs).length > 0 || jiraIdDirty; + + if (isLoading) { + return ( +
+ +
+ ); + } + + const visiblePrefs = PREF_DEFS.filter((d) => d.roles.includes(role)); + + return ( + <> + {toast && ( + setToast(null)} /> + )} +
+ {visiblePrefs.map((def) => ( + setLocalPrefs((prev) => ({ ...prev, [def.key]: v }))} + /> + ))} +
+ +
+ +
+ + ); +} + +// --------------------------------------------------------------------------- +// Profile / Jira Section (all users) +// --------------------------------------------------------------------------- + +function ProfileSection() { + const qc = useQueryClient(); + const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null); + + const { data: me, isLoading } = useQuery({ + queryKey: ["me-prefs"], + queryFn: getMe, + }); + + const [jiraAccountId, setJiraAccountId] = useState(""); + const [dirty, setDirty] = useState(false); + + // Sync from server on load + if (me && !dirty && jiraAccountId === "" && me.jira_account_id) { + setJiraAccountId(me.jira_account_id ?? ""); + } + + const saveMut = useMutation({ + mutationFn: updateMyPreferences, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["me-prefs"] }); + setDirty(false); + setToast({ msg: "Profile settings saved", type: "success" }); + }, + onError: () => setToast({ msg: "Failed to save", type: "error" }), + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + <> + {toast && ( + setToast(null)} /> + )} +
+
+
+

Username

+

{me?.username}

+
+
+

Role

+

{me?.role?.replace("_", " ")}

+
+
+

Email

+

{me?.email ?? "—"}

+
+
+

Last Login

+

+ {me?.last_login + ? new Date(me.last_login).toLocaleString("en-US", { + timeZone: "UTC", + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + " UTC" + : "—"} +

+
+
+ +
+

Jira Integration

+
+ + { + setJiraAccountId(e.target.value); + setDirty(true); + }} + placeholder="e.g. 5abcdef1234567890abcdef1" + className="w-full max-w-sm rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" + /> +

+ Your Atlassian account ID used to link Aegis items to Jira. Find it in your Jira profile URL. +

+
+
+ +
+
+
+ + ); +} + +// --------------------------------------------------------------------------- +// Main SettingsPage +// --------------------------------------------------------------------------- + +type Tab = "profile" | "notifications" | "webhooks" | "email"; + +export default function SettingsPage() { + const { user } = useAuth(); + const role = user?.role ?? "viewer"; + const isAdmin = role === "admin"; + const isLead = ["admin", "red_lead", "blue_lead"].includes(role); + + const [activeTab, setActiveTab] = useState("profile"); + + const tabs: { id: Tab; label: string; icon: React.FC<{ className?: string }>; show: boolean }[] = + [ + { id: "profile", label: "Profile", icon: User, show: true }, + { id: "notifications", label: "Notifications", icon: Bell, show: true }, + { + id: "webhooks", + label: "Webhooks", + icon: Webhook, + show: isLead, + }, + { id: "email", label: "Email / SMTP", icon: Mail, show: isAdmin }, + ]; + + const visibleTabs = tabs.filter((t) => t.show); + + return ( +
+
+ {/* Header */} +
+ +
+

Settings

+

+ Manage your preferences, integrations, and system configuration +

+
+
+ +
+ {/* Sidebar tabs */} + + + {/* Content */} +
+ {activeTab === "profile" && ( +
+ +
+ )} + {activeTab === "notifications" && ( +
+ +
+ )} + {activeTab === "webhooks" && isLead && ( +
+ +
+ )} + {activeTab === "email" && isAdmin && ( +
+ +
+ )} +
+
+
+
+ ); +}