Skip to content

Commit c50eb84

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 9f07d2f commit c50eb84

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+788
-2870
lines changed

.speakeasy/out.openapi.yaml

Lines changed: 0 additions & 498 deletions
Large diffs are not rendered by default.

.speakeasy/workflow.lock

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,19 @@ speakeasyVersion: 1.700.2
22
sources:
33
Gram-Internal:
44
sourceNamespace: gram-api-description
5-
sourceRevisionDigest: sha256:18b4401c56501c36bc65024d3f124153259b4f521fb27df6214848057ec3da2a
6-
sourceBlobDigest: sha256:5c9698f4cef7d019ea542d1a5ea311cae25f6cdc4b891becc0edc56f2b153a77
5+
sourceRevisionDigest: sha256:10dec133cae52f0e44a2fd93f739380f316c35f0a5f6d686fac13d2f10895fab
6+
sourceBlobDigest: sha256:78aae5dc79134bcc44fa139accc9af18b27c0acd17688f7f0092474b49bbaf4f
77
tags:
88
- latest
99
- 0.0.1
1010
targets:
1111
gram-internal:
1212
source: Gram-Internal
1313
sourceNamespace: gram-api-description
14-
sourceRevisionDigest: sha256:18b4401c56501c36bc65024d3f124153259b4f521fb27df6214848057ec3da2a
15-
sourceBlobDigest: sha256:5c9698f4cef7d019ea542d1a5ea311cae25f6cdc4b891becc0edc56f2b153a77
14+
sourceRevisionDigest: sha256:10dec133cae52f0e44a2fd93f739380f316c35f0a5f6d686fac13d2f10895fab
15+
sourceBlobDigest: sha256:78aae5dc79134bcc44fa139accc9af18b27c0acd17688f7f0092474b49bbaf4f
1616
codeSamplesNamespace: gram-api-description-typescript-code-samples
17-
codeSamplesRevisionDigest: sha256:68f704bdf8ef8132bf3f1380927f943b8de07ea66f9bd466759a598b72d0a05d
17+
codeSamplesRevisionDigest: sha256:fe49a9b04aafb9ecd59414ef2c012af53e21b7b1397b0fc29c94910d52446e45
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
@@ -37,6 +37,7 @@ import SDK from "./pages/sdk/SDK";
3737
import Settings from "./pages/settings/Settings";
3838
import SlackAppsIndex, { SlackAppsRoot } from "./pages/slackapp/SlackApp";
3939
import SlackAppDetailPage from "./pages/slackapp/SlackAppDetail";
40+
import SlackLogin from "./pages/slackapp/SlackLogin";
4041
import SourceDetails from "./pages/sources/SourceDetails";
4142
import { SourcesPage, SourcesRoot } from "./pages/sources/Sources";
4243
import CustomTools, { CustomToolsRoot } from "./pages/toolBuilder/CustomTools";
@@ -117,6 +118,12 @@ const ROUTE_STRUCTURE = {
117118
component: Register,
118119
unauthenticated: true,
119120
},
121+
slackLogin: {
122+
title: "Slack Login",
123+
url: "/slack/login/:token",
124+
component: SlackLogin,
125+
unauthenticated: true,
126+
},
120127
onboarding: {
121128
title: "Onboarding",
122129
url: "onboarding",

0 commit comments

Comments
 (0)