8577588a21
Jira URL fix: - JiraLinkPanel now fetches the configured Jira base URL via getJiraConfig() instead of hardcoding https://jira.atlassian.com; falls back to the old value if config is not yet loaded Description fix: - _build_test_description: renamed 'h3. Procedure' -> 'h3. Proof of Concept' so the procedure/tool block maps to the correct Jira field label Tempo debug: - New POST /system/tempo-test endpoint: checks TEMPO_ENABLED, token, user jira_account_id, and makes a real API call; always returns HTTP 200 with status field (Cloudflare-safe) - docker-compose.prod.yml: added TEMPO_ENABLED, TEMPO_API_TOKEN, TEMPO_DEFAULT_WORK_TYPE env vars (default off, ready to enable) - SettingsPage: added 'Test Tempo Connection' button in Jira admin tab with clear feedback showing what's missing
1336 lines
47 KiB
TypeScript
1336 lines
47 KiB
TypeScript
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,
|
|
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 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<string>("");
|
|
const [jiraEmail, setJiraEmail] = useState<string>("");
|
|
const [jiraApiToken, setJiraApiToken] = useState<string>("");
|
|
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: (updatedMe) => {
|
|
// Update cache immediately with the response — no extra round-trip needed
|
|
qc.setQueryData(["me-prefs"], updatedMe);
|
|
setDirty(false);
|
|
setJiraApiToken(""); // clear token field after save — it's persisted
|
|
setToast({ msg: "Profile settings saved", type: "success" });
|
|
},
|
|
onError: () => setToast({ msg: "Failed to save", type: "error" }),
|
|
});
|
|
|
|
const handleSave = () => {
|
|
const payload: Parameters<typeof updateMyPreferences>[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 (
|
|
<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">
|
|
<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 Integration (personal settings)</p>
|
|
<p className="text-xs text-gray-500 mb-4">
|
|
Configure your personal Atlassian credentials for Jira integration.
|
|
</p>
|
|
|
|
<div className="space-y-4">
|
|
{/* Jira email */}
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-cyan-400">
|
|
Atlassian / Jira Email
|
|
</label>
|
|
<input
|
|
type="email"
|
|
value={jiraEmail}
|
|
onChange={(e) => { 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"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-600">
|
|
Email used to authenticate with Atlassian.
|
|
{me?.email && !me?.jira_email && (
|
|
<span className="text-gray-500"> Currently using Aegis email: <span className="text-gray-400">{me.email}</span></span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* API Token */}
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-cyan-400">
|
|
Jira API Token (Atlassian personal token)
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showToken ? "text" : "password"}
|
|
value={jiraApiToken}
|
|
onChange={(e) => {
|
|
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"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowToken(!showToken)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
|
>
|
|
{showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
<div className="mt-1.5 flex items-center gap-2">
|
|
{me?.jira_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" />
|
|
Token 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>
|
|
)}
|
|
<a
|
|
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[11px] text-cyan-500 hover:text-cyan-400 underline"
|
|
>
|
|
Create token at id.atlassian.com →
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Account ID */}
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-cyan-400">
|
|
Jira Account ID (for Tempo time tracking, optional)
|
|
</label>
|
|
<input
|
|
value={jiraAccountId}
|
|
onChange={(e) => {
|
|
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"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-600">
|
|
Your Atlassian account ID. Found in your Jira profile URL.
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saveMut.isPending || !dirty}
|
|
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
|
|
</button>
|
|
</div>
|
|
</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 [testResult, setTestResult] = useState<{ connectedAs: string; url: string } | null>(null);
|
|
const [testError, setTestError] = useState<string | null>(null);
|
|
const [tempoResult, setTempoResult] = useState<string | null>(null);
|
|
const [tempoError, setTempoError] = useState<string | null>(null);
|
|
|
|
const { data: cfg, isLoading } = useQuery({
|
|
queryKey: ["jira-config"],
|
|
queryFn: getJiraConfig,
|
|
});
|
|
|
|
const [form, setForm] = useState<JiraConfigUpdate>({});
|
|
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) => {
|
|
// Backend always returns HTTP 200; status field tells us if it worked
|
|
if (data.status === "ok") {
|
|
setTestResult({ connectedAs: data.connected_as ?? "", url: data.jira_url ?? "" });
|
|
setTestError(null);
|
|
} else {
|
|
setTestError((data as { message?: string }).message ?? "Connection failed");
|
|
setTestResult(null);
|
|
}
|
|
},
|
|
onError: (err: Error) => {
|
|
setTestError(err.message || "Connection failed");
|
|
setTestResult(null);
|
|
},
|
|
});
|
|
|
|
const tempoTestMut = useMutation({
|
|
mutationFn: testTempoConnection,
|
|
onSuccess: (data) => {
|
|
if (data.status === "ok") {
|
|
setTempoResult(data.message ?? "Connected");
|
|
setTempoError(null);
|
|
} else {
|
|
setTempoError(data.message ?? "Tempo test failed");
|
|
setTempoResult(null);
|
|
}
|
|
},
|
|
onError: (err: Error) => {
|
|
setTempoError(err.message || "Tempo test failed");
|
|
setTempoResult(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="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"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-600">
|
|
If set, all test tickets will be created as subtasks of this issue
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center 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 Jira connection */}
|
|
<div className="mt-4 rounded-lg border border-gray-700 bg-gray-800/50 p-4 space-y-3">
|
|
<p className="text-sm font-medium text-gray-300">Test Jira Connection</p>
|
|
<div className="rounded-md bg-blue-900/20 border border-blue-800/50 px-3 py-2 text-xs text-blue-300">
|
|
Uses your personal Jira API token (configured in the Profile tab)
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setTestResult(null);
|
|
setTestError(null);
|
|
testMut.mutate();
|
|
}}
|
|
disabled={testMut.isPending}
|
|
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"
|
|
>
|
|
{testMut.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<TestTube className="h-4 w-4" />
|
|
)}
|
|
Test Jira Connection
|
|
</button>
|
|
|
|
{testResult && (
|
|
<div className="flex items-center gap-2 rounded-md bg-emerald-900/30 border border-emerald-800/50 px-3 py-2 text-sm text-emerald-300">
|
|
<CheckCircle className="h-4 w-4 shrink-0" />
|
|
Connected as: {testResult.connectedAs}
|
|
</div>
|
|
)}
|
|
{testError && (
|
|
<div className="flex items-center gap-2 rounded-md bg-red-900/30 border border-red-800/50 px-3 py-2 text-sm text-red-300">
|
|
<XCircle className="h-4 w-4 shrink-0" />
|
|
{testError}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Test Tempo connection */}
|
|
<div className="mt-2 rounded-lg border border-gray-700 bg-gray-800/50 p-4 space-y-3">
|
|
<p className="text-sm font-medium text-gray-300">Test Tempo Connection</p>
|
|
<div className="rounded-md bg-blue-900/20 border border-blue-800/50 px-3 py-2 text-xs text-blue-300">
|
|
Requires <code className="font-mono">TEMPO_ENABLED=true</code> and <code className="font-mono">TEMPO_API_TOKEN</code> set in the server environment, plus your Jira Account ID in the Profile tab.
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setTempoResult(null);
|
|
setTempoError(null);
|
|
tempoTestMut.mutate();
|
|
}}
|
|
disabled={tempoTestMut.isPending}
|
|
className="flex items-center gap-2 rounded-lg border border-purple-700 px-4 py-2 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 Connection
|
|
</button>
|
|
|
|
{tempoResult && (
|
|
<div className="flex items-center gap-2 rounded-md bg-emerald-900/30 border border-emerald-800/50 px-3 py-2 text-sm text-emerald-300">
|
|
<CheckCircle className="h-4 w-4 shrink-0" />
|
|
{tempoResult}
|
|
</div>
|
|
)}
|
|
{tempoError && (
|
|
<div className="flex items-center gap-2 rounded-md bg-red-900/30 border border-red-800/50 px-3 py-2 text-sm text-red-300">
|
|
<XCircle className="h-4 w-4 shrink-0" />
|
|
{tempoError}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<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 },
|
|
];
|
|
|
|
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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|