feat(jira): add editable jira_email field per user
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
28
backend/alembic/versions/b043_jira_email.py
Normal file
28
backend/alembic/versions/b043_jira_email.py
Normal file
@@ -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")
|
||||||
@@ -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}')
|
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_account_id = Column(String(100), nullable=True)
|
||||||
jira_api_token = Column(String(500), nullable=True) # personal Atlassian token
|
jira_api_token = Column(String(500), nullable=True) # personal Atlassian token
|
||||||
|
jira_email = Column(String(255), nullable=True) # Atlassian email (overrides account email)
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ def update_my_preferences(
|
|||||||
"""
|
"""
|
||||||
update_data = payload.model_dump(exclude_unset=True)
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
if field == "jira_api_token":
|
if field in ("jira_api_token", "jira_email"):
|
||||||
# Empty string means "clear token"
|
# Empty string means "clear the value"
|
||||||
setattr(current_user, field, value if value else None)
|
setattr(current_user, field, value if value else None)
|
||||||
else:
|
else:
|
||||||
setattr(current_user, field, value)
|
setattr(current_user, field, value)
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ class UserPreferencesUpdate(BaseModel):
|
|||||||
# Personal Jira API token (Atlassian token) — write-only, stored encrypted at rest.
|
# Personal Jira API token (Atlassian token) — write-only, stored encrypted at rest.
|
||||||
# Set to empty string "" to clear the token.
|
# Set to empty string "" to clear the token.
|
||||||
jira_api_token: str | None = None
|
jira_api_token: str | None = None
|
||||||
|
# Atlassian email for Jira auth — overrides account email.
|
||||||
|
# Set to empty string "" to clear (falls back to account email).
|
||||||
|
jira_email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserOut(BaseModel):
|
class UserOut(BaseModel):
|
||||||
@@ -144,6 +147,7 @@ class UserOut(BaseModel):
|
|||||||
last_login: datetime | None = None
|
last_login: datetime | None = None
|
||||||
notification_preferences: dict | None = None
|
notification_preferences: dict | None = None
|
||||||
jira_account_id: str | None = None
|
jira_account_id: str | None = None
|
||||||
|
jira_email: str | None = None
|
||||||
# Never return the raw token — just indicate whether it is configured.
|
# Never return the raw token — just indicate whether it is configured.
|
||||||
jira_token_set: bool = False
|
jira_token_set: bool = False
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
Authentication model
|
Authentication model
|
||||||
--------------------
|
--------------------
|
||||||
Each Aegis user authenticates to Jira with their own corporate email
|
Each Aegis user authenticates to Jira with their own Atlassian email and
|
||||||
(``user.email``) and their personal Atlassian API token
|
personal API token. The email used is ``user.jira_email`` when set, falling
|
||||||
(``user.jira_api_token``). This way every Jira action is traceable to a
|
back to ``user.email`` (the Aegis account email). This lets users specify a
|
||||||
real person rather than a shared service account.
|
separate corporate Atlassian email without changing their Aegis login.
|
||||||
|
The token is stored in ``user.jira_api_token``.
|
||||||
|
|
||||||
Admin configuration
|
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):
|
def get_user_jira_client(user: User, db: Session):
|
||||||
"""Build an Atlassian Jira client authenticated as *user*.
|
"""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
|
Raises ``InvalidOperationError`` when configuration is incomplete so
|
||||||
callers can surface meaningful error messages.
|
callers can surface meaningful error messages.
|
||||||
"""
|
"""
|
||||||
@@ -120,23 +127,24 @@ def get_user_jira_client(user: User, db: Session):
|
|||||||
"System Settings → Jira Configuration."
|
"System Settings → Jira Configuration."
|
||||||
)
|
)
|
||||||
|
|
||||||
if not user.email:
|
auth_email = _effective_jira_email(user)
|
||||||
|
if not auth_email:
|
||||||
raise InvalidOperationError(
|
raise InvalidOperationError(
|
||||||
"Your account has no email address. Set one in your profile before "
|
"No email configured for Jira authentication. "
|
||||||
"using the Jira integration."
|
"Set a Jira email in Settings → Profile → Jira Integration."
|
||||||
)
|
)
|
||||||
|
|
||||||
if not user.jira_api_token:
|
if not user.jira_api_token:
|
||||||
raise InvalidOperationError(
|
raise InvalidOperationError(
|
||||||
"You have not configured a Jira API token. "
|
"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
|
from atlassian import Jira
|
||||||
|
|
||||||
return Jira(
|
return Jira(
|
||||||
url=jira_url,
|
url=jira_url,
|
||||||
username=user.email,
|
username=auth_email,
|
||||||
password=user.jira_api_token,
|
password=user.jira_api_token,
|
||||||
cloud=True,
|
cloud=True,
|
||||||
)
|
)
|
||||||
@@ -144,7 +152,7 @@ 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."""
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ export interface UserPreferencesUpdate {
|
|||||||
notification_preferences?: Partial<NotificationPreferences>;
|
notification_preferences?: Partial<NotificationPreferences>;
|
||||||
jira_account_id?: string | null;
|
jira_account_id?: string | null;
|
||||||
jira_api_token?: 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 {
|
export interface UserMeOut {
|
||||||
@@ -134,6 +136,7 @@ export interface UserMeOut {
|
|||||||
last_login: string | null;
|
last_login: string | null;
|
||||||
notification_preferences: NotificationPreferences | null;
|
notification_preferences: NotificationPreferences | null;
|
||||||
jira_account_id: string | null;
|
jira_account_id: string | null;
|
||||||
|
jira_email: string | null;
|
||||||
jira_token_set: boolean;
|
jira_token_set: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -816,13 +816,17 @@ function ProfileSection() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [jiraAccountId, setJiraAccountId] = useState<string>("");
|
const [jiraAccountId, setJiraAccountId] = useState<string>("");
|
||||||
|
const [jiraEmail, setJiraEmail] = useState<string>("");
|
||||||
const [jiraApiToken, setJiraApiToken] = useState<string>("");
|
const [jiraApiToken, setJiraApiToken] = useState<string>("");
|
||||||
const [showToken, setShowToken] = useState(false);
|
const [showToken, setShowToken] = useState(false);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [initialised, setInitialised] = useState(false);
|
||||||
|
|
||||||
// Sync from server on load
|
// Sync from server on first load
|
||||||
if (me && !dirty && jiraAccountId === "" && me.jira_account_id) {
|
if (me && !initialised) {
|
||||||
setJiraAccountId(me.jira_account_id ?? "");
|
setJiraAccountId(me.jira_account_id ?? "");
|
||||||
|
setJiraEmail(me.jira_email ?? "");
|
||||||
|
setInitialised(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
@@ -839,6 +843,7 @@ function ProfileSection() {
|
|||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const payload: Parameters<typeof updateMyPreferences>[0] = {
|
const payload: Parameters<typeof updateMyPreferences>[0] = {
|
||||||
jira_account_id: jiraAccountId || null,
|
jira_account_id: jiraAccountId || null,
|
||||||
|
jira_email: jiraEmail || null,
|
||||||
};
|
};
|
||||||
// Only send token if user typed something (empty string clears it)
|
// Only send token if user typed something (empty string clears it)
|
||||||
if (jiraApiToken !== "") {
|
if (jiraApiToken !== "") {
|
||||||
@@ -893,14 +898,31 @@ function ProfileSection() {
|
|||||||
|
|
||||||
<div className="border-t border-gray-800 pt-4">
|
<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-sm font-semibold text-gray-300 mb-1">Jira Integration (personal settings)</p>
|
||||||
<p className="text-xs text-gray-500 mb-4 border-b border-gray-800 pb-3">
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
Auth uses your email:{" "}
|
Configure your personal Atlassian credentials for Jira integration.
|
||||||
<span className="text-gray-400 font-medium">
|
|
||||||
{me?.email || "— set email in your profile"}
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<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 */}
|
{/* API Token */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-cyan-400">
|
<label className="mb-1 block text-xs font-medium text-cyan-400">
|
||||||
|
|||||||
Reference in New Issue
Block a user