feat(tempo): per-user Tempo API token — same pattern as Jira token
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Each user can now store their own personal Tempo API token in their
profile settings. Time is logged using each user's own credentials.

Backend:
- Migration b044: adds tempo_api_token column to users table
- User model: adds tempo_api_token column
- UserPreferencesUpdate: adds tempo_api_token field (write-only)
- UserOut: adds tempo_api_token (excluded) + tempo_token_set bool;
  @model_validator derives both jira_token_set and tempo_token_set
- users router: handles tempo_api_token same as jira_api_token
  (empty string clears it, never returned in responses)
- tempo_service: refactored to per-user token; has_tempo_configured(),
  get_user_tempo_client(user) use user.tempo_api_token; global
  TEMPO_ENABLED still acts as kill-switch
- system router: /system/tempo-test now uses current user's personal
  token (any role); removed global TEMPO_API_TOKEN dependency

Frontend:
- settings.ts: UserPreferencesUpdate.tempo_api_token, UserMeOut.tempo_token_set
- SettingsPage ProfileSection: Tempo Integration section with password
  field, show/hide toggle, configured badge, and Test Tempo button —
  mirrors the Jira token UX exactly
- JiraConfigSection: removed stale global Tempo test block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-27 10:46:38 +02:00
parent 2337abe55e
commit 69d92f500a
8 changed files with 235 additions and 100 deletions

View File

@@ -0,0 +1,25 @@
"""add tempo_api_token to users
Revision ID: b044
Revises: b043
Create Date: 2026-05-27
"""
from alembic import op
import sqlalchemy as sa
revision = "b044"
down_revision = "b043"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"users",
sa.Column("tempo_api_token", sa.String(500), nullable=True),
)
def downgrade():
op.drop_column("users", "tempo_api_token")

View File

@@ -32,3 +32,4 @@ class User(Base):
jira_account_id = Column(String(100), nullable=True)
jira_api_token = Column(String(500), nullable=True) # personal Atlassian token
jira_email = Column(String(255), nullable=True) # Atlassian email (overrides account email)
tempo_api_token = Column(String(500), nullable=True) # personal Tempo API token

View File

