Files
Aegis/frontend/src/pages/SettingsPage.tsx
T

1793 lines
73 KiB
TypeScript

import { useState, useEffect, useRef } 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,
KeyRound,
Copy,
Building2,
Server,
Database,
HardDrive,
Clock,
PackageOpen,
Download,
Upload,
ShieldCheck,
} from "lucide-react";
import { useAuth } from "../context/AuthContext";
import client from "../api/client";
import {
getSsoConfig,
updateSsoConfig,
type SsoConfig,
type SsoConfigUpdate,
} from "../api/sso";
import {
getEmailConfig,
updateEmailConfig,
sendTestEmail,
getWebhooks,
createWebhook,
updateWebhook,
deleteWebhook,
testWebhook,
getMe,
updateMyPreferences,
getJiraConfig,
updateJiraConfig,
testJiraConnection,
testTempoConnection,
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 (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-6 flex items-center gap-2 text-lg font-semibold text-white">
<Icon className="h-5 w-5 text-cyan-400" />
{title}
</h2>
{children}
</div>
);
}
function ToggleRow({
label,
description,
checked,
onChange,
}: {
label: string;
description?: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between py-3 border-b border-gray-800 last:border-0">
<div>
<p className="text-sm font-medium text-gray-200">{label}</p>
{description && <p className="text-xs text-gray-500">{description}</p>}
</div>
<button
type="button"
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
checked ? "bg-cyan-500" : "bg-gray-700"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
checked ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
);
}
function Toast({
message,
type,
onClose,
}: {
message: string;
type: "success" | "error";
onClose: () => void;
}) {
return (
<div
className={`fixed bottom-6 right-6 z-50 flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg text-sm font-medium ${
type === "success"
? "bg-emerald-900/90 text-emerald-300 border border-emerald-700"
: "bg-red-900/90 text-red-300 border border-red-700"
}`}
>
{type === "success" ? (
<CheckCircle className="h-4 w-4 shrink-0" />
) : (
<XCircle className="h-4 w-4 shrink-0" />
)}
{message}
<button onClick={onClose} className="ml-2 opacity-60 hover:opacity-100">
<X className="h-4 w-4" />
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<EmailConfigUpdate & { password?: string }>({});
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 (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
);
}
const field = (
key: keyof EmailConfigUpdate,
label: string,
type = "text",
placeholder = ""
) => (
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">{label}</label>
<input
type={type}
value={String(form[key] ?? cfg?.[key as keyof typeof cfg] ?? "")}
onChange={(e) =>
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"
/>
</div>
);
return (
<>
{toast && (
<Toast message={toast.msg} type={toast.type} onClose={() => setToast(null)} />
)}
<div className="space-y-4">
{/* Enable toggle */}
<ToggleRow
label="Enable email notifications"
description="When disabled, no emails are sent regardless of other settings"
checked={Boolean(form.enabled !== undefined ? form.enabled : cfg?.enabled)}
onChange={(v) => setForm((prev) => ({ ...prev, enabled: v }))}
/>
<div className="grid grid-cols-2 gap-4 pt-2">
{field("host", "SMTP Host", "text", "smtp.gmail.com")}
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">SMTP Port</label>
<input
type="number"
value={String(form.port ?? cfg?.port ?? 587)}
onChange={(e) =>
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"
/>
</div>
{field("username", "Username / Email", "text", "you@company.com")}
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">Password</label>
<div className="relative">
<input
type={showPw ? "text" : "password"}
value={String(form.password ?? "")}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowPw(!showPw)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{field("from_email", "From Address", "email", "aegis@company.com")}
</div>
<ToggleRow
label="Use TLS (STARTTLS)"
checked={Boolean(form.use_tls !== undefined ? form.use_tls : cfg?.use_tls)}
onChange={(v) => setForm((prev) => ({ ...prev, use_tls: v }))}
/>
<div className="flex items-center justify-between pt-2">
<button
onClick={() => {
if (Object.keys(form).length > 0) saveMutation.mutate(form);
}}
disabled={saveMutation.isPending || Object.keys(form).length === 0}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{saveMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Save Configuration
</button>
</div>
{/* Test email */}
<div className="mt-4 rounded-lg border border-gray-700 bg-gray-800/50 p-4">
<p className="mb-3 text-sm font-medium text-gray-300">Send Test Email</p>
<div className="flex gap-2">
<input
type="email"
value={testEmail}
onChange={(e) => 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"
/>
<button
onClick={() => testEmail && testMutation.mutate()}
disabled={testMutation.isPending || !testEmail}
className="flex items-center gap-2 rounded-lg border border-cyan-700 px-4 py-2 text-sm font-medium text-cyan-400 hover:bg-cyan-900/30 disabled:opacity-50 transition-colors"
>
{testMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube className="h-4 w-4" />
)}
Send Test
</button>
</div>
</div>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Webhooks Section (admin + leads)
// ---------------------------------------------------------------------------
function WebhookForm({
initial,
onSave,
onCancel,
isSaving,
}: {
initial?: Partial<WebhookCreate>;
onSave: (data: WebhookCreate) => void;
onCancel: () => void;
isSaving: boolean;
}) {
const [form, setForm] = useState<WebhookCreate>({
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 (
<div className="rounded-lg border border-cyan-800/50 bg-gray-800/50 p-4 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">Name</label>
<input
value={form.name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">URL</label>
<input
value={form.url}
onChange={(e) => 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"
/>
</div>
<div className="col-span-2">
<label className="mb-1 block text-xs font-medium text-gray-400">
Secret (optional used for HMAC-SHA256 signature)
</label>
<input
value={form.secret ?? ""}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<label className="mb-2 block text-xs font-medium text-gray-400">Events</label>
<div className="flex flex-wrap gap-2">
{AVAILABLE_EVENTS.map((ev) => (
<button
key={ev}
type="button"
onClick={() => toggleEvent(ev)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
form.events.includes(ev)
? "bg-cyan-600 text-white"
: "bg-gray-700 text-gray-400 hover:bg-gray-600"
}`}
>
{ev}
</button>
))}
</div>
</div>
<ToggleRow
label="Active"
checked={form.is_active ?? true}
onChange={(v) => setForm((p) => ({ ...p, is_active: v }))}
/>
<div className="flex gap-2 pt-1">
<button
onClick={() => onSave(form)}
disabled={isSaving || !form.name || !form.url || form.events.length === 0}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save
</button>
<button
onClick={onCancel}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm font-medium text-gray-400 hover:text-gray-200 hover:border-gray-500 transition-colors"
>
Cancel
</button>
</div>
</div>
);
}
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<string | null>(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<WebhookCreate> }) =>
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 (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
);
}
return (
<>
{toast && (
<Toast message={toast.msg} type={toast.type} onClose={() => setToast(null)} />
)}
<div className="space-y-3">
{webhooks.length === 0 && !creating && (
<p className="text-center py-6 text-sm text-gray-500">No webhooks configured yet.</p>
)}
{webhooks.map((wh) =>
editingId === wh.id ? (
<WebhookForm
key={wh.id}
initial={{
name: wh.name,
url: wh.url,
events: wh.events,
is_active: wh.is_active,
}}
onSave={(data) => updateMut.mutate({ id: wh.id, data })}
onCancel={() => setEditingId(null)}
isSaving={updateMut.isPending}
/>
) : (
<WebhookRow
key={wh.id}
wh={wh}
onEdit={() => setEditingId(wh.id)}
onDelete={() => deleteMut.mutate(wh.id)}
onTest={() => testMut.mutate(wh.id)}
isDeleting={deleteMut.isPending}
isTesting={testMut.isPending}
/>
)
)}
{creating && (
<WebhookForm
onSave={(data) => createMut.mutate(data)}
onCancel={() => setCreating(false)}
isSaving={createMut.isPending}
/>
)}
{!creating && (
<button
onClick={() => setCreating(true)}
className="flex items-center gap-2 rounded-lg border border-dashed border-gray-700 px-4 py-2.5 text-sm font-medium text-gray-500 hover:border-cyan-700 hover:text-cyan-400 transition-colors w-full justify-center"
>
<Plus className="h-4 w-4" />
Add Webhook
</button>
)}
</div>
</>
);
}
function WebhookRow({
wh,
onEdit,
onDelete,
onTest,
isDeleting,
isTesting,
}: {
wh: WebhookOut;
onEdit: () => void;
onDelete: () => void;
onTest: () => void;
isDeleting: boolean;
isTesting: boolean;
}) {
return (
<div className="rounded-lg border border-gray-800 bg-gray-800/30 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-gray-200">{wh.name}</span>
<span
className={`text-[10px] font-semibold uppercase rounded-full px-2 py-0.5 ${
wh.is_active
? "bg-emerald-900/50 text-emerald-400"
: "bg-gray-700 text-gray-500"
}`}
>
{wh.is_active ? "active" : "inactive"}
</span>
{wh.failure_count > 0 && (
<span className="text-[10px] font-semibold uppercase rounded-full px-2 py-0.5 bg-red-900/50 text-red-400 flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{wh.failure_count} failures
</span>
)}
</div>
<p className="mt-0.5 text-xs text-gray-500 truncate">{wh.url}</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{wh.events.map((ev) => (
<span
key={ev}
className="text-[10px] rounded-full bg-gray-700 px-2 py-0.5 text-gray-400"
>
{ev}
</span>
))}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={onTest}
disabled={isTesting}
title="Send test ping"
className="p-1.5 rounded-lg text-gray-500 hover:text-cyan-400 hover:bg-cyan-900/20 transition-colors disabled:opacity-40"
>
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube className="h-4 w-4" />
)}
</button>
<button
onClick={onEdit}
title="Edit"
className="p-1.5 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-gray-700 transition-colors"
>
<Edit2 className="h-4 w-4" />
</button>
<button
onClick={onDelete}
disabled={isDeleting}
title="Delete"
className="p-1.5 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-900/20 transition-colors disabled:opacity-40"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<Partial<NotificationPreferences>>({});
const [jiraAccountId, setJiraAccountId] = useState<string>("");
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<typeof updateMyPreferences>[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 (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
);
}
const visiblePrefs = PREF_DEFS.filter((d) => d.roles.includes(role));
return (
<>
{toast && (
<Toast message={toast.msg} type={toast.type} onClose={() => setToast(null)} />
)}
<div className="space-y-1">
{visiblePrefs.map((def) => (
<ToggleRow
key={def.key}
label={def.label}
description={def.description}
checked={Boolean(effectivePrefs[def.key])}
onChange={(v) => setLocalPrefs((prev) => ({ ...prev, [def.key]: v }))}
/>
))}
</div>
<div className="mt-4 flex justify-end">
<button
onClick={handleSave}
disabled={saveMut.isPending || !isDirty}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{saveMut.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Save Preferences
</button>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Profile / Jira Section (all users)
// ---------------------------------------------------------------------------
function ProfileSection() {
const { data: me, isLoading } = useQuery({
queryKey: ["me-prefs"],
queryFn: getMe,
});
if (isLoading) {
return (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
);
}
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-xs text-gray-500 mb-1">Username</p>
<p className="text-gray-300 font-medium">{me?.username}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Role</p>
<p className="text-gray-300 font-medium capitalize">{me?.role?.replace("_", " ")}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Email</p>
<p className="text-gray-300">{me?.email ?? "—"}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Last Login</p>
<p className="text-gray-300">
{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"
: "—"}
</p>
</div>
</div>
<div className="border-t border-gray-800 pt-4">
<p className="text-sm font-semibold text-gray-300 mb-1">Jira / Tempo</p>
<p className="text-xs text-gray-500 mb-3">
Your Atlassian account ID is detected automatically on login using the admin Jira account. No personal tokens required.
</p>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">Account ID:</span>
{me?.jira_account_id ? (
<code className="rounded bg-gray-800 border border-gray-700 px-2 py-1 text-xs text-cyan-300 font-mono">
{me.jira_account_id}
</code>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-gray-800 border border-gray-700 px-2 py-0.5 text-[11px] text-gray-500">
Not detected yet log out and back in after admin configures Jira
</span>
)}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Jira Admin Config Section (admin only)
// ---------------------------------------------------------------------------
function JiraConfigSection() {
const qc = useQueryClient();
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
const [showAdminToken, setShowAdminToken] = useState(false);
const [showTempoToken, setShowTempoToken] = useState(false);
const [jiraTestResult, setJiraTestResult] = useState<{ connectedAs: string; url: string } | null>(null);
const [jiraTestError, setJiraTestError] = useState<string | null>(null);
const [tempoTestResult, setTempoTestResult] = useState<string | null>(null);
const [tempoTestError, setTempoTestError] = useState<string | null>(null);
const { data: cfg, isLoading } = useQuery({
queryKey: ["jira-config"],
queryFn: getJiraConfig,
});
const [form, setForm] = useState<JiraConfigUpdate>({});
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 jiraTestMut = useMutation({
mutationFn: testJiraConnection,
onSuccess: (data) => {
if (data.status === "ok") {
setJiraTestResult({ connectedAs: data.connected_as ?? "", url: data.jira_url ?? "" });
setJiraTestError(null);
} else {
setJiraTestError((data as { message?: string }).message ?? "Connection failed");
setJiraTestResult(null);
}
},
onError: (err: Error) => {
setJiraTestError(err.message || "Connection failed");
setJiraTestResult(null);
},
});
const tempoTestMut = useMutation({
mutationFn: testTempoConnection,
onSuccess: (data) => {
if (data.status === "ok") {
setTempoTestResult(data.message ?? "Connected");
setTempoTestError(null);
} else {
setTempoTestError(data.message ?? "Tempo test failed");
setTempoTestResult(null);
}
},
onError: (err: Error) => {
setTempoTestError(err.message || "Tempo test failed");
setTempoTestResult(null);
},
});
if (isLoading) {
return (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
);
}
return (
<>
{toast && (
<Toast message={toast.msg} type={toast.type} onClose={() => setToast(null)} />
)}
<div className="space-y-4">
<ToggleRow
label="Enable Jira Integration"
description="Automatically create and update Jira tickets for tests"
checked={Boolean(form.enabled !== undefined ? form.enabled : cfg?.enabled)}
onChange={(v) => setForm((prev) => ({ ...prev, enabled: v }))}
/>
<div className="grid grid-cols-2 gap-4 pt-2">
<div className="col-span-2">
<label className="mb-1 block text-xs font-medium text-cyan-400">Jira URL</label>
<input
type="text"
value={String(form.url !== undefined ? form.url : (cfg?.url ?? ""))}
onChange={(e) => 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"
/>
<p className="mt-1 text-xs text-gray-600">Base URL of your Jira instance</p>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-cyan-400">Default Project Key</label>
<input
type="text"
value={String(form.project_key !== undefined ? form.project_key : (cfg?.project_key ?? ""))}
onChange={(e) => 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"
/>
<p className="mt-1 text-xs text-gray-600">Jira project where test tickets will be created</p>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-cyan-400">Parent Ticket (optional)</label>
<input
type="text"
value={String(form.parent_ticket !== undefined ? form.parent_ticket : (cfg?.parent_ticket ?? ""))}
onChange={(e) => setForm((prev) => ({ ...prev, parent_ticket: e.target.value }))}
placeholder="OFS-20795"
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"
/>
<p className="mt-1 text-xs text-gray-600">Campaigns are created directly under this issue</p>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-cyan-400">Standalone Tests Parent Ticket (optional)</label>
<input
type="text"
value={String(form.parent_ticket_standalone !== undefined ? form.parent_ticket_standalone : (cfg?.parent_ticket_standalone ?? ""))}
onChange={(e) => setForm((prev) => ({ ...prev, parent_ticket_standalone: e.target.value }))}
placeholder="OFS-20798"
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"
/>
<p className="mt-1 text-xs text-gray-600">
Standalone tests are nested here. Falls back to Campaign Parent if not set.
</p>
</div>
</div>
{/* ── Admin account (replaces per-user tokens) ───────────── */}
<div className="mt-2 border-t border-gray-800 pt-4">
<p className="text-sm font-semibold text-gray-300 mb-1">Admin Jira Account</p>
<p className="text-xs text-gray-500 mb-4">
One admin account handles all Jira and Tempo operations. Regular users need zero configuration their Atlassian account ID is auto-detected on login.
</p>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-cyan-400">Admin Email</label>
<input
type="email"
value={String(form.admin_email !== undefined ? form.admin_email : "")}
onChange={(e) => setForm((prev) => ({ ...prev, admin_email: e.target.value }))}
placeholder="jira-admin@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"
/>
<div className="mt-1 flex items-center gap-2">
{cfg?.admin_email_set ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-900/50 px-2 py-0.5 text-[11px] font-medium text-emerald-400">
<CheckCircle className="h-3 w-3" /> Configured
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-900/50 px-2 py-0.5 text-[11px] font-medium text-amber-400">
<AlertCircle className="h-3 w-3" /> Not configured
</span>
)}
<span className="text-[11px] text-gray-600">Leave blank to keep current</span>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-cyan-400">Admin Jira API Token</label>
<div className="relative">
<input
type={showAdminToken ? "text" : "password"}
value={String(form.admin_api_token !== undefined ? form.admin_api_token : "")}
onChange={(e) => setForm((prev) => ({ ...prev, admin_api_token: e.target.value }))}
placeholder={cfg?.admin_email_set ? "Leave blank to keep current token" : "Paste 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"
/>
<button
type="button"
onClick={() => setShowAdminToken(!showAdminToken)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{showAdminToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="mt-1 text-[11px] text-gray-600">
Create at{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="text-cyan-500 hover:text-cyan-400 underline"
>
id.atlassian.com
</a>
</p>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-purple-400">Admin Tempo API Token</label>
<div className="relative">
<input
type={showTempoToken ? "text" : "password"}
value={String(form.tempo_admin_token !== undefined ? form.tempo_admin_token : "")}
onChange={(e) => setForm((prev) => ({ ...prev, tempo_admin_token: e.target.value }))}
placeholder={cfg?.tempo_admin_token_set ? "Leave blank to keep current token" : "Paste Tempo 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-purple-500 focus:outline-none"
/>
<button
type="button"
onClick={() => setShowTempoToken(!showTempoToken)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{showTempoToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div className="mt-1 flex items-center gap-2">
{cfg?.tempo_admin_token_set ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-900/50 px-2 py-0.5 text-[11px] font-medium text-emerald-400">
<CheckCircle className="h-3 w-3" /> Configured
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-900/50 px-2 py-0.5 text-[11px] font-medium text-amber-400">
<AlertCircle className="h-3 w-3" /> Not configured
</span>
)}
<span className="text-[11px] text-gray-600">
Jira Apps Tempo Settings API Integration
</span>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button
onClick={() => {
if (Object.keys(form).length > 0) saveMut.mutate(form);
}}
disabled={saveMut.isPending || Object.keys(form).length === 0}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{saveMut.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Save Configuration
</button>
</div>
{/* ── Test connections ───────────────────────────────────── */}
<div className="grid grid-cols-2 gap-3 pt-1">
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-2">
<p className="text-xs font-medium text-gray-400">Test Jira connection</p>
<button
onClick={() => {
setJiraTestResult(null);
setJiraTestError(null);
jiraTestMut.mutate();
}}
disabled={jiraTestMut.isPending || !cfg?.admin_email_set}
className="flex items-center gap-2 rounded-lg border border-cyan-700 px-3 py-1.5 text-sm font-medium text-cyan-400 hover:bg-cyan-900/30 disabled:opacity-50 transition-colors"
>
{jiraTestMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <TestTube className="h-4 w-4" />}
Test Jira
</button>
{jiraTestResult && (
<div className="flex items-center gap-2 rounded-md bg-emerald-900/30 border border-emerald-800/50 px-3 py-2 text-xs text-emerald-300">
<CheckCircle className="h-3.5 w-3.5 shrink-0" />
Connected as: {jiraTestResult.connectedAs}
</div>
)}
{jiraTestError && (
<div className="flex items-center gap-2 rounded-md bg-red-900/30 border border-red-800/50 px-3 py-2 text-xs text-red-300">
<XCircle className="h-3.5 w-3.5 shrink-0" />
{jiraTestError}
</div>
)}
</div>
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-2">
<p className="text-xs font-medium text-gray-400">Test Tempo connection</p>
<button
onClick={() => {
setTempoTestResult(null);
setTempoTestError(null);
tempoTestMut.mutate();
}}
disabled={tempoTestMut.isPending || !cfg?.tempo_admin_token_set}
className="flex items-center gap-2 rounded-lg border border-purple-700 px-3 py-1.5 text-sm font-medium text-purple-400 hover:bg-purple-900/30 disabled:opacity-50 transition-colors"
>
{tempoTestMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <TestTube className="h-4 w-4" />}
Test Tempo
</button>
{tempoTestResult && (
<div className="flex items-center gap-2 rounded-md bg-emerald-900/30 border border-emerald-800/50 px-3 py-2 text-xs text-emerald-300">
<CheckCircle className="h-3.5 w-3.5 shrink-0" />
{tempoTestResult}
</div>
)}
{tempoTestError && (
<div className="flex items-center gap-2 rounded-md bg-red-900/30 border border-red-800/50 px-3 py-2 text-xs text-red-300">
<XCircle className="h-3.5 w-3.5 shrink-0" />
{tempoTestError}
</div>
)}
</div>
</div>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// SSO / Azure AD Config Section (admin only)
// ---------------------------------------------------------------------------
const AZURE_ROLES = [
{ value: "admin", label: "Aegis Admin", desc: "Full platform access including system settings" },
{ value: "red_lead", label: "Aegis Red Lead", desc: "Red team lead — manage tests, campaigns and templates" },
{ value: "blue_lead", label: "Aegis Blue Lead", desc: "Blue team lead — validate tests and manage coverage" },
{ value: "manager", label: "Aegis Manager", desc: "Approves campaigns and campaign changes" },
{ value: "red_tech", label: "Aegis Red Tech", desc: "Red team technician — execute tests" },
{ value: "blue_tech", label: "Aegis Blue Tech", desc: "Blue team technician — review detections" },
{ value: "viewer", label: "Aegis Viewer", desc: "Read-only access to dashboards and reports" },
];
function CopyFieldSSO({ value, label }: { value: string; label: string }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(value).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">{label}</label>
<div className="flex items-center gap-2">
<code className="flex-1 truncate rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs text-cyan-300 font-mono">
{value || <span className="text-gray-600 italic">not set</span>}
</code>
<button
type="button"
onClick={copy}
disabled={!value}
className="flex-shrink-0 rounded-lg border border-gray-700 bg-gray-800 p-2 text-gray-400 transition-colors hover:text-white disabled:opacity-40"
title="Copy"
>
{copied ? <CheckCircle className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
</div>
);
}
function SsoConfigSection() {
const qc = useQueryClient();
const origin = typeof window !== "undefined" ? window.location.origin : "";
const defaultSpEntityId = `${origin}/api/v1/sso/metadata`;
const defaultSpAcsUrl = `${origin}/api/v1/sso/callback`;
const { data: existingConfig, isLoading: configLoading } = useQuery<SsoConfig | null>({
queryKey: ["sso-config"],
queryFn: async () => {
try { return await getSsoConfig(); } catch { return null; }
},
});
const [tenantId, setTenantId] = useState("");
const [form, setForm] = useState<SsoConfigUpdate>({
is_enabled: false,
provider_name: "Azure AD / Entra ID",
sp_entity_id: defaultSpEntityId,
sp_acs_url: defaultSpAcsUrl,
idp_entity_id: "",
idp_sso_url: "",
idp_certificate: "",
attr_email: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
attr_username: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
attr_role: "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
default_role: "viewer",
auto_provision: true,
});
const [formLoaded, setFormLoaded] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
if (existingConfig && !formLoaded) {
setForm({
is_enabled: existingConfig.is_enabled,
provider_name: existingConfig.provider_name ?? "Azure AD / Entra ID",
sp_entity_id: existingConfig.sp_entity_id ?? defaultSpEntityId,
sp_acs_url: existingConfig.sp_acs_url ?? defaultSpAcsUrl,
idp_entity_id: existingConfig.idp_entity_id ?? "",
idp_sso_url: existingConfig.idp_sso_url ?? "",
idp_certificate: existingConfig.idp_certificate ?? "",
attr_email: existingConfig.attr_email ?? "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
attr_username: existingConfig.attr_username ?? "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
attr_role: existingConfig.attr_role ?? "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
default_role: existingConfig.default_role ?? "viewer",
auto_provision: existingConfig.auto_provision ?? true,
});
if (existingConfig.idp_sso_url) {
const m = existingConfig.idp_sso_url.match(/microsoftonline\.com\/([^/]+)\//);
if (m) setTenantId(m[1]);
}
setFormLoaded(true);
}
const handleTenantChange = (val: string) => {
setTenantId(val);
if (val.trim()) {
setForm((f) => ({
...f,
idp_entity_id: `https://sts.windows.net/${val.trim()}/`,
idp_sso_url: `https://login.microsoftonline.com/${val.trim()}/saml2`,
}));
}
};
const saveMutation = useMutation({
mutationFn: (payload: SsoConfigUpdate) => updateSsoConfig(payload),
onSuccess: () => {
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 4000);
qc.invalidateQueries({ queryKey: ["sso-config"] });
qc.invalidateQueries({ queryKey: ["sso-status"] });
},
});
const handleSave = (e: React.FormEvent) => { e.preventDefault(); saveMutation.mutate(form); };
const isConfigured = !!(form.idp_entity_id && form.idp_sso_url && form.idp_certificate);
return (
<div className="rounded-xl border border-indigo-500/30 bg-gray-900 p-6">
<div className="flex flex-wrap items-start justify-between gap-4 mb-6">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-indigo-500/10 p-3 mt-0.5">
<KeyRound className="h-6 w-6 text-indigo-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Azure AD / Entra ID SSO</h2>
<p className="mt-1 text-sm text-gray-400 max-w-2xl">
Delegate authentication to Azure Active Directory via SAML 2.0.
Users sign in with corporate credentials; roles assigned automatically via Azure App Roles.
</p>
</div>
</div>
<span className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium ${
form.is_enabled && isConfigured
? "border-green-500/40 bg-green-900/20 text-green-400"
: "border-gray-600 bg-gray-800/50 text-gray-400"
}`}>
{form.is_enabled && isConfigured
? <><CheckCircle className="h-3 w-3" /> Active</>
: <><XCircle className="h-3 w-3" /> {isConfigured ? "Disabled" : "Not configured"}</>}
</span>
</div>
{configLoading ? (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-indigo-400" /></div>
) : (
<form onSubmit={handleSave} className="space-y-8">
{/* Step 1 */}
<div>
<div className="mb-3 flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-indigo-600 text-xs font-bold text-white">1</span>
<h3 className="text-sm font-semibold text-white">Give these values to your IT team</h3>
</div>
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-800/30 p-4">
<CopyFieldSSO label="Entity ID (Identifier)" value={form.sp_entity_id ?? defaultSpEntityId} />
<CopyFieldSSO label="Reply URL (Assertion Consumer Service)" value={form.sp_acs_url ?? defaultSpAcsUrl} />
<div className="pt-1">
<a href="/api/v1/sso/metadata" target="_blank" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-indigo-500/30 bg-indigo-900/20 px-3 py-1.5 text-xs font-medium text-indigo-400 transition-colors hover:bg-indigo-900/40">
<Download className="h-3.5 w-3.5" /> Download SP Metadata XML
</a>
</div>
</div>
</div>
{/* Step 2 */}
<div>
<div className="mb-3 flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-indigo-600 text-xs font-bold text-white">2</span>
<h3 className="text-sm font-semibold text-white">Create App Roles in Azure</h3>
</div>
<div className="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800/30">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-gray-700">
<th className="px-4 py-2.5 font-semibold text-gray-400">Display Name</th>
<th className="px-4 py-2.5 font-semibold text-gray-400">Value (exact)</th>
<th className="px-4 py-2.5 font-semibold text-gray-400">Description</th>
</tr>
</thead>
<tbody>
{AZURE_ROLES.map((r) => (
<tr key={r.value} className="border-b border-gray-700/50 hover:bg-gray-700/20">
<td className="px-4 py-2.5 text-gray-200">{r.label}</td>
<td className="px-4 py-2.5"><code className="rounded bg-gray-800 px-1.5 py-0.5 text-indigo-300 font-mono">{r.value}</code></td>
<td className="px-4 py-2.5 text-gray-400">{r.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Step 3 */}
<div>
<div className="mb-3 flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-indigo-600 text-xs font-bold text-white">3</span>
<h3 className="text-sm font-semibold text-white">Enter Azure tenant details</h3>
</div>
<div className="space-y-4 rounded-lg border border-gray-700 bg-gray-800/30 p-4">
<div>
<label className="mb-1 block text-xs font-medium text-gray-300">Tenant ID <span className="text-gray-500">(auto-fills fields below)</span></label>
<input type="text" value={tenantId} onChange={(e) => handleTenantChange(e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-indigo-500 focus:outline-none font-mono" />
<p className="mt-1 text-xs text-gray-500">Azure AD Overview Tenant ID</p>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-300">IdP Entity ID <span className="text-red-400">*</span></label>
<input type="text" value={form.idp_entity_id ?? ""} onChange={(e) => setForm({ ...form, idp_entity_id: e.target.value })}
placeholder="https://sts.windows.net/{tenant-id}/"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-indigo-500 focus:outline-none font-mono" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-300">SSO URL <span className="text-red-400">*</span></label>
<input type="text" value={form.idp_sso_url ?? ""} onChange={(e) => setForm({ ...form, idp_sso_url: e.target.value })}
placeholder="https://login.microsoftonline.com/{tenant-id}/saml2"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-indigo-500 focus:outline-none font-mono" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-300">Display name <span className="text-gray-500">(shown on login page)</span></label>
<input type="text" value={form.provider_name ?? ""} onChange={(e) => setForm({ ...form, provider_name: e.target.value })}
placeholder="Azure AD / Entra ID"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-indigo-500 focus:outline-none" />
</div>
</div>
</div>
{/* Step 4 */}
<div>
<div className="mb-3 flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-indigo-600 text-xs font-bold text-white">4</span>
<h3 className="text-sm font-semibold text-white">Paste IdP certificate</h3>
</div>
<textarea value={form.idp_certificate ?? ""} onChange={(e) => setForm({ ...form, idp_certificate: e.target.value })}
placeholder={"-----BEGIN CERTIFICATE-----\nMIIC...base64...\n-----END CERTIFICATE-----"}
rows={5}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs text-gray-300 placeholder-gray-500 focus:border-indigo-500 focus:outline-none font-mono" />
<p className="mt-1 text-xs text-gray-500">Azure AD Enterprise App Single sign-on SAML Signing Certificate Download Base64</p>
</div>
{/* Step 5 */}
<div>
<div className="mb-3 flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-indigo-600 text-xs font-bold text-white">5</span>
<h3 className="text-sm font-semibold text-white">Attribute mapping</h3>
<span className="text-xs text-gray-500">(pre-filled with Azure AD defaults)</span>
</div>
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-800/30 p-4">
<div>
<label className="mb-1 block text-xs font-medium text-gray-300">Email attribute</label>
<input type="text" value={form.attr_email ?? ""} onChange={(e) => setForm({ ...form, attr_email: e.target.value })}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs text-gray-300 focus:border-indigo-500 focus:outline-none font-mono" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-300">Username attribute</label>
<input type="text" value={form.attr_username ?? ""} onChange={(e) => setForm({ ...form, attr_username: e.target.value })}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs text-gray-300 focus:border-indigo-500 focus:outline-none font-mono" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-300">Role attribute</label>
<input type="text" value={form.attr_role ?? ""} onChange={(e) => setForm({ ...form, attr_role: e.target.value })}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs text-gray-300 focus:border-indigo-500 focus:outline-none font-mono" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-gray-300">Default role</label>
<select value={form.default_role ?? "viewer"} onChange={(e) => setForm({ ...form, default_role: e.target.value })}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-indigo-500 focus:outline-none">
{AZURE_ROLES.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
<div className="flex items-end pb-0.5">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.auto_provision ?? true}
onChange={(e) => setForm({ ...form, auto_provision: e.target.checked })}
className="h-4 w-4 rounded accent-indigo-500" />
<span className="text-sm text-gray-300">Auto-provision new users</span>
</label>
</div>
</div>
</div>
</div>
{/* Enable + Save */}
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-gray-700 bg-gray-800/30 p-4">
<label className="flex items-center gap-3 cursor-pointer">
<div onClick={() => setForm((f) => ({ ...f, is_enabled: !f.is_enabled }))}
className={`relative h-6 w-11 rounded-full transition-colors ${form.is_enabled ? "bg-indigo-600" : "bg-gray-600"}`}>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${form.is_enabled ? "translate-x-5" : "translate-x-0.5"}`} />
</div>
<span className="text-sm font-medium text-gray-200">
{form.is_enabled ? "SSO enabled — Azure AD is primary login" : "SSO disabled — local login only"}
</span>
</label>
<div className="flex items-center gap-3">
{saveSuccess && <span className="flex items-center gap-1.5 text-sm text-green-400"><CheckCircle className="h-4 w-4" /> Saved</span>}
{saveMutation.isError && <span className="text-sm text-red-400">{(saveMutation.error as Error)?.message ?? "Save failed"}</span>}
<button type="submit" disabled={saveMutation.isPending || !isConfigured}
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-500 disabled:opacity-50"
title={!isConfigured ? "Fill in IdP Entity ID, SSO URL, and Certificate first" : undefined}>
{saveMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Building2 className="h-4 w-4" />}
{saveMutation.isPending ? "Saving…" : "Save configuration"}
</button>
</div>
</div>
</form>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Export / Import Configuration (admin only)
// ---------------------------------------------------------------------------
function ExportImportSection() {
const fileRef = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<{
status: string;
summary: Record<string, number>;
warnings: string[];
} | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const handleExport = async () => {
try {
const resp = await client.get("/admin/export-config", { responseType: "blob" });
const url = URL.createObjectURL(resp.data);
const a = document.createElement("a");
a.href = url;
a.download = `aegis-config-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
alert("Export failed: " + (e as Error).message);
}
};
const handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
setImportError(null);
setImportResult(null);
setImporting(true);
try {
const json = JSON.parse(await file.text());
const resp = await client.post("/admin/import-config", json);
setImportResult(resp.data);
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
(err as Error)?.message ?? "Import failed";
setImportError(String(msg));
} finally {
setImporting(false);
}
};
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-center gap-2 mb-1">
<PackageOpen className="h-5 w-5 text-cyan-400" />
<h2 className="text-lg font-semibold text-white">Configuration Export / Import</h2>
</div>
<p className="mb-5 text-sm text-gray-400">
Export platform configuration to JSON for backup or migration. Import restores settings,
webhooks, templates and users on a fresh instance.
</p>
<div className="mb-5 rounded-lg border border-gray-800 bg-gray-800/30 p-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">What is included</p>
<div className="grid gap-x-8 gap-y-1 text-xs text-gray-400 sm:grid-cols-2">
{[
["✅", "Email & Jira settings"],
["✅", "SSO / SAML configuration"],
["✅", "Webhooks"],
["✅", "Scoring weights"],
["✅", "Custom test templates"],
["✅", "Users (roles & status)"],
["🔒", "Passwords / API tokens → REDACTED"],
["❌", "Tests, campaigns, techniques data"],
].map(([icon, text]) => (
<div key={text} className="flex items-center gap-1.5"><span>{icon}</span><span>{text}</span></div>
))}
</div>
</div>
<div className="flex flex-wrap gap-3">
<button onClick={handleExport}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors">
<Download className="h-4 w-4" /> Export Configuration
</button>
<input ref={fileRef} type="file" accept=".json" onChange={handleImportFile} className="hidden" />
<button onClick={() => fileRef.current?.click()} disabled={importing}
className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 disabled:opacity-50 transition-colors">
{importing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{importing ? "Importing…" : "Import Configuration"}
</button>
</div>
{importResult && (
<div className="mt-4 rounded-lg border border-green-500/30 bg-green-500/5 p-4 space-y-2">
<p className="text-sm font-medium text-green-400 flex items-center gap-2">
<CheckCircle className="h-4 w-4" /> Import completed successfully
</p>
<div className="grid gap-2 text-xs text-gray-400 sm:grid-cols-3">
{Object.entries(importResult.summary).map(([key, val]) => (
<div key={key} className="flex items-center justify-between rounded bg-gray-800 px-2 py-1">
<span className="capitalize">{key.replace(/_/g, " ")}</span>
<span className="font-mono text-cyan-400">{val}</span>
</div>
))}
</div>
{importResult.warnings.length > 0 && (
<ul className="mt-2 space-y-0.5 text-[11px] text-amber-400/80 list-disc list-inside">
{importResult.warnings.map((w) => <li key={w}>{w}</li>)}
</ul>
)}
</div>
)}
{importError && (
<div className="mt-4 rounded-lg border border-red-500/30 bg-red-900/20 p-3 text-sm text-red-400"> {importError}</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// System Information + Version (admin only)
// ---------------------------------------------------------------------------
function SystemInfoSection() {
return (
<div className="space-y-4">
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-400" /> System Status
</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[
{ icon: Server, label: "Backend", value: "Online", color: "text-green-400" },
{ icon: Database, label: "PostgreSQL", value: "Connected", color: "text-green-400" },
{ icon: HardDrive, label: "MinIO Storage", value: "Available", color: "text-green-400" },
{ icon: Clock, label: "Scheduler", value: "Running", color: "text-green-400" },
].map(({ icon: Icon, label, value, color }) => (
<div key={label} className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
<div className="flex items-center gap-3">
<Icon className={`h-5 w-5 ${color}`} />
<div>
<p className="text-xs font-medium uppercase text-gray-500">{label}</p>
<p className={`text-sm font-medium ${color}`}>{value}</p>
</div>
</div>
</div>
))}
</div>
</div>
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Version Information</h2>
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 text-sm">
<div><dt className="text-xs font-medium uppercase text-gray-500">Platform</dt><dd className="mt-1 text-gray-300">Aegis v0.1.0</dd></div>
<div><dt className="text-xs font-medium uppercase text-gray-500">Backend</dt><dd className="mt-1 text-gray-300">FastAPI + Python 3.11</dd></div>
<div><dt className="text-xs font-medium uppercase text-gray-500">Frontend</dt><dd className="mt-1 text-gray-300">React 19 + TypeScript</dd></div>
<div><dt className="text-xs font-medium uppercase text-gray-500">Database</dt><dd className="mt-1 text-gray-300">PostgreSQL 15</dd></div>
</dl>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main SettingsPage
// ---------------------------------------------------------------------------
type Tab = "profile" | "notifications" | "webhooks" | "email" | "jira" | "sso" | "system";
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<Tab>("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 },
{ id: "sso", label: "SSO / Azure AD", icon: KeyRound, show: isAdmin },
{ id: "system", label: "System", icon: Server, show: isAdmin },
];
const visibleTabs = tabs.filter((t) => t.show);
return (
<div className="min-h-screen bg-gray-950 px-6 py-8">
<div className="mx-auto max-w-4xl">
{/* Header */}
<div className="mb-8 flex items-center gap-3">
<Settings className="h-7 w-7 text-cyan-400" />
<div>
<h1 className="text-2xl font-bold text-white">Settings</h1>
<p className="text-sm text-gray-500">
Manage your preferences, integrations, and system configuration
</p>
</div>
</div>
<div className="flex gap-6">
{/* Sidebar tabs */}
<nav className="w-44 shrink-0 space-y-1">
{visibleTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
activeTab === tab.id
? "bg-cyan-500/10 text-cyan-400"
: "text-gray-400 hover:bg-gray-800 hover:text-gray-200"
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</nav>
{/* Content */}
<div className="flex-1">
{activeTab === "profile" && (
<Section title="Profile & Jira" icon={User}>
<ProfileSection />
</Section>
)}
{activeTab === "notifications" && (
<Section title="Notification Preferences" icon={Bell}>
<NotificationSection />
</Section>
)}
{activeTab === "webhooks" && isLead && (
<Section title="Webhook Configuration" icon={Webhook}>
<WebhooksSection />
</Section>
)}
{activeTab === "email" && isAdmin && (
<Section title="Email / SMTP Configuration" icon={Mail}>
<EmailSection />
</Section>
)}
{activeTab === "jira" && isAdmin && (
<Section title="Jira Integration" icon={Link2}>
<JiraConfigSection />
</Section>
)}
{activeTab === "sso" && isAdmin && (
<SsoConfigSection />
)}
{activeTab === "system" && isAdmin && (
<div className="space-y-6">
<SystemInfoSection />
<ExportImportSection />
</div>
)}
</div>
</div>
</div>
</div>
);
}