feat(tempo): per-user Tempo API token — same pattern as Jira token
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
25
backend/alembic/versions/b044_tempo_user_token.py
Normal file
25
backend/alembic/versions/b044_tempo_user_token.py
Normal 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")
|
||||||
@@ -32,3 +32,4 @@ class User(Base):
|
|||||||
jira_account_id = Column(String(100), nullable=True)
|
jira_account_id = Column(String(100), nullable=True)
|
||||||
jira_api_token = Column(String(500), nullable=True) # personal Atlassian token
|
jira_api_token = Column(String(500), nullable=True) # personal Atlassian token
|
||||||
jira_email = Column(String(255), nullable=True) # Atlassian email (overrides account email)
|
jira_email = Column(String(255), nullable=True) # Atlassian email (overrides account email)
|
||||||
|
tempo_api_token = Column(String(500), nullable=True) # personal Tempo API token
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import SessionLocal, get_db
|
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.models.user import User
|
||||||
from app.services.mitre_sync_service import sync_mitre
|
from app.services.mitre_sync_service import sync_mitre
|
||||||
from app.services.intel_service import scan_intel
|
from app.services.intel_service import scan_intel
|
||||||
@@ -357,25 +357,24 @@ def test_jira_connection(
|
|||||||
@router.post("/tempo-test")
|
@router.post("/tempo-test")
|
||||||
def test_tempo_connection(
|
def test_tempo_connection(
|
||||||
db: Session = Depends(get_db),
|
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
|
Always returns HTTP 200 with a ``status`` field so Cloudflare never
|
||||||
intercepts the response.
|
intercepts the response.
|
||||||
"""
|
"""
|
||||||
from app.config import settings
|
from app.services.tempo_service import has_tempo_configured
|
||||||
|
|
||||||
if not settings.TEMPO_ENABLED:
|
tempo_token = getattr(current_user, "tempo_api_token", None)
|
||||||
return {
|
if not tempo_token:
|
||||||
"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 {
|
return {
|
||||||
"status": "error",
|
"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)
|
jira_account_id = getattr(current_user, "jira_account_id", None)
|
||||||
@@ -383,15 +382,15 @@ def test_tempo_connection(
|
|||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": (
|
"message": (
|
||||||
"Your user has no Jira Account ID configured. "
|
"No Jira Account ID configured. "
|
||||||
"Set it in Settings → Profile → Jira Integration → Account ID."
|
"Set it in Settings → Profile → Jira Integration → Account ID."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tempoapiclient import client_v4 as tempo_client
|
from tempoapiclient import client_v4 as tempo_client
|
||||||
tempo = tempo_client.Tempo(auth_token=settings.TEMPO_API_TOKEN)
|
tempo = tempo_client.Tempo(auth_token=tempo_token)
|
||||||
# Fetch current user's worklogs as a connectivity check (limit 1)
|
# Use a minimal date range to verify connectivity without fetching much data
|
||||||
worklogs = tempo.get_worklogs_by_account_id(
|
worklogs = tempo.get_worklogs_by_account_id(
|
||||||
account_id=jira_account_id,
|
account_id=jira_account_id,
|
||||||
dateFrom="2024-01-01",
|
dateFrom="2024-01-01",
|
||||||
@@ -405,12 +404,12 @@ def test_tempo_connection(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
err = str(exc)
|
err = str(exc)
|
||||||
if "401" in err or "Unauthorized" in err:
|
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:
|
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:
|
else:
|
||||||
msg = f"Tempo connection failed: {err}"
|
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}
|
return {"status": "error", "message": msg}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ def update_my_preferences(
|
|||||||
"""
|
"""
|
||||||
update_data = payload.model_dump(exclude_unset=True)
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
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"
|
# Empty string means "clear the value"
|
||||||
setattr(current_user, field, value if value else None)
|
setattr(current_user, field, value if value else None)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -122,16 +122,19 @@ class PasswordChange(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserPreferencesUpdate(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
|
notification_preferences: dict | None = None
|
||||||
jira_account_id: str | 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.
|
# Set to empty string "" to clear the token.
|
||||||
jira_api_token: str | None = None
|
jira_api_token: str | None = None
|
||||||
# Atlassian email for Jira auth — overrides account email.
|
# Atlassian email for Jira auth — overrides account email.
|
||||||
# Set to empty string "" to clear (falls back to account email).
|
# Set to empty string "" to clear (falls back to account email).
|
||||||
jira_email: str | None = None
|
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):
|
class UserOut(BaseModel):
|
||||||
@@ -148,20 +151,23 @@ class UserOut(BaseModel):
|
|||||||
notification_preferences: dict | None = None
|
notification_preferences: dict | None = None
|
||||||
jira_account_id: str | None = None
|
jira_account_id: str | None = None
|
||||||
jira_email: 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)
|
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
|
jira_token_set: bool = False
|
||||||
|
tempo_token_set: bool = False
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _derive_jira_token_set(self) -> "UserOut":
|
def _derive_token_set_flags(self) -> "UserOut":
|
||||||
"""Set jira_token_set from the (excluded) jira_api_token field.
|
"""Derive *_token_set booleans from the (excluded) raw token fields.
|
||||||
|
|
||||||
Uses @model_validator(mode='after') so Pydantic's Rust core calls it
|
Uses @model_validator(mode='after') so Pydantic's Rust core calls it
|
||||||
during FastAPI response serialisation — model_validate() overrides are
|
during FastAPI response serialisation — model_validate() overrides are
|
||||||
bypassed by FastAPI's __pydantic_validator__.validate_python() path.
|
bypassed by FastAPI's __pydantic_validator__.validate_python() path.
|
||||||
"""
|
"""
|
||||||
self.jira_token_set = bool(self.jira_api_token)
|
self.jira_token_set = bool(self.jira_api_token)
|
||||||
|
self.tempo_token_set = bool(self.tempo_api_token)
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -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
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -12,22 +22,35 @@ from app.models.jira_link import JiraLink, JiraLinkEntityType
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_tempo_client():
|
def has_tempo_configured(user) -> bool:
|
||||||
"""Return a Tempo API client, or raise if disabled."""
|
"""Return True if *user* has a personal Tempo API token stored."""
|
||||||
if not settings.TEMPO_ENABLED:
|
return bool(getattr(user, "tempo_api_token", None))
|
||||||
raise InvalidOperationError("Tempo integration is not enabled")
|
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
from tempoapiclient import client_v4 as tempo_client
|
from tempoapiclient import client_v4 as tempo_client
|
||||||
|
return tempo_client.Tempo(auth_token=token)
|
||||||
return tempo_client.Tempo(auth_token=settings.TEMPO_API_TOKEN)
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise InvalidOperationError(
|
raise InvalidOperationError(
|
||||||
"tempo-api-python-client is not installed. "
|
"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(
|
def log_worklog(
|
||||||
|
user,
|
||||||
jira_issue_id: int,
|
jira_issue_id: int,
|
||||||
author_account_id: str,
|
author_account_id: str,
|
||||||
date: str,
|
date: str,
|
||||||
@@ -35,8 +58,8 @@ def log_worklog(
|
|||||||
description: str,
|
description: str,
|
||||||
work_type: str | None = None,
|
work_type: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a worklog entry in Tempo."""
|
"""Create a worklog entry in Tempo using *user*'s personal token."""
|
||||||
tempo = get_tempo_client()
|
tempo = get_user_tempo_client(user)
|
||||||
kwargs: dict = {
|
kwargs: dict = {
|
||||||
"accountId": author_account_id,
|
"accountId": author_account_id,
|
||||||
"issueId": jira_issue_id,
|
"issueId": jira_issue_id,
|
||||||
@@ -56,13 +79,24 @@ def auto_log_test_worklog(
|
|||||||
user,
|
user,
|
||||||
activity_type: str,
|
activity_type: str,
|
||||||
) -> Optional[dict]:
|
) -> 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.
|
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:
|
if not settings.TEMPO_ENABLED:
|
||||||
return None
|
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 = (
|
link = (
|
||||||
db.query(JiraLink)
|
db.query(JiraLink)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -76,24 +110,37 @@ def auto_log_test_worklog(
|
|||||||
logger.debug("No Jira link for test %s, skipping Tempo worklog", test.id)
|
logger.debug("No Jira link for test %s, skipping Tempo worklog", test.id)
|
||||||
return None
|
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)
|
duration = _calculate_duration(test, activity_type)
|
||||||
if duration <= 0:
|
if duration <= 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = log_worklog(
|
result = log_worklog(
|
||||||
|
user=user,
|
||||||
jira_issue_id=int(link.jira_issue_id),
|
jira_issue_id=int(link.jira_issue_id),
|
||||||
author_account_id=getattr(user, "jira_account_id", "") or "",
|
author_account_id=jira_account_id,
|
||||||
date=(getattr(test, "updated_at", None) or test.created_at).strftime(
|
date=(getattr(test, "updated_at", None) or test.created_at).strftime("%Y-%m-%d"),
|
||||||
"%Y-%m-%d",
|
|
||||||
),
|
|
||||||
time_spent_seconds=duration,
|
time_spent_seconds=duration,
|
||||||
description=f"[Aegis] {activity_type}: {test.name}",
|
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
|
return result
|
||||||
except Exception as e:
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ export interface UserPreferencesUpdate {
|
|||||||
jira_api_token?: string | null;
|
jira_api_token?: string | null;
|
||||||
/** Atlassian email used for Jira auth. Overrides Aegis account email. Empty string clears it. */
|
/** Atlassian email used for Jira auth. Overrides Aegis account email. Empty string clears it. */
|
||||||
jira_email?: string | null;
|
jira_email?: string | null;
|
||||||
|
/** Personal Tempo API token. Empty string clears it. */
|
||||||
|
tempo_api_token?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserMeOut {
|
export interface UserMeOut {
|
||||||
@@ -138,6 +140,7 @@ export interface UserMeOut {
|
|||||||
jira_account_id: string | null;
|
jira_account_id: string | null;
|
||||||
jira_email: string | null;
|
jira_email: string | null;
|
||||||
jira_token_set: boolean;
|
jira_token_set: boolean;
|
||||||
|
tempo_token_set: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMe(): Promise<UserMeOut> {
|
export async function getMe(): Promise<UserMeOut> {
|
||||||
|
|||||||
@@ -820,6 +820,10 @@ function ProfileSection() {
|
|||||||
const [jiraEmail, setJiraEmail] = useState<string>("");
|
const [jiraEmail, setJiraEmail] = useState<string>("");
|
||||||
const [jiraApiToken, setJiraApiToken] = useState<string>("");
|
const [jiraApiToken, setJiraApiToken] = useState<string>("");
|
||||||
const [showToken, setShowToken] = useState(false);
|
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);
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
// Initialise editable fields from server on first successful load
|
// 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
|
// Update cache immediately with the response — no extra round-trip needed
|
||||||
qc.setQueryData(["me-prefs"], updatedMe);
|
qc.setQueryData(["me-prefs"], updatedMe);
|
||||||
setDirty(false);
|
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" });
|
setToast({ msg: "Profile settings saved", type: "success" });
|
||||||
},
|
},
|
||||||
onError: () => setToast({ msg: "Failed to save", type: "error" }),
|
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 handleSave = () => {
|
||||||
const payload: Parameters<typeof updateMyPreferences>[0] = {
|
const payload: Parameters<typeof updateMyPreferences>[0] = {
|
||||||
jira_account_id: jiraAccountId || null,
|
jira_account_id: jiraAccountId || null,
|
||||||
jira_email: jiraEmail || 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")
|
// (empty field = "keep current token unchanged")
|
||||||
if (jiraApiToken.trim() !== "") {
|
if (jiraApiToken.trim() !== "") {
|
||||||
payload.jira_api_token = jiraApiToken.trim();
|
payload.jira_api_token = jiraApiToken.trim();
|
||||||
}
|
}
|
||||||
|
if (tempoApiToken.trim() !== "") {
|
||||||
|
payload.tempo_api_token = tempoApiToken.trim();
|
||||||
|
}
|
||||||
saveMut.mutate(payload);
|
saveMut.mutate(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -979,7 +1004,7 @@ function ProfileSection() {
|
|||||||
{/* Account ID */}
|
{/* Account ID */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-cyan-400">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={jiraAccountId}
|
value={jiraAccountId}
|
||||||
@@ -994,6 +1019,90 @@ function ProfileSection() {
|
|||||||
Your Atlassian account ID. Found in your Jira profile URL.
|
Your Atlassian account ID. Found in your Jira profile URL.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<button
|
<button
|
||||||
@@ -1025,8 +1134,6 @@ function JiraConfigSection() {
|
|||||||
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
|
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
|
||||||
const [testResult, setTestResult] = useState<{ connectedAs: string; url: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ connectedAs: string; url: string } | null>(null);
|
||||||
const [testError, setTestError] = useState<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({
|
const { data: cfg, isLoading } = useQuery({
|
||||||
queryKey: ["jira-config"],
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
@@ -1196,42 +1286,6 @@ function JiraConfigSection() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user