@@ -16,7 +16,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import SessionLocal, get_db
from app.dependencies.auth import require_role
from app.dependencies.auth import get_current_user, require_role
from app.models.user import User
from app.services.mitre_sync_service import sync_mitre
from app.services.intel_service import scan_intel
@@ -357,25 +357,24 @@ def test_jira_connection(
@router.post("/tempo-test")
def test_tempo_connection(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
current_user: User = Depends(get_current_user),
):
"""Test the Tempo connection and report configuration status.
"""Test the current user's personal Tempo connection.
Uses the Tempo API token stored in the user's profile (not a global token).
Always returns HTTP 200 with a ``status`` field so Cloudflare never
intercepts the response.
"""
from app.config import settings
from app.services.tempo_service import has_tempo_configured
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:
tempo_token = getattr(current_user, "tempo_api_token", None)
if not tempo_token:
return {
"status": "error",
"message": "TEMPO_API_TOKEN is empty. Add it to your environment.",
"message": (
"No Tempo API token configured. "
"Add it in Settings → Profile → Tempo Integration."
),
}
jira_account_id = getattr(current_user, "jira_account_id", None)
@@ -383,15 +382,15 @@ def test_tempo_connection(
return {
"status": "error",
"message": (
"Your user has no Jira Account ID configured. "
"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)
tempo = tempo_client.Tempo(auth_token=tempo_token)
# Use a minimal date range to verify connectivity without fetching much data
worklogs = tempo.get_worklogs_by_account_id(
account_id=jira_account_id,
dateFrom="2024-01-01",
@@ -405,12 +404,12 @@ def test_tempo_connection(
except Exception as exc:
err = str(exc)
if "401" in err or "Unauthorized" in err:
msg = "Authentication failed (401). Check your TEMPO_API_TOKEN."
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."
msg = "Access denied (403). The token may not have the required Tempo permissions."
else:
msg = f"Tempo connection failed: {err}"
logger.warning("Tempo test connection failed: %s", err)
logger.warning("Tempo test connection failed for user %s: %s", current_user.username, err)
return {"status": "error", "message": msg}

View File

@@ -40,7 +40,7 @@ def update_my_preferences(
"""
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field in ("jira_api_token", "jira_email"):
if field in ("jira_api_token", "jira_email", "tempo_api_token"):
# Empty string means "clear the value"
setattr(current_user, field, value if value else None)
else:

View File

@@ -122,16 +122,19 @@ class PasswordChange(BaseModel):
class UserPreferencesUpdate(BaseModel):
"""Payload for updating current user's notification preferences and Jira settings."""
"""Payload for updating current user's notification preferences and Jira/Tempo settings."""
notification_preferences: dict | None = None
jira_account_id: str | None = None
# Personal Jira API token (Atlassian token) — write-only, stored encrypted at rest.
# Personal Jira API token (Atlassian token) — write-only.
# Set to empty string "" to clear the token.
jira_api_token: str | None = None
# Atlassian email for Jira auth — overrides account email.
# Set to empty string "" to clear (falls back to account email).
jira_email: str | None = None
# Personal Tempo API token — write-only.
# Set to empty string "" to clear the token.
tempo_api_token: str | None = None
class UserOut(BaseModel):
@@ -148,20 +151,23 @@ class UserOut(BaseModel):
notification_preferences: dict | None = None
jira_account_id: str | None = None
jira_email: str | None = None
# Read from ORM but NEVER exposed in responses — used only to derive jira_token_set.
# Read from ORM but NEVER exposed in responses — used only to derive *_token_set flags.
jira_api_token: str | None = Field(default=None, exclude=True)
# True when the user has a personal Atlassian token stored.
tempo_api_token: str | None = Field(default=None, exclude=True)
# True when the user has the respective token stored.
jira_token_set: bool = False
tempo_token_set: bool = False
model_config = ConfigDict(from_attributes=True)
@model_validator(mode="after")
def _derive_jira_token_set(self) -> "UserOut":
"""Set jira_token_set from the (excluded) jira_api_token field.
def _derive_token_set_flags(self) -> "UserOut":
"""Derive *_token_set booleans from the (excluded) raw token fields.
Uses @model_validator(mode='after') so Pydantic's Rust core calls it
during FastAPI response serialisation — model_validate() overrides are
bypassed by FastAPI's __pydantic_validator__.validate_python() path.
"""
self.jira_token_set = bool(self.jira_api_token)
self.tempo_token_set = bool(self.tempo_api_token)
return self

View File

@@ -1,4 +1,14 @@
"""Tempo time-tracking integration service."""
"""Tempo time-tracking integration service.
Authentication model
--------------------
Each user authenticates to Tempo with their own personal Tempo API token,
stored in ``user.tempo_api_token``. This is different from the Jira API token.
Obtain a Tempo token at: Jira → Apps → Tempo → Settings → API Integration.
The global ``settings.TEMPO_ENABLED`` flag acts as a kill-switch. When False,
all Tempo calls are silently skipped regardless of whether users have tokens.
"""
import logging
from typing import Optional
@@ -12,22 +22,35 @@ from app.models.jira_link import JiraLink, JiraLinkEntityType
logger = logging.getLogger(__name__)
def get_tempo_client():
"""Return a Tempo API client, or raise if disabled."""
if not settings.TEMPO_ENABLED:
raise InvalidOperationError("Tempo integration is not enabled")
def has_tempo_configured(user) -> bool:
"""Return True if *user* has a personal Tempo API token stored."""
return bool(getattr(user, "tempo_api_token", None))
def get_user_tempo_client(user):
"""Return a Tempo API v4 client authenticated as *user*.
Raises ``InvalidOperationError`` when the user has no token or the
client library is not installed.
"""
token = getattr(user, "tempo_api_token", None)
if not token:
raise InvalidOperationError(
"No Tempo API token configured. "
"Add it in Settings → Profile → Tempo Integration."
)
try:
from tempoapiclient import client_v4 as tempo_client
return tempo_client.Tempo(auth_token=settings.TEMPO_API_TOKEN)
return tempo_client.Tempo(auth_token=token)
except ImportError:
raise InvalidOperationError(
"tempo-api-python-client is not installed. "
"Install it with: pip install tempo-api-python-client"
"Run: pip install tempo-api-python-client"
)
def log_worklog(
user,
jira_issue_id: int,
author_account_id: str,
date: str,
@@ -35,8 +58,8 @@ def log_worklog(
description: str,
work_type: str | None = None,
) -> dict:
"""Create a worklog entry in Tempo."""
tempo = get_tempo_client()
"""Create a worklog entry in Tempo using *user*'s personal token."""
tempo = get_user_tempo_client(user)
kwargs: dict = {
"accountId": author_account_id,
"issueId": jira_issue_id,
@@ -56,13 +79,24 @@ def auto_log_test_worklog(
user,
activity_type: str,
) -> Optional[dict]:
"""If the test has a Jira link, log time to Tempo automatically.
"""If the test has a Jira link and *user* has a Tempo token, log time.
Returns the Tempo worklog response, or None if skipped.
Completely non-fatal — errors are logged and swallowed.
"""
# Global kill-switch
if not settings.TEMPO_ENABLED:
return None
# Per-user token required
if not has_tempo_configured(user):
logger.debug(
"User %s has no Tempo token; skipping worklog for test %s",
getattr(user, "username", user), test.id,
)
return None
# Need a Jira link with a numeric issue ID
link = (
db.query(JiraLink)
.filter(
@@ -76,24 +110,37 @@ def auto_log_test_worklog(
logger.debug("No Jira link for test %s, skipping Tempo worklog", test.id)
return None
jira_account_id = getattr(user, "jira_account_id", "") or ""
if not jira_account_id:
logger.debug(
"User %s has no jira_account_id; skipping Tempo worklog",
getattr(user, "username", user),
)
return None
duration = _calculate_duration(test, activity_type)
if duration <= 0:
return None
try:
result = log_worklog(
user=user,
jira_issue_id=int(link.jira_issue_id),
author_account_id=getattr(user, "jira_account_id", "") or "",
date=(getattr(test, "updated_at", None) or test.created_at).strftime(
"%Y-%m-%d",
),
author_account_id=jira_account_id,
date=(getattr(test, "updated_at", None) or test.created_at).strftime("%Y-%m-%d"),
time_spent_seconds=duration,
description=f"[Aegis] {activity_type}: {test.name}",
)
logger.info("Tempo worklog created for test %s, %ds", test.id, duration)
logger.info(
"Tempo worklog created for test %s by user %s, %ds",
test.id, getattr(user, "username", user), duration,
)
return result
except Exception as e:
logger.warning("Tempo worklog failed for test %s: %s", test.id, e, exc_info=True)
logger.warning(
"Tempo worklog failed for test %s (user %s): %s",
test.id, getattr(user, "username", user), e, exc_info=True,
)
return None

View File

@@ -123,6 +123,8 @@ export interface UserPreferencesUpdate {
jira_api_token?: string | null;
/** Atlassian email used for Jira auth. Overrides Aegis account email. Empty string clears it. */
jira_email?: string | null;
/** Personal Tempo API token. Empty string clears it. */
tempo_api_token?: string | null;
}
export interface UserMeOut {
@@ -138,6 +140,7 @@ export interface UserMeOut {
jira_account_id: string | null;
jira_email: string | null;
jira_token_set: boolean;
tempo_token_set: boolean;
}
export async function getMe(): Promise<UserMeOut> {

View File

@@ -820,6 +820,10 @@ function ProfileSection() {
const [jiraEmail, setJiraEmail] = useState<string>("");
const [jiraApiToken, setJiraApiToken] = useState<string>("");
const [showToken, setShowToken] = useState(false);
const [tempoApiToken, setTempoApiToken] = useState<string>("");
const [showTempoToken, setShowTempoToken] = useState(false);
const [tempoTestResult, setTempoTestResult] = useState<string | null>(null);
const [tempoTestError, setTempoTestError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
// Initialise editable fields from server on first successful load
@@ -839,22 +843,43 @@ function ProfileSection() {
// Update cache immediately with the response — no extra round-trip needed
qc.setQueryData(["me-prefs"], updatedMe);
setDirty(false);
setJiraApiToken(""); // clear token field after save — it's persisted
setJiraApiToken(""); // clear token fields after save — they're persisted
setTempoApiToken("");
setToast({ msg: "Profile settings saved", type: "success" });
},
onError: () => setToast({ msg: "Failed to save", type: "error" }),
});
const tempoTestMut = useMutation({
mutationFn: testTempoConnection,
onSuccess: (data) => {
if (data.status === "ok") {
setTempoTestResult(data.message ?? "Connected");
setTempoTestError(null);
} else {
setTempoTestError(data.message ?? "Tempo test failed");
setTempoTestResult(null);
}
},
onError: (err: Error) => {
setTempoTestError(err.message || "Tempo test failed");
setTempoTestResult(null);
},
});
const handleSave = () => {
const payload: Parameters<typeof updateMyPreferences>[0] = {
jira_account_id: jiraAccountId || null,
jira_email: jiraEmail || null,
};
// Only send token when the user has typed something new
// Only send tokens when the user has typed something new
// (empty field = "keep current token unchanged")
if (jiraApiToken.trim() !== "") {
payload.jira_api_token = jiraApiToken.trim();
}
if (tempoApiToken.trim() !== "") {
payload.tempo_api_token = tempoApiToken.trim();
}
saveMut.mutate(payload);
};
@@ -979,7 +1004,7 @@ function ProfileSection() {
{/* Account ID */}
<div>
<label className="mb-1 block text-xs font-medium text-cyan-400">
Jira Account ID (for Tempo time tracking, optional)
Jira Account ID (required for Tempo time tracking)
</label>
<input
value={jiraAccountId}
@@ -994,6 +1019,90 @@ function ProfileSection() {
Your Atlassian account ID. Found in your Jira profile URL.
</p>
</div>
</div>
</div>
{/* ── Tempo Integration ─────────────────────────────────── */}
<div className="border-t border-gray-800 pt-4">
<p className="text-sm font-semibold text-gray-300 mb-1">Tempo Integration (personal settings)</p>
<p className="text-xs text-gray-500 mb-4">
Your personal Tempo API token logs work time on Jira tickets automatically.
</p>
<div className="space-y-4">
{/* Tempo API Token */}
<div>
<label className="mb-1 block text-xs font-medium text-purple-400">
Tempo API Token
</label>
<div className="relative">
<input
type={showTempoToken ? "text" : "password"}
value={tempoApiToken}
onChange={(e) => {
setTempoApiToken(e.target.value);
setDirty(true);
}}
placeholder={me?.tempo_token_set ? "Leave blank to keep current token" : "Paste your Tempo API token here"}
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-purple-500 focus:outline-none"
/>
<button
type="button"
onClick={() => setShowTempoToken(!showTempoToken)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{showTempoToken ? <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?.tempo_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>
)}
<span className="text-[11px] text-gray-500">
Get it at: Jira Apps Tempo Settings API Integration
</span>
</div>
</div>
{/* Test Tempo connection */}
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-2">
<button
onClick={() => {
setTempoTestResult(null);
setTempoTestError(null);
tempoTestMut.mutate();
}}
disabled={tempoTestMut.isPending || !me?.tempo_token_set}
className="flex items-center gap-2 rounded-lg border border-purple-700 px-3 py-1.5 text-sm font-medium text-purple-400 hover:bg-purple-900/30 disabled:opacity-50 transition-colors"
>
{tempoTestMut.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube className="h-4 w-4" />
)}
Test Tempo Connection
</button>
{tempoTestResult && (
<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" />
{tempoTestResult}
</div>
)}
{tempoTestError && (
<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" />
{tempoTestError}
</div>
)}
</div>
<div>
<button
@@ -1025,8 +1134,6 @@ function JiraConfigSection() {
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 [tempoResult, setTempoResult] = useState<string | null>(null);
const [tempoError, setTempoError] = useState<string | null>(null);
const { data: cfg, isLoading } = useQuery({
queryKey: ["jira-config"],
@@ -1064,23 +1171,6 @@ 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 (
<div className="flex justify-center py-8">
@@ -1196,42 +1286,6 @@ function JiraConfigSection() {
)}
</div>
{/* Test Tempo connection */}
<div className="mt-2 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 Tempo Connection</p>
<div className="rounded-md bg-blue-900/20 border border-blue-800/50 px-3 py-2 text-xs text-blue-300">
Requires <code className="font-mono">TEMPO_ENABLED=true</code> and <code className="font-mono">TEMPO_API_TOKEN</code> set in the server environment, plus your Jira Account ID in the Profile tab.
</div>
<button
onClick={() => {
setTempoResult(null);
setTempoError(null);
tempoTestMut.mutate();
}}
disabled={tempoTestMut.isPending}
className="flex items-center gap-2 rounded-lg border border-purple-700 px-4 py-2 text-sm font-medium text-purple-400 hover:bg-purple-900/30 disabled:opacity-50 transition-colors"
>
{tempoTestMut.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube className="h-4 w-4" />
)}
Test Tempo Connection
</button>
{tempoResult && (
<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" />
{tempoResult}
</div>
)}
{tempoError && (
<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" />
{tempoError}
</div>
)}
</div>
</div>
</>
);