diff --git a/integrations/slack-gateway/backend_client_test.go b/integrations/slack-gateway/backend_client_test.go
index 8a2d607..0ad82f8 100644
--- a/integrations/slack-gateway/backend_client_test.go
+++ b/integrations/slack-gateway/backend_client_test.go
@@ -241,10 +241,6 @@ func TestManagedChannelRoutesDefaultMissingBooleansSafely(t *testing.T) {
if policies[0].ExternalChannelType != "im" {
t.Fatalf("expected channel type to be preserved, got %#v", policies[0])
}
- rows := channelRouteSettingsRows(connection)
- if len(rows) != 1 || rows[0].ModeLabel != "Mentions required" {
- t.Fatalf("expected settings row to render as mention-required, got %#v", rows)
- }
}
func TestChannelSessionUnavailablePolicySnapshotRequiresStructuredPayload(t *testing.T) {
diff --git a/integrations/slack-gateway/channel_settings.go b/integrations/slack-gateway/channel_settings.go
index b2c0c30..02b486f 100644
--- a/integrations/slack-gateway/channel_settings.go
+++ b/integrations/slack-gateway/channel_settings.go
@@ -1,480 +1,10 @@
package main
import (
- "fmt"
- "html/template"
- "net/http"
- "net/url"
"sort"
"strings"
)
-type channelSettingsNotice struct {
- Title string
- Message string
-}
-
-type channelSettingsListRow struct {
- InstallationID string
- ConnectionID string
- ConnectionName string
- TeamID string
- State string
- TargetName string
- RouteSummary string
- SettingsHref string
-}
-
-type channelSettingsPageData struct {
- Notice *channelSettingsNotice
- Rows []channelSettingsListRow
-}
-
-type channelRouteSettingsRow struct {
- ExternalChannelID string
- RequireMention bool
- ModeLabel string
-}
-
-type channelConnectionSettingsPageData struct {
- Notice *channelSettingsNotice
- InstallationID string
- ConnectionID string
- TeamID string
- State string
- TargetName string
- Rows []channelRouteSettingsRow
- UpdateAction string
- BackHref string
-}
-
-type channelInstallationSettingsPageData struct {
- Notice *channelSettingsNotice
- InstallationID string
- TeamID string
- State string
- TargetName string
- Rows []channelSettingsListRow
- BackHref string
-}
-
-var channelSettingsListTemplate = template.Must(template.New("channel-settings-list").Parse(`
-
-
-
-
- Channel settings
-
-
-
-
-
- {{ if .Notice }}
-
- {{ .Notice.Title }}
- {{ .Notice.Message }}
-
- {{ end }}
- {{ if .Rows }}
- {{ range .Rows }}
-
-
-
-
{{ .TeamID }}
-
{{ .TargetName }}
- {{ if .ConnectionName }}
{{ .ConnectionName }}
{{ end }}
-
{{ .RouteSummary }}
-
-
{{ .State }}
-
-
- {{ if .SettingsHref }}
-
Open settings
- {{ else }}
-
Settings unavailable
- {{ end }}
-
-
- {{ end }}
- {{ else }}
- No manageable channel installations are connected for this account.
- {{ end }}
-
-
-`))
-
-var channelInstallationSettingsTemplate = template.Must(template.New("channel-installation-settings").Parse(`
-
-
-
-
- Channel settings
-
-
-
-
-
-
-
-
{{ .TeamID }}
-
{{ .TargetName }}
-
-
Back
-
-
- {{ if .Notice }}
-
- {{ .Notice.Title }}
- {{ .Notice.Message }}
-
- {{ end }}
- {{ if .Rows }}
- {{ range .Rows }}
-
-
-
-
{{ if .ConnectionName }}{{ .ConnectionName }}{{ else }}{{ .ConnectionID }}{{ end }}
-
{{ .RouteSummary }}
-
-
{{ .State }}
-
-
- {{ if .SettingsHref }}
-
Open settings
- {{ else }}
-
Settings unavailable
- {{ end }}
-
-
- {{ end }}
- {{ else }}
- No manageable connections are connected for this installation.
- {{ end }}
-
-
-`))
-
-var channelConnectionSettingsTemplate = template.Must(template.New("channel-connection-settings").Parse(`
-
-
-
-
- Channel settings
-
-
-
-
-
-
-
-
{{ .TeamID }}
-
{{ .TargetName }}
-
-
Back
-
-
- {{ if .Notice }}
-
- {{ .Notice.Title }}
- {{ .Notice.Message }}
-
- {{ end }}
-
-
- {{ if .Rows }}
-
- {{ range .Rows }}
-
-
-
{{ .ExternalChannelID }}
-
{{ .ModeLabel }}
-
-
-
-
-
-
- {{ end }}
-
- {{ else }}
- No channel overrides are configured.
- {{ end }}
-
-
-
-`))
-
-func (g *slackGateway) channelSettingsPath() string {
- return g.publicPathPrefix() + "/settings/channels"
-}
-
-func (g *slackGateway) channelSettingsInstallationPath(installationID string) string {
- return g.publicPathPrefix() + "/settings/channels/installations/" + url.PathEscape(strings.TrimSpace(installationID))
-}
-
-func (g *slackGateway) channelSettingsConnectionPath(installationID, connectionID string) string {
- return g.channelSettingsInstallationPath(installationID) + "/connections/" + url.PathEscape(strings.TrimSpace(connectionID))
-}
-
func (g *slackGateway) relativeGatewayPath(requestPath string) string {
requestPath = strings.TrimSpace(requestPath)
prefix := g.publicPathPrefix()
@@ -489,20 +19,6 @@ func (g *slackGateway) relativeGatewayPath(requestPath string) string {
return requestPath
}
-func channelSettingsNoticeFromRequest(r *http.Request) *channelSettingsNotice {
- if r == nil {
- return nil
- }
- switch strings.TrimSpace(r.URL.Query().Get("notice")) {
- case "routes-updated":
- return &channelSettingsNotice{Title: "Channel settings updated", Message: "The channel routing settings were saved."}
- case "routes-update-failed":
- return &channelSettingsNotice{Title: "Channel settings update failed", Message: "The channel routing settings could not be saved right now."}
- default:
- return nil
- }
-}
-
func primaryManagedConnection(installation backendManagedInstallation) backendManagedConnection {
for _, connection := range installation.Connections {
if connection.IsDefault {
@@ -530,32 +46,6 @@ func managedConnectionByID(installation backendManagedInstallation, connectionID
return backendManagedConnection{}, false
}
-func installationTargetName(installation backendManagedInstallation) string {
- if installation.CurrentTarget != nil {
- if name := strings.TrimSpace(installation.CurrentTarget.Profile.Name); name != "" {
- return name
- }
- }
- return "No target selected"
-}
-
-func managedConnectionName(connection backendManagedConnection) string {
- if name := strings.TrimSpace(connection.DisplayName); name != "" {
- return name
- }
- if id := strings.TrimSpace(connection.ID); id != "" {
- return id
- }
- return "Connection"
-}
-
-func managedConnectionState(installation backendManagedInstallation, connection backendManagedConnection) string {
- if state := strings.TrimSpace(connection.State); state != "" {
- return state
- }
- return strings.TrimSpace(installation.State)
-}
-
func routesFromInstallationConfig(config installationConfig) []backendManagedChannelRoute {
routes := make([]backendManagedChannelRoute, 0, len(config.ChannelPolicies))
for _, policy := range config.ChannelPolicies {
@@ -607,118 +97,3 @@ func channelPoliciesFromConnection(connection backendManagedConnection) []instal
})
return policies
}
-
-func channelRouteSettingsRows(connection backendManagedConnection) []channelRouteSettingsRow {
- policies := channelPoliciesFromConnection(connection)
- rows := make([]channelRouteSettingsRow, 0, len(policies))
- for _, policy := range policies {
- requireMention := true
- if policy.RequireMention != nil {
- requireMention = *policy.RequireMention
- }
- modeLabel := "Mentions required"
- if !requireMention {
- modeLabel = "Relays without mention"
- }
- rows = append(rows, channelRouteSettingsRow{
- ExternalChannelID: policy.ExternalChannelID,
- RequireMention: requireMention,
- ModeLabel: modeLabel,
- })
- }
- return rows
-}
-
-func routeSummary(connection backendManagedConnection) string {
- count := len(channelPoliciesFromConnection(connection))
- switch count {
- case 0:
- return "No channel overrides"
- case 1:
- return "1 channel override"
- default:
- return fmt.Sprintf("%d channel overrides", count)
- }
-}
-
-func channelSettingsRowsForInstallation(installation backendManagedInstallation) []channelSettingsListRow {
- connections := installation.Connections
- if len(connections) == 0 {
- connections = []backendManagedConnection{primaryManagedConnection(installation)}
- }
- targetName := installationTargetName(installation)
- rows := make([]channelSettingsListRow, 0, len(connections))
- for _, connection := range connections {
- rows = append(rows, channelSettingsListRow{
- InstallationID: strings.TrimSpace(installation.ID),
- ConnectionID: strings.TrimSpace(connection.ID),
- ConnectionName: managedConnectionName(connection),
- TeamID: strings.TrimSpace(installation.Route.ExternalTenantID),
- State: managedConnectionState(installation, connection),
- TargetName: targetName,
- RouteSummary: routeSummary(connection),
- SettingsHref: "",
- })
- }
- return rows
-}
-
-func channelSettingsRows(installations []backendManagedInstallation) []channelSettingsListRow {
- rows := []channelSettingsListRow{}
- for _, installation := range installations {
- rows = append(rows, channelSettingsRowsForInstallation(installation)...)
- }
- return rows
-}
-
-func (g *slackGateway) applyChannelSettingsRowLinks(rows []channelSettingsListRow) {
- for index := range rows {
- if strings.TrimSpace(rows[index].InstallationID) == "" || strings.TrimSpace(rows[index].ConnectionID) == "" {
- continue
- }
- rows[index].SettingsHref = g.channelSettingsConnectionPath(rows[index].InstallationID, rows[index].ConnectionID)
- }
-}
-
-func (g *slackGateway) renderChannelSettingsList(w http.ResponseWriter, r *http.Request, installations []backendManagedInstallation) {
- rows := channelSettingsRows(installations)
- g.applyChannelSettingsRowLinks(rows)
- w.Header().Set("Cache-Control", "no-store")
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- _ = channelSettingsListTemplate.Execute(w, channelSettingsPageData{
- Notice: channelSettingsNoticeFromRequest(r),
- Rows: rows,
- })
-}
-
-func (g *slackGateway) renderChannelInstallationSettings(w http.ResponseWriter, r *http.Request, installation backendManagedInstallation) {
- rows := channelSettingsRowsForInstallation(installation)
- g.applyChannelSettingsRowLinks(rows)
- w.Header().Set("Cache-Control", "no-store")
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- _ = channelInstallationSettingsTemplate.Execute(w, channelInstallationSettingsPageData{
- Notice: channelSettingsNoticeFromRequest(r),
- InstallationID: strings.TrimSpace(installation.ID),
- TeamID: strings.TrimSpace(installation.Route.ExternalTenantID),
- State: strings.TrimSpace(installation.State),
- TargetName: installationTargetName(installation),
- Rows: rows,
- BackHref: g.channelSettingsPath(),
- })
-}
-
-func (g *slackGateway) renderChannelConnectionSettings(w http.ResponseWriter, r *http.Request, installation backendManagedInstallation, connection backendManagedConnection) {
- w.Header().Set("Cache-Control", "no-store")
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- _ = channelConnectionSettingsTemplate.Execute(w, channelConnectionSettingsPageData{
- Notice: channelSettingsNoticeFromRequest(r),
- InstallationID: installation.ID,
- ConnectionID: connection.ID,
- TeamID: strings.TrimSpace(installation.Route.ExternalTenantID),
- State: strings.TrimSpace(installation.State),
- TargetName: installationTargetName(installation),
- Rows: channelRouteSettingsRows(connection),
- UpdateAction: g.channelSettingsConnectionPath(installation.ID, connection.ID),
- BackHref: g.channelSettingsInstallationPath(installation.ID),
- })
-}
diff --git a/integrations/slack-gateway/channel_settings_handlers.go b/integrations/slack-gateway/channel_settings_handlers.go
index 8401eb4..2cb00ab 100644
--- a/integrations/slack-gateway/channel_settings_handlers.go
+++ b/integrations/slack-gateway/channel_settings_handlers.go
@@ -2,7 +2,6 @@ package main
import (
"net/http"
- "sort"
"strings"
)
@@ -11,7 +10,7 @@ func (g *slackGateway) handleChannelSettings(w http.ResponseWriter, r *http.Requ
if !ok {
return
}
- if r.Method == http.MethodGet && g.reactRoutesShareGatewayOrigin() {
+ if r.Method == http.MethodGet {
g.redirectToReactRoute(
w,
r,
@@ -19,58 +18,12 @@ func (g *slackGateway) handleChannelSettings(w http.ResponseWriter, r *http.Requ
)
return
}
- installations, err := g.listManagedInstallations(r.Context(), principal.ID)
- if err != nil {
- g.logger.ErrorContext(
- r.Context(),
- "channel settings list failed",
- "err",
- err,
- "caller_auth_id",
- principal.ID,
- )
- http.Error(w, "channel settings unavailable", http.StatusBadGateway)
- return
- }
-
- relativePath := strings.TrimRight(g.relativeGatewayPath(r.URL.Path), "/")
- if relativePath == "/settings/channels" {
- g.renderChannelSettingsList(w, r, installations)
- return
- }
-
- if installationID, ok := channelSettingsInstallationPathID(relativePath); ok {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- for _, installation := range installations {
- if strings.TrimSpace(installation.ID) == installationID {
- g.renderChannelInstallationSettings(w, r, installation)
- return
- }
- }
- http.NotFound(w, r)
- return
- }
-
- installation, connection, ok := g.matchChannelSettingsConnectionPath(
- w,
- r,
- installations,
- relativePath,
- )
- if !ok {
+ _ = principal
+ if r.Method == http.MethodPost {
+ http.Error(w, "legacy channel settings form removed; use /api/settings/channels", http.StatusGone)
return
}
- switch r.Method {
- case http.MethodGet:
- g.renderChannelConnectionSettings(w, r, installation, connection)
- case http.MethodPost:
- g.handleChannelSettingsUpdate(w, r, principal.ID, installation, connection)
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
func channelSettingsInstallationPathID(relativePath string) (string, bool) {
@@ -82,23 +35,6 @@ func channelSettingsInstallationPathID(relativePath string) (string, bool) {
return strings.TrimSpace(parts[1]), true
}
-func (g *slackGateway) matchChannelSettingsConnectionPath(
- w http.ResponseWriter,
- r *http.Request,
- installations []backendManagedInstallation,
- relativePath string,
-) (backendManagedInstallation, backendManagedConnection, bool) {
- installation, connection, ok := matchManagedChannelSettingsConnection(
- installations,
- relativePath,
- )
- if !ok {
- http.NotFound(w, r)
- return backendManagedInstallation{}, backendManagedConnection{}, false
- }
- return installation, connection, true
-}
-
func matchManagedChannelSettingsConnection(
installations []backendManagedInstallation,
relativePath string,
@@ -122,109 +58,3 @@ func matchManagedChannelSettingsConnection(
}
return backendManagedInstallation{}, backendManagedConnection{}, false
}
-
-func (g *slackGateway) handleChannelSettingsUpdate(
- w http.ResponseWriter,
- r *http.Request,
- callerAuthID string,
- installation backendManagedInstallation,
- connection backendManagedConnection,
-) {
- if err := r.ParseForm(); err != nil {
- http.Error(w, "invalid form payload", http.StatusBadRequest)
- return
- }
- channelID := strings.TrimSpace(r.FormValue("externalChannelId"))
- if channelID == "" {
- http.Error(w, "externalChannelId is required", http.StatusBadRequest)
- return
- }
-
- policiesByChannel := map[string]installationChannelPolicy{}
- for _, policy := range channelPoliciesFromConnection(connection) {
- policiesByChannel[policy.ExternalChannelID] = policy
- }
-
- switch strings.TrimSpace(r.FormValue("action")) {
- case "delete":
- delete(policiesByChannel, channelID)
- case "toggle":
- requireMention := strings.EqualFold(strings.TrimSpace(r.FormValue("requireMention")), "true")
- policy := policiesByChannel[channelID]
- policy.ExternalChannelID = channelID
- policy.RequireMention = &requireMention
- policiesByChannel[channelID] = policy
- default:
- requireMention := r.FormValue("requireMention") == "on"
- policy := policiesByChannel[channelID]
- policy.ExternalChannelID = channelID
- policy.RequireMention = &requireMention
- policiesByChannel[channelID] = policy
- }
-
- policies := make([]installationChannelPolicy, 0, len(policiesByChannel))
- for _, policy := range policiesByChannel {
- policies = append(policies, policy)
- }
- sort.Slice(policies, func(i, j int) bool {
- return policies[i].ExternalChannelID < policies[j].ExternalChannelID
- })
- requestID := newInstallRequestID()
- if err := g.updateManagedInstallationRoutes(
- r.Context(),
- callerAuthID,
- installation.ID,
- connection.ID,
- requestID,
- policies,
- ); err != nil {
- g.logger.ErrorContext(
- r.Context(),
- "channel settings update failed",
- "err",
- err,
- "caller_auth_id",
- callerAuthID,
- "installation_id",
- installation.ID,
- "connection_id",
- connection.ID,
- "request_id",
- requestID,
- )
- http.Redirect(
- w,
- r,
- g.channelSettingsConnectionNoticeURL(
- installation.ID,
- connection.ID,
- "routes-update-failed",
- ),
- http.StatusSeeOther,
- )
- return
- }
- g.policies.forget(installation.Route.ExternalTenantID)
- http.Redirect(
- w,
- r,
- g.channelSettingsConnectionNoticeURL(
- installation.ID,
- connection.ID,
- "routes-updated",
- ),
- http.StatusSeeOther,
- )
-}
-
-func (g *slackGateway) channelSettingsConnectionNoticeURL(installationID, connectionID, notice string) string {
- target := g.channelSettingsConnectionPath(installationID, connectionID)
- if strings.TrimSpace(notice) == "" {
- return target
- }
- separator := "?"
- if strings.Contains(target, "?") {
- separator = "&"
- }
- return target + separator + "notice=" + strings.TrimSpace(notice)
-}
diff --git a/integrations/slack-gateway/gateway_test.go b/integrations/slack-gateway/gateway_test.go
index 4fa0a6b..ccdaae0 100644
--- a/integrations/slack-gateway/gateway_test.go
+++ b/integrations/slack-gateway/gateway_test.go
@@ -202,7 +202,7 @@ func TestOAuthCallbackAutoSelectsSingleInstallTargetAndUpsertsRegistry(t *testin
}
}
-func TestInstallResultKeepsLegacyPageWhenReactGatewayIsCrossOrigin(t *testing.T) {
+func TestInstallResultRedirectsToReactPageWhenReactGatewayIsCrossOrigin(t *testing.T) {
gateway := newSlackGateway(config{
PublicURL: "https://gateway.example.test",
SpritzBaseURL: "https://spritz.example.test",
@@ -216,14 +216,15 @@ func TestInstallResultKeepsLegacyPageWhenReactGatewayIsCrossOrigin(t *testing.T)
rec := httptest.NewRecorder()
gateway.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusOK {
- t.Fatalf("expected legacy result page, got %d: %s", rec.Code, rec.Body.String())
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("expected React redirect, got %d: %s", rec.Code, rec.Body.String())
}
- if location := rec.Header().Get("Location"); location != "" {
- t.Fatalf("expected no React redirect, got %q", location)
+ location, err := url.Parse(rec.Header().Get("Location"))
+ if err != nil {
+ t.Fatalf("parse redirect: %v", err)
}
- if body := rec.Body.String(); !strings.Contains(body, "Slack workspace connected") || !strings.Contains(body, "req_123") {
- t.Fatalf("expected legacy install result page, got %q", body)
+ if location.String() != "https://spritz.example.test/settings/slack/install/result?status=success&code=installed&requestId=req_123" {
+ t.Fatalf("expected React install result redirect, got %q", location.String())
}
}
@@ -366,7 +367,7 @@ func TestOAuthCallbackRedirectsToReactInstallTargetPickerWhenSameOrigin(t *testi
}
}
-func TestOAuthCallbackRendersGatewayInstallTargetPickerWhenCrossOrigin(t *testing.T) {
+func TestOAuthCallbackRedirectsToReactInstallTargetPickerWhenCrossOrigin(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/internal/v2/spritz/channel-install-targets/list":
@@ -441,27 +442,50 @@ func TestOAuthCallbackRendersGatewayInstallTargetPickerWhenCrossOrigin(t *testin
rec := httptest.NewRecorder()
gateway.handleOAuthCallback(rec, req)
- if rec.Code != http.StatusOK {
- t.Fatalf("expected gateway picker page, got %d: %s", rec.Code, rec.Body.String())
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("expected React picker redirect, got %d: %s", rec.Code, rec.Body.String())
+ }
+ redirectURL, err := url.Parse(rec.Header().Get("Location"))
+ if err != nil {
+ t.Fatalf("parse picker redirect: %v", err)
+ }
+ if redirectURL.Scheme != "https" || redirectURL.Host != "spritz.example.test" {
+ t.Fatalf("expected picker redirect to use Spritz host, got %s", redirectURL.String())
}
- body := rec.Body.String()
- if !strings.Contains(body, "Choose an install target") {
- t.Fatalf("expected picker title, got %q", body)
+ if redirectURL.Path != "/settings/slack/install/select" {
+ t.Fatalf("expected React picker route, got %q", redirectURL.Path)
}
- if !strings.Contains(body, "Personal Helper") || !strings.Contains(body, "Workspace Helper") {
- t.Fatalf("expected picker targets, got %q", body)
+ requestID := redirectURL.Query().Get("requestId")
+ if requestID == "" {
+ t.Fatalf("expected picker redirect to include requestId, got %q", redirectURL.RawQuery)
}
- if !strings.Contains(body, `/slack/install/select`) {
- t.Fatalf("expected picker form action, got %q", body)
+ pendingState := redirectURL.Query().Get("state")
+ if pendingState == "" {
+ t.Fatalf("expected cross-origin picker redirect to include pending state, got %q", redirectURL.RawQuery)
}
- if strings.Contains(body, "xoxb-installed") {
- t.Fatalf("expected picker state to keep bot token encrypted, got %q", body)
+ if strings.Contains(pendingState, "xoxb-installed") {
+ t.Fatalf("expected pending state query to keep bot token encrypted")
}
for _, cookie := range rec.Result().Cookies() {
if strings.HasPrefix(cookie.Name, pendingInstallCookieName) {
t.Fatalf("expected cross-origin picker to avoid pending install cookie, got %#v", cookie)
}
}
+
+ selectionReq := httptest.NewRequest(
+ http.MethodGet,
+ "/api/slack/install/selection?requestId="+url.QueryEscape(requestID)+"&state="+url.QueryEscape(pendingState),
+ nil,
+ )
+ selectionRec := httptest.NewRecorder()
+ gateway.routes().ServeHTTP(selectionRec, selectionReq)
+
+ if selectionRec.Code != http.StatusOK {
+ t.Fatalf("expected selection API response, got %d: %s", selectionRec.Code, selectionRec.Body.String())
+ }
+ if strings.Contains(selectionRec.Body.String(), "xoxb-installed") || strings.Contains(selectionRec.Body.String(), "botAccessToken") {
+ t.Fatalf("selection API leaked bot token material: %s", selectionRec.Body.String())
+ }
}
func TestInstallTargetSelectionAPIUsesRequestScopedPendingCookie(t *testing.T) {
@@ -617,7 +641,7 @@ func TestWorkspaceManagementRendersManagedInstallations(t *testing.T) {
}
}
-func TestWorkspaceManagementKeepsLegacyPageWhenReactGatewayIsCrossOrigin(t *testing.T) {
+func TestWorkspaceManagementRedirectsToReactPageWhenReactGatewayIsCrossOrigin(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/internal/v2/spritz/channel-installations/list" {
t.Fatalf("unexpected backend path %s", r.URL.Path)
@@ -669,15 +693,11 @@ func TestWorkspaceManagementKeepsLegacyPageWhenReactGatewayIsCrossOrigin(t *test
rec := httptest.NewRecorder()
gateway.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusOK {
- t.Fatalf("expected legacy page, got %d: %s", rec.Code, rec.Body.String())
- }
- if location := rec.Header().Get("Location"); location != "" {
- t.Fatalf("expected no React redirect, got %q", location)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("expected React redirect, got %d: %s", rec.Code, rec.Body.String())
}
- body := rec.Body.String()
- if !strings.Contains(body, "Slack workspaces") || !strings.Contains(body, "Workspace Helper") {
- t.Fatalf("expected legacy workspace page, got %q", body)
+ if location := rec.Header().Get("Location"); location != "https://spritz.example.test/settings/slack/workspaces" {
+ t.Fatalf("expected React workspace redirect, got %q", location)
}
}
@@ -806,7 +826,7 @@ func TestChannelSettingsRendersManagedConnections(t *testing.T) {
}
}
-func TestChannelSettingsKeepsLegacyPageWhenReactGatewayIsCrossOrigin(t *testing.T) {
+func TestChannelSettingsRedirectsToReactPageWhenReactGatewayIsCrossOrigin(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/internal/v2/spritz/channel-installations/list" {
t.Fatalf("unexpected backend path %s", r.URL.Path)
@@ -869,15 +889,11 @@ func TestChannelSettingsKeepsLegacyPageWhenReactGatewayIsCrossOrigin(t *testing.
rec := httptest.NewRecorder()
gateway.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusOK {
- t.Fatalf("expected legacy page, got %d: %s", rec.Code, rec.Body.String())
- }
- if location := rec.Header().Get("Location"); location != "" {
- t.Fatalf("expected no React redirect, got %q", location)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("expected React redirect, got %d: %s", rec.Code, rec.Body.String())
}
- body := rec.Body.String()
- if !strings.Contains(body, "Channel settings") || !strings.Contains(body, "C_channel_1") {
- t.Fatalf("expected legacy channel settings page, got %q", body)
+ if location := rec.Header().Get("Location"); location != "https://spritz.example.test/settings/slack/channels/installations/opaque-installation-id/connections/opaque-connection-id" {
+ t.Fatalf("expected React channel settings redirect, got %q", location)
}
}
@@ -1008,18 +1024,34 @@ func TestChannelSettingsUpdatePostsRoutePolicies(t *testing.T) {
botUserID: "U_bot",
})
+ requestBody, err := json.Marshal(map[string]any{
+ "requestId": "req_routes_1",
+ "channelPolicies": []map[string]any{
+ {
+ "externalChannelId": "C_existing",
+ "requireMention": true,
+ },
+ {
+ "externalChannelId": "C_new",
+ "requireMention": false,
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("marshal request body: %v", err)
+ }
req := httptest.NewRequest(
- http.MethodPost,
- "/settings/channels/installations/chinst_workspace_1/connections/chconn_default",
- strings.NewReader("action=upsert&externalChannelId=C_new"),
+ http.MethodPut,
+ "/api/settings/channels/installations/chinst_workspace_1/connections/chconn_default",
+ bytes.NewReader(requestBody),
)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Spritz-User-Id", "user-1")
rec := httptest.NewRecorder()
gateway.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("expected 303, got %d: %s", rec.Code, rec.Body.String())
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if updatePayload["callerAuthId"] != "user-1" {
t.Fatalf("expected caller auth id, got %#v", updatePayload["callerAuthId"])
@@ -1027,6 +1059,9 @@ func TestChannelSettingsUpdatePostsRoutePolicies(t *testing.T) {
if updatePayload["installationId"] != "chinst_workspace_1" || updatePayload["connectionId"] != "chconn_default" {
t.Fatalf("expected installation and connection ids, got %#v", updatePayload)
}
+ if updatePayload["requestId"] != "req_routes_1" {
+ t.Fatalf("expected request id to be forwarded, got %#v", updatePayload["requestId"])
+ }
policies, ok := updatePayload["channelPolicies"].([]any)
if !ok || len(policies) != 2 {
t.Fatalf("expected two channel policies, got %#v", updatePayload["channelPolicies"])
@@ -1235,6 +1270,48 @@ func TestManagementAPIReturnsJSONAuthErrors(t *testing.T) {
}
}
+func TestLegacyRenderedPageFormPostsAreGone(t *testing.T) {
+ gateway := newSlackGateway(config{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ cases := []struct {
+ name string
+ path string
+ }{
+ {
+ name: "install selection",
+ path: "/slack/install/select",
+ },
+ {
+ name: "workspace target",
+ path: "/slack/workspaces/target",
+ },
+ {
+ name: "workspace disconnect",
+ path: "/slack/workspaces/disconnect",
+ },
+ {
+ name: "workspace test",
+ path: "/slack/workspaces/test",
+ },
+ {
+ name: "channel settings",
+ path: "/settings/channels/installations/chinst_workspace_1/connections/chconn_default",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader("field=value"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("X-Spritz-User-Id", "user-1")
+ rec := httptest.NewRecorder()
+ gateway.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusGone {
+ t.Fatalf("expected 410 for removed legacy form, got %d: %s", rec.Code, rec.Body.String())
+ }
+ })
+ }
+}
+
func TestWorkspaceManagementAcceptsConfiguredBrowserAuthHeaders(t *testing.T) {
t.Setenv("SPRITZ_AUTH_HEADER_ID", "X-Forwarded-User")
t.Setenv("SPRITZ_AUTH_HEADER_EMAIL", "X-Forwarded-Email")
@@ -1343,7 +1420,7 @@ func TestWorkspaceTargetPickerUsesCurrentBrowserPrincipal(t *testing.T) {
}
}
-func TestWorkspaceTargetUpdateRedirectsOnSuccess(t *testing.T) {
+func TestWorkspaceTargetAPIUpdateSucceeds(t *testing.T) {
var updatePayload map[string]any
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/internal/v2/spritz/channel-installations/target/update" {
@@ -1368,29 +1445,45 @@ func TestWorkspaceTargetUpdateRedirectsOnSuccess(t *testing.T) {
HTTPTimeout: 5 * time.Second,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
- req := httptest.NewRequest(
- http.MethodPost,
- "/slack/workspaces/target",
- strings.NewReader("teamId=T_workspace_1&requestId=req_manage_1&target=eyJhZ2VudElkIjoiYWdfd29ya3NwYWNlIn0"),
- )
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ requestBody, err := json.Marshal(map[string]any{
+ "teamId": "T_workspace_1",
+ "requestId": "req_manage_1",
+ "presetInputs": map[string]any{
+ "agentId": "ag_workspace",
+ },
+ })
+ if err != nil {
+ t.Fatalf("marshal request body: %v", err)
+ }
+ req := httptest.NewRequest(http.MethodPost, "/api/slack/workspaces/target", bytes.NewReader(requestBody))
+ req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Spritz-User-Id", "user-1")
rec := httptest.NewRecorder()
gateway.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("expected 303, got %d: %s", rec.Code, rec.Body.String())
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if updatePayload["callerAuthId"] != "user-1" {
t.Fatalf("expected caller auth id to be forwarded, got %#v", updatePayload["callerAuthId"])
}
- location := rec.Header().Get("Location")
- if !strings.Contains(location, "notice=target-updated") {
- t.Fatalf("expected success redirect notice, got %q", location)
+ if updatePayload["requestId"] != "req_manage_1" {
+ t.Fatalf("expected request id to be forwarded, got %#v", updatePayload["requestId"])
+ }
+ presetInputs, ok := updatePayload["presetInputs"].(map[string]any)
+ if !ok || presetInputs["agentId"] != "ag_workspace" {
+ t.Fatalf("expected preset inputs to be forwarded, got %#v", updatePayload["presetInputs"])
+ }
+ var response map[string]any
+ if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if response["status"] != "updated" || response["teamId"] != "T_workspace_1" {
+ t.Fatalf("expected updated response, got %#v", response)
}
}
-func TestWorkspaceDisconnectRedirectsOnSuccess(t *testing.T) {
+func TestWorkspaceDisconnectAPISucceeds(t *testing.T) {
var disconnectPayload map[string]any
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/internal/v2/spritz/channel-installations/disconnect" {
@@ -1412,25 +1505,28 @@ func TestWorkspaceDisconnectRedirectsOnSuccess(t *testing.T) {
HTTPTimeout: 5 * time.Second,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
- req := httptest.NewRequest(
- http.MethodPost,
- "/slack/workspaces/disconnect",
- strings.NewReader("teamId=T_workspace_1"),
- )
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ requestBody, err := json.Marshal(map[string]any{"teamId": "T_workspace_1"})
+ if err != nil {
+ t.Fatalf("marshal request body: %v", err)
+ }
+ req := httptest.NewRequest(http.MethodPost, "/api/slack/workspaces/disconnect", bytes.NewReader(requestBody))
+ req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Spritz-User-Id", "user-1")
rec := httptest.NewRecorder()
gateway.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("expected 303, got %d: %s", rec.Code, rec.Body.String())
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if disconnectPayload["callerAuthId"] != "user-1" {
t.Fatalf("expected caller auth id to be forwarded, got %#v", disconnectPayload["callerAuthId"])
}
- location := rec.Header().Get("Location")
- if !strings.Contains(location, "notice=workspace-disconnected") {
- t.Fatalf("expected disconnect redirect notice, got %q", location)
+ var response map[string]any
+ if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if response["status"] != "disconnected" || response["teamId"] != "T_workspace_1" {
+ t.Fatalf("expected disconnected response, got %#v", response)
}
}
@@ -1636,13 +1732,17 @@ func TestWorkspaceTestSubmitDryRunSkipsSlackPostAndMarksPromptSynthetic(t *testi
DedupeTTL: time.Minute,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
- form := url.Values{}
- form.Set("teamId", "T_workspace_1")
- form.Set("channelId", "C_workspace_1")
- form.Set("prompt", "synthetic smoke")
- form.Set("mode", "dry-run")
- req := httptest.NewRequest(http.MethodPost, "/slack/workspaces/test", strings.NewReader(form.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ requestBody, err := json.Marshal(map[string]any{
+ "teamId": "T_workspace_1",
+ "channelId": "C_workspace_1",
+ "prompt": "synthetic smoke",
+ "mode": "dry-run",
+ })
+ if err != nil {
+ t.Fatalf("marshal request body: %v", err)
+ }
+ req := httptest.NewRequest(http.MethodPost, "/api/slack/workspaces/test", bytes.NewReader(requestBody))
+ req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Spritz-User-Id", "user-1")
rec := httptest.NewRecorder()
gateway.routes().ServeHTTP(rec, req)
@@ -1653,8 +1753,12 @@ func TestWorkspaceTestSubmitDryRunSkipsSlackPostAndMarksPromptSynthetic(t *testi
if slackPostHits != 0 {
t.Fatalf("expected no slack posts during dry-run, got %d", slackPostHits)
}
- if !strings.Contains(rec.Body.String(), "Outcome: dry_run") {
- t.Fatalf("expected dry_run outcome, got %q", rec.Body.String())
+ var response map[string]any
+ if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if response["outcome"] != messageEventOutcomeDryRun {
+ t.Fatalf("expected dry_run outcome, got %#v", response)
}
params, ok := promptPayload["params"].(map[string]any)
if !ok {
@@ -1811,13 +1915,17 @@ func TestWorkspaceTestSubmitRealModePostsSlackReply(t *testing.T) {
DedupeTTL: time.Minute,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
- form := url.Values{}
- form.Set("teamId", "T_workspace_1")
- form.Set("channelId", "C_workspace_1")
- form.Set("prompt", "synthetic smoke")
- form.Set("mode", "real")
- req := httptest.NewRequest(http.MethodPost, "/slack/workspaces/test", strings.NewReader(form.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ requestBody, err := json.Marshal(map[string]any{
+ "teamId": "T_workspace_1",
+ "channelId": "C_workspace_1",
+ "prompt": "synthetic smoke",
+ "mode": "real",
+ })
+ if err != nil {
+ t.Fatalf("marshal request body: %v", err)
+ }
+ req := httptest.NewRequest(http.MethodPost, "/api/slack/workspaces/test", bytes.NewReader(requestBody))
+ req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Spritz-User-Id", "user-1")
rec := httptest.NewRecorder()
gateway.routes().ServeHTTP(rec, req)
@@ -1831,8 +1939,12 @@ func TestWorkspaceTestSubmitRealModePostsSlackReply(t *testing.T) {
if !strings.Contains(fmt.Sprint(slackPayload["text"]), "Hello from concierge") {
t.Fatalf("expected concierge reply to be posted, got %#v", slackPayload["text"])
}
- if !strings.Contains(rec.Body.String(), "Outcome: delivered") {
- t.Fatalf("expected delivered outcome, got %q", rec.Body.String())
+ var response map[string]any
+ if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if response["outcome"] != messageEventOutcomeDelivered {
+ t.Fatalf("expected delivered outcome, got %#v", response)
}
}
@@ -1884,29 +1996,30 @@ func TestInstallTargetSelectionUsesSelectedPresetInputs(t *testing.T) {
if err != nil {
t.Fatalf("generate pending install state failed: %v", err)
}
- encodedTarget, err := encodeInstallTargetSelection(map[string]any{"agentId": "ag_456"})
+ requestBody, err := json.Marshal(map[string]any{
+ "state": pendingState,
+ "requestId": "install-request-1",
+ "presetInputs": map[string]any{
+ "agentId": "ag_456",
+ },
+ })
if err != nil {
- t.Fatalf("encode target selection failed: %v", err)
+ t.Fatalf("marshal request body: %v", err)
}
-
- form := url.Values{}
- form.Set("state", pendingState)
- form.Set("target", encodedTarget)
- form.Set("requestId", "install-request-1")
- req := httptest.NewRequest(http.MethodPost, "/slack/install/select", strings.NewReader(form.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req := httptest.NewRequest(http.MethodPost, "/api/slack/install/selection", bytes.NewReader(requestBody))
+ req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
- gateway.handleInstallTargetSelection(rec, req)
+ gateway.routes().ServeHTTP(rec, req)
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("expected 303 after selection submit, got %d: %s", rec.Code, rec.Body.String())
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200 after selection submit, got %d: %s", rec.Code, rec.Body.String())
}
- redirectURL, err := url.Parse(rec.Header().Get("Location"))
- if err != nil {
- t.Fatalf("parse callback redirect: %v", err)
+ var response map[string]any
+ if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
+ t.Fatalf("decode response: %v", err)
}
- if redirectURL.Query().Get("code") != "installed" {
- t.Fatalf("expected installed code, got %q", redirectURL.Query().Get("code"))
+ if response["status"] != "installed" {
+ t.Fatalf("expected installed status, got %#v", response)
}
presetInputs, ok := upsertPayload["presetInputs"].(map[string]any)
if !ok {
diff --git a/integrations/slack-gateway/install_result.go b/integrations/slack-gateway/install_result.go
index dd9c64d..4a0a143 100644
--- a/integrations/slack-gateway/install_result.go
+++ b/integrations/slack-gateway/install_result.go
@@ -5,7 +5,6 @@ import (
"encoding/hex"
"encoding/json"
"errors"
- "html/template"
"net/http"
"net/url"
"strings"
@@ -60,14 +59,6 @@ type installResultDescriptor struct {
ActionHref string
}
-type installResultPageData struct {
- Title string
- Message string
- RequestID string
- ActionLabel string
- ActionHref string
-}
-
type backendInstallErrorPayload struct {
Status string `json:"status"`
Field string `json:"field,omitempty"`
@@ -83,87 +74,6 @@ func classifyInstallStateError(err error) installResultCode {
return installResultCodeStateInvalid
}
-var installResultPageTemplate = template.Must(template.New("install-result").Parse(`
-
-
-
-
- {{ .Title }}
-
-
-
-
- {{ .Title }}
- {{ .Message }}
- {{ if .ActionHref }}
- {{ .ActionLabel }}
- {{ end }}
- {{ if .RequestID }}
- Request ID: {{ .RequestID }}
- {{ end }}
-
-
-`))
-
func newInstallRequestID() string {
var bytes [8]byte
if _, err := rand.Read(bytes[:]); err == nil {
@@ -442,37 +352,6 @@ func classifyInstallUpsertError(err error) installResultCode {
}
func (g *slackGateway) handleInstallResult(w http.ResponseWriter, r *http.Request) {
- if !g.reactRoutesShareGatewayOrigin() {
- g.renderInstallResultPage(w, r)
- return
- }
target := url.URL{Path: reactSlackInstallResultPath(), RawQuery: r.URL.RawQuery}
g.redirectToReactRoute(w, r, target.String())
}
-
-func (g *slackGateway) renderInstallResultPage(w http.ResponseWriter, r *http.Request) {
- result := installResult{
- Status: installResultStatus(firstNonEmpty(r.URL.Query().Get("status"), string(installResultStatusError))),
- Code: normalizeInstallResultCode(firstNonEmpty(r.URL.Query().Get("code"), string(installResultCodeInternalError))),
- Operation: strings.TrimSpace(r.URL.Query().Get("operation")),
- Retryable: strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("retryable")), "true"),
- Provider: firstNonEmpty(r.URL.Query().Get("provider"), slackProvider),
- RequestID: strings.TrimSpace(r.URL.Query().Get("requestId")),
- TeamID: strings.TrimSpace(r.URL.Query().Get("teamId")),
- }
- descriptor := installResultDescriptorFor(result.Code, g.installRedirectPath())
- if result.Status == installResultStatusSuccess && result.Code == installResultCodeInternalError {
- descriptor = installResultDescriptorFor(installResultCodeInstalled, g.installRedirectPath())
- }
- page := installResultPageData{
- Title: descriptor.Title,
- Message: descriptor.Message,
- RequestID: result.RequestID,
- ActionLabel: descriptor.ActionLabel,
- ActionHref: descriptor.ActionHref,
- }
- w.Header().Set("Cache-Control", "no-store")
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.WriteHeader(http.StatusOK)
- _ = installResultPageTemplate.Execute(w, page)
-}
diff --git a/integrations/slack-gateway/install_target_picker.go b/integrations/slack-gateway/install_target_picker.go
deleted file mode 100644
index 101daee..0000000
--- a/integrations/slack-gateway/install_target_picker.go
+++ /dev/null
@@ -1,255 +0,0 @@
-package main
-
-import (
- "encoding/base64"
- "encoding/json"
- "fmt"
- "html/template"
- "net/http"
- "strings"
-)
-
-type installTargetPickerOption struct {
- Name string
- ImageURL string
- OwnerLabel string
- EncodedPresetInput string
-}
-
-type installTargetPickerHiddenField struct {
- Name string
- Value string
-}
-
-type installTargetPickerPageData struct {
- Title string
- Description string
- RequestID string
- FormAction string
- SubmitLabel string
- HiddenFields []installTargetPickerHiddenField
- Options []installTargetPickerOption
-}
-
-var installTargetPickerTemplate = template.Must(template.New("install-target-picker").Parse(`
-
-
-
-
- Choose an install target
-
-
-
-
- {{ .Title }}
- {{ .Description }}
-
- {{ if .RequestID }}
- Request ID: {{ .RequestID }}
- {{ end }}
-
-
-`))
-
-func encodeInstallTargetSelection(presetInputs map[string]any) (string, error) {
- if len(presetInputs) == 0 {
- return "", fmt.Errorf("preset inputs are required")
- }
- encoded, err := json.Marshal(presetInputs)
- if err != nil {
- return "", err
- }
- return base64.RawURLEncoding.EncodeToString(encoded), nil
-}
-
-func decodeInstallTargetSelection(raw string) (map[string]any, error) {
- decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw))
- if err != nil {
- return nil, err
- }
- var presetInputs map[string]any
- if err := json.Unmarshal(decoded, &presetInputs); err != nil {
- return nil, err
- }
- if len(presetInputs) == 0 {
- return nil, fmt.Errorf("preset inputs are required")
- }
- return presetInputs, nil
-}
-
-func (g *slackGateway) renderInstallTargetPickerPage(w http.ResponseWriter, data installTargetPickerPageData, targets []backendInstallTarget) {
- options := make([]installTargetPickerOption, 0, len(targets))
- for _, target := range targets {
- encodedPresetInput, err := encodeInstallTargetSelection(target.PresetInputs)
- if err != nil {
- http.Error(w, "install target is invalid", http.StatusInternalServerError)
- return
- }
- options = append(options, installTargetPickerOption{
- Name: strings.TrimSpace(target.Profile.Name),
- ImageURL: strings.TrimSpace(target.Profile.ImageURL),
- OwnerLabel: strings.TrimSpace(target.OwnerLabel),
- EncodedPresetInput: encodedPresetInput,
- })
- }
- w.Header().Set("Cache-Control", "no-store")
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.WriteHeader(http.StatusOK)
- data.Options = options
- _ = installTargetPickerTemplate.Execute(w, data)
-}
-
-func (g *slackGateway) renderInstallTargetPicker(w http.ResponseWriter, stateToken, requestID string, targets []backendInstallTarget) {
- g.renderInstallTargetPickerPage(w, installTargetPickerPageData{
- Title: "Choose an install target",
- Description: "Select which target this Slack workspace should use.",
- RequestID: requestID,
- FormAction: g.selectInstallTargetPath(),
- SubmitLabel: "Continue",
- HiddenFields: []installTargetPickerHiddenField{
- {Name: "state", Value: stateToken},
- {Name: "requestId", Value: requestID},
- },
- }, targets)
-}
-
-func (g *slackGateway) renderWorkspaceTargetPicker(w http.ResponseWriter, teamID, requestID string, targets []backendInstallTarget) {
- g.renderInstallTargetPickerPage(w, installTargetPickerPageData{
- Title: "Change workspace target",
- Description: "Select which target this Slack workspace should use now.",
- RequestID: requestID,
- FormAction: g.workspaceTargetPath(),
- SubmitLabel: "Update target",
- HiddenFields: []installTargetPickerHiddenField{
- {Name: "teamId", Value: teamID},
- {Name: "requestId", Value: requestID},
- },
- }, targets)
-}
diff --git a/integrations/slack-gateway/management_api.go b/integrations/slack-gateway/management_api.go
index a779a09..26112fa 100644
--- a/integrations/slack-gateway/management_api.go
+++ b/integrations/slack-gateway/management_api.go
@@ -109,7 +109,7 @@ func (g *slackGateway) handleInstallTargetSelectionAPI(w http.ResponseWriter, r
func (g *slackGateway) handleInstallTargetSelectionAPIGet(w http.ResponseWriter, r *http.Request) {
requestID := strings.TrimSpace(r.URL.Query().Get("requestId"))
- state := g.pendingInstallStateFromRequest(r, requestID, "")
+ state := g.pendingInstallStateFromRequest(r, requestID, strings.TrimSpace(r.URL.Query().Get("state")))
pendingInstall, err := g.state.parsePendingInstall(state)
if err != nil {
g.clearPendingInstallCookie(w, r, requestID)
diff --git a/integrations/slack-gateway/react_routes.go b/integrations/slack-gateway/react_routes.go
index 4352a6d..49660a0 100644
--- a/integrations/slack-gateway/react_routes.go
+++ b/integrations/slack-gateway/react_routes.go
@@ -10,11 +10,16 @@ func reactSlackInstallResultPath() string {
return "/settings/slack/install/result"
}
-func reactSlackInstallSelectPath(requestID string) string {
+func reactSlackInstallSelectPath(requestID string, pendingState string) string {
target := url.URL{Path: "/settings/slack/install/select"}
+ query := target.Query()
if requestID := strings.TrimSpace(requestID); requestID != "" {
- query := target.Query()
query.Set("requestId", requestID)
+ }
+ if pendingState := strings.TrimSpace(pendingState); pendingState != "" {
+ query.Set("state", pendingState)
+ }
+ if len(query) > 0 {
target.RawQuery = query.Encode()
}
return target.String()
diff --git a/integrations/slack-gateway/slack_oauth.go b/integrations/slack-gateway/slack_oauth.go
index 8628259..9799906 100644
--- a/integrations/slack-gateway/slack_oauth.go
+++ b/integrations/slack-gateway/slack_oauth.go
@@ -215,12 +215,12 @@ func (g *slackGateway) handleOAuthCallback(w http.ResponseWriter, r *http.Reques
})
return
}
- if !g.reactRoutesShareGatewayOrigin() {
- g.renderInstallTargetPicker(w, pendingState, requestID, targets)
+ if g.reactRoutesShareGatewayOrigin() {
+ g.setPendingInstallCookie(w, r, requestID, pendingState)
+ g.redirectToReactRoute(w, r, reactSlackInstallSelectPath(requestID, ""))
return
}
- g.setPendingInstallCookie(w, r, requestID, pendingState)
- g.redirectToReactRoute(w, r, reactSlackInstallSelectPath(requestID))
+ g.redirectToReactRoute(w, r, reactSlackInstallSelectPath(requestID, pendingState))
return
}
if err := g.upsertInstallation(r.Context(), &installation, requestID, targets[0].PresetInputs); err != nil {
@@ -268,10 +268,6 @@ func (g *slackGateway) oauthCallbackURL() string {
return g.cfg.PublicURL + "/slack/oauth/callback"
}
-func (g *slackGateway) selectInstallTargetPath() string {
- return g.publicPathPrefix() + "/slack/install/select"
-}
-
func (g *slackGateway) exchangeSlackOAuthCode(ctx context.Context, code string) (slackInstallation, error) {
form := url.Values{}
form.Set("client_id", g.cfg.SlackClientID)
@@ -316,72 +312,15 @@ func (g *slackGateway) exchangeSlackOAuthCode(ctx context.Context, code string)
}
func (g *slackGateway) handleInstallTargetSelection(w http.ResponseWriter, r *http.Request) {
- if err := r.ParseForm(); err != nil {
- g.redirectToInstallResult(w, r, installResult{
- Status: installResultStatusError,
- Code: installResultCodeTargetInvalid,
- Operation: installResultOperationChannelInstall,
- Provider: slackProvider,
- })
- return
- }
-
- pendingInstall, err := g.state.parsePendingInstall(strings.TrimSpace(r.FormValue("state")))
- if err != nil {
- g.redirectToInstallResult(w, r, installResult{
- Status: installResultStatusError,
- Code: classifyInstallStateError(err),
- Operation: installResultOperationChannelInstall,
- Provider: slackProvider,
- })
+ if r.Method == http.MethodGet {
+ g.redirectToReactRoute(w, r, reactSlackInstallSelectPath(r.URL.Query().Get("requestId"), r.URL.Query().Get("state")))
return
}
-
- selectedPresetInputs, err := decodeInstallTargetSelection(strings.TrimSpace(r.FormValue("target")))
- if err != nil {
- g.redirectToInstallResult(w, r, installResult{
- Status: installResultStatusError,
- Code: installResultCodeTargetInvalid,
- Operation: installResultOperationChannelInstall,
- Provider: slackProvider,
- RequestID: pendingInstall.RequestID,
- TeamID: pendingInstall.Installation.TeamID,
- })
- return
- }
-
- requestID := firstNonEmpty(strings.TrimSpace(r.FormValue("requestId")), pendingInstall.RequestID)
- installation := pendingInstall.Installation
- if err := g.upsertInstallation(r.Context(), &installation, requestID, selectedPresetInputs); err != nil {
- g.logger.ErrorContext(
- r.Context(),
- "slack install target selection upsert failed",
- "err",
- err,
- "team_id",
- installation.TeamID,
- "request_id",
- requestID,
- )
- g.redirectToInstallResult(w, r, installResult{
- Status: installResultStatusError,
- Code: classifyInstallUpsertError(err),
- Operation: installResultOperationChannelInstall,
- Provider: slackProvider,
- RequestID: requestID,
- TeamID: installation.TeamID,
- })
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
-
- g.redirectToInstallResult(w, r, installResult{
- Status: installResultStatusSuccess,
- Code: installResultCodeInstalled,
- Operation: installResultOperationChannelInstall,
- Provider: slackProvider,
- RequestID: requestID,
- TeamID: installation.TeamID,
- })
+ http.Error(w, "legacy install selection form removed; use /api/slack/install/selection", http.StatusGone)
}
func (g *slackGateway) upsertInstallation(ctx context.Context, installation *slackInstallation, requestID string, presetInputs map[string]any) error {
diff --git a/integrations/slack-gateway/workspace_handlers.go b/integrations/slack-gateway/workspace_handlers.go
index 41cc6ab..0b15b4f 100644
--- a/integrations/slack-gateway/workspace_handlers.go
+++ b/integrations/slack-gateway/workspace_handlers.go
@@ -1,15 +1,8 @@
package main
-import (
- "net/http"
- "strings"
-)
+import "net/http"
func (g *slackGateway) handleWorkspaceManagement(w http.ResponseWriter, r *http.Request) {
- if !g.reactRoutesShareGatewayOrigin() {
- g.renderLegacyWorkspaceManagement(w, r)
- return
- }
principal, ok := requireBrowserPrincipal(g.cfg, w, r)
if !ok {
return
@@ -18,27 +11,6 @@ func (g *slackGateway) handleWorkspaceManagement(w http.ResponseWriter, r *http.
_ = principal
}
-func (g *slackGateway) renderLegacyWorkspaceManagement(w http.ResponseWriter, r *http.Request) {
- principal, ok := requireBrowserPrincipal(g.cfg, w, r)
- if !ok {
- return
- }
- installations, err := g.listManagedInstallations(r.Context(), principal.ID)
- if err != nil {
- g.logger.ErrorContext(
- r.Context(),
- "workspace management list failed",
- "err",
- err,
- "caller_auth_id",
- principal.ID,
- )
- http.Error(w, "workspace list unavailable", http.StatusBadGateway)
- return
- }
- g.renderWorkspaceManagementPage(w, r, installations)
-}
-
func (g *slackGateway) handleWorkspaceTarget(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@@ -51,10 +23,6 @@ func (g *slackGateway) handleWorkspaceTarget(w http.ResponseWriter, r *http.Requ
}
func (g *slackGateway) handleWorkspaceTargetPicker(w http.ResponseWriter, r *http.Request) {
- if !g.reactRoutesShareGatewayOrigin() {
- g.renderLegacyWorkspaceTargetPicker(w, r)
- return
- }
principal, ok := requireBrowserPrincipal(g.cfg, w, r)
if !ok {
return
@@ -63,113 +31,8 @@ func (g *slackGateway) handleWorkspaceTargetPicker(w http.ResponseWriter, r *htt
_ = principal
}
-func (g *slackGateway) renderLegacyWorkspaceTargetPicker(w http.ResponseWriter, r *http.Request) {
- principal, ok := requireBrowserPrincipal(g.cfg, w, r)
- if !ok {
- return
- }
- teamID := strings.TrimSpace(r.URL.Query().Get("teamId"))
- if teamID == "" {
- http.Error(w, "teamId is required", http.StatusBadRequest)
- return
- }
- requestID := newInstallRequestID()
- targets, err := g.listInstallTargetsForOwnerAuthID(
- r.Context(),
- teamID,
- principal.ID,
- requestID,
- nil,
- )
- if err != nil {
- g.logger.ErrorContext(
- r.Context(),
- "workspace target picker lookup failed",
- "err",
- err,
- "caller_auth_id",
- principal.ID,
- "team_id",
- teamID,
- "request_id",
- requestID,
- )
- http.Redirect(
- w,
- r,
- g.buildWorkspaceNoticeURL("target-update-failed", teamID),
- http.StatusSeeOther,
- )
- return
- }
- if len(targets) == 0 {
- http.Redirect(
- w,
- r,
- g.buildWorkspaceNoticeURL("target-update-failed", teamID),
- http.StatusSeeOther,
- )
- return
- }
- g.renderWorkspaceTargetPicker(w, teamID, requestID, targets)
-}
-
func (g *slackGateway) handleWorkspaceTargetUpdate(w http.ResponseWriter, r *http.Request) {
- principal, ok := requireBrowserPrincipal(g.cfg, w, r)
- if !ok {
- return
- }
- if err := r.ParseForm(); err != nil {
- http.Error(w, "invalid form payload", http.StatusBadRequest)
- return
- }
- teamID := strings.TrimSpace(r.FormValue("teamId"))
- if teamID == "" {
- http.Error(w, "teamId is required", http.StatusBadRequest)
- return
- }
- presetInputs, err := decodeInstallTargetSelection(r.FormValue("target"))
- if err != nil {
- http.Error(w, "install target is invalid", http.StatusBadRequest)
- return
- }
- requestID := strings.TrimSpace(r.FormValue("requestId"))
- if requestID == "" {
- requestID = newInstallRequestID()
- }
- if err := g.updateManagedInstallationTarget(
- r.Context(),
- principal.ID,
- teamID,
- requestID,
- presetInputs,
- ); err != nil {
- g.logger.ErrorContext(
- r.Context(),
- "workspace target update failed",
- "err",
- err,
- "caller_auth_id",
- principal.ID,
- "team_id",
- teamID,
- "request_id",
- requestID,
- )
- http.Redirect(
- w,
- r,
- g.buildWorkspaceNoticeURL("target-update-failed", teamID),
- http.StatusSeeOther,
- )
- return
- }
- http.Redirect(
- w,
- r,
- g.buildWorkspaceNoticeURL("target-updated", teamID),
- http.StatusSeeOther,
- )
+ http.Error(w, "legacy workspace target form removed; use /api/slack/workspaces/target", http.StatusGone)
}
func (g *slackGateway) handleWorkspaceDisconnect(w http.ResponseWriter, r *http.Request) {
@@ -177,42 +40,5 @@ func (g *slackGateway) handleWorkspaceDisconnect(w http.ResponseWriter, r *http.
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- principal, ok := requireBrowserPrincipal(g.cfg, w, r)
- if !ok {
- return
- }
- if err := r.ParseForm(); err != nil {
- http.Error(w, "invalid form payload", http.StatusBadRequest)
- return
- }
- teamID := strings.TrimSpace(r.FormValue("teamId"))
- if teamID == "" {
- http.Error(w, "teamId is required", http.StatusBadRequest)
- return
- }
- if err := g.disconnectManagedInstallation(r.Context(), principal.ID, teamID); err != nil {
- g.logger.ErrorContext(
- r.Context(),
- "workspace disconnect failed",
- "err",
- err,
- "caller_auth_id",
- principal.ID,
- "team_id",
- teamID,
- )
- http.Redirect(
- w,
- r,
- g.buildWorkspaceNoticeURL("workspace-disconnect-failed", teamID),
- http.StatusSeeOther,
- )
- return
- }
- http.Redirect(
- w,
- r,
- g.buildWorkspaceNoticeURL("workspace-disconnected", teamID),
- http.StatusSeeOther,
- )
+ http.Error(w, "legacy workspace disconnect form removed; use /api/slack/workspaces/disconnect", http.StatusGone)
}
diff --git a/integrations/slack-gateway/workspace_management.go b/integrations/slack-gateway/workspace_management.go
deleted file mode 100644
index 6b4de4a..0000000
--- a/integrations/slack-gateway/workspace_management.go
+++ /dev/null
@@ -1,323 +0,0 @@
-package main
-
-import (
- "html/template"
- "net/http"
- "net/url"
- "strings"
-)
-
-type workspaceManagementNotice struct {
- Title string
- Message string
-}
-
-type workspaceManagementRow struct {
- TeamID string
- State string
- CurrentTargetName string
- CurrentTargetOwner string
- TargetStatus string
- ChangeTargetHref string
- ChannelSettingsHref string
- TestHref string
- ReconnectHref string
- ShowReconnect bool
- ShowDisconnect bool
- ShowTest bool
-}
-
-type workspaceManagementPageData struct {
- Notice *workspaceManagementNotice
- Rows []workspaceManagementRow
- DisconnectAction string
-}
-
-var workspaceManagementTemplate = template.Must(template.New("workspace-management").Parse(`
-
-
-
-
- Slack workspaces
-
-
-
-
-
- Slack workspaces
- Manage which target each connected Slack workspace uses.
-
- {{ if .Notice }}
-
- {{ .Notice.Title }}
- {{ .Notice.Message }}
-
- {{ end }}
- {{ if .Rows }}
- {{ range .Rows }}
-
-
-
-
{{ .TeamID }}
-
{{ .CurrentTargetName }}
- {{ if .CurrentTargetOwner }}
-
{{ .CurrentTargetOwner }}
- {{ end }}
- {{ if .TargetStatus }}
-
{{ .TargetStatus }}
- {{ end }}
-
-
{{ .State }}
-
-
-
- {{ end }}
- {{ else }}
-
- No manageable Slack workspaces are connected for this account yet.
-
- {{ end }}
-
-
-`))
-
-func (g *slackGateway) workspacesPath() string {
- return g.publicPathPrefix() + "/slack/workspaces"
-}
-
-func (g *slackGateway) workspaceTargetPath() string {
- return g.publicPathPrefix() + "/slack/workspaces/target"
-}
-
-func (g *slackGateway) workspaceDisconnectPath() string {
- return g.publicPathPrefix() + "/slack/workspaces/disconnect"
-}
-
-func (g *slackGateway) buildWorkspaceTestHref(teamID string) string {
- target := url.URL{Path: g.workspaceTestPath()}
- query := target.Query()
- query.Set("teamId", strings.TrimSpace(teamID))
- target.RawQuery = query.Encode()
- return target.String()
-}
-
-func workspaceNoticeFromRequest(r *http.Request) *workspaceManagementNotice {
- if r == nil {
- return nil
- }
- teamID := strings.TrimSpace(r.URL.Query().Get("teamId"))
- switch strings.TrimSpace(r.URL.Query().Get("notice")) {
- case "target-updated":
- return &workspaceManagementNotice{
- Title: "Workspace target updated",
- Message: "The selected target was updated for " + teamID + ".",
- }
- case "workspace-disconnected":
- return &workspaceManagementNotice{
- Title: "Workspace disconnected",
- Message: "Routing has been disabled for " + teamID + ".",
- }
- case "target-update-failed":
- return &workspaceManagementNotice{
- Title: "Workspace target update failed",
- Message: "The selected target could not be updated right now. Try again shortly.",
- }
- case "workspace-disconnect-failed":
- return &workspaceManagementNotice{
- Title: "Workspace disconnect failed",
- Message: "The workspace could not be disconnected right now. Try again shortly.",
- }
- default:
- return nil
- }
-}
-
-func workspaceTargetStatus(installation backendManagedInstallation) string {
- if installation.ProblemCode == "install.target.invalid" {
- return "Repair needed: the saved target is no longer valid."
- }
- if installation.CurrentTarget == nil {
- return "Legacy install: no explicit saved target is recorded yet."
- }
- return ""
-}
-
-func hasAllowedAction(installation backendManagedInstallation, action string) bool {
- for _, candidate := range installation.AllowedActions {
- if strings.EqualFold(strings.TrimSpace(candidate), action) {
- return true
- }
- }
- return false
-}
-
-func (g *slackGateway) buildWorkspaceTargetHref(teamID string) string {
- target := url.URL{Path: g.workspaceTargetPath()}
- query := target.Query()
- query.Set("teamId", strings.TrimSpace(teamID))
- target.RawQuery = query.Encode()
- return target.String()
-}
-
-func (g *slackGateway) buildWorkspaceNoticeURL(notice, teamID string) string {
- target := url.URL{Path: g.workspacesPath()}
- query := target.Query()
- query.Set("notice", strings.TrimSpace(notice))
- if strings.TrimSpace(teamID) != "" {
- query.Set("teamId", strings.TrimSpace(teamID))
- }
- target.RawQuery = query.Encode()
- return target.String()
-}
-
-func (g *slackGateway) renderWorkspaceManagementPage(w http.ResponseWriter, r *http.Request, installations []backendManagedInstallation) {
- rows := make([]workspaceManagementRow, 0, len(installations))
- for _, installation := range installations {
- currentTargetName := "No saved target"
- currentTargetOwner := ""
- if installation.CurrentTarget != nil {
- currentTargetName = strings.TrimSpace(installation.CurrentTarget.Profile.Name)
- currentTargetOwner = strings.TrimSpace(installation.CurrentTarget.OwnerLabel)
- }
- settingsHref := ""
- connection := primaryManagedConnection(installation)
- if strings.TrimSpace(installation.ID) != "" && strings.TrimSpace(connection.ID) != "" {
- settingsHref = g.channelSettingsConnectionPath(installation.ID, connection.ID)
- }
- rows = append(rows, workspaceManagementRow{
- TeamID: strings.TrimSpace(installation.Route.ExternalTenantID),
- State: strings.TrimSpace(installation.State),
- CurrentTargetName: currentTargetName,
- CurrentTargetOwner: currentTargetOwner,
- TargetStatus: workspaceTargetStatus(installation),
- ChangeTargetHref: g.buildWorkspaceTargetHref(installation.Route.ExternalTenantID),
- ChannelSettingsHref: settingsHref,
- TestHref: g.buildWorkspaceTestHref(installation.Route.ExternalTenantID),
- ReconnectHref: g.installRedirectPath(),
- ShowReconnect: hasAllowedAction(installation, "reconnect"),
- ShowDisconnect: hasAllowedAction(installation, "disconnect"),
- ShowTest: !strings.EqualFold(strings.TrimSpace(installation.State), "disconnected"),
- })
- }
-
- w.Header().Set("Cache-Control", "no-store")
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.WriteHeader(http.StatusOK)
- _ = workspaceManagementTemplate.Execute(w, workspaceManagementPageData{
- Notice: workspaceNoticeFromRequest(r),
- Rows: rows,
- DisconnectAction: g.workspaceDisconnectPath(),
- })
-}
diff --git a/integrations/slack-gateway/workspace_test_messages.go b/integrations/slack-gateway/workspace_test_messages.go
index f5333b0..96f9f7a 100644
--- a/integrations/slack-gateway/workspace_test_messages.go
+++ b/integrations/slack-gateway/workspace_test_messages.go
@@ -2,7 +2,6 @@ package main
import (
"fmt"
- "html/template"
"net/http"
"strings"
"time"
@@ -10,203 +9,6 @@ import (
const syntheticSlackActorUserID = "U_SYNTHETIC"
-type workspaceTestPageData struct {
- TeamID string
- ChannelID string
- ThreadTS string
- Prompt string
- Mode string
- CurrentTarget string
- Outcome string
- Reply string
- ConversationID string
- PostedMessageTS string
- ErrorMessage string
-}
-
-var workspaceTestTemplate = template.Must(template.New("workspace-test").Parse(`
-
-
-
-
- Slack workspace test
-
-
-
-
-
- Send a synthetic Slack test message
- Workspace {{ .TeamID }}{{ if .CurrentTarget }} ยท target {{ .CurrentTarget }}{{ end }}
-
-
- {{ if .Outcome }}
-
- Test completed
- Outcome: {{ .Outcome }}
- {{ if .ConversationID }}Conversation: {{ .ConversationID }}
{{ end }}
- {{ if .PostedMessageTS }}Posted Slack TS: {{ .PostedMessageTS }}
{{ end }}
- {{ if .Reply }}Reply:
{{ .Reply }}
{{ end }}
-
- {{ end }}
-
- {{ if .ErrorMessage }}
-
- Test failed
- {{ .ErrorMessage }}
-
- {{ end }}
-
-
-
-
-`))
-
-type workspaceSyntheticTestPageData struct {
- workspaceTestPageData
- BackHref string
-}
-
-func (g *slackGateway) workspaceTestPath() string {
- return g.publicPathPrefix() + "/slack/workspaces/test"
-}
-
-func defaultWorkspaceTestPrompt() string {
- return fmt.Sprintf("spritz-slack-smoke-%d", time.Now().UTC().Unix())
-}
-
func inferSyntheticSlackEvent(channelID, text, threadTS string) (slackEnvelope, error) {
normalizedChannelID := strings.TrimSpace(channelID)
if normalizedChannelID == "" {
@@ -262,35 +64,18 @@ func (g *slackGateway) lookupManagedWorkspace(
return nil, nil
}
-func (g *slackGateway) renderWorkspaceTestPage(
- w http.ResponseWriter,
- data workspaceTestPageData,
-) {
- w.Header().Set("Cache-Control", "no-store")
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.WriteHeader(http.StatusOK)
- _ = workspaceTestTemplate.Execute(w, workspaceSyntheticTestPageData{
- workspaceTestPageData: data,
- BackHref: g.workspacesPath(),
- })
-}
-
func (g *slackGateway) handleWorkspaceTest(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
g.handleWorkspaceTestForm(w, r)
case http.MethodPost:
- g.handleWorkspaceTestSubmit(w, r)
+ http.Error(w, "legacy workspace test form removed; use /api/slack/workspaces/test", http.StatusGone)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (g *slackGateway) handleWorkspaceTestForm(w http.ResponseWriter, r *http.Request) {
- if !g.reactRoutesShareGatewayOrigin() {
- g.renderLegacyWorkspaceTestForm(w, r)
- return
- }
principal, ok := requireBrowserPrincipal(g.cfg, w, r)
if !ok {
return
@@ -298,129 +83,3 @@ func (g *slackGateway) handleWorkspaceTestForm(w http.ResponseWriter, r *http.Re
g.redirectToReactRoute(w, r, reactSlackWorkspaceTestPath(r.URL.Query()))
_ = principal
}
-
-func (g *slackGateway) renderLegacyWorkspaceTestForm(w http.ResponseWriter, r *http.Request) {
- principal, ok := requireBrowserPrincipal(g.cfg, w, r)
- if !ok {
- return
- }
- teamID := strings.TrimSpace(r.URL.Query().Get("teamId"))
- if teamID == "" {
- http.Error(w, "teamId is required", http.StatusBadRequest)
- return
- }
- installation, err := g.lookupManagedWorkspace(r, principal.ID, teamID)
- if err != nil {
- g.logger.ErrorContext(
- r.Context(),
- "workspace test lookup failed",
- "err", err,
- "caller_auth_id", principal.ID,
- "team_id", teamID,
- )
- http.Error(w, "workspace lookup unavailable", http.StatusBadGateway)
- return
- }
- if installation == nil {
- http.Error(w, "workspace not manageable", http.StatusForbidden)
- return
- }
- currentTarget := ""
- if installation.CurrentTarget != nil {
- currentTarget = strings.TrimSpace(installation.CurrentTarget.Profile.Name)
- }
- g.renderWorkspaceTestPage(w, workspaceTestPageData{
- TeamID: teamID,
- Prompt: defaultWorkspaceTestPrompt(),
- Mode: "real",
- CurrentTarget: currentTarget,
- })
-}
-
-func (g *slackGateway) handleWorkspaceTestSubmit(w http.ResponseWriter, r *http.Request) {
- principal, ok := requireBrowserPrincipal(g.cfg, w, r)
- if !ok {
- return
- }
- if err := r.ParseForm(); err != nil {
- http.Error(w, "invalid form payload", http.StatusBadRequest)
- return
- }
- teamID := strings.TrimSpace(r.FormValue("teamId"))
- if teamID == "" {
- http.Error(w, "teamId is required", http.StatusBadRequest)
- return
- }
- installation, err := g.lookupManagedWorkspace(r, principal.ID, teamID)
- if err != nil {
- g.logger.ErrorContext(
- r.Context(),
- "workspace test lookup failed",
- "err", err,
- "caller_auth_id", principal.ID,
- "team_id", teamID,
- )
- http.Error(w, "workspace lookup unavailable", http.StatusBadGateway)
- return
- }
- if installation == nil {
- http.Error(w, "workspace not manageable", http.StatusForbidden)
- return
- }
- channelID := strings.TrimSpace(r.FormValue("channelId"))
- threadTS := strings.TrimSpace(r.FormValue("threadTs"))
- prompt := strings.TrimSpace(r.FormValue("prompt"))
- mode := strings.TrimSpace(r.FormValue("mode"))
- if mode == "" {
- mode = "real"
- }
- pageData := workspaceTestPageData{
- TeamID: teamID,
- ChannelID: channelID,
- ThreadTS: threadTS,
- Prompt: prompt,
- Mode: mode,
- CurrentTarget: "",
- }
- if installation.CurrentTarget != nil {
- pageData.CurrentTarget = strings.TrimSpace(installation.CurrentTarget.Profile.Name)
- }
- envelope, err := inferSyntheticSlackEvent(channelID, prompt, threadTS)
- if err != nil {
- pageData.ErrorMessage = err.Error()
- g.renderWorkspaceTestPage(w, pageData)
- return
- }
- envelope.TeamID = teamID
-
- delivery, process, err := g.beginMessageEventDelivery(envelope)
- if err != nil {
- pageData.ErrorMessage = err.Error()
- g.renderWorkspaceTestPage(w, pageData)
- return
- }
- if !process {
- pageData.Outcome = messageEventOutcomeIgnored
- g.renderWorkspaceTestPage(w, pageData)
- return
- }
- result, err := g.processMessageEventWithDeliveryOptions(
- r.Context(),
- envelope,
- delivery,
- messageEventProcessOptions{
- Synthetic: true,
- DryRun: mode == "dry-run",
- },
- )
- if err != nil {
- pageData.ErrorMessage = err.Error()
- g.renderWorkspaceTestPage(w, pageData)
- return
- }
- pageData.Outcome = result.Outcome
- pageData.Reply = result.Reply
- pageData.ConversationID = result.ConversationID
- pageData.PostedMessageTS = result.PostedMessageTS
- g.renderWorkspaceTestPage(w, pageData)
-}
diff --git a/ui/src/pages/chat.test.tsx b/ui/src/pages/chat.test.tsx
index a1080cc..b655cbf 100644
--- a/ui/src/pages/chat.test.tsx
+++ b/ui/src/pages/chat.test.tsx
@@ -509,11 +509,11 @@ describe('ChatPage draft persistence', () => {
});
});
- it('routes the settings entrypoint through the Slack gateway', async () => {
+ it('routes the settings entrypoint through the React settings page', async () => {
await renderChat('/c/covo/conv-1');
const settingsLink = screen.getByLabelText('Open settings') as HTMLAnchorElement;
- expect(settingsLink.getAttribute('href')).toBe('/slack-gateway/slack/workspaces');
+ expect(settingsLink.getAttribute('href')).toBe('/settings/slack/workspaces');
});
it('retries ACP connect-ticket failures automatically', async () => {
diff --git a/ui/src/pages/chat.tsx b/ui/src/pages/chat.tsx
index 8a8a096..0b40f26 100644
--- a/ui/src/pages/chat.tsx
+++ b/ui/src/pages/chat.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
-import { useParams, useNavigate } from 'react-router-dom';
+import { Link, useParams, useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { MenuIcon, RotateCwIcon, ExternalLinkIcon, SettingsIcon } from 'lucide-react';
import { request } from '@/lib/api';
@@ -18,7 +18,6 @@ import {
getConversationAgentName,
} from '@/lib/spritz-profile';
import { chatConversationPath } from '@/lib/urls';
-import { slackGatewayPath } from '@/lib/slack-management';
import { AgentAvatar } from '@/components/agent-avatar';
import { useNotice } from '@/components/notice-banner';
import { Sidebar } from '@/components/acp/sidebar';
@@ -574,8 +573,8 @@ export function ChatPage() {
diff --git a/ui/src/pages/settings.test.tsx b/ui/src/pages/settings.test.tsx
index cdfcfa7..671859c 100644
--- a/ui/src/pages/settings.test.tsx
+++ b/ui/src/pages/settings.test.tsx
@@ -578,7 +578,7 @@ describe('SettingsPage', () => {
actionHref: '/slack-gateway/slack/install',
};
requestMock.mockImplementation((path: string, options?: RequestInit) => {
- if (path === '/api/slack/install/selection?requestId=install-request-1' && options?.method !== 'POST') {
+ if (path === '/api/slack/install/selection?requestId=install-request-1&state=pending-state-1' && options?.method !== 'POST') {
return Promise.resolve({
status: 'resolved',
requestId: 'install-request-1',
@@ -602,7 +602,7 @@ describe('SettingsPage', () => {
});
render(
-
+
} />
@@ -615,6 +615,14 @@ describe('SettingsPage', () => {
expect(await screen.findByText('Install could not be linked')).toBeTruthy();
expect(await screen.findByText('identity.unresolved')).toBeTruthy();
expect(await screen.findByText('Request ID: install-request-1')).toBeTruthy();
+ const postCall = requestMock.mock.calls.find(
+ ([path, options]) => path === '/api/slack/install/selection' && (options as RequestInit | undefined)?.method === 'POST',
+ );
+ expect(JSON.parse(String((postCall?.[1] as RequestInit).body))).toMatchObject({
+ requestId: 'install-request-1',
+ state: 'pending-state-1',
+ presetInputs: { agentId: 'ag_workspace' },
+ });
});
it('routes typed install picker load failures to the install result page', async () => {
diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx
index 4501a5b..de701fd 100644
--- a/ui/src/pages/settings.tsx
+++ b/ui/src/pages/settings.tsx
@@ -873,9 +873,14 @@ function InstallSelectPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const requestId = (params.get('requestId') || '').trim();
- const selectionPath = requestId
- ? `/api/slack/install/selection?requestId=${encodeURIComponent(requestId)}`
- : '/api/slack/install/selection';
+ const state = (params.get('state') || '').trim();
+ const selectionPath = useMemo(() => {
+ const query = new URLSearchParams();
+ if (requestId) query.set('requestId', requestId);
+ if (state) query.set('state', state);
+ const encoded = query.toString();
+ return encoded ? `/api/slack/install/selection?${encoded}` : '/api/slack/install/selection';
+ }, [requestId, state]);
const { value, error, loading } = useAsyncValue(
() => slackGatewayRequest(selectionPath),
[selectionPath],
@@ -910,6 +915,7 @@ function InstallSelectPage() {
method: 'POST',
body: JSON.stringify({
requestId: selection.requestId,
+ state: state || undefined,
presetInputs: selectedTarget.presetInputs,
}),
});