import { useState, useEffect } 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, Link2, } from "lucide-react"; import { useAuth } from "../context/AuthContext"; import { getEmailConfig, updateEmailConfig, sendTestEmail, getWebhooks, createWebhook, updateWebhook, deleteWebhook, testWebhook, getMe, updateMyPreferences, getJiraConfig, updateJiraConfig, testJiraConnection, type EmailConfigUpdate, type WebhookCreate, type WebhookOut, type NotificationPreferences, type JiraConfigUpdate, } 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 [jiraEmail, setJiraEmail] = useState(""); const [jiraApiToken, setJiraApiToken] = useState(""); const [showToken, setShowToken] = useState(false); const [dirty, setDirty] = useState(false); // Initialise editable fields from server on first successful load useEffect(() => { if (me) { setJiraAccountId(me.jira_account_id ?? ""); setJiraEmail(me.jira_email ?? ""); // Never pre-fill the token — we only know whether it is set, not its value } // Only run when `me` transitions from undefined → data (i.e., first load) // eslint-disable-next-line react-hooks/exhaustive-deps }, [!!me]); const saveMut = useMutation({ mutationFn: updateMyPreferences, onSuccess: () => { qc.invalidateQueries({ queryKey: ["me-prefs"] }); setDirty(false); setJiraApiToken(""); // clear token field after save — it's now persisted setToast({ msg: "Profile settings saved", type: "success" }); }, onError: () => setToast({ msg: "Failed to save", type: "error" }), }); const handleSave = () => { const payload: Parameters[0] = { jira_account_id: jiraAccountId || null, jira_email: jiraEmail || null, }; // Only send token when the user has typed something new // (empty field = "keep current token unchanged") if (jiraApiToken.trim() !== "") { payload.jira_api_token = jiraApiToken.trim(); } saveMut.mutate(payload); }; 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 (personal settings)

Configure your personal Atlassian credentials for Jira integration.

{/* Jira email */}
{ setJiraEmail(e.target.value); setDirty(true); }} placeholder={me?.email ?? "your@company.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" />

Email used to authenticate with Atlassian. {me?.email && !me?.jira_email && ( Currently using Aegis email: {me.email} )}

{/* API Token */}
{ setJiraApiToken(e.target.value); setDirty(true); }} placeholder={me?.jira_token_set ? "Leave blank to keep current token" : "Paste your Atlassian API token here"} 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" />
{me?.jira_token_set ? ( Token configured ) : ( Not configured )} Create token at id.atlassian.com →
{/* Account ID */}
{ 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. Found in your Jira profile URL.

); } // --------------------------------------------------------------------------- // Jira Admin Config Section (admin only) // --------------------------------------------------------------------------- function JiraConfigSection() { const qc = useQueryClient(); const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null); const [testResult, setTestResult] = useState<{ connectedAs: string; url: string } | null>(null); const [testError, setTestError] = useState(null); const { data: cfg, isLoading } = useQuery({ queryKey: ["jira-config"], queryFn: getJiraConfig, }); const [form, setForm] = useState({}); const effective = { ...cfg, ...form }; const saveMut = useMutation({ mutationFn: updateJiraConfig, onSuccess: () => { qc.invalidateQueries({ queryKey: ["jira-config"] }); setForm({}); setToast({ msg: "Jira configuration saved", type: "success" }); }, onError: () => setToast({ msg: "Failed to save Jira configuration", type: "error" }), }); const testMut = useMutation({ mutationFn: testJiraConnection, onSuccess: (data) => { setTestResult({ connectedAs: data.connected_as, url: data.jira_url }); setTestError(null); }, onError: (err: Error) => { setTestError(err.message || "Connection failed"); setTestResult(null); }, }); if (isLoading) { return (
); } return ( <> {toast && ( setToast(null)} /> )}
setForm((prev) => ({ ...prev, enabled: v }))} />
setForm((prev) => ({ ...prev, url: e.target.value }))} placeholder="https://yourcompany.atlassian.net" 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" />

Base URL of your Jira instance

setForm((prev) => ({ ...prev, project_key: e.target.value }))} placeholder="SEC" 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" />

Jira project where test tickets will be created

setForm((prev) => ({ ...prev, parent_ticket: e.target.value }))} placeholder="SEC-100" 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" />

If set, all test tickets will be created as subtasks of this issue

{/* Test connection */}

Test Connection

Uses your personal Jira API token (configured in the Profile tab)
{testResult && (
Connected as: {testResult.connectedAs}
)} {testError && (
{testError}
)}
); } // --------------------------------------------------------------------------- // Main SettingsPage // --------------------------------------------------------------------------- type Tab = "profile" | "notifications" | "webhooks" | "email" | "jira"; 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 }, { id: "jira", label: "Jira", icon: Link2, 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 && (
)} {activeTab === "jira" && isAdmin && (
)}
); }