From 217c4c88b2a997eb4faaa4f159efdcb28c7ffeba Mon Sep 17 00:00:00 2001 From: kitos Date: Tue, 26 May 2026 16:40:46 +0200 Subject: [PATCH] feat(jira): add editable jira_email field per user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now set a separate Atlassian email for Jira authentication in Settings → Profile → Jira Integration. Falls back to the Aegis account email when not set, so existing setups are unaffected. - Migration b043: adds jira_email column to users table - User model/schema: expose jira_email read/write - jira_service: _effective_jira_email() uses jira_email ?? email - Frontend: replaces read-only email display with editable input Co-Authored-By: Claude Sonnet 4.6 --- backend/alembic/versions/b043_jira_email.py | 28 ++++++++++++++++ backend/app/models/user.py | 1 + backend/app/routers/users.py | 4 +-- backend/app/schemas/user.py | 4 +++ backend/app/services/jira_service.py | 28 ++++++++++------ frontend/src/api/settings.ts | 3 ++ frontend/src/pages/SettingsPage.tsx | 36 +++++++++++++++++---- 7 files changed, 85 insertions(+), 19 deletions(-) create mode 100644 backend/alembic/versions/b043_jira_email.py diff --git a/backend/alembic/versions/b043_jira_email.py b/backend/alembic/versions/b043_jira_email.py new file mode 100644 index 0000000..1bef350 --- /dev/null +++ b/backend/alembic/versions/b043_jira_email.py @@ -0,0 +1,28 @@ +"""Add jira_email to users table. + +Allows each user to specify a separate email for Jira authentication, +independent of their Aegis account email. + +Revision ID: b043 +Revises: b042 +Create Date: 2026-05-26 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "b043" +down_revision = "b042" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("jira_email", sa.String(255), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("users", "jira_email") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 660e126..99f5285 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -31,3 +31,4 @@ class User(Base): notification_preferences = Column(JSONB, nullable=True, server_default='{"email_on_test_validated": true, "email_on_campaign_completed": true, "email_on_new_mitre_techniques": false, "in_app_all": true}') jira_account_id = Column(String(100), nullable=True) jira_api_token = Column(String(500), nullable=True) # personal Atlassian token + jira_email = Column(String(255), nullable=True) # Atlassian email (overrides account email) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 847a114..5beeee3 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -40,8 +40,8 @@ def update_my_preferences( """ update_data = payload.model_dump(exclude_unset=True) for field, value in update_data.items(): - if field == "jira_api_token": - # Empty string means "clear token" + if field in ("jira_api_token", "jira_email"): + # Empty string means "clear the value" setattr(current_user, field, value if value else None) else: setattr(current_user, field, value) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 2174f5c..ecb3fd6 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -129,6 +129,9 @@ class UserPreferencesUpdate(BaseModel): # Personal Jira API token (Atlassian token) — write-only, stored encrypted at rest. # Set to empty string "" to clear the token. jira_api_token: str | None = None + # Atlassian email for Jira auth — overrides account email. + # Set to empty string "" to clear (falls back to account email). + jira_email: str | None = None class UserOut(BaseModel): @@ -144,6 +147,7 @@ class UserOut(BaseModel): last_login: datetime | None = None notification_preferences: dict | None = None jira_account_id: str | None = None + jira_email: str | None = None # Never return the raw token — just indicate whether it is configured. jira_token_set: bool = False diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index 55fce00..c040a3c 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -2,10 +2,11 @@ Authentication model -------------------- -Each Aegis user authenticates to Jira with their own corporate email -(``user.email``) and their personal Atlassian API token -(``user.jira_api_token``). This way every Jira action is traceable to a -real person rather than a shared service account. +Each Aegis user authenticates to Jira with their own Atlassian email and +personal API token. The email used is ``user.jira_email`` when set, falling +back to ``user.email`` (the Aegis account email). This lets users specify a +separate corporate Atlassian email without changing their Aegis login. +The token is stored in ``user.jira_api_token``. Admin configuration ------------------- @@ -107,9 +108,15 @@ def upsert_jira_config(db: Session, key: str, value: str) -> None: # --------------------------------------------------------------------------- +def _effective_jira_email(user: User) -> Optional[str]: + """Return the email to use for Jira auth: jira_email if set, otherwise email.""" + return getattr(user, "jira_email", None) or user.email + + def get_user_jira_client(user: User, db: Session): """Build an Atlassian Jira client authenticated as *user*. + Uses ``user.jira_email`` when set, otherwise falls back to ``user.email``. Raises ``InvalidOperationError`` when configuration is incomplete so callers can surface meaningful error messages. """ @@ -120,23 +127,24 @@ def get_user_jira_client(user: User, db: Session): "System Settings → Jira Configuration." ) - if not user.email: + auth_email = _effective_jira_email(user) + if not auth_email: raise InvalidOperationError( - "Your account has no email address. Set one in your profile before " - "using the Jira integration." + "No email configured for Jira authentication. " + "Set a Jira email in Settings → Profile → Jira Integration." ) if not user.jira_api_token: raise InvalidOperationError( "You have not configured a Jira API token. " - "Go to Settings → Integrations and add your personal Atlassian token." + "Go to Settings → Profile → Jira Integration and add your personal Atlassian token." ) from atlassian import Jira return Jira( url=jira_url, - username=user.email, + username=auth_email, password=user.jira_api_token, cloud=True, ) @@ -144,7 +152,7 @@ def get_user_jira_client(user: User, db: Session): def has_jira_configured(user: User, db: Session) -> bool: """Return True if *user* has everything needed to call Jira.""" - return bool(get_jira_url(db) and user.email and user.jira_api_token) + return bool(get_jira_url(db) and _effective_jira_email(user) and user.jira_api_token) # --------------------------------------------------------------------------- diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index d29a18c..ff4d185 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -121,6 +121,8 @@ export interface UserPreferencesUpdate { notification_preferences?: Partial; jira_account_id?: string | null; jira_api_token?: string | null; + /** Atlassian email used for Jira auth. Overrides Aegis account email. Empty string clears it. */ + jira_email?: string | null; } export interface UserMeOut { @@ -134,6 +136,7 @@ export interface UserMeOut { last_login: string | null; notification_preferences: NotificationPreferences | null; jira_account_id: string | null; + jira_email: string | null; jira_token_set: boolean; } diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index d6e458f..e6b54b5 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -816,13 +816,17 @@ function ProfileSection() { }); const [jiraAccountId, setJiraAccountId] = useState(""); + const [jiraEmail, setJiraEmail] = useState(""); const [jiraApiToken, setJiraApiToken] = useState(""); const [showToken, setShowToken] = useState(false); const [dirty, setDirty] = useState(false); + const [initialised, setInitialised] = useState(false); - // Sync from server on load - if (me && !dirty && jiraAccountId === "" && me.jira_account_id) { + // Sync from server on first load + if (me && !initialised) { setJiraAccountId(me.jira_account_id ?? ""); + setJiraEmail(me.jira_email ?? ""); + setInitialised(true); } const saveMut = useMutation({ @@ -839,6 +843,7 @@ function ProfileSection() { const handleSave = () => { const payload: Parameters[0] = { jira_account_id: jiraAccountId || null, + jira_email: jiraEmail || null, }; // Only send token if user typed something (empty string clears it) if (jiraApiToken !== "") { @@ -893,14 +898,31 @@ function ProfileSection() {

Jira Integration (personal settings)

-

- Auth uses your email:{" "} - - {me?.email || "— set email in your profile"} - +

+ Configure your personal Atlassian credentials for Jira integration.

+ {/* Jira email */} +
+ + { 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" + /> +

+ Email used to authenticate with Atlassian. + {me?.email && !me?.jira_email && ( + Currently using Aegis email: {me.email} + )} +

+
+ {/* API Token */}