diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index 8ef40d6..5743319 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -210,6 +210,7 @@ class JiraConfigOut(BaseModel): enabled: bool url: str project_key: str + parent_ticket: str # Credentials are never returned @@ -217,12 +218,14 @@ class JiraConfigUpdate(BaseModel): enabled: Optional[bool] = None url: Optional[str] = None project_key: Optional[str] = None + parent_ticket: Optional[str] = None _JIRA_KEYS = { "enabled": "jira.enabled", "url": "jira.url", "project_key": "jira.project_key", + "parent_ticket": "jira.parent_ticket", } @@ -235,12 +238,13 @@ def get_jira_config( **Requires** the ``admin`` role. Credentials are never returned. """ - from app.services.jira_service import get_jira_url, get_jira_project_key, is_jira_enabled + from app.services.jira_service import get_jira_url, get_jira_project_key, is_jira_enabled, get_jira_parent_ticket return JiraConfigOut( enabled=is_jira_enabled(db), url=get_jira_url(db) or "", project_key=get_jira_project_key(db) or "", + parent_ticket=get_jira_parent_ticket(db) or "", ) @@ -255,7 +259,7 @@ def update_jira_config( **Requires** the ``admin`` role. Only provided fields are updated. """ from app.services.jira_service import ( - upsert_jira_config, get_jira_url, get_jira_project_key, is_jira_enabled, + upsert_jira_config, get_jira_url, get_jira_project_key, is_jira_enabled, get_jira_parent_ticket, ) update_data = payload.model_dump(exclude_unset=True) @@ -269,6 +273,7 @@ def update_jira_config( enabled=is_jira_enabled(db), url=get_jira_url(db) or "", project_key=get_jira_project_key(db) or "", + parent_ticket=get_jira_parent_ticket(db) or "", ) diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index 3382401..55fce00 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -86,6 +86,11 @@ def is_jira_enabled(db: Session) -> bool: return settings.JIRA_ENABLED +def get_jira_parent_ticket(db: Session) -> Optional[str]: + """Return the configured parent ticket key, or None if not set.""" + return _read_system_config(db, "jira.parent_ticket") or None + + def upsert_jira_config(db: Session, key: str, value: str) -> None: """Persist a Jira config key-value pair.""" from app.models.system_config import SystemConfig @@ -327,6 +332,10 @@ def auto_create_test_issue( "labels": ["aegis", "security-test", mitre_id.replace(".", "-")], } + parent_ticket = get_jira_parent_ticket(db) + if parent_ticket: + fields["parent"] = {"key": parent_ticket} + result = jira.issue_create(fields=fields) issue_key = result["key"] issue_id = result.get("id", "") diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index e1f7175..d29a18c 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -120,6 +120,7 @@ export interface NotificationPreferences { export interface UserPreferencesUpdate { notification_preferences?: Partial; jira_account_id?: string | null; + jira_api_token?: string | null; } export interface UserMeOut { @@ -133,6 +134,7 @@ export interface UserMeOut { last_login: string | null; notification_preferences: NotificationPreferences | null; jira_account_id: string | null; + jira_token_set: boolean; } export async function getMe(): Promise { @@ -145,3 +147,36 @@ export async function updateMyPreferences(payload: UserPreferencesUpdate): Promi return data; } +// --------------------------------------------------------------------------- +// Jira system config (admin only) +// --------------------------------------------------------------------------- + +export interface JiraConfigOut { + enabled: boolean; + url: string; + project_key: string; + parent_ticket: string; +} + +export interface JiraConfigUpdate { + enabled?: boolean; + url?: string; + project_key?: string; + parent_ticket?: string; +} + +export async function getJiraConfig(): Promise { + const { data } = await client.get("/system/jira-config"); + return data; +} + +export async function updateJiraConfig(payload: JiraConfigUpdate): Promise { + const { data } = await client.patch("/system/jira-config", payload); + return data; +} + +export async function testJiraConnection(): Promise<{ status: string; connected_as: string; jira_url: string }> { + const { data } = await client.post("/system/jira-test"); + return data; +} + diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 547533e..d6e458f 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -18,6 +18,7 @@ import { Loader2, Edit2, X, + Link2, } from "lucide-react"; import { useAuth } from "../context/AuthContext"; import { @@ -31,10 +32,14 @@ import { testWebhook, getMe, updateMyPreferences, + getJiraConfig, + updateJiraConfig, + testJiraConnection, type EmailConfigUpdate, type WebhookCreate, type WebhookOut, type NotificationPreferences, + type JiraConfigUpdate, } from "../api/settings"; // --------------------------------------------------------------------------- @@ -811,6 +816,8 @@ function ProfileSection() { }); const [jiraAccountId, setJiraAccountId] = useState(""); + const [jiraApiToken, setJiraApiToken] = useState(""); + const [showToken, setShowToken] = useState(false); const [dirty, setDirty] = useState(false); // Sync from server on load @@ -823,11 +830,23 @@ function ProfileSection() { onSuccess: () => { qc.invalidateQueries({ queryKey: ["me-prefs"] }); setDirty(false); + setJiraApiToken(""); setToast({ msg: "Profile settings saved", type: "success" }); }, onError: () => setToast({ msg: "Failed to save", type: "error" }), }); + const handleSave = () => { + const payload: Parameters[0] = { + jira_account_id: jiraAccountId || null, + }; + // Only send token if user typed something (empty string clears it) + if (jiraApiToken !== "") { + payload.jira_api_token = jiraApiToken; + } + saveMut.mutate(payload); + }; + if (isLoading) { return (
@@ -873,38 +892,255 @@ function ProfileSection() {
-

Jira Integration

-
- +

Jira Integration (personal settings)

+

+ Auth uses your email:{" "} + + {me?.email || "— set email in your profile"} + +

+ +
+ {/* API Token */} +
+ +
+ { + setJiraApiToken(e.target.value); + setDirty(true); + }} + placeholder={me?.jira_token_set ? "•••••••••••• (leave blank to keep current)" : "Paste your Atlassian API token"} + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 pr-10 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" + /> + +
+
+ {me?.jira_token_set ? ( + + + Token configured + + ) : ( + + + Not configured + + )} + + Create token at id.atlassian.com → + +
+
+ + {/* Account ID */} +
+ + { + setJiraAccountId(e.target.value); + setDirty(true); + }} + placeholder="e.g. 5abcdef1234567890abcdef1" + className="w-full max-w-sm rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" + /> +

