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")}
{field("from_email", "From Address", "email", "aegis@company.com")}
setForm((prev) => ({ ...prev, use_tls: v }))}
/>
{/* Test email */}
>
);
}
// ---------------------------------------------------------------------------
// 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 (
{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)} />
)}
Role
{me?.role?.replace("_", " ")}
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
>
);
}
// ---------------------------------------------------------------------------
// 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 && (
)}
);
}