mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 11:33:14 +00:00
fix: reliable, safe Slack account-connect prompt + first-login Slack dialog (#1383)
* fix: deliver Slack account-link prompt as a visible threaded reply Blocked Slack users got no prompt at all. Prod logs show chat.postEphemeral returns ok, but ephemeral messages are silently dropped in Slack's assistant threads (where Open SWE runs), so the user sees nothing. Post the prompt as a normal threaded reply instead — the same channel the agent uses to reply. * fix: deliver Slack auth-failure prompt as a visible threaded reply leave_failure_comment() tried an ephemeral message first and only fell back to a thread reply on failure. Ephemeral messages succeed (ok) but are dropped in Slack's assistant threads, so the fallback never fired and the user saw no auth-failure prompt. Post the visible threaded reply directly, matching the account-link prompt fix. * fix: prompt blocked Slack users with a generic, token-free dashboard link Addresses the review findings that posting the per-user account-link token / auth URL in a visible thread lets any channel member bind their GitHub account to the triggering user's Slack identity. Drop the per-user signed link entirely. Both the account-link prompt (_post_account_link_prompt) and the runtime auth-failure prompt (leave_failure_comment) now post a plain dashboard settings link (build_settings_url) as a visible threaded reply. The user signs in with GitHub from their own session and connects Slack via verified OIDC on the settings page — no secret in the thread, nothing to hijack, and no DM machinery. * feat: nudge first-time users to connect Slack from the dashboard home Show a Connect Slack banner on the agents landing page whenever Slack OAuth is enabled and the user hasn't linked Slack yet. A first-time user (no Slack mapping) sees it immediately after signing in; it disappears once connected. * feat: prompt first-time users to connect Slack via a dialog Replace the inline Connect Slack card on the agents home with a modal dialog (Base UI). It opens automatically once the mapping query resolves to "not connected" and closes itself once Slack is linked; "Maybe later" dismisses it for the session. No new dependency — uses the design system's Base UI. * copy: frame Slack connect as resolving the user's GitHub account Drop 'act/reply on your behalf' wording across the connect-Slack dialog, the Slack thread prompts (blocked + auth-failure), and the settings description. Connecting Slack lets Open SWE resolve the user's GitHub account when they tag it in Slack.
This commit is contained in:
parent
046388a4e9
commit
c8a997c125
8 changed files with 320 additions and 182 deletions
|
|
@ -205,6 +205,19 @@ def decode_account_link(token: str) -> dict[str, Any] | None:
|
|||
return payload
|
||||
|
||||
|
||||
def build_settings_url() -> str | None:
|
||||
"""Return the dashboard Profile Settings URL, or ``None`` if not configured.
|
||||
|
||||
This is a plain, token-free link: it carries no per-user identity, so it is
|
||||
safe to share in a public Slack thread. The user signs in with GitHub from
|
||||
their own session and connects Slack via verified OIDC on the settings page.
|
||||
"""
|
||||
frontend_base = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
|
||||
if not frontend_base:
|
||||
return 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from ..encryption import encrypt_token
|
|||
from .github_app import get_github_app_installation_token_with_expiry
|
||||
from .github_token import get_github_token_from_thread
|
||||
from .linear import comment_on_linear_issue
|
||||
from .slack import post_slack_ephemeral_message, post_slack_thread_reply
|
||||
from .slack import post_slack_thread_reply
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -254,39 +254,30 @@ async def leave_failure_comment(
|
|||
slack_thread = configurable.get("slack_thread", {})
|
||||
channel_id = slack_thread.get("channel_id") if isinstance(slack_thread, dict) else None
|
||||
thread_ts = slack_thread.get("thread_ts") if isinstance(slack_thread, dict) else None
|
||||
triggering_user_id = (
|
||||
slack_thread.get("triggering_user_id") if isinstance(slack_thread, dict) else None
|
||||
)
|
||||
if channel_id and thread_ts:
|
||||
if isinstance(triggering_user_id, str) and triggering_user_id:
|
||||
logger.info(
|
||||
"Posting auth failure ephemeral reply to Slack user %s in channel %s thread %s",
|
||||
triggering_user_id,
|
||||
channel_id,
|
||||
thread_ts,
|
||||
)
|
||||
sent = await post_slack_ephemeral_message(
|
||||
channel_id=channel_id,
|
||||
user_id=triggering_user_id,
|
||||
text=message,
|
||||
thread_ts=thread_ts,
|
||||
)
|
||||
if sent:
|
||||
return
|
||||
logger.warning(
|
||||
"Failed to post ephemeral auth failure reply for Slack user %s; falling back to thread reply",
|
||||
triggering_user_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Missing Slack triggering_user_id for auth failure reply; falling back to thread reply",
|
||||
)
|
||||
# The auth-failure ``message`` can carry a per-user GitHub auth URL,
|
||||
# which must not be posted in a shared thread (anyone could complete
|
||||
# it and bind the wrong account). Post a generic, token-free notice and
|
||||
# let the user finish sign-in from their own authenticated dashboard.
|
||||
from ..dashboard.oauth import build_settings_url
|
||||
|
||||
settings_url = build_settings_url()
|
||||
link = (
|
||||
f"<{settings_url}|your Open SWE settings>"
|
||||
if settings_url
|
||||
else "your Open SWE settings"
|
||||
)
|
||||
logger.info(
|
||||
"Posting auth failure reply to Slack channel %s thread %s",
|
||||
"Posting generic auth-failure notice to Slack channel %s thread %s",
|
||||
channel_id,
|
||||
thread_ts,
|
||||
)
|
||||
await post_slack_thread_reply(channel_id, thread_ts, message)
|
||||
await post_slack_thread_reply(
|
||||
channel_id,
|
||||
thread_ts,
|
||||
"⚠️ I couldn't resolve your GitHub account for this run. Sign in with GitHub and "
|
||||
f"connect your Slack account in {link}, then tag me again.",
|
||||
)
|
||||
return
|
||||
if source == "github":
|
||||
logger.warning(
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ from .dashboard.agent_overrides import (
|
|||
resolve_login_from_email_async,
|
||||
)
|
||||
from .dashboard.enabled_repos import is_review_repo_enabled
|
||||
from .dashboard.oauth import build_account_link_url
|
||||
from .dashboard.oauth import build_settings_url
|
||||
from .dashboard.profiles import get_profile, get_valid_access_token, has_access_token_record
|
||||
from .dashboard.team_settings import get_team_settings
|
||||
from .dashboard.user_mappings import (
|
||||
|
|
@ -89,7 +89,6 @@ from .utils.slack import (
|
|||
get_slack_user_info,
|
||||
get_slack_user_names,
|
||||
parse_github_pr_url,
|
||||
post_slack_ephemeral_message,
|
||||
post_slack_thread_reply,
|
||||
post_slack_trace_reply,
|
||||
resolve_slack_links_in_context,
|
||||
|
|
@ -875,33 +874,37 @@ async def _post_account_link_prompt(
|
|||
user_email: str | None,
|
||||
reason: str = "unlinked",
|
||||
) -> None:
|
||||
"""Prompt a Slack user to connect their account via the dashboard (ephemeral).
|
||||
"""Prompt a Slack user to connect their account via the dashboard.
|
||||
|
||||
``reason`` is ``"unlinked"`` (never signed in with GitHub) or ``"revoked"``
|
||||
(signed in before, but the stored GitHub authorization is no longer usable).
|
||||
Open SWE opens PRs as the triggering user, so it cannot start until the user
|
||||
has signed in with GitHub and connected their Slack account in the dashboard.
|
||||
The link runs the GitHub sign-in and lands them on Profile Settings, where
|
||||
they can connect Slack.
|
||||
|
||||
Posts a plain, token-free dashboard link as a visible threaded reply. The
|
||||
link carries no per-user identity, so it's safe to show in a shared channel:
|
||||
the user signs in with GitHub from their own session and connects Slack via
|
||||
verified OIDC on the settings page.
|
||||
"""
|
||||
link_url = build_account_link_url(slack_user_id=user_id, work_email=user_email)
|
||||
if not link_url:
|
||||
logger.debug("Account-link URL unavailable (DASHBOARD_API_BASE_URL unset); skipping prompt")
|
||||
settings_url = build_settings_url()
|
||||
if not settings_url:
|
||||
logger.debug(
|
||||
"Dashboard settings URL unavailable (DASHBOARD_BASE_URL unset); skipping prompt"
|
||||
)
|
||||
return
|
||||
if reason == "revoked":
|
||||
text = (
|
||||
"🔐 Your GitHub sign-in is no longer valid, so I can't act on your behalf. "
|
||||
"Sign in with GitHub again to reconnect:\n"
|
||||
f"<{link_url}|Sign in with GitHub>"
|
||||
"🔐 Your GitHub sign-in is no longer valid, so I can't resolve your GitHub "
|
||||
f"account. Re-connect it in <{settings_url}|your Open SWE settings>, then tag me again."
|
||||
)
|
||||
else:
|
||||
text = (
|
||||
"👋 To act on your behalf I need you to sign in with GitHub and connect your "
|
||||
"Slack account. Set that up in your dashboard:\n"
|
||||
f"<{link_url}|Sign in with GitHub & connect Slack>"
|
||||
"👋 I couldn't resolve your GitHub account from Slack. Sign in with GitHub and "
|
||||
f"connect your Slack account in <{settings_url}|your Open SWE settings>, then tag me "
|
||||
"again."
|
||||
)
|
||||
try:
|
||||
await post_slack_ephemeral_message(channel_id, user_id, text, thread_ts=thread_ts)
|
||||
await post_slack_thread_reply(channel_id, thread_ts, text)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Failed to post account-link prompt to Slack", exc_info=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -62,3 +62,68 @@ def test_build_account_link_url_redirects_to_profile_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:
|
||||
"""The prompt posts a plain settings link in the thread — no per-user token."""
|
||||
import asyncio
|
||||
|
||||
from agent import webapp
|
||||
|
||||
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
async def fake_reply(channel_id, thread_ts, text):
|
||||
calls["reply"] = {"channel_id": channel_id, "thread_ts": thread_ts, "text": text}
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(webapp, "post_slack_thread_reply", fake_reply)
|
||||
|
||||
asyncio.run(webapp._post_account_link_prompt("C1", "1.1", "U1", "d@x.com", reason="unlinked"))
|
||||
assert calls["reply"]["channel_id"] == "C1"
|
||||
assert calls["reply"]["thread_ts"] == "1.1"
|
||||
assert "https://app.example.com/my-settings" in calls["reply"]["text"]
|
||||
# No signed account-link token may appear in the public thread.
|
||||
assert "link=" not in calls["reply"]["text"]
|
||||
|
||||
|
||||
def test_account_link_prompt_revoked_wording(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import asyncio
|
||||
|
||||
from agent import webapp
|
||||
|
||||
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
async def fake_reply(channel_id, thread_ts, text):
|
||||
calls["text"] = text
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(webapp, "post_slack_thread_reply", fake_reply)
|
||||
|
||||
asyncio.run(webapp._post_account_link_prompt("C1", "1.1", "U1", "d@x.com", reason="revoked"))
|
||||
assert "no longer valid" in calls["text"]
|
||||
assert "link=" not in calls["text"]
|
||||
|
||||
|
||||
def test_account_link_prompt_skips_when_dashboard_url_unset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import asyncio
|
||||
|
||||
from agent import webapp
|
||||
|
||||
monkeypatch.delenv("DASHBOARD_BASE_URL", raising=False)
|
||||
posted = False
|
||||
|
||||
async def fake_reply(channel_id, thread_ts, text):
|
||||
nonlocal posted
|
||||
posted = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(webapp, "post_slack_thread_reply", fake_reply)
|
||||
|
||||
asyncio.run(webapp._post_account_link_prompt("C1", "1.1", "U1", "d@x.com", reason="unlinked"))
|
||||
assert posted is False
|
||||
|
|
|
|||
|
|
@ -7,66 +7,19 @@ import pytest
|
|||
from agent.utils import auth
|
||||
|
||||
|
||||
def test_leave_failure_comment_posts_to_slack_thread(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
called: dict[str, str] = {}
|
||||
|
||||
async def fake_post_slack_ephemeral_message(
|
||||
channel_id: str, user_id: str, text: str, thread_ts: str | None = None
|
||||
) -> bool:
|
||||
called["channel_id"] = channel_id
|
||||
called["user_id"] = user_id
|
||||
called["thread_ts"] = thread_ts
|
||||
called["message"] = text
|
||||
return True
|
||||
|
||||
async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, message: str) -> bool:
|
||||
raise AssertionError("post_slack_thread_reply should not be called when ephemeral succeeds")
|
||||
|
||||
monkeypatch.setattr(auth, "post_slack_ephemeral_message", fake_post_slack_ephemeral_message)
|
||||
monkeypatch.setattr(auth, "post_slack_thread_reply", fake_post_slack_thread_reply)
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"get_config",
|
||||
lambda: {
|
||||
"configurable": {
|
||||
"slack_thread": {
|
||||
"channel_id": "C123",
|
||||
"thread_ts": "1.2",
|
||||
"triggering_user_id": "U123",
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
asyncio.run(auth.leave_failure_comment("slack", "auth failed"))
|
||||
|
||||
assert called == {
|
||||
"channel_id": "C123",
|
||||
"user_id": "U123",
|
||||
"thread_ts": "1.2",
|
||||
"message": "auth failed",
|
||||
}
|
||||
|
||||
|
||||
def test_leave_failure_comment_falls_back_to_slack_thread_when_ephemeral_fails(
|
||||
def test_leave_failure_comment_posts_generic_token_free_slack_notice(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Slack auth failures post a generic notice, never the (possibly sensitive) message."""
|
||||
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
|
||||
thread_called: dict[str, str] = {}
|
||||
|
||||
async def fake_post_slack_ephemeral_message(
|
||||
channel_id: str, user_id: str, text: str, thread_ts: str | None = None
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, message: str) -> bool:
|
||||
thread_called["channel_id"] = channel_id
|
||||
thread_called["thread_ts"] = thread_ts
|
||||
thread_called["message"] = message
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(auth, "post_slack_ephemeral_message", fake_post_slack_ephemeral_message)
|
||||
monkeypatch.setattr(auth, "post_slack_thread_reply", fake_post_slack_thread_reply)
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
|
|
@ -82,9 +35,13 @@ def test_leave_failure_comment_falls_back_to_slack_thread_when_ephemeral_fails(
|
|||
},
|
||||
)
|
||||
|
||||
asyncio.run(auth.leave_failure_comment("slack", "auth failed"))
|
||||
# Pass a message that embeds a per-user auth URL; it must NOT be echoed publicly.
|
||||
asyncio.run(auth.leave_failure_comment("slack", "Click https://auth.example/secret-token"))
|
||||
|
||||
assert thread_called == {"channel_id": "C123", "thread_ts": "1.2", "message": "auth failed"}
|
||||
assert thread_called["channel_id"] == "C123"
|
||||
assert thread_called["thread_ts"] == "1.2"
|
||||
assert "secret-token" not in thread_called["message"]
|
||||
assert "https://app.example.com/my-settings" in thread_called["message"]
|
||||
|
||||
|
||||
def _slack_config(github_login: str | None = "mason-gh") -> dict:
|
||||
|
|
|
|||
|
|
@ -1,25 +1,28 @@
|
|||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { AgentPromptBar } from "@/components/agents/AgentPromptBar";
|
||||
import { AgentRunCard } from "@/components/agents/AgentRunCard";
|
||||
import { Logo } from "@/components/agents/ported/Logo";
|
||||
import { useAgentThreads, useCreateAgentThread } from "@/lib/agents/queries";
|
||||
import { useModelOptions, type ModelSelection } from "@/lib/agents/useModelOptions";
|
||||
import type { ModelSelection } from "@/lib/agents/useModelOptions"
|
||||
import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
|
||||
import { AgentRunCard } from "@/components/agents/AgentRunCard"
|
||||
import { SlackConnectDialog } from "@/components/agents/SlackConnectDialog"
|
||||
import { Logo } from "@/components/agents/ported/Logo"
|
||||
import { useAgentThreads, useCreateAgentThread } from "@/lib/agents/queries"
|
||||
import { useModelOptions } from "@/lib/agents/useModelOptions"
|
||||
|
||||
export function AgentsHome() {
|
||||
const threadsQuery = useAgentThreads();
|
||||
const createThread = useCreateAgentThread();
|
||||
const recentRuns = (threadsQuery.data ?? []).slice(0, 5);
|
||||
const { models, defaultSelection } = useModelOptions();
|
||||
const [selection, setSelection] = useState<ModelSelection | null>(null);
|
||||
const threadsQuery = useAgentThreads()
|
||||
const createThread = useCreateAgentThread()
|
||||
const recentRuns = (threadsQuery.data ?? []).slice(0, 5)
|
||||
const { models, defaultSelection } = useModelOptions()
|
||||
const [selection, setSelection] = useState<ModelSelection | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (selection === null && defaultSelection) setSelection(defaultSelection);
|
||||
}, [defaultSelection, selection]);
|
||||
if (selection === null && defaultSelection) setSelection(defaultSelection)
|
||||
}, [defaultSelection, selection])
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto px-6 py-8">
|
||||
<SlackConnectDialog />
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col items-center justify-center">
|
||||
<div className="flex w-full flex-col items-center gap-6">
|
||||
<Logo />
|
||||
|
|
@ -40,16 +43,20 @@ export function AgentsHome() {
|
|||
|
||||
<div className="mx-auto mt-6 w-full max-w-[640px] space-y-2">
|
||||
{threadsQuery.isLoading ? (
|
||||
<p className="text-center text-sm text-[var(--ui-text-dim)]">Loading agents…</p>
|
||||
<p className="text-center text-sm text-[var(--ui-text-dim)]">
|
||||
Loading agents…
|
||||
</p>
|
||||
) : recentRuns.length === 0 ? (
|
||||
<AgentsHomeEmptyState />
|
||||
) : (
|
||||
recentRuns.map((thread) => <AgentRunCard key={thread.id} thread={thread} />)
|
||||
recentRuns.map((thread) => (
|
||||
<AgentRunCard key={thread.id} thread={thread} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentsHomeEmptyState() {
|
||||
|
|
@ -63,5 +70,5 @@ export function AgentsHomeEmptyState() {
|
|||
Start your first agent
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
71
ui/src/components/agents/SlackConnectDialog.tsx
Normal file
71
ui/src/components/agents/SlackConnectDialog.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { Dialog } from "@base-ui/react/dialog"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { IoLogoSlack } from "react-icons/io5"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { api, slackConnectUrl } from "@/lib/api"
|
||||
import { useSession } from "@/lib/session"
|
||||
|
||||
/**
|
||||
* Modal shown on first login (and until connected) prompting the user to link
|
||||
* Slack so Open SWE can resolve their GitHub account when tagged in Slack.
|
||||
* ``open`` is derived from the mapping query, so it appears once the data
|
||||
* resolves to "not connected" and closes itself once Slack is linked; dismissing
|
||||
* it hides it for the session.
|
||||
*/
|
||||
export function SlackConnectDialog() {
|
||||
const session = useSession()
|
||||
const mapping = useQuery({ queryKey: ["myMapping"], queryFn: api.myMapping })
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
|
||||
const slackEnabled = session.data?.slack_oauth_enabled ?? false
|
||||
const connected = !!mapping.data?.slack_user_id
|
||||
const shouldShow =
|
||||
slackEnabled && !connected && !mapping.isLoading && !mapping.isError
|
||||
const open = shouldShow && !dismissed
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setDismissed(true)
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Backdrop className="fixed inset-0 z-50 bg-black/50 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0" />
|
||||
<Dialog.Popup className="fixed top-1/2 left-1/2 z-50 w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-lg bg-popover p-6 text-popover-foreground shadow-md ring-1 ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<IoLogoSlack className="size-6 shrink-0 text-muted-foreground" />
|
||||
<Dialog.Title className="text-base font-semibold">
|
||||
Connect your Slack account
|
||||
</Dialog.Title>
|
||||
</div>
|
||||
<Dialog.Description className="text-sm text-muted-foreground">
|
||||
Connect Slack so that when you tag Open SWE, it can resolve your
|
||||
GitHub account. We use the email Slack verifies, which also lets
|
||||
Linear mentions resolve to you.
|
||||
</Dialog.Description>
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
Maybe later
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => window.location.assign(slackConnectUrl())}
|
||||
>
|
||||
<IoLogoSlack className="size-4" />
|
||||
Connect Slack
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Popup>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,65 +1,76 @@
|
|||
import { Navigate, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { IoLogoSlack } from "react-icons/io5";
|
||||
import { Navigate, createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { IoLogoSlack } from "react-icons/io5"
|
||||
|
||||
import type { SessionUser } from "@/lib/api";
|
||||
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { SessionUser } from "@/lib/api"
|
||||
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api, slackConnectUrl } from "@/lib/api";
|
||||
import { buildProfileUpdate, useOptions, useProfile, useSaveProfile } from "@/lib/profile";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { cn } from "@/lib/utils";
|
||||
} from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { api, slackConnectUrl } from "@/lib/api"
|
||||
import {
|
||||
buildProfileUpdate,
|
||||
useOptions,
|
||||
useProfile,
|
||||
useSaveProfile,
|
||||
} from "@/lib/profile"
|
||||
import { useSession } from "@/lib/session"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export const Route = createFileRoute("/my-settings")({ component: MySettingsPage });
|
||||
export const Route = createFileRoute("/my-settings")({
|
||||
component: MySettingsPage,
|
||||
})
|
||||
|
||||
type DraftReviewChoice = "team_default" | "always_on" | "always_off";
|
||||
type DraftReviewChoice = "team_default" | "always_on" | "always_off"
|
||||
|
||||
function toChoice(value: boolean | null | undefined): DraftReviewChoice {
|
||||
if (value === true) return "always_on";
|
||||
if (value === false) return "always_off";
|
||||
return "team_default";
|
||||
if (value === true) return "always_on"
|
||||
if (value === false) return "always_off"
|
||||
return "team_default"
|
||||
}
|
||||
|
||||
function fromChoice(choice: DraftReviewChoice): boolean | null {
|
||||
if (choice === "always_on") return true;
|
||||
if (choice === "always_off") return false;
|
||||
return null;
|
||||
if (choice === "always_on") return true
|
||||
if (choice === "always_off") return false
|
||||
return null
|
||||
}
|
||||
|
||||
function UserMappingSection({ session }: { session: SessionUser }) {
|
||||
const qc = useQueryClient();
|
||||
const mapping = useQuery({ queryKey: ["myMapping"], queryFn: api.myMapping });
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const qc = useQueryClient()
|
||||
const mapping = useQuery({ queryKey: ["myMapping"], queryFn: api.myMapping })
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
|
||||
const slackUserId = mapping.data?.slack_user_id ?? null;
|
||||
const workEmail = mapping.data?.work_email ?? null;
|
||||
const connected = !!slackUserId;
|
||||
const slackUserId = mapping.data?.slack_user_id ?? null
|
||||
const workEmail = mapping.data?.work_email ?? null
|
||||
const connected = !!slackUserId
|
||||
|
||||
const connect = () => {
|
||||
setConnecting(true);
|
||||
setConnecting(true)
|
||||
// Refresh the cached mapping when the user returns from the OAuth redirect.
|
||||
void qc.invalidateQueries({ queryKey: ["myMapping"] });
|
||||
window.location.assign(slackConnectUrl());
|
||||
};
|
||||
void qc.invalidateQueries({ queryKey: ["myMapping"] })
|
||||
window.location.assign(slackConnectUrl())
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="User mapping"
|
||||
description="Connect your Slack account so Open SWE can act as you when you tag it from Slack. We use the email Slack verifies, which also lets Linear mentions resolve to you."
|
||||
description="Connect your Slack account so Open SWE can resolve your GitHub account when you tag it in Slack. We use the email Slack verifies, which also lets Linear mentions resolve to you."
|
||||
>
|
||||
<div className="divide-y divide-border">
|
||||
<SettingsRow
|
||||
label="GitHub account"
|
||||
control={<span className="text-xs text-muted-foreground">{session.login}</span>}
|
||||
control={
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{session.login}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label="Slack account"
|
||||
|
|
@ -73,7 +84,9 @@ function UserMappingSection({ session }: { session: SessionUser }) {
|
|||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
connected ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
|
||||
connected
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{connected ? "Connected" : "Not connected"}
|
||||
|
|
@ -86,69 +99,75 @@ function UserMappingSection({ session }: { session: SessionUser }) {
|
|||
disabled={connecting || mapping.isLoading}
|
||||
>
|
||||
<IoLogoSlack className="size-4" />
|
||||
{connecting ? "Redirecting…" : connected ? "Reconnect" : "Connect Slack"}
|
||||
{connecting
|
||||
? "Redirecting…"
|
||||
: connected
|
||||
? "Reconnect"
|
||||
: "Connect Slack"}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">Sign in with Slack unavailable</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Sign in with Slack unavailable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function MySettingsPage() {
|
||||
const session = useSession();
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const profile = useProfile();
|
||||
const options = useOptions();
|
||||
const save = useSaveProfile();
|
||||
const session = useSession()
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const profile = useProfile()
|
||||
const options = useOptions()
|
||||
const save = useSaveProfile()
|
||||
const teamSettings = useQuery({
|
||||
queryKey: ["teamSettings"],
|
||||
queryFn: api.getTeamSettings,
|
||||
enabled: !!session.data,
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (session.isLoading) {
|
||||
return (
|
||||
<main className="p-6">
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</main>
|
||||
);
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />;
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
|
||||
const handleLogout = async () => {
|
||||
await api.logout();
|
||||
qc.setQueryData(["session"], null);
|
||||
void navigate({ to: "/login" });
|
||||
};
|
||||
await api.logout()
|
||||
qc.setQueryData(["session"], null)
|
||||
void navigate({ to: "/login" })
|
||||
}
|
||||
|
||||
const firstModel = options.data?.models[0];
|
||||
const fallbackModel = firstModel?.id ?? "";
|
||||
const fallbackEffort = firstModel?.default_effort ?? "";
|
||||
const firstModel = options.data?.models[0]
|
||||
const fallbackModel = firstModel?.id ?? ""
|
||||
const fallbackEffort = firstModel?.default_effort ?? ""
|
||||
|
||||
const draftChoice = toChoice(profile.data?.review_draft_prs);
|
||||
const teamDefaultOn = teamSettings.data?.review_draft_prs ?? false;
|
||||
const teamDefaultLabel = `Use team default (currently: ${teamDefaultOn ? "On" : "Off"})`;
|
||||
const draftChoice = toChoice(profile.data?.review_draft_prs)
|
||||
const teamDefaultOn = teamSettings.data?.review_draft_prs ?? false
|
||||
const teamDefaultLabel = `Use team default (currently: ${teamDefaultOn ? "On" : "Off"})`
|
||||
|
||||
const handleDraftChoiceChange = (next: DraftReviewChoice) => {
|
||||
setError(null);
|
||||
setError(null)
|
||||
save
|
||||
.mutateAsync(
|
||||
buildProfileUpdate(
|
||||
profile.data,
|
||||
{ review_draft_prs: fromChoice(next) },
|
||||
fallbackModel,
|
||||
fallbackEffort,
|
||||
),
|
||||
fallbackEffort
|
||||
)
|
||||
)
|
||||
.catch((e: Error) => setError(e.message));
|
||||
};
|
||||
.catch((e: Error) => setError(e.message))
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell user={session.data} title="Profile Settings">
|
||||
|
|
@ -156,7 +175,9 @@ function MySettingsPage() {
|
|||
<SettingsRow
|
||||
label="Email"
|
||||
control={
|
||||
<span className="text-xs text-muted-foreground">{session.data.email ?? "—"}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{session.data.email ?? "—"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
|
@ -170,7 +191,9 @@ function MySettingsPage() {
|
|||
control={
|
||||
<Select
|
||||
value={draftChoice}
|
||||
onValueChange={(v) => handleDraftChoiceChange(v as DraftReviewChoice)}
|
||||
onValueChange={(v) =>
|
||||
handleDraftChoiceChange(v as DraftReviewChoice)
|
||||
}
|
||||
disabled={profile.isLoading || save.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-56">
|
||||
|
|
@ -178,8 +201,12 @@ function MySettingsPage() {
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="team_default">{teamDefaultLabel}</SelectItem>
|
||||
<SelectItem value="always_on">Always review my drafts</SelectItem>
|
||||
<SelectItem value="always_off">Never review my drafts</SelectItem>
|
||||
<SelectItem value="always_on">
|
||||
Always review my drafts
|
||||
</SelectItem>
|
||||
<SelectItem value="always_off">
|
||||
Never review my drafts
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
|
|
@ -191,7 +218,11 @@ function MySettingsPage() {
|
|||
label="Sign out"
|
||||
description="End your dashboard session."
|
||||
control={
|
||||
<Button size="sm" variant="outline" onClick={() => void handleLogout()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleLogout()}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
}
|
||||
|
|
@ -200,5 +231,5 @@ function MySettingsPage() {
|
|||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</AppShell>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue