Skip to content

Commit bf86e58

Browse files
qstearnsclaude
andcommitted
feat: add Slack user authentication gating and login flow
Gate Slack event processing behind user mapping — unmapped Slack users receive an ephemeral login link instead of triggering tool calls. Adds auth info/complete endpoints and a browser-based SlackLogin page. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent bd0b840 commit bf86e58

File tree

13 files changed

+659
-37
lines changed

13 files changed

+659
-37
lines changed

.speakeasy/workflow.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ targets:
1414
sourceRevisionDigest: sha256:7859f0c4231beff392cc2708bf2a2671c0d64c7621d6c48503a6864072130736
1515
sourceBlobDigest: sha256:4e1326f50db729bca558f25ad4836238cf74f7488028f3fe4353ec22101892e9
1616
codeSamplesNamespace: gram-api-description-typescript-code-samples
17-
codeSamplesRevisionDigest: sha256:051bf94cdc75c3d08a003d73c497d5fdec5d436ca0a79fdb981c810248b17d24
17+
codeSamplesRevisionDigest: sha256:a1b85695335f0616063c0d5e7db6ccc2e8532aa4d12a71c41b90c8c8697e2b3b
1818
workflow:
1919
workflowVersion: 1.0.0
2020
speakeasyVersion: pinned
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { Type } from "@/components/ui/type";
2+
import { getServerURL } from "@/lib/utils";
3+
import { Button, Icon, Stack } from "@speakeasy-api/moonshine";
4+
import { useCallback, useEffect, useState } from "react";
5+
import { useParams, useNavigate } from "react-router";
6+
7+
type AuthInfo = {
8+
appName: string;
9+
toolsets: { name: string; slug: string }[];
10+
token: string;
11+
};
12+
13+
type PageState =
14+
| "loading"
15+
| "awaiting_login"
16+
| "completing"
17+
| "complete"
18+
| "error";
19+
20+
export default function SlackLogin() {
21+
const { token } = useParams<{ token: string }>();
22+
const navigate = useNavigate();
23+
const [state, setState] = useState<PageState>("loading");
24+
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
25+
const [errorMessage, setErrorMessage] = useState("");
26+
27+
useEffect(() => {
28+
if (!token) {
29+
setState("error");
30+
setErrorMessage("No token provided.");
31+
return;
32+
}
33+
34+
fetch(`${getServerURL()}/rpc/slack-apps/auth/${token}`, {
35+
credentials: "include",
36+
})
37+
.then((res) => {
38+
if (!res.ok) {
39+
throw new Error("Link expired or invalid");
40+
}
41+
return res.json();
42+
})
43+
.then((data: AuthInfo) => {
44+
setAuthInfo(data);
45+
setState("awaiting_login");
46+
})
47+
.catch((err) => {
48+
setState("error");
49+
setErrorMessage(err.message || "Failed to load auth info");
50+
});
51+
}, [token]);
52+
53+
const completeAuth = useCallback(async () => {
54+
if (!token) return;
55+
setState("completing");
56+
57+
try {
58+
const res = await fetch(
59+
`${getServerURL()}/rpc/slack-apps/auth/${token}/complete`,
60+
{
61+
method: "POST",
62+
credentials: "include",
63+
},
64+
);
65+
66+
if (res.status === 401) {
67+
// Not logged in — redirect to login with redirect back here
68+
navigate(`/login?redirect=/slack/login/${token}`);
69+
return;
70+
}
71+
72+
if (!res.ok) {
73+
throw new Error("Failed to complete authentication");
74+
}
75+
76+
setState("complete");
77+
} catch (err) {
78+
setState("error");
79+
setErrorMessage(
80+
err instanceof Error ? err.message : "Something went wrong",
81+
);
82+
}
83+
}, [token, navigate]);
84+
85+
// Auto-attempt completion on mount if user might already be logged in
86+
useEffect(() => {
87+
if (state === "awaiting_login") {
88+
// Try completing — if it fails with 401, we'll show the sign-in button
89+
completeAuth();
90+
}
91+
}, [state === "awaiting_login"]); // eslint-disable-line react-hooks/exhaustive-deps
92+
93+
return (
94+
<div className="flex min-h-screen items-center justify-center bg-background p-4">
95+
<div className="w-full max-w-md rounded-xl border bg-card p-8 shadow-sm">
96+
<Stack gap={6} align="center">
97+
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted/50">
98+
<Icon name="slack" className="h-6 w-6 text-muted-foreground" />
99+
</div>
100+
101+
{state === "loading" && (
102+
<Stack gap={2} align="center">
103+
<Icon
104+
name="loader-circle"
105+
className="h-6 w-6 animate-spin text-muted-foreground"
106+
/>
107+
<Type muted small>
108+
Loading...
109+
</Type>
110+
</Stack>
111+
)}
112+
113+
{state === "completing" && (
114+
<Stack gap={2} align="center">
115+
<Icon
116+
name="loader-circle"
117+
className="h-6 w-6 animate-spin text-muted-foreground"
118+
/>
119+
<Type muted small>
120+
Linking your account...
121+
</Type>
122+
</Stack>
123+
)}
124+
125+
{state === "awaiting_login" && authInfo && (
126+
<Stack gap={4} align="center" className="w-full">
127+
<Stack gap={1} align="center">
128+
<Type variant="subheading">Sign in to Gram</Type>
129+
<Type muted small className="text-center">
130+
<strong>{authInfo.appName}</strong> needs to verify your
131+
identity to process your messages.
132+
</Type>
133+
</Stack>
134+
135+
{authInfo.toolsets.length > 0 && (
136+
<div className="w-full rounded-lg border bg-muted/20 p-3">
137+
<Type muted small className="mb-2 block font-medium">
138+
MCP Servers
139+
</Type>
140+
<Stack gap={1}>
141+
{authInfo.toolsets.map((ts) => (
142+
<div
143+
key={ts.slug}
144+
className="flex items-center gap-2 rounded-md px-2 py-1"
145+
>
146+
<Icon
147+
name="network"
148+
className="h-3.5 w-3.5 text-muted-foreground"
149+
/>
150+
<Type small>{ts.name}</Type>
151+
</div>
152+
))}
153+
</Stack>
154+
</div>
155+
)}
156+
157+
<Button
158+
className="w-full"
159+
onClick={() =>
160+
navigate(`/login?redirect=/slack/login/${token}`)
161+
}
162+
>
163+
<Button.Text>Sign in to Gram</Button.Text>
164+
</Button>
165+
</Stack>
166+
)}
167+
168+
{state === "complete" && (
169+
<Stack gap={3} align="center">
170+
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
171+
<Icon name="check" className="h-5 w-5 text-primary" />
172+
</div>
173+
<Stack gap={1} align="center">
174+
<Type variant="subheading">You're connected!</Type>
175+
<Type muted small className="text-center">
176+
You can close this page and return to Slack.
177+
</Type>
178+
</Stack>
179+
</Stack>
180+
)}
181+
182+
{state === "error" && (
183+
<Stack gap={3} align="center">
184+
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10">
185+
<Icon name="circle-x" className="h-5 w-5 text-destructive" />
186+
</div>
187+
<Stack gap={1} align="center">
188+
<Type variant="subheading">Link expired</Type>
189+
<Type muted small className="text-center">
190+
{errorMessage ||
191+
"This login link is no longer valid. Send another message in Slack to get a new one."}
192+
</Type>
193+
</Stack>
194+
</Stack>
195+
)}
196+
</Stack>
197+
</div>
198+
</div>
199+
);
200+
}

client/dashboard/src/routes.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import SDK from "./pages/sdk/SDK";
3838
import Settings from "./pages/settings/Settings";
3939
import SlackAppsIndex, { SlackAppsRoot } from "./pages/slackapp/SlackApp";
4040
import SlackAppDetailPage from "./pages/slackapp/SlackAppDetail";
41+
import SlackLogin from "./pages/slackapp/SlackLogin";
4142
import SourceDetails from "./pages/sources/SourceDetails";
4243
import { SourcesPage, SourcesRoot } from "./pages/sources/Sources";
4344
import CustomTools, { CustomToolsRoot } from "./pages/toolBuilder/CustomTools";
@@ -118,6 +119,12 @@ const ROUTE_STRUCTURE = {
118119
component: Register,
119120
unauthenticated: true,
120121
},
122+
slackLogin: {
123+
title: "Slack Login",
124+
url: "/slack/login/:token",
125+
component: SlackLogin,
126+
unauthenticated: true,
127+
},
121128
onboarding: {
122129
title: "Onboarding",
123130
url: "onboarding",

client/sdk/.speakeasy/gen.lock

Lines changed: 18 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/sdk/.speakeasy/gen.yaml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/sdk/jsr.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/sdk/package.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/sdk/src/lib/config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function serverURLFromOptions(options: SDKOptions): URL | null {
5959
export const SDK_METADATA = {
6060
language: "typescript",
6161
openapiDocVersion: "0.0.1",
62-
sdkVersion: "0.27.17",
62+
sdkVersion: "0.27.18",
6363
genVersion: "2.801.2",
64-
userAgent: "speakeasy-sdk/typescript 0.27.17 2.801.2 0.0.1 @gram/client",
64+
userAgent: "speakeasy-sdk/typescript 0.27.18 2.801.2 0.0.1 @gram/client",
6565
} as const;

0 commit comments

Comments
 (0)