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
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:
@@ -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 "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user