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 - - - -
-
-

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 }}
-
-
-
- - - {{ if .RequireMention }} - - - {{ else }} - - - {{ end }} -
-
- - - -
-
-
- {{ 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 }}

-
- {{ range .HiddenFields }} - - {{ end }} - {{ range $index, $option := .Options }} - - {{ end }} - -
- {{ 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 }}
-
-
- Change target - {{ if .ChannelSettingsHref }} - Channel settings - {{ end }} - {{ if .ShowTest }} - Send test - {{ end }} - {{ if .ShowReconnect }} - Reconnect - {{ end }} - {{ if .ShowDisconnect }} -
- - -
- {{ end }} -
-
- {{ 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 }} - -
-
- - - - -
- - -
-
- - Back to workspaces -
-
-
-
- -`)) - -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, }), });