feat(settings): Jira config UI — admin config tab + per-user token in Profile
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

- backend: add parent_ticket field to JiraConfigOut/JiraConfigUpdate/_JIRA_KEYS
- backend: add get_jira_parent_ticket() helper in jira_service; use it in auto_create_test_issue() to set issue parent
- frontend/api: add jira_token_set to UserMeOut, jira_api_token to UserPreferencesUpdate, and full JiraConfigOut/Update types with getJiraConfig/updateJiraConfig/testJiraConnection functions
- frontend: expand ProfileSection with Jira API token password field (show/hide), token status badge, and account-id field
- frontend: add JiraConfigSection component (admin): enabled toggle, URL, project key, parent ticket, save + test connection
- frontend: add Jira tab (admin-only) with Link2 icon in SettingsPage sidebar
This commit is contained in:
kitos
2026-05-26 16:23:24 +02:00
parent 2675a4b7c2
commit f316a249cc
4 changed files with 320 additions and 29 deletions

View File

@@ -210,6 +210,7 @@ class JiraConfigOut(BaseModel):
enabled: bool enabled: bool
url: str url: str
project_key: str project_key: str
parent_ticket: str
# Credentials are never returned # Credentials are never returned
@@ -217,12 +218,14 @@ class JiraConfigUpdate(BaseModel):
enabled: Optional[bool] = None enabled: Optional[bool] = None
url: Optional[str] = None url: Optional[str] = None
project_key: Optional[str] = None project_key: Optional[str] = None
parent_ticket: Optional[str] = None
_JIRA_KEYS = { _JIRA_KEYS = {
"enabled": "jira.enabled", "enabled": "jira.enabled",
"url": "jira.url", "url": "jira.url",
"project_key": "jira.project_key", "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. **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( return JiraConfigOut(
enabled=is_jira_enabled(db), enabled=is_jira_enabled(db),
url=get_jira_url(db) or "", url=get_jira_url(db) or "",
project_key=get_jira_project_key(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. **Requires** the ``admin`` role. Only provided fields are updated.
""" """
from app.services.jira_service import ( 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) update_data = payload.model_dump(exclude_unset=True)
@@ -269,6 +273,7 @@ def update_jira_config(
enabled=is_jira_enabled(db), enabled=is_jira_enabled(db),
url=get_jira_url(db) or "", url=get_jira_url(db) or "",
project_key=get_jira_project_key(db) or "", project_key=get_jira_project_key(db) or "",
parent_ticket=get_jira_parent_ticket(db) or "",
) )

View File

@@ -86,6 +86,11 @@ def is_jira_enabled(db: Session) -> bool:
return settings.JIRA_ENABLED 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: def upsert_jira_config(db: Session, key: str, value: str) -> None:
"""Persist a Jira config key-value pair.""" """Persist a Jira config key-value pair."""
from app.models.system_config import SystemConfig from app.models.system_config import SystemConfig
@@ -327,6 +332,10 @@ def auto_create_test_issue(
"labels": ["aegis", "security-test", mitre_id.replace(".", "-")], "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) result = jira.issue_create(fields=fields)
issue_key = result["key"] issue_key = result["key"]
issue_id = result.get("id", "") issue_id = result.get("id", "")

View File

@@ -120,6 +120,7 @@ export interface NotificationPreferences {
export interface UserPreferencesUpdate { export interface UserPreferencesUpdate {
notification_preferences?: Partial<NotificationPreferences>; notification_preferences?: Partial<NotificationPreferences>;
jira_account_id?: string | null; jira_account_id?: string | null;
jira_api_token?: string | null;
} }
export interface UserMeOut { export interface UserMeOut {
@@ -133,6 +134,7 @@ export interface UserMeOut {
last_login: string | null; last_login: string | null;
notification_preferences: NotificationPreferences | null; notification_preferences: NotificationPreferences | null;
jira_account_id: string | null; jira_account_id: string | null;
jira_token_set: boolean;
} }
export async function getMe(): Promise<UserMeOut> { export async function getMe(): Promise<UserMeOut> {
@@ -145,3 +147,36 @@ export async function updateMyPreferences(payload: UserPreferencesUpdate): Promi
return data; 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<JiraConfigOut> {
const { data } = await client.get<JiraConfigOut>("/system/jira-config");
return data;
}
export async function updateJiraConfig(payload: JiraConfigUpdate): Promise<JiraConfigOut> {
const { data } = await client.patch<JiraConfigOut>("/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;
}

View File

@@ -18,6 +18,7 @@ import {
Loader2, Loader2,
Edit2, Edit2,
X, X,
Link2,
} from "lucide-react"; } from "lucide-react";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { import {
@@ -31,10 +32,14 @@ import {
testWebhook, testWebhook,
getMe, getMe,
updateMyPreferences, updateMyPreferences,
getJiraConfig,
updateJiraConfig,
testJiraConnection,
type EmailConfigUpdate, type EmailConfigUpdate,
type WebhookCreate, type WebhookCreate,
type WebhookOut, type WebhookOut,
type NotificationPreferences, type NotificationPreferences,
type JiraConfigUpdate,
} from "../api/settings"; } from "../api/settings";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -811,6 +816,8 @@ function ProfileSection() {
}); });
const [jiraAccountId, setJiraAccountId] = useState<string>(""); const [jiraAccountId, setJiraAccountId] = useState<string>("");
const [jiraApiToken, setJiraApiToken] = useState<string>("");
const [showToken, setShowToken] = useState(false);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
// Sync from server on load // Sync from server on load
@@ -823,11 +830,23 @@ function ProfileSection() {
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["me-prefs"] }); qc.invalidateQueries({ queryKey: ["me-prefs"] });
setDirty(false); setDirty(false);
setJiraApiToken("");
setToast({ msg: "Profile settings saved", type: "success" }); setToast({ msg: "Profile settings saved", type: "success" });
}, },
onError: () => setToast({ msg: "Failed to save", type: "error" }), onError: () => setToast({ msg: "Failed to save", type: "error" }),
}); });
const handleSave = () => {
const payload: Parameters<typeof updateMyPreferences>[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) { if (isLoading) {
return ( return (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
@@ -873,10 +892,66 @@ function ProfileSection() {
</div> </div>
<div className="border-t border-gray-800 pt-4"> <div className="border-t border-gray-800 pt-4">
<p className="text-sm font-medium text-gray-300 mb-3">Jira Integration</p> <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 border-b border-gray-800 pb-3">
Auth uses your email:{" "}
<span className="text-gray-400 font-medium">
{me?.email || "— set email in your profile"}
</span>
</p>
<div className="space-y-4">
{/* API Token */}
<div> <div>
<label className="mb-1 block text-xs font-medium text-gray-400"> <label className="mb-1 block text-xs font-medium text-cyan-400">
Jira Account ID 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)" : "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"
/>
<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> </label>
<input <input
value={jiraAccountId} value={jiraAccountId}
@@ -888,12 +963,13 @@ function ProfileSection() {
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" 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"> <p className="mt-1 text-xs text-gray-600">
Your Atlassian account ID used to link Aegis items to Jira. Find it in your Jira profile URL. Your Atlassian account ID. Found in your Jira profile URL.
</p> </p>
</div> </div>
<div className="mt-4">
<div>
<button <button
onClick={() => saveMut.mutate({ jira_account_id: jiraAccountId || null })} onClick={handleSave}
disabled={saveMut.isPending || !dirty} 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" 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"
> >
@@ -907,6 +983,166 @@ function ProfileSection() {
</div> </div>
</div> </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 { 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) => {
setTestResult({ connectedAs: data.connected_as, url: data.jira_url });
setTestError(null);
},
onError: (err: Error) => {
setTestError(err.message || "Connection failed");
setTestResult(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 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 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 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>
</div>
</> </>
); );
} }
@@ -915,7 +1151,7 @@ function ProfileSection() {
// Main SettingsPage // Main SettingsPage
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type Tab = "profile" | "notifications" | "webhooks" | "email"; type Tab = "profile" | "notifications" | "webhooks" | "email" | "jira";
export default function SettingsPage() { export default function SettingsPage() {
const { user } = useAuth(); const { user } = useAuth();
@@ -936,6 +1172,7 @@ export default function SettingsPage() {
show: isLead, show: isLead,
}, },
{ id: "email", label: "Email / SMTP", icon: Mail, show: isAdmin }, { id: "email", label: "Email / SMTP", icon: Mail, show: isAdmin },
{ id: "jira", label: "Jira", icon: Link2, show: isAdmin },
]; ];
const visibleTabs = tabs.filter((t) => t.show); const visibleTabs = tabs.filter((t) => t.show);
@@ -995,6 +1232,11 @@ export default function SettingsPage() {
<EmailSection /> <EmailSection />
</Section> </Section>
)} )}
{activeTab === "jira" && isAdmin && (
<Section title="Jira Integration" icon={Link2}>
<JiraConfigSection />
</Section>
)}
</div> </div>
</div> </div>
</div> </div>