+ Your Atlassian account ID. Found in your Jira profile URL. +

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

Base URL of your Jira instance

+
+ +
+ + setForm((prev) => ({ ...prev, project_key: e.target.value }))} + placeholder="SEC" + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" + /> +

Jira project where test tickets will be created

+
+ +
+ + setForm((prev) => ({ ...prev, parent_ticket: e.target.value }))} + placeholder="SEC-100" + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" />

- Your Atlassian account ID used to link Aegis items to Jira. Find it in your Jira profile URL. + If set, all test tickets will be created as subtasks of this issue

-
- +
+ +
+ +
+ + {/* Test connection */} +
+

Test Connection

+
+ Uses your personal Jira API token (configured in the Profile tab)
+ + + {testResult && ( +
+ + Connected as: {testResult.connectedAs} +
+ )} + {testError && ( +
+ + {testError} +
+ )}
@@ -915,7 +1151,7 @@ function ProfileSection() { // Main SettingsPage // --------------------------------------------------------------------------- -type Tab = "profile" | "notifications" | "webhooks" | "email"; +type Tab = "profile" | "notifications" | "webhooks" | "email" | "jira"; export default function SettingsPage() { const { user } = useAuth(); @@ -936,6 +1172,7 @@ export default function SettingsPage() { 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); @@ -995,6 +1232,11 @@ export default function SettingsPage() { )} + {activeTab === "jira" && isAdmin && ( +
+ +
+ )}