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:
Johannes du Plessis 2026-06-02 20:55:07 -07:00 • committed by GitHub
parent 046388a4e9
commit c8a997c125
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 320 additions and 182 deletions

View file

@ -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.

View file

@ -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(

View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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>
);
)
}

View 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>
)
}

View file

@ -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>
);
)
}