mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
fix: use Slack OIDC mappings for Slack thread ownership (#1410)
* fix: tag Slack threads with stored identity so they surface in web process_slack_mention gated the run on mapped_login (resolved from the stable Slack user id), but upsert_agent_thread_owner_metadata independently re-resolved the GitHub login from the Slack profile email. When that email differs from the user's mapping email (e.g. a personal vs work address), the lookup returned None, so github_login was never stamped on the thread and the thread never surfaced in the web Agents UI (which searches by github_login / triggering_user_email). Resolve the GitHub user from the store via the Slack id, pass that login through to the owner metadata, and use the mapping's stored work email (falling back to the Slack profile email for unmapped users) for both the run config and the thread tagging, so Slack-started threads reliably appear in web. * fix: stamp github_login on Slack threads so they surface in web process_slack_mention gated the run on mapped_login (resolved from the stable Slack user id) but upsert_agent_thread_owner_metadata re-resolved the login from the Slack profile email; when that email isn't the user's mapping email the lookup returns None and github_login is never stamped, so the thread is invisible in the web Agents UI (which searches by github_login / triggering_user_email). Pass the already-resolved mapped_login through to the owner metadata. The dashboard match keys on the stable GitHub login, so this is sufficient; the triggering email stays the live Slack profile value. * fix: preserve Slack email during account mapping * fix: require Slack OIDC for email mappings * chore: format Slack OIDC mapping cleanup
This commit is contained in:
parent
60426e8b10
commit
58e0f84470
12 changed files with 29 additions and 342 deletions
|
|
@ -456,7 +456,7 @@ DEFAULT_REPO_NAME="" # Default GitHub repo (e.g. "my-repo")
|
|||
DASHBOARD_API_BASE_URL="http://localhost:2024"
|
||||
# Public base URL of the dashboard frontend (the ui/ app). Default post-login redirect.
|
||||
DASHBOARD_BASE_URL="http://localhost:3000"
|
||||
# HMAC secret for all dashboard JWTs (session cookie, OAuth state, account-link tokens).
|
||||
# HMAC secret for dashboard JWTs (session cookie and OAuth state).
|
||||
DASHBOARD_JWT_SECRET="" # Generate with: openssl rand -hex 32
|
||||
# Comma-separated origins allowed for credentialed CORS and post-login redirects.
|
||||
# Required whenever the frontend and API are on different origins — including local
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import secrets
|
|||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlparse
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
|
|
@ -148,7 +148,7 @@ def hash_state_nonce(nonce: str) -> str:
|
|||
return hmac.new(_secret().encode(), nonce.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def issue_state(*, redirect_to: str, nonce_hash: str, link: str | None = None) -> str:
|
||||
def issue_state(*, redirect_to: str, nonce_hash: str) -> str:
|
||||
now = int(time.time())
|
||||
payload: dict[str, Any] = {
|
||||
"nonce_hash": nonce_hash,
|
||||
|
|
@ -156,8 +156,6 @@ def issue_state(*, redirect_to: str, nonce_hash: str, link: str | None = None) -
|
|||
"iat": now,
|
||||
"exp": now + STATE_TTL_SECONDS,
|
||||
}
|
||||
if link:
|
||||
payload["link"] = link
|
||||
return jwt.encode(payload, _secret(), algorithm=JWT_ALG)
|
||||
|
||||
|
||||
|
|
@ -168,43 +166,10 @@ def decode_state(state: str) -> dict[str, Any]:
|
|||
raise HTTPException(400, f"invalid state: {e}") from e
|
||||
|
||||
|
||||
LINK_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
# Dashboard route where users manage their GitHub↔Slack link.
|
||||
PROFILE_SETTINGS_PATH = "/my-settings"
|
||||
|
||||
|
||||
def issue_account_link(*, slack_user_id: str | None, work_email: str | None) -> str:
|
||||
"""Sign a short-lived token carrying the Slack identity to map after login.
|
||||
|
||||
Threaded through the OAuth ``state`` so the callback can attach the
|
||||
resolved ``github_login`` to the originating Slack user/email in one step.
|
||||
"""
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"kind": "account_link",
|
||||
"slack_user_id": slack_user_id or None,
|
||||
"work_email": work_email or None,
|
||||
"iat": now,
|
||||
"exp": now + LINK_TTL_SECONDS,
|
||||
}
|
||||
return jwt.encode(payload, _secret(), algorithm=JWT_ALG)
|
||||
|
||||
|
||||
def decode_account_link(token: str) -> dict[str, Any] | None:
|
||||
"""Decode an account-link token; return ``None`` if absent/invalid/expired."""
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, _secret(), algorithms=[JWT_ALG])
|
||||
except jwt.PyJWTError as e:
|
||||
logger.warning("invalid account-link token: %s", e)
|
||||
return None
|
||||
if payload.get("kind") != "account_link":
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def build_settings_url() -> str | None:
|
||||
"""Return the dashboard Profile Settings URL, or ``None`` if not configured.
|
||||
|
||||
|
|
@ -218,26 +183,6 @@ def build_settings_url() -> str | None:
|
|||
return f"{frontend_base}{PROFILE_SETTINGS_PATH}"
|
||||
|
||||
|
||||
def build_account_link_url(*, slack_user_id: str | None, work_email: str | None) -> str | None:
|
||||
"""Return the dashboard login URL that links a Slack identity on completion.
|
||||
|
||||
Returns ``None`` when ``DASHBOARD_API_BASE_URL`` isn't configured (login
|
||||
can't be initiated), so callers can skip the prompt cleanly.
|
||||
"""
|
||||
api_base = os.environ.get("DASHBOARD_API_BASE_URL", "").rstrip("/")
|
||||
if not api_base:
|
||||
return None
|
||||
token = issue_account_link(slack_user_id=slack_user_id, work_email=work_email)
|
||||
url = f"{api_base}/dashboard/api/auth/login?link={quote(token, safe='')}"
|
||||
# Land the user on the Profile Settings page so they can review/complete
|
||||
# their GitHub↔Slack link after re-authenticating.
|
||||
frontend_base = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
|
||||
if frontend_base:
|
||||
redirect_to = f"{frontend_base}{PROFILE_SETTINGS_PATH}"
|
||||
url += f"&redirect_to={quote(redirect_to, safe='')}"
|
||||
return url
|
||||
|
||||
|
||||
def require_session(request: Request) -> dict[str, Any]:
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ from .oauth import (
|
|||
SESSION_TTL_SECONDS,
|
||||
STATE_COOKIE_NAME,
|
||||
STATE_TTL_SECONDS,
|
||||
decode_account_link,
|
||||
decode_state,
|
||||
enforce_org_login_gate,
|
||||
exchange_code,
|
||||
|
|
@ -198,21 +197,16 @@ def _clear_slack_state_cookie(response: Response) -> None:
|
|||
async def auth_login(
|
||||
request: Request,
|
||||
redirect_to: str | None = None,
|
||||
link: str | None = None,
|
||||
) -> RedirectResponse:
|
||||
client_id = os.environ.get("GITHUB_APP_CLIENT_ID", "")
|
||||
if not client_id:
|
||||
raise HTTPException(500, "GITHUB_APP_CLIENT_ID not configured")
|
||||
safe_redirect = sanitize_redirect_to(redirect_to) or _frontend_base_url()
|
||||
|
||||
# Only carry a structurally valid account-link token onward.
|
||||
link_token = link if (link and decode_account_link(link)) else None
|
||||
|
||||
nonce = new_state_nonce()
|
||||
state = issue_state(
|
||||
redirect_to=safe_redirect,
|
||||
nonce_hash=hash_state_nonce(nonce),
|
||||
link=link_token,
|
||||
)
|
||||
redirect_uri = f"{_api_base_url()}/dashboard/api/auth/callback"
|
||||
url = (
|
||||
|
|
@ -254,7 +248,6 @@ async def auth_callback(request: Request, code: str, state: str) -> RedirectResp
|
|||
await enforce_org_login_gate(login)
|
||||
|
||||
await upsert_access_token_from_github_response(login, email or "", token_data)
|
||||
await _complete_account_mapping(login, email, state_payload.get("link"))
|
||||
|
||||
session_jwt = issue_session(login=login, email=email, avatar_url=user.get("avatar_url"))
|
||||
response = RedirectResponse(redirect_to, status_code=302)
|
||||
|
|
@ -263,33 +256,6 @@ async def auth_callback(request: Request, code: str, state: str) -> RedirectResp
|
|||
return response
|
||||
|
||||
|
||||
async def _complete_account_mapping(login: str, github_email: str | None, link_token: Any) -> None:
|
||||
"""Create/refresh the user mapping after a successful org-gated login.
|
||||
|
||||
Self-service signup: the user is already org-gated (only members reach
|
||||
here), so we record a ``source="self"`` mapping. The Slack identity (user
|
||||
id + work email) is carried in the signed account-link token when the flow
|
||||
started from an unmapped Slack mention; otherwise we fall back to the
|
||||
user's verified GitHub email.
|
||||
"""
|
||||
link = decode_account_link(link_token) if isinstance(link_token, str) else None
|
||||
slack_user_id = link.get("slack_user_id") if link else None
|
||||
work_email = (link.get("work_email") if link else None) or github_email
|
||||
if not work_email:
|
||||
logger.warning("No work email available to map GitHub login %r", login)
|
||||
return
|
||||
try:
|
||||
await upsert_mapping(
|
||||
github_login=login,
|
||||
work_email=work_email,
|
||||
slack_user_id=slack_user_id if isinstance(slack_user_id, str) else None,
|
||||
source="self",
|
||||
status="active",
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Failed to persist self-service mapping for %r", login, exc_info=True)
|
||||
|
||||
|
||||
@router.post("/auth/logout")
|
||||
async def auth_logout() -> Response:
|
||||
response = Response(status_code=204)
|
||||
|
|
@ -440,12 +406,6 @@ async def api_set_enabled_review_repo(
|
|||
return {"repos": repos}
|
||||
|
||||
|
||||
class UserMappingUpsert(BaseModel):
|
||||
github_login: str
|
||||
work_email: str
|
||||
slack_user_id: str | None = None
|
||||
|
||||
|
||||
@router.get("/admin/user-mappings")
|
||||
async def admin_list_user_mappings(
|
||||
page: int = 1,
|
||||
|
|
@ -466,23 +426,6 @@ async def admin_list_user_mappings(
|
|||
}
|
||||
|
||||
|
||||
@router.put("/admin/user-mappings")
|
||||
async def admin_upsert_user_mapping(
|
||||
body: UserMappingUpsert,
|
||||
_admin: dict[str, Any] = _ADMIN_DEP,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await upsert_mapping(
|
||||
github_login=body.github_login,
|
||||
work_email=body.work_email,
|
||||
slack_user_id=body.slack_user_id,
|
||||
source="admin",
|
||||
status="active",
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/admin/user-mappings/{github_login}")
|
||||
async def admin_delete_user_mapping(
|
||||
github_login: str,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ keyed by GitHub login in the ``["user_mappings"]`` LangGraph Store namespace::
|
|||
"github_login": "octocat",
|
||||
"work_email": "octo@example.com",
|
||||
"slack_user_id": "U123" | None,
|
||||
"source": "hardcoded" | "self" | "admin",
|
||||
"source": "slack_oauth",
|
||||
"status": "active" | "pending",
|
||||
"created_at": "...", "updated_at": "...",
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
USER_MAPPINGS_NAMESPACE: list[str] = ["user_mappings"]
|
||||
|
||||
MappingSource = Literal["hardcoded", "self", "admin", "slack_oauth"]
|
||||
MappingSource = Literal["slack_oauth"]
|
||||
MappingStatus = Literal["active", "pending"]
|
||||
|
||||
|
||||
|
|
@ -259,7 +259,7 @@ async def upsert_mapping(
|
|||
github_login: str,
|
||||
work_email: str,
|
||||
slack_user_id: str | None = None,
|
||||
source: MappingSource = "admin",
|
||||
source: MappingSource = "slack_oauth",
|
||||
status: MappingStatus = "active",
|
||||
) -> dict[str, Any]:
|
||||
"""Create or update a mapping keyed by GitHub login."""
|
||||
|
|
@ -299,28 +299,3 @@ async def delete_mapping(github_login: str) -> bool:
|
|||
raise
|
||||
_deindex_login(login)
|
||||
return True
|
||||
|
||||
|
||||
async def bulk_import(mapping: dict[str, str], *, source: MappingSource = "hardcoded") -> int:
|
||||
"""Seed the Store from a ``{github_login: work_email}`` dict.
|
||||
|
||||
Existing records are left untouched so re-running the import never
|
||||
downgrades a richer (self/admin) record back to ``hardcoded``. Returns the
|
||||
number of records newly created.
|
||||
"""
|
||||
created = 0
|
||||
for raw_login, raw_email in mapping.items():
|
||||
login = _norm_login(raw_login)
|
||||
email = _norm_email(raw_email)
|
||||
if not login or not email:
|
||||
continue
|
||||
if await get_mapping(login):
|
||||
continue
|
||||
await upsert_mapping(
|
||||
github_login=login,
|
||||
work_email=email,
|
||||
source=source,
|
||||
status="active",
|
||||
)
|
||||
created += 1
|
||||
return created
|
||||
|
|
|
|||
|
|
@ -1097,10 +1097,14 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st
|
|||
langgraph_client = get_client(url=LANGGRAPH_URL)
|
||||
is_first_mention = not await _thread_exists(thread_id)
|
||||
await _upsert_slack_thread_repo_metadata(thread_id, repo_config, langgraph_client)
|
||||
# Pass the login resolved above (from the stable Slack user id) so the thread is
|
||||
# always tagged with github_login — the key the dashboard searches by. Without
|
||||
# it, upsert re-resolves from the Slack profile email, which can miss.
|
||||
await upsert_agent_thread_owner_metadata(
|
||||
thread_id,
|
||||
source="slack",
|
||||
repo_config=repo_config,
|
||||
github_login=mapped_login or "",
|
||||
user_email=user_email or "",
|
||||
title=clean_text if is_first_mention else "",
|
||||
source_context={"slack_thread": configurable["slack_thread"]},
|
||||
|
|
|
|||
|
|
@ -1,69 +1,15 @@
|
|||
"""Tests for the Slack→GitHub account-link OAuth threading."""
|
||||
"""Tests for the Slack account-link prompt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.dashboard import oauth
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _jwt_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DASHBOARD_JWT_SECRET", "test-secret")
|
||||
|
||||
|
||||
def test_account_link_round_trip() -> None:
|
||||
token = oauth.issue_account_link(slack_user_id="U123", work_email="dev@x.com")
|
||||
payload = oauth.decode_account_link(token)
|
||||
assert payload is not None
|
||||
assert payload["slack_user_id"] == "U123"
|
||||
assert payload["work_email"] == "dev@x.com"
|
||||
assert payload["kind"] == "account_link"
|
||||
|
||||
|
||||
def test_decode_account_link_rejects_garbage() -> None:
|
||||
assert oauth.decode_account_link("") is None
|
||||
assert oauth.decode_account_link("not-a-jwt") is None
|
||||
|
||||
|
||||
def test_decode_account_link_rejects_wrong_kind() -> None:
|
||||
# A session token is a valid JWT but not an account-link token.
|
||||
session = oauth.issue_session(login="x", email="x@x.com", avatar_url=None)
|
||||
assert oauth.decode_account_link(session) is None
|
||||
|
||||
|
||||
def test_build_account_link_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DASHBOARD_API_BASE_URL", "https://api.example.com/")
|
||||
monkeypatch.delenv("DASHBOARD_BASE_URL", raising=False)
|
||||
url = oauth.build_account_link_url(slack_user_id="U1", work_email="d@x.com")
|
||||
assert url is not None
|
||||
assert url.startswith("https://api.example.com/dashboard/api/auth/login?link=")
|
||||
# The embedded token must decode back to the same identity.
|
||||
token = url.split("link=", 1)[1].split("&", 1)[0]
|
||||
from urllib.parse import unquote
|
||||
|
||||
payload = oauth.decode_account_link(unquote(token))
|
||||
assert payload["slack_user_id"] == "U1"
|
||||
|
||||
|
||||
def test_build_account_link_url_redirects_to_profile_settings(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DASHBOARD_API_BASE_URL", "https://api.example.com")
|
||||
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
|
||||
url = oauth.build_account_link_url(slack_user_id="U1", work_email="d@x.com")
|
||||
assert url is not None
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
query = parse_qs(urlparse(url).query)
|
||||
assert query["redirect_to"] == ["https://app.example.com/my-settings"]
|
||||
|
||||
|
||||
def test_build_account_link_url_none_without_base(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("DASHBOARD_API_BASE_URL", raising=False)
|
||||
assert oauth.build_account_link_url(slack_user_id="U1", work_email="d@x.com") is None
|
||||
|
||||
|
||||
def test_account_link_prompt_posts_generic_token_free_link(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
"""Tests for self-service mapping completion in the OAuth callback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.dashboard import oauth, routes
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _jwt_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DASHBOARD_JWT_SECRET", "test-secret")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completion_uses_link_token_slack_identity(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mock_upsert = AsyncMock()
|
||||
monkeypatch.setattr(routes, "upsert_mapping", mock_upsert)
|
||||
link = oauth.issue_account_link(slack_user_id="U999", work_email="slack@x.com")
|
||||
|
||||
await routes._complete_account_mapping("octo", "gh@x.com", link)
|
||||
|
||||
mock_upsert.assert_awaited_once()
|
||||
kwargs = mock_upsert.await_args.kwargs
|
||||
assert kwargs["github_login"] == "octo"
|
||||
# Slack work email from the link token wins over the GitHub email.
|
||||
assert kwargs["work_email"] == "slack@x.com"
|
||||
assert kwargs["slack_user_id"] == "U999"
|
||||
assert kwargs["source"] == "self"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completion_falls_back_to_github_email(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mock_upsert = AsyncMock()
|
||||
monkeypatch.setattr(routes, "upsert_mapping", mock_upsert)
|
||||
|
||||
await routes._complete_account_mapping("octo", "gh@x.com", None)
|
||||
|
||||
kwargs = mock_upsert.await_args.kwargs
|
||||
assert kwargs["work_email"] == "gh@x.com"
|
||||
assert kwargs["slack_user_id"] is None
|
||||
assert kwargs["source"] == "self"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completion_noop_without_any_email(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mock_upsert = AsyncMock()
|
||||
monkeypatch.setattr(routes, "upsert_mapping", mock_upsert)
|
||||
|
||||
await routes._complete_account_mapping("octo", None, None)
|
||||
|
||||
mock_upsert.assert_not_awaited()
|
||||
|
|
@ -43,7 +43,6 @@ async def test_run_email_prefers_github_mapping(fake_store: _FakeStore) -> None:
|
|||
await um.upsert_mapping(
|
||||
github_login="johannes117",
|
||||
work_email="johannes@langchain.dev",
|
||||
source="admin",
|
||||
)
|
||||
# OAuth profile carries a personal account that isn't an org member.
|
||||
profile = {"email": "johannesduplessis117@gmail.com"}
|
||||
|
|
|
|||
|
|
@ -863,8 +863,14 @@ def test_process_slack_mention_mapped_user_with_token_runs_as_user(
|
|||
async def fake_login_for_slack_id(slack_user_id):
|
||||
return "mason-gh" if slack_user_id == "U123" else None
|
||||
|
||||
owner_meta: dict[str, object] = {}
|
||||
|
||||
async def fake_upsert_owner(thread_id: str, **kwargs: object) -> None:
|
||||
owner_meta.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
||||
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
|
||||
monkeypatch.setattr(webapp, "upsert_agent_thread_owner_metadata", fake_upsert_owner)
|
||||
|
||||
asyncio.run(
|
||||
webapp.process_slack_mention(
|
||||
|
|
@ -883,6 +889,10 @@ def test_process_slack_mention_mapped_user_with_token_runs_as_user(
|
|||
run_create = captured["run_create"]
|
||||
configurable = run_create["kwargs"]["config"]["configurable"]
|
||||
assert configurable["github_login"] == "mason-gh"
|
||||
# The thread is tagged with the login resolved from the Slack user id, so it
|
||||
# surfaces in the web Agents UI even when the Slack profile email does not
|
||||
# resolve to a mapping (login_for_email returns None in this harness).
|
||||
assert owner_meta["github_login"] == "mason-gh"
|
||||
assert "use_installation_token_fallback" not in configurable
|
||||
assert "prompt" not in captured
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ async def test_upsert_and_bidirectional_lookup(fake_store: _FakeStore) -> None:
|
|||
github_login="Octocat",
|
||||
work_email="OCTO@example.com",
|
||||
slack_user_id="U123",
|
||||
source="admin",
|
||||
)
|
||||
# Login lookups are case-insensitive; email is normalized to lowercase.
|
||||
assert await um.email_for_login("octocat") == "octo@example.com"
|
||||
|
|
@ -58,7 +57,7 @@ async def test_upsert_and_bidirectional_lookup(fake_store: _FakeStore) -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_readers_after_refresh(fake_store: _FakeStore) -> None:
|
||||
await um.upsert_mapping(github_login="dev", work_email="dev@x.com", source="admin")
|
||||
await um.upsert_mapping(github_login="dev", work_email="dev@x.com")
|
||||
um.clear_cache()
|
||||
await um.refresh_cache()
|
||||
assert um.cached_email_for_login("dev") == "dev@x.com"
|
||||
|
|
@ -69,9 +68,7 @@ async def test_cache_readers_after_refresh(fake_store: _FakeStore) -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_status_not_trusted(fake_store: _FakeStore) -> None:
|
||||
await um.upsert_mapping(
|
||||
github_login="newbie", work_email="n@x.com", source="self", status="pending"
|
||||
)
|
||||
await um.upsert_mapping(github_login="newbie", work_email="n@x.com", status="pending")
|
||||
um.clear_cache()
|
||||
await um.refresh_cache()
|
||||
assert um.is_login_mapped("newbie") is False
|
||||
|
|
@ -79,7 +76,7 @@ async def test_pending_status_not_trusted(fake_store: _FakeStore) -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_record_and_indexes(fake_store: _FakeStore) -> None:
|
||||
await um.upsert_mapping(github_login="gone", work_email="g@x.com", source="admin")
|
||||
await um.upsert_mapping(github_login="gone", work_email="g@x.com")
|
||||
assert await um.email_for_login("gone") == "g@x.com"
|
||||
deleted = await um.delete_mapping("gone")
|
||||
assert deleted is True
|
||||
|
|
@ -96,7 +93,7 @@ async def test_resolve_login_from_email_async_cold_cache(
|
|||
from agent.dashboard import agent_overrides
|
||||
|
||||
monkeypatch.setattr(agent_overrides, "login_for_email", um.login_for_email)
|
||||
await um.upsert_mapping(github_login="cold", work_email="cold@x.com", source="admin")
|
||||
await um.upsert_mapping(github_login="cold", work_email="cold@x.com")
|
||||
um.clear_cache()
|
||||
|
||||
assert await agent_overrides.resolve_login_from_email_async("cold@x.com") == "cold"
|
||||
|
|
@ -110,13 +107,11 @@ async def test_update_deindexes_stale_email_and_slack_id(fake_store: _FakeStore)
|
|||
github_login="mover",
|
||||
work_email="old@x.com",
|
||||
slack_user_id="UOLD",
|
||||
source="admin",
|
||||
)
|
||||
await um.upsert_mapping(
|
||||
github_login="mover",
|
||||
work_email="new@x.com",
|
||||
slack_user_id="UNEW",
|
||||
source="admin",
|
||||
)
|
||||
|
||||
assert um.cached_login_for_email("old@x.com") is None
|
||||
|
|
@ -125,22 +120,6 @@ async def test_update_deindexes_stale_email_and_slack_id(fake_store: _FakeStore)
|
|||
assert um.cached_login_for_slack_id("UNEW") == "mover"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_import_skips_existing(fake_store: _FakeStore) -> None:
|
||||
# Pre-existing richer record must survive a legacy re-import.
|
||||
await um.upsert_mapping(github_login="keep", work_email="keep@x.com", source="self")
|
||||
created = await um.bulk_import(
|
||||
{"keep": "legacy@x.com", "fresh": "fresh@x.com", "bad": ""},
|
||||
source="hardcoded",
|
||||
)
|
||||
assert created == 1
|
||||
keep = await um.get_mapping("keep")
|
||||
assert keep["work_email"] == "keep@x.com"
|
||||
assert keep["source"] == "self"
|
||||
fresh = await um.get_mapping("fresh")
|
||||
assert fresh["source"] == "hardcoded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_requires_login_and_email(fake_store: _FakeStore) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
|
|
@ -151,7 +130,7 @@ async def test_upsert_requires_login_and_email(fake_store: _FakeStore) -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_mappings_sorted(fake_store: _FakeStore) -> None:
|
||||
await um.upsert_mapping(github_login="zeta", work_email="z@x.com", source="admin")
|
||||
await um.upsert_mapping(github_login="alpha", work_email="a@x.com", source="admin")
|
||||
await um.upsert_mapping(github_login="zeta", work_email="z@x.com")
|
||||
await um.upsert_mapping(github_login="alpha", work_email="a@x.com")
|
||||
listed = await um.list_mappings()
|
||||
assert [m["github_login"] for m in listed] == ["alpha", "zeta"]
|
||||
|
|
|
|||
|
|
@ -122,12 +122,6 @@ export interface UserMapping {
|
|||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface UserMappingUpsert {
|
||||
github_login: string;
|
||||
work_email: string;
|
||||
slack_user_id?: string | null;
|
||||
}
|
||||
|
||||
export interface UserMappingsPage {
|
||||
items: Array<UserMapping>;
|
||||
total: number;
|
||||
|
|
@ -218,11 +212,6 @@ export const api = {
|
|||
request<UserMappingsPage>(
|
||||
`/admin/user-mappings?page=${page}&page_size=${pageSize}`,
|
||||
),
|
||||
adminSaveUserMapping: (body: UserMappingUpsert) =>
|
||||
request<UserMapping>("/admin/user-mappings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
adminDeleteUserMapping: (github_login: string) =>
|
||||
request<{ deleted: boolean }>(
|
||||
`/admin/user-mappings/${encodeURIComponent(github_login)}`,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useEffect, useState } from "react";
|
|||
import type { ModelOption, TeamSettings, UserMapping } from "@/lib/api";
|
||||
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -54,10 +53,6 @@ function AdminPage() {
|
|||
const PAGE_SIZE = 20;
|
||||
|
||||
function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [login, setLogin] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [slackId, setSlackId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
|
|
@ -76,29 +71,9 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
|||
}
|
||||
}, [mappings.isFetching, page, pageCount]);
|
||||
|
||||
const invalidate = () =>
|
||||
void qc.invalidateQueries({ queryKey: ["adminUserMappings"] });
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api.adminSaveUserMapping({
|
||||
github_login: login.trim(),
|
||||
work_email: email.trim(),
|
||||
slack_user_id: slackId.trim() || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setLogin("");
|
||||
setEmail("");
|
||||
setSlackId("");
|
||||
setError(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (gh: string) => api.adminDeleteUserMapping(gh),
|
||||
onSuccess: invalidate,
|
||||
onSuccess: () => void mappings.refetch(),
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
|
|
@ -107,33 +82,9 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
|||
return (
|
||||
<SettingsSection
|
||||
title="User mappings"
|
||||
description="Link GitHub logins to work emails (and Slack IDs) so tagged users run as themselves."
|
||||
description="Mappings are created when users connect Slack from settings. Admins can remove stale mappings here."
|
||||
>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_1fr_auto]">
|
||||
<Input
|
||||
placeholder="github-login"
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="work@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Slack ID (optional)"
|
||||
value={slackId}
|
||||
onChange={(e) => setSlackId(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!login.trim() || !email.trim() || save.isPending}
|
||||
>
|
||||
{save.isPending ? "Saving…" : "Add / Update"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue