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
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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user