Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions apps/server/src/slack/SlackAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down
42 changes: 39 additions & 3 deletions apps/web/src/components/settings/SlackSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -92,6 +92,7 @@ function SlackConnectionRows({
const [error, setError] = useState<string | null>(null);
// Null until edited, so the field shows the client ID the server last signed in with.
const [clientIdDraft, setClientIdDraft] = useState<string | null>(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;
Expand Down Expand Up @@ -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);
Expand All @@ -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 (
<>
Expand Down Expand Up @@ -199,15 +205,32 @@ function SlackConnectionRows({
)
}
/>
{signedOut ? (
{signedOut && hasConfiguredApp && !editingApp ? (
<SettingsRow
title="Slack app"
description="An app is already configured for this environment. Connect Slack to sign in with your account. Your workspace may require an owner to approve the app."
control={
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={() => setEditingApp(true)}
>
Change app
</Button>
}
/>
) : null}
{signedOut && (!hasConfiguredApp || editingApp) ? (
<SettingsRow
title="Slack app"
description="Slack signs in through an app in your own workspace. At api.slack.com/apps, choose Create New App → From a manifest, paste the copied manifest, then enter the app's Client ID here."
description="Enter a shared app's Client ID, or create a workspace app at api.slack.com/apps: choose Create New App → From a manifest and paste the copied manifest. Your workspace may require owner approval."
control={
<div className="flex w-full flex-wrap gap-2 sm:w-auto">
<Input
size="sm"
aria-label="Slack app client ID"
disabled={pending}
placeholder="Client ID, e.g. 1234567890.1234567890"
className="min-w-0 flex-1 sm:w-64"
value={clientIdDraft ?? state?.clientId ?? ""}
Expand All @@ -230,6 +253,19 @@ function SlackConnectionRows({
Create Slack app
<ExternalLinkIcon className="size-3.5" aria-hidden="true" />
</Button>
{hasConfiguredApp ? (
<Button
size="sm"
variant="ghost"
disabled={pending}
onClick={() => {
setClientIdDraft(null);
setEditingApp(false);
}}
>
Cancel
</Button>
) : null}
</div>
}
/>
Expand Down
13 changes: 8 additions & 5 deletions docs/fork-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
33 changes: 26 additions & 7 deletions docs/user/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down