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

@@ -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<string>("");
const [jiraApiToken, setJiraApiToken] = useState<string>("");
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<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) {
return (
<div className="flex justify-center py-8">
@@ -873,38 +892,255 @@ function ProfileSection() {
</div>
<div className="border-t border-gray-800 pt-4">
<p className="text-sm font-medium text-gray-300 mb-3">Jira Integration</p>
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">
Jira Account ID
</label>
<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>
<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)" : "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>
<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 { 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
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"
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">
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
</p>
</div>
<div className="mt-4">
<button
onClick={() => saveMut.mutate({ jira_account_id: jiraAccountId || null })}
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 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
// ---------------------------------------------------------------------------
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() {
<EmailSection />
</Section>
)}
{activeTab === "jira" && isAdmin && (
<Section title="Jira Integration" icon={Link2}>
<JiraConfigSection />
</Section>
)}
</div>
</div>
</div>