From 2337abe55ec81d714f83c5895fb265942f3cb076 Mon Sep 17 00:00:00 2001 From: kitos Date: Wed, 27 May 2026 10:33:57 +0200 Subject: [PATCH] fix(jira): correct browse URL, rename Procedure to Proof of Concept; feat(tempo): debug endpoint + UI 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 Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routers/system.py | 65 +++++++++++++++++++++++ backend/app/services/jira_service.py | 2 +- docker-compose.prod.yml | 4 ++ frontend/src/api/settings.ts | 9 ++++ frontend/src/components/JiraLinkPanel.tsx | 11 +++- frontend/src/pages/SettingsPage.tsx | 63 ++++++++++++++++++++-- 6 files changed, 149 insertions(+), 5 deletions(-) diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index 91e5177..e9ab966 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -349,6 +349,71 @@ def test_jira_connection( return {"status": "error", "message": msg, "jira_url": jira_url} +# --------------------------------------------------------------------------- +# POST /system/tempo-test +# --------------------------------------------------------------------------- + + +@router.post("/tempo-test") +def test_tempo_connection( + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Test the Tempo connection and report configuration status. + + Always returns HTTP 200 with a ``status`` field so Cloudflare never + intercepts the response. + """ + from app.config import settings + + if not settings.TEMPO_ENABLED: + return { + "status": "disabled", + "message": "Tempo is not enabled. Set TEMPO_ENABLED=true and TEMPO_API_TOKEN in your environment.", + } + + if not settings.TEMPO_API_TOKEN: + return { + "status": "error", + "message": "TEMPO_API_TOKEN is empty. Add it to your environment.", + } + + jira_account_id = getattr(current_user, "jira_account_id", None) + if not jira_account_id: + return { + "status": "error", + "message": ( + "Your user has no Jira Account ID configured. " + "Set it in Settings → Profile → Jira Integration → Account ID." + ), + } + + try: + from tempoapiclient import client_v4 as tempo_client + tempo = tempo_client.Tempo(auth_token=settings.TEMPO_API_TOKEN) + # Fetch current user's worklogs as a connectivity check (limit 1) + worklogs = tempo.get_worklogs_by_account_id( + account_id=jira_account_id, + dateFrom="2024-01-01", + dateTo="2024-01-02", + ) + return { + "status": "ok", + "message": f"Tempo connected. Account ID: {jira_account_id}", + "worklogs_found": len(worklogs) if isinstance(worklogs, list) else "n/a", + } + except Exception as exc: + err = str(exc) + if "401" in err or "Unauthorized" in err: + msg = "Authentication failed (401). Check your TEMPO_API_TOKEN." + elif "403" in err or "Forbidden" in err: + msg = "Access denied (403). The Tempo token may not have the required permissions." + else: + msg = f"Tempo connection failed: {err}" + logger.warning("Tempo test connection failed: %s", err) + return {"status": "error", "message": msg} + + # --------------------------------------------------------------------------- # GET /system/email-config # --------------------------------------------------------------------------- diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index 3cd7830..48a0f4b 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -208,7 +208,7 @@ def _build_test_description(test: Test, technique: Optional[Technique]) -> str: "h3. Description", test.description or "_No description provided._", "", - "h3. Procedure", + "h3. Proof of Concept", f"{{code}}{test.procedure_text or 'N/A'}{{code}}", "", f"*Tool:* {test.tool_used or 'N/A'}", diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d319b2b..ecba788 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -87,6 +87,10 @@ services: SECURE_COOKIES: ${SECURE_COOKIES:-false} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + # ── Tempo time-tracking (optional) ──────────────────────────────────── + TEMPO_ENABLED: ${TEMPO_ENABLED:-false} + TEMPO_API_TOKEN: ${TEMPO_API_TOKEN:-} + TEMPO_DEFAULT_WORK_TYPE: ${TEMPO_DEFAULT_WORK_TYPE:-Red Team} depends_on: postgres: condition: service_healthy diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 3cf9532..9ef7639 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -188,3 +188,12 @@ export async function testJiraConnection(): Promise<{ return data; } +export async function testTempoConnection(): Promise<{ + status: "ok" | "error" | "disabled"; + message?: string; + worklogs_found?: number | string; +}> { + const { data } = await client.post("/system/tempo-test"); + return data; +} + diff --git a/frontend/src/components/JiraLinkPanel.tsx b/frontend/src/components/JiraLinkPanel.tsx index 4bbc32b..07f3c46 100644 --- a/frontend/src/components/JiraLinkPanel.tsx +++ b/frontend/src/components/JiraLinkPanel.tsx @@ -20,6 +20,7 @@ import { type JiraLinkEntityType, type JiraIssueResult, } from "../api/jira"; +import { getJiraConfig } from "../api/settings"; import { useDebounce } from "../hooks/useDebounce"; interface JiraLinkPanelProps { @@ -49,6 +50,14 @@ export default function JiraLinkPanel({ entityType, entityId }: JiraLinkPanelPro // ── Queries ───────────────────────────────────────────────────── + const { data: jiraConfig } = useQuery({ + queryKey: ["jira-config"], + queryFn: getJiraConfig, + staleTime: 5 * 60 * 1000, + }); + + const jiraBaseUrl = jiraConfig?.url?.replace(/\/$/, "") ?? "https://jira.atlassian.com"; + const { data: links = [], isLoading: isLoadingLinks } = useQuery({ queryKey: ["jira-links", entityType, entityId], queryFn: () => listJiraLinks({ entity_type: entityType, entity_id: entityId }), @@ -247,7 +256,7 @@ export default function JiraLinkPanel({ entityType, entityId }: JiraLinkPanelPro /> (null); const [testResult, setTestResult] = useState<{ connectedAs: string; url: string } | null>(null); const [testError, setTestError] = useState(null); + const [tempoResult, setTempoResult] = useState(null); + const [tempoError, setTempoError] = useState(null); const { data: cfg, isLoading } = useQuery({ queryKey: ["jira-config"], @@ -1061,6 +1064,23 @@ function JiraConfigSection() { }, }); + 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 (
@@ -1139,9 +1159,9 @@ function JiraConfigSection() {
- {/* Test connection */} + {/* Test Jira connection */}
-

Test Connection

+

Test Jira Connection

Uses your personal Jira API token (configured in the Profile tab)
@@ -1159,7 +1179,7 @@ function JiraConfigSection() { ) : ( )} - Test Connection + Test Jira Connection {testResult && ( @@ -1175,6 +1195,43 @@ function JiraConfigSection() {
)} + + {/* Test Tempo connection */} +
+

Test Tempo Connection

+
+ Requires TEMPO_ENABLED=true and TEMPO_API_TOKEN set in the server environment, plus your Jira Account ID in the Profile tab. +
+ + + {tempoResult && ( +
+ + {tempoResult} +
+ )} + {tempoError && ( +
+ + {tempoError} +
+ )} +
);