diff --git a/apps/server/src/slack/SlackAuth.test.ts b/apps/server/src/slack/SlackAuth.test.ts index 23ddfd7e5f4f..cc68a35cc83e 100644 --- a/apps/server/src/slack/SlackAuth.test.ts +++ b/apps/server/src/slack/SlackAuth.test.ts @@ -160,6 +160,58 @@ it.effect("falls back to T3CODE_SLACK_CLIENT_ID and reports it in the state", () }).pipe(Effect.provide(harness.layer)); }); +it.effect("connects with the configured app without requiring a user-supplied client ID", () => { + const harness = makeHarness({ env: { T3CODE_SLACK_CLIENT_ID: "shared-client" } }); + return Effect.gen(function* () { + const auth = yield* SlackAuth.SlackAuth; + const waiting = yield* auth.startLogin({}); + const state = new URL(waiting.authorizationUrl ?? "").searchParams.get("state"); + yield* auth.completeLogin({ + flowId: waiting.flowId ?? "", + callbackUrl: `http://localhost:47832/callback?code=shared-code&state=${state}`, + }); + const connected = Option.getOrNull(yield* firstStateWhere(auth, "connected")); + assert.deepEqual(connected?.account, account); + assert.strictEqual(connected?.clientId, "shared-client"); + assert.strictEqual(harness.storedToken()?.clientId, "shared-client"); + const exchange = harness.requests.find((request) => request.url.endsWith("/oauth.v2.access")); + assert.strictEqual(exchange?.params?.get("client_id"), "shared-client"); + assert.isTrue(exchange?.params?.has("code_verifier")); + assert.isFalse(exchange?.params?.has("client_secret")); + const disconnected = yield* auth.disconnect; + assert.strictEqual(disconnected.clientId, "shared-client"); + assert.isNull(harness.storedToken()); + const reconnect = yield* auth.startLogin({}); + assert.strictEqual( + new URL(reconnect.authorizationUrl ?? "").searchParams.get("client_id"), + "shared-client", + ); + yield* auth.cancelLogin({ flowId: reconnect.flowId ?? "" }); + }).pipe(Effect.provide(harness.layer)); +}); + +it.effect("preserves a saved workspace app and allows overriding the configured app", () => { + const harness = makeHarness({ + env: { T3CODE_SLACK_CLIENT_ID: "shared-client" }, + clientId: "workspace-client", + }); + return Effect.gen(function* () { + const auth = yield* SlackAuth.SlackAuth; + const waiting = yield* auth.startLogin({}); + assert.strictEqual(waiting.clientId, "workspace-client"); + yield* auth.cancelLogin({ flowId: waiting.flowId ?? "" }); + const overridden = yield* auth.startLogin({ clientId: "another-client" }); + assert.strictEqual( + new URL(overridden.authorizationUrl ?? "").searchParams.get("client_id"), + "another-client", + ); + yield* auth.cancelLogin({ flowId: overridden.flowId ?? "" }); + const restored = Option.getOrNull(yield* firstStateWhere(auth, "disconnected")); + assert.strictEqual(restored?.clientId, "workspace-client"); + assert.strictEqual(harness.readSecret(CLIENT_ID_SECRET), "workspace-client"); + }).pipe(Effect.provide(harness.layer)); +}); + it.effect("finishes a login from a pasted redirect URL and remembers the client ID", () => { const harness = makeHarness({}); return Effect.gen(function* () { diff --git a/apps/web/src/components/settings/SlackSettings.tsx b/apps/web/src/components/settings/SlackSettings.tsx index cf267673ea37..f2d2b58e7384 100644 --- a/apps/web/src/components/settings/SlackSettings.tsx +++ b/apps/web/src/components/settings/SlackSettings.tsx @@ -28,7 +28,7 @@ import { useSettingsScope } from "./SettingsScopeContext"; /** * Connects the selected environment's Slack account. Like Linear, the account belongs to the * environment, so every client of that environment can attach messages. Each workspace signs in - * through its own Slack app, made from the manifest this section copies. + * through a configured Slack app or its own app made from the copied manifest. */ export function SlackSettingsSection() { const { environment: scopedEnvironment } = useSettingsScope(); @@ -92,6 +92,7 @@ function SlackConnectionRows({ const [error, setError] = useState(null); // Null until edited, so the field shows the client ID the server last signed in with. const [clientIdDraft, setClientIdDraft] = useState(null); + const [editingApp, setEditingApp] = useState(false); const [pasted, setPasted] = useState({ flowId: null as string | null, value: "" }); const clientId = (clientIdDraft ?? state?.clientId ?? "").trim(); const flowId = state?.phase === "waiting" ? state.flowId : null; @@ -131,6 +132,10 @@ function SlackConnectionRows({ const next = await run(() => startLogin({ environmentId, input: clientId.length > 0 ? { clientId } : {} }), ); + if (next) { + setClientIdDraft(null); + setEditingApp(false); + } // Only the desktop shell can open a tab after an await; browsers block // it as a popup, so the web build relies on the Open Slack button. if (isElectron && next?.authorizationUrl) await openAuthorization(next.authorizationUrl); @@ -154,6 +159,7 @@ function SlackConnectionRows({ const status = error ?? describeConnection(state); const statusClass = error !== null || state?.phase === "failed" ? "text-destructive" : undefined; const signedOut = state?.phase === "disconnected" || state?.phase === "failed"; + const hasConfiguredApp = Boolean(state?.clientId?.trim()); return ( <> @@ -199,15 +205,32 @@ function SlackConnectionRows({ ) } /> - {signedOut ? ( + {signedOut && hasConfiguredApp && !editingApp ? ( + setEditingApp(true)} + > + Change app + + } + /> + ) : null} + {signedOut && (!hasConfiguredApp || editingApp) ? ( + {hasConfiguredApp ? ( + + ) : null} } /> diff --git a/docs/fork-differences.md b/docs/fork-differences.md index 356363d868dc..d81cbf670923 100644 --- a/docs/fork-differences.md +++ b/docs/fork-differences.md @@ -429,11 +429,14 @@ Code: `apps/server/src/linear/`, `packages/contracts/src/linear.ts`, `LinearIssu ## Slack messages and threads **Settings > Integrations > Slack** connects a Slack account to the environment with OAuth (PKCE, -read-only user scopes, no client secret). There is no built-in Slack app: Slack limits apps that -are distributed without Marketplace approval to one `conversations.replies` call a minute, 15 -messages each. Each workspace makes its own app from a manifest the settings section copies -(`slackAppManifest` in `packages/contracts/src/slack.ts`), and the user enters its client ID. The -server keeps that client ID across disconnects, and `T3CODE_SLACK_CLIENT_ID` can supply one. The +read-only user scopes, no client secret). When an app client ID is already configured, users can +connect their account directly; **Change app** keeps workspace-specific setup available. There +is no bundled shared app registration. An operator can supply a shared app's client ID through +`T3CODE_SLACK_CLIENT_ID`, or users can enter it in Settings. Shared apps must enable distribution +and register T3 Code's PKCE redirect. Commercially distributed apps without Marketplace approval +have restrictive thread-reading limits; workspace app approval still applies. Users can also +make a workspace app from the copied manifest (`slackAppManifest` in +`packages/contracts/src/slack.ts`). The server keeps the client ID across disconnects. The redirect is `http://localhost:47832/callback`, since Slack only treats `localhost` as a desktop redirect, with the same paste-back path as Linear for remote browsers. diff --git a/docs/user/slack.md b/docs/user/slack.md index 04e19d117acf..1176903ba1c2 100644 --- a/docs/user/slack.md +++ b/docs/user/slack.md @@ -3,9 +3,14 @@ Connect Slack, then attach Slack messages and threads to messages as context for the agent. T3 Code reads Slack as you, with read-only access, and never posts. -## Set up the Slack app +## Choose a Slack app -Slack signs in through an app in your own workspace. You make it once, and it takes a minute: +If an app is already configured for your environment, click **Connect Slack** and sign in with +your account. You don't need to create an app. Use **Change app** to connect through another app. +Your workspace may require an owner to approve the app before you can authorize it. + +If no app is configured, enter the client ID of a shared app supplied by your server operator, +or create an app in your workspace: 1. Open **Settings > Integrations** and find **Slack**. Click **Copy manifest**. 2. At [api.slack.com/apps](https://api.slack.com/apps), choose **Create New App > From a @@ -14,15 +19,29 @@ Slack signs in through an app in your own workspace. You make it once, and it ta Integrations > Slack**. The manifest asks only for read scopes and turns on PKCE, so T3 Code never needs the app's client -secret. If your workspace requires admin approval for new apps, approve the app first. +secret. If your workspace requires approval for new apps, ask a workspace owner to approve it. + +A shared app must be configured by its operator to accept installations in other workspaces. +For commercially distributed apps without Slack Marketplace approval, Slack limits thread reads +to one request per minute with up to 15 messages per request. Internal customer-built apps and +Marketplace-approved apps have higher limits. See [Slack's thread-reading limits](https://docs.slack.dev/reference/methods/conversations.replies/). + +### Configure an app for an environment + +Server operators can set `T3CODE_SLACK_CLIENT_ID` to the app's client ID before starting the +server. This supplies the app for users who have not connected one before. A client ID previously +saved through Settings takes precedence; use **Change app** to switch it. -Use an app made in your own workspace, not one shared across workspaces. Slack limits apps -installed in other workspaces to reading 15 thread messages a minute, which is too few to attach -threads. +For a shared app, enable public distribution in Slack's app settings. Register +`http://localhost:47832/callback` as a redirect URL, enable PKCE, and configure the read-only +user scopes from T3 Code's copied manifest. T3 Code does not need a client secret. App registration, +distribution, and Marketplace approval are managed separately in Slack; setting a client ID does +not enable them automatically. ## Connect an account -1. Click **Connect Slack**. Slack's approval page opens in your browser. +1. Click **Connect Slack**, then **Open Slack** to open the approval page. On desktop, it opens + automatically. 2. Click **Allow**. The account belongs to the T3 Code server you're connected to, not to one device. Every client