diff --git a/backend/alembic/versions/b044_tempo_user_token.py b/backend/alembic/versions/b044_tempo_user_token.py new file mode 100644 index 0000000..e498d7c --- /dev/null +++ b/backend/alembic/versions/b044_tempo_user_token.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 99f5285..6436edc 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index e9ab966..894c952 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -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} diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 5beeee3..cfcb941 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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: diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 6cb6ebe..870befa 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/services/tempo_service.py b/backend/app/services/tempo_service.py index 9569787..6919fe2 100644 --- a/backend/app/services/tempo_service.py +++ b/backend/app/services/tempo_service.py @@ -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 diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 9ef7639..9ec2886 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -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 { diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 4715ac4..1de9fce 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -820,6 +820,10 @@ function ProfileSection() { const [jiraEmail, setJiraEmail] = useState(""); const [jiraApiToken, setJiraApiToken] = useState(""); const [showToken, setShowToken] = useState(false); + const [tempoApiToken, setTempoApiToken] = useState(""); + const [showTempoToken, setShowTempoToken] = useState(false); + const [tempoTestResult, setTempoTestResult] = useState(null); + const [tempoTestError, setTempoTestError] = useState(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[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 */}
+ + + + {/* ── Tempo Integration ─────────────────────────────────── */} +
+

Tempo Integration (personal settings)

+

+ Your personal Tempo API token logs work time on Jira tickets automatically. +

+ +
+ {/* Tempo API Token */} +
+ +
+ { + 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" + /> + +
+
+ {me?.tempo_token_set ? ( + + + Token configured + + ) : ( + + + Not configured + + )} + + Get it at: Jira → Apps → Tempo → Settings → API Integration + +
+
+ + {/* Test Tempo connection */} +
+ + {tempoTestResult && ( +
+ + {tempoTestResult} +
+ )} + {tempoTestError && ( +
+ + {tempoTestError} +
+ )} +
- - {tempoResult && ( -
- - {tempoResult} -
- )} - {tempoError && ( -
- - {tempoError} -
- )} -
);