feat(jira): admin account replaces per-user tokens for all Jira/Tempo ops
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled

- Single admin Jira account stored in system_configs (jira.admin_email, jira.admin_api_token)
- Admin Tempo token in system_configs (tempo.admin_token)
- get_admin_jira_client() + has_admin_jira_configured() replace per-user auth in all lifecycle hooks
- lookup_user_jira_account_id() auto-discovers each user's Atlassian accountId by email on login
- auto_log_test_worklog() now uses admin Tempo token and logs both red+blue team time
- Settings > Jira: admin credentials fields + test buttons moved to admin Jira tab
- Profile section simplified: jira_account_id shown read-only (auto-detected)
This commit is contained in:
kitos
2026-06-24 09:15:35 +02:00
parent bb8b9a6a72
commit 18695197e8
7 changed files with 530 additions and 521 deletions
+8
View File
@@ -172,6 +172,14 @@ def login(
# Call uow.commit() # Call uow.commit()
uow.commit() uow.commit()
# Auto-discover the user's Jira account ID on every login (non-fatal)
try:
from app.services.jira_service import lookup_user_jira_account_id
if lookup_user_jira_account_id(db, user):
db.commit()
except Exception:
pass
# Call response.set_cookie() # Call response.set_cookie()
response.set_cookie( response.set_cookie(
# Keyword argument: key # Keyword argument: key
+3 -3
View File
@@ -237,14 +237,14 @@ def _attach_evidence_to_jira(
) -> None: ) -> None:
"""Attach uploaded evidence to the linked Jira ticket (non-fatal).""" """Attach uploaded evidence to the linked Jira ticket (non-fatal)."""
try: try:
from app.services.jira_service import get_test_jira_key, get_user_jira_client, has_jira_configured from app.services.jira_service import get_test_jira_key, get_admin_jira_client, has_admin_jira_configured
if not has_jira_configured(actor, db): if not has_admin_jira_configured(db):
return return
issue_key = get_test_jira_key(db, test_id) issue_key = get_test_jira_key(db, test_id)
if not issue_key: if not issue_key:
return return
import io import io
jira = get_user_jira_client(actor, db) jira = get_admin_jira_client(db)
buf = io.BytesIO(content) buf = io.BytesIO(content)
buf.name = file_name # requests uses .name as the multipart filename buf.name = file_name # requests uses .name as the multipart filename
jira.add_attachment_object(issue_key, buf) jira.add_attachment_object(issue_key, buf)
+54 -70
View File
@@ -254,8 +254,9 @@ class JiraConfigOut(BaseModel):
url: str url: str
project_key: str project_key: str
parent_ticket: str parent_ticket: str
parent_ticket_standalone: str # parent for tests not in a campaign parent_ticket_standalone: str
# Credentials are never returned admin_email_set: bool # whether admin email is configured (value never returned)
tempo_admin_token_set: bool # whether admin Tempo token is configured
class JiraConfigUpdate(BaseModel): class JiraConfigUpdate(BaseModel):
@@ -264,6 +265,9 @@ class JiraConfigUpdate(BaseModel):
project_key: Optional[str] = None project_key: Optional[str] = None
parent_ticket: Optional[str] = None parent_ticket: Optional[str] = None
parent_ticket_standalone: Optional[str] = None parent_ticket_standalone: Optional[str] = None
admin_email: Optional[str] = None
admin_api_token: Optional[str] = None
tempo_admin_token: Optional[str] = None
_JIRA_KEYS = { _JIRA_KEYS = {
@@ -272,6 +276,9 @@ _JIRA_KEYS = {
"project_key": "jira.project_key", "project_key": "jira.project_key",
"parent_ticket": "jira.parent_ticket", "parent_ticket": "jira.parent_ticket",
"parent_ticket_standalone": "jira.parent_ticket_standalone", "parent_ticket_standalone": "jira.parent_ticket_standalone",
"admin_email": "jira.admin_email",
"admin_api_token": "jira.admin_api_token",
"tempo_admin_token": "tempo.admin_token",
} }
@@ -282,11 +289,12 @@ def get_jira_config(
): ):
"""Return current Jira configuration (merged DB + env). """Return current Jira configuration (merged DB + env).
**Requires** the ``admin`` role. Credentials are never returned. **Requires** the ``admin`` role. Credential values are never returned.
""" """
from app.services.jira_service import ( from app.services.jira_service import (
get_jira_url, get_jira_project_key, is_jira_enabled, get_jira_url, get_jira_project_key, is_jira_enabled,
get_jira_parent_ticket, get_jira_parent_ticket_standalone, get_jira_parent_ticket, get_jira_parent_ticket_standalone,
get_admin_jira_email, _read_system_config,
) )
return JiraConfigOut( return JiraConfigOut(
@@ -295,6 +303,8 @@ def get_jira_config(
project_key=get_jira_project_key(db) or "", project_key=get_jira_project_key(db) or "",
parent_ticket=get_jira_parent_ticket(db) or "", parent_ticket=get_jira_parent_ticket(db) or "",
parent_ticket_standalone=get_jira_parent_ticket_standalone(db) or "", parent_ticket_standalone=get_jira_parent_ticket_standalone(db) or "",
admin_email_set=bool(get_admin_jira_email(db)),
tempo_admin_token_set=bool(_read_system_config(db, "tempo.admin_token")),
) )
@@ -311,6 +321,7 @@ def update_jira_config(
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, get_jira_parent_ticket_standalone, get_jira_parent_ticket, get_jira_parent_ticket_standalone,
get_admin_jira_email, _read_system_config,
) )
update_data = payload.model_dump(exclude_unset=True) update_data = payload.model_dump(exclude_unset=True)
@@ -326,6 +337,8 @@ def update_jira_config(
project_key=get_jira_project_key(db) or "", project_key=get_jira_project_key(db) or "",
parent_ticket=get_jira_parent_ticket(db) or "", parent_ticket=get_jira_parent_ticket(db) or "",
parent_ticket_standalone=get_jira_parent_ticket_standalone(db) or "", parent_ticket_standalone=get_jira_parent_ticket_standalone(db) or "",
admin_email_set=bool(get_admin_jira_email(db)),
tempo_admin_token_set=bool(_read_system_config(db, "tempo.admin_token")),
) )
@@ -334,70 +347,67 @@ def test_jira_connection(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_role("admin")),
): ):
"""Test the Jira connection using the current user's credentials. """Test the Jira connection using the admin account credentials.
Requires the admin to have a personal Jira API token configured in their
profile settings.
Always returns HTTP 200 with a ``status`` field so Cloudflare never Always returns HTTP 200 with a ``status`` field so Cloudflare never
replaces the response with its own error page. replaces the response with its own error page.
""" """
from app.services.jira_service import get_user_jira_client, get_jira_url, _effective_jira_email from app.services.jira_service import (
get_admin_jira_client, get_jira_url, has_admin_jira_configured,
get_admin_jira_email,
)
jira_url = get_jira_url(db) jira_url = get_jira_url(db)
if not jira_url: if not jira_url:
return {"status": "error", "message": "Jira URL is not configured. Set it in System Settings → Jira Configuration.", "jira_url": ""} return {"status": "error", "message": "Jira URL is not configured.", "jira_url": ""}
auth_email = _effective_jira_email(current_user) if not has_admin_jira_configured(db):
return {
"status": "error",
"message": (
"Admin Jira credentials not configured. "
"Set the Admin Email and Admin API Token in the fields above."
),
"jira_url": jira_url,
}
admin_email = get_admin_jira_email(db)
try: try:
jira = get_user_jira_client(current_user, db) jira = get_admin_jira_client(db)
# 10-second timeout so we never block Cloudflare into a 524
try: try:
jira._session.timeout = 10 # type: ignore[attr-defined] jira._session.timeout = 10 # type: ignore[attr-defined]
except Exception: # nosec B110 except Exception: # nosec B110
pass pass
myself = jira.myself() myself = jira.myself()
logger.info("Jira myself() response keys: %s", list(myself.keys()) if isinstance(myself, dict) else type(myself))
# Use displayName → emailAddress → name → the auth email as fallback
connected_as = ( connected_as = (
(myself.get("displayName") if isinstance(myself, dict) else None) (myself.get("displayName") if isinstance(myself, dict) else None)
or (myself.get("emailAddress") if isinstance(myself, dict) else None) or (myself.get("emailAddress") if isinstance(myself, dict) else None)
or (myself.get("name") if isinstance(myself, dict) else None) or admin_email
or auth_email
or "authenticated" or "authenticated"
) )
return { return {"status": "ok", "connected_as": connected_as, "jira_url": jira_url}
"status": "ok",
"connected_as": connected_as,
"jira_url": jira_url,
}
except Exception as exc: except Exception as exc:
err = str(exc) err = str(exc)
# Always return HTTP 200 with status="error" so Cloudflare never
# intercepts the response and the frontend always sees our message.
if "Expecting value" in err or "line 1 column 1" in err: if "Expecting value" in err or "line 1 column 1" in err:
msg = ( msg = (
"Jira returned a non-JSON response. " "Jira returned a non-JSON response. "
"Verify the URL (e.g. https://company.atlassian.net), " "Verify the URL (e.g. https://company.atlassian.net), "
"email and API token." "admin email and API token."
) )
elif "401" in err or "Unauthorized" in err: elif "401" in err or "Unauthorized" in err:
msg = ( msg = (
"Authentication failed (401). " "Authentication failed (401). "
f"Check that the Atlassian email ({auth_email or 'not set'}) " f"Check that the admin email ({admin_email or 'not set'}) "
"and API token are correct. The token must be an Atlassian API token " "and API token are correct."
"(not your account password)."
) )
elif "403" in err or "Forbidden" in err: elif "403" in err or "Forbidden" in err:
msg = "Access denied (403). The token may not have permission for this Jira project." msg = "Access denied (403). The admin token may not have permission for this Jira project."
elif "timed out" in err.lower() or "timeout" in err.lower(): elif "timed out" in err.lower() or "timeout" in err.lower():
msg = "Connection timed out. Check that the Jira URL is reachable from the server." msg = "Connection timed out. Check that the Jira URL is reachable from the server."
elif "not configured" in err.lower():
msg = err
else: else:
msg = f"Jira connection failed: {err}" msg = f"Jira connection failed: {err}"
logger.warning("Jira test connection failed: %s", err) logger.warning("Admin Jira test connection failed: %s", err)
return {"status": "error", "message": msg, "jira_url": jira_url} return {"status": "error", "message": msg, "jira_url": jira_url}
@@ -409,49 +419,32 @@ 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(get_current_user), current_user: User = Depends(require_role("admin")),
): ):
"""Test the current user's personal Tempo connection. """Test the admin Tempo token connectivity.
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.services.tempo_service import get_admin_tempo_token, get_admin_tempo_client
tempo_token = getattr(current_user, "tempo_api_token", None) admin_token = get_admin_tempo_token(db)
if not tempo_token: if not admin_token:
return { return {
"status": "error", "status": "error",
"message": ( "message": (
"No Tempo API token configured. " "Admin Tempo token not configured. "
"Add it in Settings → Profile → Tempo Integration." "Set it in System Settings → Jira Configuration → Admin Tempo Token."
),
}
jira_account_id = getattr(current_user, "jira_account_id", None)
if not jira_account_id:
return {
"status": "error",
"message": (
"No Jira Account ID configured. "
"Set it in Settings → Profile → Jira Integration → Account ID."
), ),
} }
try: try:
from tempoapiclient import client_v4 as tempo_client tempo = get_admin_tempo_client(db)
tempo = tempo_client.Tempo(auth_token=tempo_token) worklogs = tempo.search_worklogs(dateFrom="2024-01-01", dateTo="2024-01-02")
# search_worklogs by authorId is the correct v4 method; use a tight
# date range so we fetch almost nothing but still verify connectivity.
worklogs = tempo.search_worklogs(
dateFrom="2024-01-01",
dateTo="2024-01-02",
authorIds=[jira_account_id],
)
count = len(worklogs) if isinstance(worklogs, list) else "n/a" count = len(worklogs) if isinstance(worklogs, list) else "n/a"
return { return {
"status": "ok", "status": "ok",
"message": f"Tempo connected successfully. Account ID: {jira_account_id}", "message": "Admin Tempo token connected successfully.",
"worklogs_found": count, "worklogs_found": count,
} }
except Exception as exc: except Exception as exc:
@@ -459,23 +452,14 @@ def test_tempo_connection(
if "401" in err or "Unauthorized" in err: if "401" in err or "Unauthorized" in err:
msg = ( msg = (
"Authentication failed (401). " "Authentication failed (401). "
"Check your Tempo API token — obtain it at " "Check the admin Tempo API token — obtain it at "
"Jira → Apps → Tempo → Settings → API Integration." "Jira → Apps → Tempo → Settings → API Integration."
) )
elif "403" in err or "Forbidden" in err: elif "403" in err or "Forbidden" in err:
msg = "Access denied (403). The Tempo token lacks the required permissions." msg = "Access denied (403). The admin Tempo token lacks the required permissions."
elif "404" in err or "not found" in err.lower():
msg = (
"Account ID not found (404). "
f"The value '{jira_account_id}' may be wrong — see the instructions "
"below to find your correct Atlassian Account ID."
)
else: else:
msg = f"Tempo connection failed: {err}" msg = f"Tempo connection failed: {err}"
logger.warning( logger.warning("Admin Tempo test connection failed: %s", err)
"Tempo test connection failed for user %s (account_id=%s): %s",
current_user.username, jira_account_id, err,
)
return {"status": "error", "message": msg} return {"status": "error", "message": msg}
+109 -9
View File
@@ -188,10 +188,110 @@ def get_user_jira_client(user: User, db: Session):
def has_jira_configured(user: User, db: Session) -> bool: def has_jira_configured(user: User, db: Session) -> bool:
"""Return True if *user* has everything needed to call Jira.""" """Return True if *user* has everything needed to call Jira (legacy per-user check)."""
return bool(get_jira_url(db) and _effective_jira_email(user) and user.jira_api_token) return bool(get_jira_url(db) and _effective_jira_email(user) and user.jira_api_token)
# ---------------------------------------------------------------------------
# Admin Jira client (single account for all lifecycle hooks)
# ---------------------------------------------------------------------------
def get_admin_jira_email(db: Session) -> Optional[str]:
"""Return the admin Jira email from system_configs."""
return _read_system_config(db, "jira.admin_email") or None
def get_admin_jira_api_token(db: Session) -> Optional[str]:
"""Return the admin Jira API token from system_configs."""
return _read_system_config(db, "jira.admin_api_token") or None
def has_admin_jira_configured(db: Session) -> bool:
"""Return True if the global Jira admin account is fully configured."""
return bool(
is_jira_enabled(db)
and get_jira_url(db)
and get_admin_jira_email(db)
and get_admin_jira_api_token(db)
)
def get_admin_jira_client(db: Session):
"""Return a Jira client authenticated with the global admin credentials.
All Aegis-to-Jira operations (issue creation, comments, transitions)
go through this single account. Users do not need personal Jira tokens.
"""
jira_url = get_jira_url(db)
if not jira_url:
raise InvalidOperationError("Jira URL is not configured.")
admin_email = get_admin_jira_email(db)
admin_token = get_admin_jira_api_token(db)
if not admin_email or not admin_token:
raise InvalidOperationError(
"Admin Jira credentials not configured. "
"Set them in System Settings → Jira Configuration → Admin Account."
)
from atlassian import Jira
return Jira(
url=jira_url.rstrip("/"),
username=admin_email,
password=admin_token,
cloud=True,
)
def lookup_user_jira_account_id(db: Session, user: User) -> bool:
"""Lookup *user*'s Atlassian account ID by email using the admin Jira client.
Updates ``user.jira_account_id`` in-place when found or changed.
Returns ``True`` when the value was updated, ``False`` otherwise.
Non-fatal — all errors are logged at DEBUG level and swallowed.
"""
if not has_admin_jira_configured(db):
return False
email = getattr(user, "email", None)
if not email:
return False
try:
jira = get_admin_jira_client(db)
results = jira.user_find_by_user_string(query=email, maxResults=10)
account_id: Optional[str] = None
for u in results or []:
if isinstance(u, dict) and u.get("emailAddress", "").lower() == email.lower():
account_id = u.get("accountId")
break
if not account_id:
logger.debug("No Jira user found for email %s", email)
return False
current = getattr(user, "jira_account_id", None)
if account_id != current:
user.jira_account_id = account_id
db.flush()
logger.info(
"Auto-updated jira_account_id for %s: %s", user.username, account_id
)
return True
return False
except Exception as exc:
logger.debug(
"Could not lookup Jira account_id for %s: %s",
getattr(user, "username", "?"),
exc,
)
return False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Ticket content builders (inspired by the pentest-to-Jira script) # Ticket content builders (inspired by the pentest-to-Jira script)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -403,7 +503,7 @@ def auto_create_campaign_issue(
Called once right after a campaign is committed to the database. Called once right after a campaign is committed to the database.
The created ticket is stored as a JiraLink with entity_type=campaign. The created ticket is stored as a JiraLink with entity_type=campaign.
""" """
if not has_jira_configured(actor, db): if not has_admin_jira_configured(db):
return None return None
project_key = get_jira_project_key(db) project_key = get_jira_project_key(db)
@@ -417,7 +517,7 @@ def auto_create_campaign_issue(
parent_ticket = get_jira_parent_ticket(db) parent_ticket = get_jira_parent_ticket(db)
try: try:
jira = get_user_jira_client(actor, db) jira = get_admin_jira_client(db)
fields: dict = { fields: dict = {
"project": {"key": project_key}, "project": {"key": project_key},
@@ -487,7 +587,7 @@ def auto_create_test_issue(
instead of the system-configured parent (e.g. OFS-9107). instead of the system-configured parent (e.g. OFS-9107).
Use this to nest test tickets under a campaign ticket. Use this to nest test tickets under a campaign ticket.
""" """
if not has_jira_configured(actor, db): if not has_admin_jira_configured(db):
return None return None
project_key = get_jira_project_key(db) project_key = get_jira_project_key(db)
@@ -502,7 +602,7 @@ def auto_create_test_issue(
mitre_id = technique.mitre_id if technique else "N/A" mitre_id = technique.mitre_id if technique else "N/A"
try: try:
jira = get_user_jira_client(actor, db) jira = get_admin_jira_client(db)
# All tests — whether inside a campaign or standalone — are created # All tests — whether inside a campaign or standalone — are created
# as Task. Campaign tests use the campaign Jira key as parent # as Task. Campaign tests use the campaign Jira key as parent
@@ -574,7 +674,7 @@ def push_test_event(
Completely non-fatal — any Jira error is logged and swallowed so it Completely non-fatal — any Jira error is logged and swallowed so it
never blocks the test workflow. never blocks the test workflow.
""" """
if not has_jira_configured(actor, db): if not has_admin_jira_configured(db):
return return
link = ( link = (
@@ -589,7 +689,7 @@ def push_test_event(
return return
try: try:
jira = get_user_jira_client(actor, db) jira = get_admin_jira_client(db)
comment = _build_state_comment(test, new_state, actor, extra) comment = _build_state_comment(test, new_state, actor, extra)
jira.issue_add_comment(link.jira_issue_key, comment) jira.issue_add_comment(link.jira_issue_key, comment)
@@ -650,7 +750,7 @@ def push_hold_event(
Non-fatal — any Jira error is logged and swallowed. Non-fatal — any Jira error is logged and swallowed.
""" """
if not has_jira_configured(actor, db): if not has_admin_jira_configured(db):
return return
link = ( link = (
@@ -665,7 +765,7 @@ def push_hold_event(
return return
try: try:
jira = get_user_jira_client(actor, db) jira = get_admin_jira_client(db)
ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
if resuming: if resuming:
+108 -85
View File
@@ -45,17 +45,50 @@ from app.models.user import User
# Assign logger = logging.getLogger(__name__) # Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Only red team execution time goes to Tempo.
# Blue team evaluation time is tracked internally (worklogs table) for SLA
# purposes but is NOT forwarded to Tempo — blue team has no Jira access.
_TEMPO_ACTIVITY_TYPES = {"red_team_execution"}
def has_tempo_configured(user) -> bool: def has_tempo_configured(user) -> bool:
"""Return True if *user* has a personal Tempo API token stored.""" """Return True if *user* has a personal Tempo API token stored (legacy)."""
return bool(getattr(user, "tempo_api_token", None)) return bool(getattr(user, "tempo_api_token", None))
def get_admin_tempo_token(db=None) -> Optional[str]:
"""Return the admin Tempo API token from system_configs or env-var fallback."""
if db is not None:
try:
from app.models.system_config import SystemConfig
row = db.query(SystemConfig).filter(
SystemConfig.key == "tempo.admin_token"
).first()
if row and row.value:
return row.value
except Exception: # nosec B110
pass
return getattr(settings, "TEMPO_API_TOKEN", None) or None
def has_admin_tempo_configured(db=None) -> bool:
"""Return True if the admin Tempo token is configured."""
return bool(get_admin_tempo_token(db))
def get_admin_tempo_client(db=None):
"""Return a Tempo API v4 client authenticated with the admin token."""
token = get_admin_tempo_token(db)
if not token:
raise InvalidOperationError(
"Admin Tempo token not configured. "
"Set it in System Settings → Jira Configuration."
)
try:
from tempoapiclient import client_v4 as tempo_client
base_url = _get_tempo_base_url(db)
return tempo_client.Tempo(auth_token=token, base_url=base_url)
except ImportError:
raise InvalidOperationError(
"tempo-api-python-client is not installed. "
"Run: pip install tempo-api-python-client"
)
_TEMPO_DEFAULT_BASE_URL = "https://api.tempo.io/4" _TEMPO_DEFAULT_BASE_URL = "https://api.tempo.io/4"
_TEMPO_EU_BASE_URL = "https://api.eu.tempo.io/4" _TEMPO_EU_BASE_URL = "https://api.eu.tempo.io/4"
@@ -166,93 +199,56 @@ def get_tempo_client():
) )
# Define function auto_log_test_worklog
def auto_log_test_worklog( def auto_log_test_worklog(
# Entry: db
db: Session, db: Session,
# Entry: test
test: Test, test: Test,
# Entry: user
user: User, user: User,
# Entry: activity_type
activity_type: str, activity_type: str,
duration_seconds: Optional[int] = None, duration_seconds: Optional[int] = None,
) -> Optional[dict]: ) -> Optional[dict]:
"""Log time to Tempo for the given test if conditions are met. """Log time to Tempo for the given test if conditions are met.
``duration_seconds``, when provided, is used as-is so the Tempo entry Uses the admin Tempo token (preferred) so regular users need no personal
matches the Aegis worklog exactly. When omitted, the duration is computed token. Falls back to the user's personal token when no admin token is set.
from the test's phase start timestamp to ``updated_at`` (or now). Both ``red_team_execution`` and ``blue_team_evaluation`` are logged.
Only ``red_team_execution`` activities are forwarded to Tempo. ``duration_seconds``, when provided, is used as-is. When omitted, the
``blue_team_evaluation`` is tracked internally but not sent. duration is computed from the test's phase start timestamp.
Returns the Tempo worklog response dict, or ``None`` if skipped. Returns the Tempo worklog response dict, or ``None`` if skipped.
Completely non-fatal — errors are logged and swallowed. Completely non-fatal — errors are logged and swallowed.
""" """
# Only whitelisted activity types go to Tempo # Only recognised activity types
if activity_type not in _TEMPO_ACTIVITY_TYPES: if activity_type not in ("red_team_execution", "blue_team_evaluation"):
logger.debug( logger.debug("Skipping Tempo sync for activity_type=%s", activity_type)
"Skipping Tempo sync for activity_type=%s (not in whitelist)", activity_type
)
return None return None
# Global kill-switch # Global kill-switch
if not settings.TEMPO_ENABLED: if not settings.TEMPO_ENABLED:
# Return None
return None return None
# Compute duration from test timestamps when not supplied by the caller # Compute duration from test timestamps when not supplied by the caller
if duration_seconds is None: if duration_seconds is None:
from datetime import datetime as _dt from datetime import datetime as _dt
started = getattr(test, "red_started_at", None) if activity_type == "blue_team_evaluation":
started = getattr(test, "blue_work_started_at", None) or getattr(test, "blue_started_at", None)
else:
started = getattr(test, "red_started_at", None)
if started is None: if started is None:
logger.debug("No red_started_at on test %s; skipping Tempo worklog", test.id) logger.debug("No start timestamp on test %s for %s; skipping Tempo worklog", test.id, activity_type)
return None return None
ended = getattr(test, "updated_at", None) or _dt.utcnow() ended = getattr(test, "updated_at", None) or _dt.utcnow()
duration_seconds = max(int((ended - started).total_seconds()), 0) duration_seconds = max(int((ended - started).total_seconds()), 0)
if duration_seconds <= 0: if duration_seconds <= 0:
logger.debug( logger.debug("Skipping Tempo sync for test %s: duration=%ds", test.id, duration_seconds)
"Skipping Tempo sync for test %s: duration=%ds", test.id, duration_seconds
)
return None return None
# Tempo requires whole minutes. Always round UP to the nearest minute # Round UP to the nearest whole minute; enforce ≥ 60 s
# and enforce a minimum of 60 seconds (1 minute).
# 2 seconds → 60 s (1 min, minimum)
# 3 min 20 s (200s) → 240 s (4 min, ceiling)
# 5 min 0 s (300s) → 300 s (5 min, exact)
import math import math
duration_seconds = max(60, math.ceil(duration_seconds / 60) * 60) duration_seconds = max(60, math.ceil(duration_seconds / 60) * 60)
# Per-user token required # user.jira_account_id is required for Tempo worklog attribution
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)
# Chain .filter() call
.filter(
JiraLink.entity_id == test.id,
JiraLink.entity_type == JiraLinkEntityType.test,
)
# Chain .first() call
.first()
)
# Check: not link or not link.jira_issue_id
if not link or not link.jira_issue_id:
# Log debug: "No Jira link for test %s, skipping Tempo worklog"
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 "").strip() jira_account_id = (getattr(user, "jira_account_id", "") or "").strip()
if not jira_account_id: if not jira_account_id:
logger.debug( logger.debug(
@@ -261,37 +257,64 @@ def auto_log_test_worklog(
) )
return None return None
# Attempt the following; catch errors below # Need a Jira link with a numeric issue ID
try: link = (
# Use the phase start timestamp as the worklog date so it matches when db.query(JiraLink)
# work actually happened (not the submission timestamp). .filter(
if activity_type == "blue_team_evaluation": JiraLink.entity_id == test.id,
work_date = ( JiraLink.entity_type == JiraLinkEntityType.test,
(test.blue_work_started_at or test.blue_started_at or test.created_at)
.strftime("%Y-%m-%d")
)
description = f"[Aegis] Blue Team evaluation: {test.name}"
else:
work_date = (
(test.red_started_at or getattr(test, "updated_at", None) or test.created_at)
.strftime("%Y-%m-%d")
)
description = f"[Aegis] Red Team execution: {test.name}"
result = log_worklog(
user=user,
jira_issue_id=int(link.jira_issue_id),
author_account_id=jira_account_id,
date=work_date,
time_spent_seconds=duration_seconds,
description=description,
db=db,
) )
.first()
)
if not link or not link.jira_issue_id:
logger.debug("No Jira link for test %s, skipping Tempo worklog", test.id)
return None
# Use the phase start timestamp for the worklog date
if activity_type == "blue_team_evaluation":
work_date = (
(test.blue_work_started_at or test.blue_started_at or test.created_at)
.strftime("%Y-%m-%d")
)
description = f"[Aegis] Blue Team evaluation: {test.name}"
else:
work_date = (
(test.red_started_at or getattr(test, "updated_at", None) or test.created_at)
.strftime("%Y-%m-%d")
)
description = f"[Aegis] Red Team execution: {test.name}"
try:
# Admin token preferred; fall back to user's personal token for legacy setups
admin_token = get_admin_tempo_token(db)
if admin_token:
from tempoapiclient import client_v4 as tempo_client
base_url = _get_tempo_base_url(db)
tempo = tempo_client.Tempo(auth_token=admin_token, base_url=base_url)
elif has_tempo_configured(user):
tempo = get_user_tempo_client(user, db=db)
else:
logger.debug(
"No Tempo credentials available; skipping worklog for test %s", test.id
)
return None
try:
result = tempo.create_worklog(
accountId=jira_account_id,
issueId=int(link.jira_issue_id),
dateFrom=work_date,
timeSpentSeconds=duration_seconds,
description=description,
)
except BaseException as exc:
raise RuntimeError(f"Tempo API error: {exc}") from exc
logger.info( logger.info(
"Tempo worklog created for test %s by user %s: %ds on %s", "Tempo worklog created for test %s by user %s: %ds on %s",
test.id, getattr(user, "username", user), duration_seconds, work_date, test.id, getattr(user, "username", user), duration_seconds, work_date,
) )
return result return result
# Handle Exception
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"Tempo worklog failed for test %s (user %s): %s", "Tempo worklog failed for test %s (user %s): %s",
+5
View File
@@ -163,6 +163,8 @@ export interface JiraConfigOut {
project_key: string; project_key: string;
parent_ticket: string; parent_ticket: string;
parent_ticket_standalone: string; parent_ticket_standalone: string;
admin_email_set: boolean;
tempo_admin_token_set: boolean;
} }
export interface JiraConfigUpdate { export interface JiraConfigUpdate {
@@ -171,6 +173,9 @@ export interface JiraConfigUpdate {
project_key?: string; project_key?: string;
parent_ticket?: string; parent_ticket?: string;
parent_ticket_standalone?: string; parent_ticket_standalone?: string;
admin_email?: string;
admin_api_token?: string;
tempo_admin_token?: string;
} }
export async function getJiraConfig(): Promise<JiraConfigOut> { export async function getJiraConfig(): Promise<JiraConfigOut> {
+243 -354
View File
@@ -826,51 +826,102 @@ function NotificationSection() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function ProfileSection() { function ProfileSection() {
const qc = useQueryClient();
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
const { user } = useAuth();
// Blue team roles have no Jira/Tempo access — hide those settings for them
const showJiraTempoSettings = !["blue_lead", "blue_tech"].includes(user?.role ?? "");
const { data: me, isLoading } = useQuery({ const { data: me, isLoading } = useQuery({
queryKey: ["me-prefs"], queryKey: ["me-prefs"],
queryFn: getMe, queryFn: getMe,
}); });
const [jiraAccountId, setJiraAccountId] = useState<string>(""); if (isLoading) {
const [jiraEmail, setJiraEmail] = useState<string>(""); return (
const [jiraApiToken, setJiraApiToken] = useState<string>(""); <div className="flex justify-center py-8">
const [showToken, setShowToken] = useState(false); <Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
const [tempoApiToken, setTempoApiToken] = useState<string>(""); </div>
);
}
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-xs text-gray-500 mb-1">Username</p>
<p className="text-gray-300 font-medium">{me?.username}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Role</p>
<p className="text-gray-300 font-medium capitalize">{me?.role?.replace("_", " ")}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Email</p>
<p className="text-gray-300">{me?.email ?? "—"}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Last Login</p>
<p className="text-gray-300">
{me?.last_login
? new Date(me.last_login).toLocaleString("en-US", {
timeZone: "UTC",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}) + " UTC"
: "—"}
</p>
</div>
</div>
<div className="border-t border-gray-800 pt-4">
<p className="text-sm font-semibold text-gray-300 mb-1">Jira / Tempo</p>
<p className="text-xs text-gray-500 mb-3">
Your Atlassian account ID is detected automatically on login using the admin Jira account. No personal tokens required.
</p>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">Account ID:</span>
{me?.jira_account_id ? (
<code className="rounded bg-gray-800 border border-gray-700 px-2 py-1 text-xs text-cyan-300 font-mono">
{me.jira_account_id}
</code>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-gray-800 border border-gray-700 px-2 py-0.5 text-[11px] text-gray-500">
Not detected yet log out and back in after admin configures Jira
</span>
)}
</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 [showAdminToken, setShowAdminToken] = useState(false);
const [showTempoToken, setShowTempoToken] = useState(false); const [showTempoToken, setShowTempoToken] = useState(false);
const [tempoTestResult, setTempoTestResult] = useState<string | null>(null);
const [tempoTestError, setTempoTestError] = useState<string | null>(null);
const [jiraTestResult, setJiraTestResult] = useState<{ connectedAs: string; url: string } | null>(null); const [jiraTestResult, setJiraTestResult] = useState<{ connectedAs: string; url: string } | null>(null);
const [jiraTestError, setJiraTestError] = useState<string | null>(null); const [jiraTestError, setJiraTestError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false); const [tempoTestResult, setTempoTestResult] = useState<string | null>(null);
const [tempoTestError, setTempoTestError] = useState<string | null>(null);
// Initialise editable fields from server on first successful load const { data: cfg, isLoading } = useQuery({
useEffect(() => { queryKey: ["jira-config"],
if (me) { queryFn: getJiraConfig,
setJiraAccountId(me.jira_account_id ?? ""); });
setJiraEmail(me.jira_email ?? "");
// Never pre-fill the token — we only know whether it is set, not its value const [form, setForm] = useState<JiraConfigUpdate>({});
}
// Only run when `me` transitions from undefined → data (i.e., first load)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [!!me]);
const saveMut = useMutation({ const saveMut = useMutation({
mutationFn: updateMyPreferences, mutationFn: updateJiraConfig,
onSuccess: (updatedMe) => { onSuccess: () => {
// Update cache immediately with the response — no extra round-trip needed qc.invalidateQueries({ queryKey: ["jira-config"] });
qc.setQueryData(["me-prefs"], updatedMe); setForm({});
setDirty(false); setToast({ msg: "Jira configuration saved", type: "success" });
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" }), onError: () => setToast({ msg: "Failed to save Jira configuration", type: "error" }),
}); });
const jiraTestMut = useMutation({ const jiraTestMut = useMutation({
@@ -907,322 +958,6 @@ function ProfileSection() {
}, },
}); });
const handleSave = () => {
const payload: Parameters<typeof updateMyPreferences>[0] = {
jira_account_id: jiraAccountId || null,
jira_email: jiraEmail || null,
};
// 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);
};
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">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-xs text-gray-500 mb-1">Username</p>
<p className="text-gray-300 font-medium">{me?.username}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Role</p>
<p className="text-gray-300 font-medium capitalize">{me?.role?.replace("_", " ")}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Email</p>
<p className="text-gray-300">{me?.email ?? "—"}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Last Login</p>
<p className="text-gray-300">
{me?.last_login
? new Date(me.last_login).toLocaleString("en-US", {
timeZone: "UTC",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}) + " UTC"
: "—"}
</p>
</div>
</div>
{showJiraTempoSettings && <div className="border-t border-gray-800 pt-4">
<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">
Configure your personal Atlassian credentials for Jira integration.
</p>
<div className="space-y-4">
{/* Jira email */}
<div>
<label className="mb-1 block text-xs font-medium text-cyan-400">
Atlassian / Jira Email
</label>
<input
type="email"
value={jiraEmail}
onChange={(e) => { setJiraEmail(e.target.value); setDirty(true); }}
placeholder={me?.email ?? "your@company.com"}
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">
Email used to authenticate with Atlassian.
{me?.email && !me?.jira_email && (
<span className="text-gray-500"> Currently using Aegis email: <span className="text-gray-400">{me.email}</span></span>
)}
</p>
</div>
{/* API Token */}
<div>
<label className="mb-1 block text-xs font-medium text-cyan-400">
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 token" : "Paste your Atlassian 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-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 (required for Tempo time tracking)
</label>
<input
value={jiraAccountId}
onChange={(e) => {
setJiraAccountId(e.target.value);
setDirty(true);
}}
placeholder="e.g. 5abcdef1234567890abcdef1"
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">
Your Atlassian account ID. Found in your Jira profile URL.
</p>
</div>
</div>
{/* Test Jira connection */}
<div className="mt-4 rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-2">
<button
onClick={() => {
setJiraTestResult(null);
setJiraTestError(null);
jiraTestMut.mutate();
}}
disabled={jiraTestMut.isPending || !me?.jira_token_set}
className="flex items-center gap-2 rounded-lg border border-cyan-700 px-3 py-1.5 text-sm font-medium text-cyan-400 hover:bg-cyan-900/30 disabled:opacity-50 transition-colors"
>
{jiraTestMut.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube className="h-4 w-4" />
)}
Test Jira Connection
</button>
{jiraTestResult && (
<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: {jiraTestResult.connectedAs}
</div>
)}
{jiraTestError && (
<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" />
{jiraTestError}
</div>
)}
</div>
</div>}
{/* ── Tempo Integration ─────────────────────────────────── */}
{showJiraTempoSettings && <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
onClick={handleSave}
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"
>
{saveMut.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Save
</button>
</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 { 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" }),
});
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
@@ -1278,9 +1013,7 @@ function JiraConfigSection() {
placeholder="OFS-20795" placeholder="OFS-20795"
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" 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"> <p className="mt-1 text-xs text-gray-600">Campaigns are created directly under this issue</p>
Campaigns are created directly under this issue
</p>
</div> </div>
<div> <div>
@@ -1293,12 +1026,110 @@ function JiraConfigSection() {
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" 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"> <p className="mt-1 text-xs text-gray-600">
Standalone tests (not in a campaign) are nested under this issue. Falls back to Campaign Parent if not set. Standalone tests are nested here. Falls back to Campaign Parent if not set.
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center pt-2"> {/* ── Admin account (replaces per-user tokens) ───────────── */}
<div className="mt-2 border-t border-gray-800 pt-4">
<p className="text-sm font-semibold text-gray-300 mb-1">Admin Jira Account</p>
<p className="text-xs text-gray-500 mb-4">
One admin account handles all Jira and Tempo operations. Regular users need zero configuration their Atlassian account ID is auto-detected on login.
</p>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-cyan-400">Admin Email</label>
<input
type="email"
value={String(form.admin_email !== undefined ? form.admin_email : "")}
onChange={(e) => setForm((prev) => ({ ...prev, admin_email: e.target.value }))}
placeholder="jira-admin@company.com"
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"
/>
<div className="mt-1 flex items-center gap-2">
{cfg?.admin_email_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" /> 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-600">Leave blank to keep current</span>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-cyan-400">Admin Jira API Token</label>
<div className="relative">
<input
type={showAdminToken ? "text" : "password"}
value={String(form.admin_api_token !== undefined ? form.admin_api_token : "")}
onChange={(e) => setForm((prev) => ({ ...prev, admin_api_token: e.target.value }))}
placeholder={cfg?.admin_email_set ? "Leave blank to keep current token" : "Paste Atlassian 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-cyan-500 focus:outline-none"
/>
<button
type="button"
onClick={() => setShowAdminToken(!showAdminToken)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{showAdminToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="mt-1 text-[11px] text-gray-600">
Create at{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="text-cyan-500 hover:text-cyan-400 underline"
>
id.atlassian.com
</a>
</p>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-purple-400">Admin Tempo API Token</label>
<div className="relative">
<input
type={showTempoToken ? "text" : "password"}
value={String(form.tempo_admin_token !== undefined ? form.tempo_admin_token : "")}
onChange={(e) => setForm((prev) => ({ ...prev, tempo_admin_token: e.target.value }))}
placeholder={cfg?.tempo_admin_token_set ? "Leave blank to keep current token" : "Paste 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 flex items-center gap-2">
{cfg?.tempo_admin_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" /> 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-600">
Jira Apps Tempo Settings API Integration
</span>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button <button
onClick={() => { onClick={() => {
if (Object.keys(form).length > 0) saveMut.mutate(form); if (Object.keys(form).length > 0) saveMut.mutate(form);
@@ -1315,6 +1146,64 @@ function JiraConfigSection() {
</button> </button>
</div> </div>
{/* ── Test connections ───────────────────────────────────── */}
<div className="grid grid-cols-2 gap-3 pt-1">
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-2">
<p className="text-xs font-medium text-gray-400">Test Jira connection</p>
<button
onClick={() => {
setJiraTestResult(null);
setJiraTestError(null);
jiraTestMut.mutate();
}}
disabled={jiraTestMut.isPending || !cfg?.admin_email_set}
className="flex items-center gap-2 rounded-lg border border-cyan-700 px-3 py-1.5 text-sm font-medium text-cyan-400 hover:bg-cyan-900/30 disabled:opacity-50 transition-colors"
>
{jiraTestMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <TestTube className="h-4 w-4" />}
Test Jira
</button>
{jiraTestResult && (
<div className="flex items-center gap-2 rounded-md bg-emerald-900/30 border border-emerald-800/50 px-3 py-2 text-xs text-emerald-300">
<CheckCircle className="h-3.5 w-3.5 shrink-0" />
Connected as: {jiraTestResult.connectedAs}
</div>
)}
{jiraTestError && (
<div className="flex items-center gap-2 rounded-md bg-red-900/30 border border-red-800/50 px-3 py-2 text-xs text-red-300">
<XCircle className="h-3.5 w-3.5 shrink-0" />
{jiraTestError}
</div>
)}
</div>
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-2">
<p className="text-xs font-medium text-gray-400">Test Tempo connection</p>
<button
onClick={() => {
setTempoTestResult(null);
setTempoTestError(null);
tempoTestMut.mutate();
}}
disabled={tempoTestMut.isPending || !cfg?.tempo_admin_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
</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-xs text-emerald-300">
<CheckCircle className="h-3.5 w-3.5 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-xs text-red-300">
<XCircle className="h-3.5 w-3.5 shrink-0" />
{tempoTestError}
</div>
)}
</div>
</div>
</div> </div>
</> </>
); );