From 87db37d5c504904512c81502d92d61129a1812f2 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Sat, 25 Apr 2026 07:46:46 +0300 Subject: [PATCH 01/26] docs: plan React migration for management UI --- AGENTS.md | 8 + ...4-channel-install-message-policy-config.md | 9 +- ...6-04-24-channel-installation-data-model.md | 75 +++-- ...4-25-react-management-ui-migration-plan.md | 287 ++++++++++++++++++ 4 files changed, 354 insertions(+), 25 deletions(-) create mode 100644 docs/2026-04-25-react-management-ui-migration-plan.md diff --git a/AGENTS.md b/AGENTS.md index 6120c02..8708634 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,14 @@ This repository is open-source and must remain provider- and organization-agnost - Never use TextCortex hostnames in test fixtures. - New or changed data structures, schemas, DTOs, CRDs, and API models must use `type` as the field name, never `kind`. If an external payload already sends `kind`, convert it at the boundary with an alias and keep the internal field named `type`. +## UI Ownership Rules (Mandatory) + +- Add user-facing UI inside the Spritz React app under `ui/`. +- Do not add new server-rendered HTML pages in gateway, API, operator, or integration services. +- Integration and gateway services should expose redirects, callbacks, event ingestion, and JSON APIs for the React UI to consume. +- If a temporary server-rendered page is unavoidable for an external protocol callback, document why it cannot be served by the React app and keep it minimal. +- When touching existing server-rendered integration pages, prefer migrating the surface to React over extending Go templates. + Exception: - Documentation `author` front matter may use a real maintainer identity, including a real email address, when the document is intentionally attributed to that maintainer. diff --git a/docs/2026-04-24-channel-install-message-policy-config.md b/docs/2026-04-24-channel-install-message-policy-config.md index c852a82..c5173d9 100644 --- a/docs/2026-04-24-channel-install-message-policy-config.md +++ b/docs/2026-04-24-channel-install-message-policy-config.md @@ -150,11 +150,16 @@ Recommended operation: - `channel.installation.config.update` +`installationId` is a deployment-backend-assigned opaque ID. Spritz should +receive it from the installation-management API, render it in UI URLs when +needed, and send it back unchanged. Spritz must not generate it, parse it, or +assume any specific prefix or ID-generation scheme. + Recommended request shape: ```json { - "installationId": "inst_123", + "installationId": "opaque-installation-id", "installationConfig": { "channelPolicies": [ { @@ -171,7 +176,7 @@ Recommended response shape: ```json { "installation": { - "id": "inst_123", + "id": "opaque-installation-id", "provider": "slack", "externalScopeType": "workspace", "externalTenantId": "T1234567890", diff --git a/docs/2026-04-24-channel-installation-data-model.md b/docs/2026-04-24-channel-installation-data-model.md index 3a90154..3b07b00 100644 --- a/docs/2026-04-24-channel-installation-data-model.md +++ b/docs/2026-04-24-channel-installation-data-model.md @@ -104,7 +104,7 @@ Spritz should define the generic channel-installation contract. That includes: - provider route identity fields -- stable installation, connection, and route IDs +- stable opaque installation, connection, and route IDs - management API shapes - UI routing and rendering expectations - gateway behavior for routing and mention policy @@ -121,6 +121,33 @@ The entity names in this document are canonical logical names. The SQL below is an illustrative relational implementation shape, not a requirement that Spritz core ships or runs these migrations. +## ID Ownership And Opaqueness + +The deployment-owned backend assigns channel-installation IDs. + +Spritz must treat these IDs as opaque strings. Spritz should list them, render +them into URLs, and send the same values back on management requests. Spritz +must not generate them, parse them, derive meaning from prefixes, or assume +that they encode database rows, Slack workspace IDs, Slack channel IDs, agent +IDs, users, organizations, or runtime instances. + +The required ID properties are: + +- stable for the lifetime of the logical record +- unique within the deployment-owned backend +- safe to use in URLs and logs +- non-sequential from Spritz's perspective +- not derived from external provider IDs or internal database row numbers + +The Spritz contract intentionally does not require TypeID, UUID, ULID, or any +specific prefix format. A deployment may use typed IDs internally, but that is +an implementation detail of the backend that owns the storage and uniqueness +constraints. + +Examples in this document use placeholder IDs such as +`opaque-installation-id`, `opaque-connection-id`, and `opaque-route-id`. They +are placeholders, not required literal formats. + ## Naming Decisions ### `provider` @@ -220,7 +247,7 @@ A relational implementation might use this shape: ```sql CREATE TABLE channel_installation ( - id VARCHAR(32) PRIMARY KEY, + id VARCHAR(64) PRIMARY KEY, provider VARCHAR(32) NOT NULL, app_principal_id VARCHAR(128) NOT NULL, external_installation_key VARCHAR(256) NOT NULL, @@ -250,7 +277,7 @@ Column justifications: | Column | Decision | Justification | |---|---|---| -| `id` | keep | Stable internal ID for APIs and UI. External provider IDs should not be the primary product URL. | +| `id` | keep | Stable opaque backend-assigned ID for APIs and UI. External provider IDs and database row numbers should not be the primary product URL. | | `provider` | keep | Identifies the messaging provider, such as `slack`, `discord`, or `msteams`. Routing and provider adapters need this. | | `app_principal_id` | keep | Identifies which shared app or gateway identity receives events for this route. This is not the product owner. The old `principal_id` name is too vague. | | `external_installation_key` | keep | Provider-adapter-generated stable key for the external install surface. This avoids a core `scopeType` enum while still giving routing a deterministic key. | @@ -266,7 +293,7 @@ Example Slack row: ```json { - "id": "ci_01k...", + "id": "opaque-installation-id", "provider": "slack", "appPrincipalId": "shared-slack-gateway", "externalInstallationKey": "workspace:T021GRS5F4P", @@ -298,8 +325,8 @@ A relational implementation might use this shape: ```sql CREATE TABLE channel_connection ( - id VARCHAR(32) PRIMARY KEY, - installation_id VARCHAR(32) NOT NULL, + id VARCHAR(64) PRIMARY KEY, + installation_id VARCHAR(64) NOT NULL, display_name VARCHAR(512) NULL, is_default BOOLEAN NOT NULL DEFAULT FALSE, status VARCHAR(32) NOT NULL, @@ -322,7 +349,7 @@ Column justifications: | Column | Decision | Justification | |---|---|---| -| `id` | keep | Stable internal connection ID for APIs and UI. | +| `id` | keep | Stable opaque backend-assigned connection ID for APIs and UI. | | `installation_id` | keep | Parent external installation. A connection cannot exist without an installed provider app. | | `display_name` | keep | Human-facing label in settings. The UI should not need Spritz runtime details to list connections. | | `is_default` | keep | Defines fallback routing when a channel has no explicit route. Without this, workspace-mode behavior is ambiguous. | @@ -352,7 +379,7 @@ A relational implementation might use this shape: ```sql CREATE TABLE spritz_channel_connection ( - connection_id VARCHAR(32) PRIMARY KEY, + connection_id VARCHAR(64) PRIMARY KEY, preset_id VARCHAR(128) NOT NULL, preset_inputs JSON NULL, spritz_binding_key VARCHAR(256) NULL, @@ -408,9 +435,9 @@ A relational implementation might use this shape: ```sql CREATE TABLE channel_route ( - id VARCHAR(32) PRIMARY KEY, - installation_id VARCHAR(32) NOT NULL, - connection_id VARCHAR(32) NOT NULL, + id VARCHAR(64) PRIMARY KEY, + installation_id VARCHAR(64) NOT NULL, + connection_id VARCHAR(64) NOT NULL, external_channel_id VARCHAR(256) NOT NULL, require_mention BOOLEAN NOT NULL DEFAULT TRUE, enabled BOOLEAN NOT NULL DEFAULT TRUE, @@ -438,7 +465,7 @@ Column justifications: | Column | Decision | Justification | |---|---|---| -| `id` | keep | Stable internal route ID for update, delete, and audit. | +| `id` | keep | Stable opaque backend-assigned route ID for update, delete, and audit. | | `installation_id` | keep | Scopes the route to one external app installation. Provider channel IDs are not globally unique across installs. | | `connection_id` | keep | Destination for messages from this external channel. | | `external_channel_id` | keep | Provider channel ID from incoming events. This is the actual routing selector. | @@ -585,7 +612,7 @@ Example request: ```json { - "installationId": "ci_123", + "installationId": "opaque-installation-id", "query": "support" } ``` @@ -614,13 +641,13 @@ Examples: ```http GET /channel/installations -GET /channel/installations/ci_123 -GET /channel/installations/ci_123/connections -POST /channel/installations/ci_123/connections -PATCH /channel/connections/cc_456 -GET /channel/connections/cc_456/routes -PUT /channel/connections/cc_456/routes/C0ANJGDB4Q5 -DELETE /channel/routes/cr_789 +GET /channel/installations/{installationId} +GET /channel/installations/{installationId}/connections +POST /channel/installations/{installationId}/connections +PATCH /channel/connections/{connectionId} +GET /channel/connections/{connectionId}/routes +PUT /channel/connections/{connectionId}/routes/C0ANJGDB4Q5 +DELETE /channel/routes/{routeId} ``` The list and detail responses should be server-driven. Spritz should use @@ -632,7 +659,7 @@ Example installation list response: { "installations": [ { - "id": "ci_123", + "id": "opaque-installation-id", "provider": "slack", "displayName": "Example Workspace", "status": "active", @@ -672,8 +699,8 @@ set for one connection: ```json { - "installationId": "ci_123", - "connectionId": "cc_456", + "installationId": "opaque-installation-id", + "connectionId": "opaque-connection-id", "channelPolicies": [ { "externalChannelId": "C0ANJGDB4Q5", @@ -808,6 +835,8 @@ Minimum validation for this model: - Keep `provider`; for Slack the value is `slack`. - Use internal IDs for URLs and management APIs. +- The deployment-owned backend assigns stable opaque IDs. Spritz only + round-trips them and must not generate or parse them. - Model provider installation, internal connection, Spritz runtime backing, and channel routes as separate concepts. - Do not add core `targetType`, `preset`, or `runtime` fields. diff --git a/docs/2026-04-25-react-management-ui-migration-plan.md b/docs/2026-04-25-react-management-ui-migration-plan.md new file mode 100644 index 0000000..485fbfc --- /dev/null +++ b/docs/2026-04-25-react-management-ui-migration-plan.md @@ -0,0 +1,287 @@ +--- +date: 2026-04-25 +author: Onur Solmaz +title: React Management UI Migration Plan +tags: [spritz, ui, react, slack-gateway, settings, migration] +--- + +## Overview + +Spritz user-facing management pages should be served by the first-party React +UI under `ui/`. + +The Slack gateway currently renders several HTML pages directly from Go +templates. That was useful for a fast admin path, but it creates a second UI +stack with different navigation, styling, routing, form behavior, and +confirmation patterns. It also makes future provider settings harder because +Teams, Discord, and Slack would each be tempted to add their own integration +templates. + +This plan migrates the rendered gateway pages to the React app and leaves the +gateway responsible for protocol work: Slack OAuth redirects and callbacks, +Slack event ingestion, health checks, and JSON APIs consumed by the React UI. + +## Decision + +All new user-facing UI belongs in the Spritz React app. + +Gateway and integration services may expose: + +- external protocol redirects +- external protocol callbacks +- event ingestion endpoints +- health endpoints +- JSON APIs for the React app + +Gateway and integration services should not own product settings pages, +admin pages, channel settings pages, install result pages, or test-message +forms. + +## Current Server-Rendered Surfaces + +The current Slack gateway owns these rendered pages: + +| Current gateway route | Current purpose | React destination | +|---|---|---| +| `/slack-gateway/slack/install/select` | Select install target after Slack OAuth | `/settings/integrations/slack/install/select` | +| `/slack-gateway/slack/install/result` | Show install success or failure | `/settings/integrations/slack/install/result` | +| `/slack-gateway/slack/workspaces` | List manageable Slack workspaces | `/settings/integrations/slack/workspaces` | +| `/slack-gateway/slack/workspaces/target` | Change a workspace target | `/settings/integrations/slack/workspaces/:externalTenantId/target` | +| `/slack-gateway/slack/workspaces/test` | Send a test message | `/settings/integrations/slack/workspaces/:externalTenantId/test` | +| `/slack-gateway/settings/channels` | List channel-installation settings | `/settings/integrations/slack/channels` | +| `/slack-gateway/settings/channels/installations/:installationId` | List connections for one installation | `/settings/integrations/slack/installations/:installationId` | +| `/slack-gateway/settings/channels/installations/:installationId/connections/:connectionId` | Edit channel route policies | `/settings/integrations/slack/installations/:installationId/connections/:connectionId/channels` | + +The gateway should keep these non-UI protocol routes: + +| Gateway route | Reason it remains in gateway | +|---|---| +| `/slack-gateway/slack/install` | Starts Slack OAuth and builds provider-specific state. | +| `/slack-gateway/slack/oauth/callback` | Receives Slack OAuth callback and exchanges provider code. | +| `/slack-gateway/slack/events` | Receives Slack signed events. | +| `/slack-gateway/healthz` | Service health. | + +## Target React Route Shape + +Add a settings section to the React app: + +```text +/settings +/settings/integrations +/settings/integrations/slack +/settings/integrations/slack/install/select +/settings/integrations/slack/install/result +/settings/integrations/slack/workspaces +/settings/integrations/slack/workspaces/:externalTenantId/target +/settings/integrations/slack/workspaces/:externalTenantId/test +/settings/integrations/slack/channels +/settings/integrations/slack/installations/:installationId +/settings/integrations/slack/installations/:installationId/connections/:connectionId/channels +``` + +The settings section should use shared React layout components: + +- settings sidebar +- page header +- status and notice banners +- destructive-action confirmation dialogs +- loading and empty states +- shared table/list and form controls + +Slack-specific content can live under a Slack integration section, but the +shell should be generic enough for future providers. + +## JSON API Shape + +The React UI needs JSON endpoints with the same browser authentication model +as the current gateway pages. The endpoints may live behind the Slack gateway +while the backend contract settles. + +Recommended gateway JSON endpoints: + +```text +GET /slack-gateway/api/slack/install/selection?state=... +POST /slack-gateway/api/slack/install/selection +GET /slack-gateway/api/slack/install/result?... + +GET /slack-gateway/api/slack/workspaces +PATCH /slack-gateway/api/slack/workspaces/:externalTenantId/target +POST /slack-gateway/api/slack/workspaces/:externalTenantId/test-message +POST /slack-gateway/api/slack/workspaces/:externalTenantId/disconnect + +GET /slack-gateway/api/channel-installations +GET /slack-gateway/api/channel-installations/:installationId +GET /slack-gateway/api/channel-installations/:installationId/connections/:connectionId/routes +PUT /slack-gateway/api/channel-installations/:installationId/connections/:connectionId/routes +``` + +API rules: + +- Return JSON only. +- Require the same browser principal checks as the current pages. +- Use opaque backend-assigned IDs exactly as returned by the backend. +- Do not expose Slack tokens or credential material. +- Return `allowedActions` so React can enable or hide controls. +- Return provider display labels from the backend or provider adapter. +- Use structured errors with user-safe messages and request IDs. + +## OAuth Flow + +The OAuth entry and callback remain gateway-owned. + +Target flow: + +1. User starts at React: `/settings/integrations/slack`. +2. React links to gateway: `/slack-gateway/slack/install`. +3. Gateway redirects to Slack OAuth. +4. Slack calls gateway callback: `/slack-gateway/slack/oauth/callback`. +5. If target selection is needed, gateway redirects to React: + `/settings/integrations/slack/install/select?state=...`. +6. React fetches selection data from gateway JSON and submits the selected + opaque target payload back to gateway JSON. +7. Gateway upserts the install and redirects to React result: + `/settings/integrations/slack/install/result?...`. +8. React renders the result page. + +The opaque OAuth state should stay gateway-owned. React may carry it in the +URL long enough to fetch or submit target selection, but it must not parse it. + +## Migration Phases + +### Phase 1: JSON API parity + +Add JSON endpoints that mirror the current rendered page capabilities. + +Required work: + +- Add response DTOs for workspace list, target picker, test message result, + channel installation list, connection detail, and route update result. +- Reuse the existing backend client calls where possible. +- Add tests for browser auth, allowed actions, validation errors, and backend + error mapping. +- Keep existing rendered pages working during this phase. + +Exit criteria: + +- Every rendered page can be rebuilt from JSON endpoint data. +- The existing Go template tests still pass. +- New JSON handler tests cover success and failure paths. + +### Phase 2: React settings shell + +Add the shared settings routes and layout in `ui/`. + +Required work: + +- Add `/settings` route group. +- Add a settings sidebar with integration navigation. +- Add Slack integration landing and workspace list pages. +- Add API client helpers under `ui/src/lib`. +- Add loading, empty, notice, and error states. +- Add React tests for route rendering and API failure states. + +Exit criteria: + +- React can show the Slack workspace list using JSON endpoints. +- The page uses the shared Spritz UI components and design tokens. +- `pnpm build`, `pnpm typecheck`, and UI tests pass. + +### Phase 3: Management actions + +Move workspace and channel management actions to React. + +Required work: + +- Move change-target flow into React. +- Move channel route policy editing into React. +- Move test-message form into React. +- Add confirmation dialogs for disconnect and destructive route removals. +- Add optimistic or explicit reload behavior after successful saves. +- Preserve old URL redirects to the new React routes. + +Exit criteria: + +- A user can manage a Slack workspace without visiting a Go-rendered page. +- A user can edit `requireMention` channel policy from React. +- A user can send a test message from React. +- Disconnect requires confirmation. + +### Phase 4: Install result and target selection + +Move install completion pages into React. + +Required work: + +- Redirect OAuth callback outcomes to React result routes. +- Render all known install result codes in React. +- Move target selection rendering and submission into React. +- Keep OAuth state validation in the gateway. + +Exit criteria: + +- Slack OAuth install flow starts at React and returns to React. +- Gateway only handles provider OAuth mechanics and JSON submission. + +### Phase 5: Remove rendered templates + +Retire the Go templates after the React routes are deployed. + +Required work: + +- Replace old rendered page routes with redirects to React equivalents. +- Keep redirects for bookmarks and stale links. +- Delete template constants and template-only tests after one stable release. +- Keep protocol routes and JSON endpoints. + +Exit criteria: + +- No user-facing Slack management page is rendered from Go. +- `integrations/slack-gateway` has no product UI templates. +- Existing old URLs land on React pages. + +## Acceptance Criteria + +- All user-facing Slack management UI is implemented in `ui/`. +- Slack gateway no longer owns product settings pages. +- React pages share a settings sidebar and common layout. +- Browser auth behavior stays the same or becomes stricter. +- Existing OAuth, event, and routing behavior remains unchanged. +- Old gateway page URLs redirect to React equivalents. +- UI tests cover the main Slack management routes. +- Gateway tests cover JSON endpoint auth, validation, and backend mapping. + +## Non-Goals + +- Rewriting Slack event handling. +- Moving Slack OAuth code into React. +- Making React handle Slack signed events. +- Changing the provider-agnostic channel installation data model. +- Changing how deployment backends assign opaque installation IDs. +- Removing gateway JSON endpoints before a stable backend product API exists. + +## Risks + +The main risk is breaking the Slack OAuth callback path while moving result +and target-selection UI. Keep the callback handler gateway-owned and migrate +only the rendered page after JSON parity exists. + +The second risk is routing ambiguity between `/slack-gateway/*` and React +routes. Avoid that by putting React settings pages under `/settings/*` and +using explicit redirects from the old gateway page URLs. + +The third risk is duplicating backend calls between gateway and React. Keep +React on gateway JSON endpoints at first. Move to a direct product API only +after that API is stable and authenticated for browser use. + +## Implementation Checklist + +- [ ] Add gateway JSON endpoints for each current rendered page. +- [ ] Add React settings shell and sidebar. +- [ ] Add Slack workspace list page in React. +- [ ] Add Slack workspace target page in React. +- [ ] Add Slack test message page in React. +- [ ] Add Slack channel settings list/detail pages in React. +- [ ] Add install target selection page in React. +- [ ] Add install result page in React. +- [ ] Redirect old gateway page URLs to React routes. +- [ ] Delete rendered templates after one stable release. From d55aadca76312f7acbc46fc4b2d4732b4de433ca Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Sat, 25 Apr 2026 07:51:56 +0300 Subject: [PATCH 02/26] docs: clarify chat UI settings boundary --- ...-04-25-react-management-ui-migration-plan.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/2026-04-25-react-management-ui-migration-plan.md b/docs/2026-04-25-react-management-ui-migration-plan.md index 485fbfc..ea429c5 100644 --- a/docs/2026-04-25-react-management-ui-migration-plan.md +++ b/docs/2026-04-25-react-management-ui-migration-plan.md @@ -37,6 +37,19 @@ Gateway and integration services should not own product settings pages, admin pages, channel settings pages, install result pages, or test-message forms. +## Existing Chat UI Boundary + +The existing chat view should change as little as possible. + +The migration should add only one settings entry point to the existing chat UI, +such as a settings button or link in the current header or navigation area. +That entry point should navigate to the new `/settings/*` route group. + +The settings experience should be its own React view. It should not redesign +the chat page, add a settings sidebar inside the chat surface, or make Slack +management part of the active chat layout. Chat remains focused on agent +conversation. Settings owns integration management. + ## Current Server-Rendered Surfaces The current Slack gateway owns these rendered pages: @@ -174,6 +187,7 @@ Add the shared settings routes and layout in `ui/`. Required work: - Add `/settings` route group. +- Add one minimal settings button or link to the existing chat view. - Add a settings sidebar with integration navigation. - Add Slack integration landing and workspace list pages. - Add API client helpers under `ui/src/lib`. @@ -182,6 +196,7 @@ Required work: Exit criteria: +- The chat view has no layout redesign beyond the settings entry point. - React can show the Slack workspace list using JSON endpoints. - The page uses the shared Spritz UI components and design tokens. - `pnpm build`, `pnpm typecheck`, and UI tests pass. @@ -243,6 +258,7 @@ Exit criteria: - All user-facing Slack management UI is implemented in `ui/`. - Slack gateway no longer owns product settings pages. +- Existing chat UI changes are limited to a settings entry point. - React pages share a settings sidebar and common layout. - Browser auth behavior stays the same or becomes stricter. - Existing OAuth, event, and routing behavior remains unchanged. @@ -255,6 +271,7 @@ Exit criteria: - Rewriting Slack event handling. - Moving Slack OAuth code into React. - Making React handle Slack signed events. +- Redesigning the existing chat UI. - Changing the provider-agnostic channel installation data model. - Changing how deployment backends assign opaque installation IDs. - Removing gateway JSON endpoints before a stable backend product API exists. From ac479258be3041a7764c9563c1ae8dd84ab749b7 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Sat, 25 Apr 2026 08:30:29 +0300 Subject: [PATCH 03/26] feat: migrate Slack management pages to React --- .../channel_settings_handlers.go | 8 + integrations/slack-gateway/gateway.go | 8 + integrations/slack-gateway/gateway_test.go | 235 +++-- integrations/slack-gateway/install_result.go | 5 + integrations/slack-gateway/management_api.go | 536 +++++++++++ integrations/slack-gateway/react_routes.go | 48 + integrations/slack-gateway/slack_oauth.go | 2 +- .../slack-gateway/workspace_handlers.go | 18 + .../slack-gateway/workspace_test_messages.go | 9 + ui/src/App.tsx | 2 + ui/src/lib/slack-management.ts | 144 +++ ui/src/pages/chat.tsx | 19 +- ui/src/pages/settings.tsx | 885 ++++++++++++++++++ 13 files changed, 1835 insertions(+), 84 deletions(-) create mode 100644 integrations/slack-gateway/management_api.go create mode 100644 integrations/slack-gateway/react_routes.go create mode 100644 ui/src/lib/slack-management.ts create mode 100644 ui/src/pages/settings.tsx diff --git a/integrations/slack-gateway/channel_settings_handlers.go b/integrations/slack-gateway/channel_settings_handlers.go index bedfc77..5a7dab4 100644 --- a/integrations/slack-gateway/channel_settings_handlers.go +++ b/integrations/slack-gateway/channel_settings_handlers.go @@ -11,6 +11,14 @@ func (g *slackGateway) handleChannelSettings(w http.ResponseWriter, r *http.Requ if !ok { return } + if r.Method == http.MethodGet { + redirectToReactRoute( + w, + r, + reactSlackChannelSettingsPath(g.relativeGatewayPath(r.URL.Path), r.URL.Query()), + ) + return + } installations, err := g.listManagedInstallations(r.Context(), principal.ID) if err != nil { g.logger.ErrorContext( diff --git a/integrations/slack-gateway/gateway.go b/integrations/slack-gateway/gateway.go index 13dce2d..e53245a 100644 --- a/integrations/slack-gateway/gateway.go +++ b/integrations/slack-gateway/gateway.go @@ -92,6 +92,14 @@ func (g *slackGateway) routes() http.Handler { g.registerRoute(mux, "/slack/workspaces/disconnect", g.handleWorkspaceDisconnect) g.registerRoute(mux, "/settings/channels", g.handleChannelSettings) g.registerRoute(mux, "/settings/channels/", g.handleChannelSettings) + g.registerRoute(mux, "/api/slack/install/selection", g.handleInstallTargetSelectionAPI) + g.registerRoute(mux, "/api/slack/install/result", g.handleInstallResultAPI) + g.registerRoute(mux, "/api/slack/workspaces", g.handleWorkspaceManagementAPI) + g.registerRoute(mux, "/api/slack/workspaces/target", g.handleWorkspaceTargetAPI) + g.registerRoute(mux, "/api/slack/workspaces/test", g.handleWorkspaceTestAPI) + g.registerRoute(mux, "/api/slack/workspaces/disconnect", g.handleWorkspaceDisconnectAPI) + g.registerRoute(mux, "/api/settings/channels", g.handleChannelSettingsAPI) + g.registerRoute(mux, "/api/settings/channels/", g.handleChannelSettingsAPI) g.registerRoute(mux, "/slack/oauth/callback", g.handleOAuthCallback) g.registerRoute(mux, "/slack/events", g.handleSlackEvents) return mux diff --git a/integrations/slack-gateway/gateway_test.go b/integrations/slack-gateway/gateway_test.go index e4e82a1..b10f871 100644 --- a/integrations/slack-gateway/gateway_test.go +++ b/integrations/slack-gateway/gateway_test.go @@ -184,18 +184,18 @@ func TestOAuthCallbackAutoSelectsSingleInstallTargetAndUpsertsRegistry(t *testin resultRec := httptest.NewRecorder() gateway.routes().ServeHTTP(resultRec, resultReq) - if resultRec.Code != http.StatusOK { - t.Fatalf("expected result page 200, got %d: %s", resultRec.Code, resultRec.Body.String()) + if resultRec.Code != http.StatusSeeOther { + t.Fatalf("expected result redirect, got %d: %s", resultRec.Code, resultRec.Body.String()) } - if contentType := resultRec.Header().Get("Content-Type"); !strings.Contains(contentType, "text/html") { - t.Fatalf("expected html content type, got %q", contentType) + resultLocation, err := url.Parse(resultRec.Header().Get("Location")) + if err != nil { + t.Fatalf("parse result redirect: %v", err) } - body := resultRec.Body.String() - if !strings.Contains(body, "Slack workspace connected") { - t.Fatalf("expected success title in result page, got %q", body) + if resultLocation.Path != "/settings/slack/install/result" { + t.Fatalf("expected React result route, got %q", resultLocation.Path) } - if !strings.Contains(body, requestID) { - t.Fatalf("expected request id in result page, got %q", body) + if resultLocation.Query().Get("requestId") != requestID { + t.Fatalf("expected request id in result redirect, got %q", resultLocation.RawQuery) } } @@ -274,21 +274,21 @@ func TestOAuthCallbackRendersInstallTargetPickerWhenMultipleTargetsAvailable(t * rec := httptest.NewRecorder() gateway.handleOAuthCallback(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected picker page 200, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected picker redirect, got %d: %s", rec.Code, rec.Body.String()) } - body := rec.Body.String() - if !strings.Contains(body, "Choose an install target") { - t.Fatalf("expected picker title, got %q", body) + redirectURL, err := url.Parse(rec.Header().Get("Location")) + if err != nil { + t.Fatalf("parse picker redirect: %v", err) } - if !strings.Contains(body, "Personal Helper") || !strings.Contains(body, "Org Helper") { - t.Fatalf("expected picker targets, got %q", body) + if redirectURL.Path != "/settings/slack/install/select" { + t.Fatalf("expected React picker route, got %q", redirectURL.Path) } - if !strings.Contains(body, `/slack/install/select`) { - t.Fatalf("expected picker form action, got %q", body) + if redirectURL.Query().Get("state") == "" { + t.Fatalf("expected pending install state in redirect, 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(rec.Body.String(), "xoxb-installed") { + t.Fatalf("expected picker redirect to keep bot token encrypted, got %q", rec.Body.String()) } } @@ -351,21 +351,11 @@ func TestWorkspaceManagementRendersManagedInstallations(t *testing.T) { rec := httptest.NewRecorder() gateway.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) - } - body := rec.Body.String() - if !strings.Contains(body, "Slack workspaces") { - t.Fatalf("expected workspace page title, got %q", body) - } - if !strings.Contains(body, "T_workspace_1") { - t.Fatalf("expected workspace id on page, got %q", body) - } - if !strings.Contains(body, "Workspace Helper") { - t.Fatalf("expected current target on page, got %q", body) + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected React redirect, got %d: %s", rec.Code, rec.Body.String()) } - if !strings.Contains(body, "Send test") { - t.Fatalf("expected workspace test action on page, got %q", body) + if location := rec.Header().Get("Location"); location != "/settings/slack/workspaces" { + t.Fatalf("expected workspace React route, got %q", location) } } @@ -438,13 +428,11 @@ func TestChannelSettingsRendersManagedConnections(t *testing.T) { installationRec := httptest.NewRecorder() gateway.routes().ServeHTTP(installationRec, installationReq) - if installationRec.Code != http.StatusOK { - t.Fatalf("expected installation page 200, got %d: %s", installationRec.Code, installationRec.Body.String()) + if installationRec.Code != http.StatusSeeOther { + t.Fatalf("expected installation redirect, got %d: %s", installationRec.Code, installationRec.Body.String()) } - installationBody := installationRec.Body.String() - if !strings.Contains(installationBody, "/settings/channels/installations/ci_1/connections/cc_1") || - !strings.Contains(installationBody, "/settings/channels/installations/ci_1/connections/cc_2") { - t.Fatalf("expected installation page to link every connection, got %q", installationBody) + if location := installationRec.Header().Get("Location"); location != "/settings/slack/channels/installations/ci_1" { + t.Fatalf("expected installation React route, got %q", location) } req := httptest.NewRequest(http.MethodGet, "/settings/channels/installations/ci_1/connections/cc_2", nil) @@ -452,15 +440,11 @@ func TestChannelSettingsRendersManagedConnections(t *testing.T) { rec := httptest.NewRecorder() gateway.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) - } - body := rec.Body.String() - if !strings.Contains(body, "T_workspace_1") || !strings.Contains(body, "Workspace Helper") { - t.Fatalf("expected channel settings context on page, got %q", body) + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected connection redirect, got %d: %s", rec.Code, rec.Body.String()) } - if !strings.Contains(body, "C_channel_2") || !strings.Contains(body, "Mentions required") { - t.Fatalf("expected configured channel route on page, got %q", body) + if location := rec.Header().Get("Location"); location != "/settings/slack/channels/installations/ci_1/connections/cc_2" { + t.Fatalf("expected connection React route, got %q", location) } } @@ -506,15 +490,11 @@ func TestChannelSettingsListDoesNotInventLegacyConnectionIDs(t *testing.T) { rec := httptest.NewRecorder() gateway.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) - } - body := rec.Body.String() - if strings.Contains(body, "/connections/cc_1") || strings.Contains(body, "Open settings") { - t.Fatalf("expected legacy response without connection id to be read-only, got %q", body) + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected React redirect, got %d: %s", rec.Code, rec.Body.String()) } - if !strings.Contains(body, "Settings unavailable") { - t.Fatalf("expected unavailable settings marker, got %q", body) + if location := rec.Header().Get("Location"); location != "/settings/slack/channels" { + t.Fatalf("expected channel settings React route, got %q", location) } } @@ -632,6 +612,102 @@ func TestChannelSettingsUpdatePostsRoutePolicies(t *testing.T) { } } +func TestChannelSettingsAPIUpdatePostsRoutePolicies(t *testing.T) { + var updatePayload map[string]any + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/internal/v2/spritz/channel-installations/list": + writeJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "installations": []map[string]any{ + { + "id": "chinst_example", + "route": map[string]any{ + "principalId": "shared-slack-gateway", + "provider": "slack", + "externalScopeType": "workspace", + "externalTenantId": "T_workspace_1", + }, + "state": "ready", + "connections": []map[string]any{ + { + "id": "chconn_example", + "isDefault": true, + "state": "ready", + "routes": []map[string]any{ + { + "id": "chroute_existing", + "externalChannelId": "C_existing", + "requireMention": true, + "enabled": true, + }, + }, + }, + }, + "allowedActions": []string{"changeTarget", "disconnect"}, + }, + }, + }) + case "/internal/v2/spritz/channel-installations/routes/update": + if err := json.NewDecoder(r.Body).Decode(&updatePayload); err != nil { + t.Fatalf("decode update payload: %v", err) + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "needsProvisioning": false, + }) + default: + t.Fatalf("unexpected backend path %s", r.URL.Path) + } + })) + defer backend.Close() + + gateway := newSlackGateway(config{ + BackendFastAPIBaseURL: backend.URL, + BackendInternalToken: "backend-internal-token", + PrincipalID: "shared-slack-gateway", + HTTPTimeout: 5 * time.Second, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + gateway.policies.remember("T_workspace_1", installationPolicySnapshot{ + config: installationConfig{}, + botUserID: "U_bot", + }) + + req := httptest.NewRequest( + http.MethodPut, + "/api/settings/channels/installations/chinst_example/connections/chconn_example", + strings.NewReader(`{"channelPolicies":[{"externalChannelId":"C_new","externalChannelType":"channel","requireMention":false}]}`), + ) + 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.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"]) + } + if updatePayload["installationId"] != "chinst_example" || updatePayload["connectionId"] != "chconn_example" { + t.Fatalf("expected opaque installation and connection ids, got %#v", updatePayload) + } + policies, ok := updatePayload["channelPolicies"].([]any) + if !ok || len(policies) != 1 { + t.Fatalf("expected one channel policy, got %#v", updatePayload["channelPolicies"]) + } + policy, ok := policies[0].(map[string]any) + if !ok { + t.Fatalf("expected policy object, got %#v", policies[0]) + } + if policy["externalChannelId"] != "C_new" || policy["requireMention"] != false { + t.Fatalf("expected no-mention policy for C_new, got %#v", policy) + } + if _, ok := gateway.policies.lookup("T_workspace_1"); ok { + t.Fatalf("expected channel settings update to evict cached policy") + } +} + func TestWorkspaceManagementAcceptsConfiguredBrowserAuthHeaders(t *testing.T) { t.Setenv("SPRITZ_AUTH_HEADER_ID", "X-Forwarded-User") t.Setenv("SPRITZ_AUTH_HEADER_EMAIL", "X-Forwarded-Email") @@ -678,8 +754,11 @@ func TestWorkspaceManagementAcceptsConfiguredBrowserAuthHeaders(t *testing.T) { rec := httptest.NewRecorder() gateway.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, 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 != "/settings/slack/workspaces" { + t.Fatalf("expected workspace React route, got %q", location) } } @@ -717,7 +796,7 @@ func TestWorkspaceTargetPickerUsesCurrentBrowserPrincipal(t *testing.T) { HTTPTimeout: 5 * time.Second, }, slog.New(slog.NewTextHandler(io.Discard, nil))) - req := httptest.NewRequest(http.MethodGet, "/slack/workspaces/target?teamId=T_workspace_1", nil) + req := httptest.NewRequest(http.MethodGet, "/api/slack/workspaces/target?teamId=T_workspace_1", nil) req.Header.Set("X-Spritz-User-Id", "user-1") rec := httptest.NewRecorder() gateway.routes().ServeHTTP(rec, req) @@ -728,8 +807,8 @@ func TestWorkspaceTargetPickerUsesCurrentBrowserPrincipal(t *testing.T) { if listPayload["ownerAuthId"] != "user-1" { t.Fatalf("expected ownerAuthId to come from browser principal, got %#v", listPayload["ownerAuthId"]) } - if !strings.Contains(rec.Body.String(), "Change workspace target") { - t.Fatalf("expected target picker title, got %q", rec.Body.String()) + if !strings.Contains(rec.Body.String(), "Workspace Helper") { + t.Fatalf("expected target payload, got %q", rec.Body.String()) } } @@ -878,15 +957,11 @@ func TestWorkspaceTestFormRendersForManageableWorkspace(t *testing.T) { rec := httptest.NewRecorder() gateway.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) - } - body := rec.Body.String() - if !strings.Contains(body, "Send a synthetic Slack test message") { - t.Fatalf("expected synthetic test title, got %q", body) + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected React redirect, got %d: %s", rec.Code, rec.Body.String()) } - if !strings.Contains(body, "Workspace Helper") { - t.Fatalf("expected current target name on form, got %q", body) + if location := rec.Header().Get("Location"); location != "/settings/slack/workspaces/test?teamId=T_workspace_1" { + t.Fatalf("expected workspace test React route, got %q", location) } } @@ -1473,11 +1548,11 @@ func TestOAuthCallbackRedirectsToControlledRetryableErrorWhenBackendUpsertFails( resultRec := httptest.NewRecorder() gateway.routes().ServeHTTP(resultRec, resultReq) - if resultRec.Code != http.StatusOK { - t.Fatalf("expected result page 200, got %d: %s", resultRec.Code, resultRec.Body.String()) + if resultRec.Code != http.StatusSeeOther { + t.Fatalf("expected result redirect, got %d: %s", resultRec.Code, resultRec.Body.String()) } - if strings.Contains(resultRec.Body.String(), "backend unavailable") { - t.Fatalf("expected retryable result page to hide backend body, got %q", resultRec.Body.String()) + if strings.Contains(resultRec.Header().Get("Location"), "backend unavailable") { + t.Fatalf("expected retryable result redirect to hide backend body, got %q", resultRec.Header().Get("Location")) } logOutput := logBuffer.String() if !strings.Contains(logOutput, "slack oauth callback installation upsert failed") { @@ -1573,15 +1648,15 @@ func TestOAuthCallbackRedirectsToControlledOwnerResolutionError(t *testing.T) { resultRec := httptest.NewRecorder() gateway.routes().ServeHTTP(resultRec, resultReq) - if resultRec.Code != http.StatusOK { - t.Fatalf("expected result page 200, got %d: %s", resultRec.Code, resultRec.Body.String()) + if resultRec.Code != http.StatusSeeOther { + t.Fatalf("expected result redirect, got %d: %s", resultRec.Code, resultRec.Body.String()) } - body := resultRec.Body.String() - if !strings.Contains(body, "could not be linked") { - t.Fatalf("expected owner resolution guidance in result page, got %q", body) + location := resultRec.Header().Get("Location") + if !strings.Contains(location, "identity.unresolved") { + t.Fatalf("expected owner resolution code in result redirect, got %q", location) } - if strings.Contains(body, "ownerRef") { - t.Fatalf("expected result page to avoid leaking backend field names, got %q", body) + if strings.Contains(location, "ownerRef") { + t.Fatalf("expected result redirect to avoid leaking backend field names, got %q", location) } } diff --git a/integrations/slack-gateway/install_result.go b/integrations/slack-gateway/install_result.go index b132088..97803fc 100644 --- a/integrations/slack-gateway/install_result.go +++ b/integrations/slack-gateway/install_result.go @@ -421,6 +421,11 @@ func classifyInstallUpsertError(err error) installResultCode { } func (g *slackGateway) handleInstallResult(w http.ResponseWriter, r *http.Request) { + target := url.URL{Path: reactSlackInstallResultPath(), RawQuery: r.URL.RawQuery} + 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))), diff --git a/integrations/slack-gateway/management_api.go b/integrations/slack-gateway/management_api.go new file mode 100644 index 0000000..87304bc --- /dev/null +++ b/integrations/slack-gateway/management_api.go @@ -0,0 +1,536 @@ +package main + +import ( + "encoding/json" + "net/http" + "sort" + "strings" +) + +type slackGatewayErrorResponse struct { + Status string `json:"status"` + Message string `json:"message"` +} + +type installSelectionResponse struct { + Status string `json:"status"` + State string `json:"state"` + RequestID string `json:"requestId"` + TeamID string `json:"teamId"` + Installation slackInstallation `json:"installation"` + Targets []backendInstallTarget `json:"targets"` +} + +type installSelectionRequest struct { + State string `json:"state"` + RequestID string `json:"requestId"` + PresetInputs map[string]any `json:"presetInputs"` +} + +type workspaceTargetRequest struct { + TeamID string `json:"teamId"` + RequestID string `json:"requestId"` + PresetInputs map[string]any `json:"presetInputs"` +} + +type workspaceDisconnectRequest struct { + TeamID string `json:"teamId"` +} + +type workspaceTestRequest struct { + TeamID string `json:"teamId"` + ChannelID string `json:"channelId"` + ThreadTS string `json:"threadTs"` + Prompt string `json:"prompt"` + Mode string `json:"mode"` +} + +type workspaceTestResponse struct { + Status string `json:"status"` + Outcome string `json:"outcome,omitempty"` + Reply string `json:"reply,omitempty"` + ConversationID string `json:"conversationId,omitempty"` + PostedMessageTS string `json:"postedMessageTs,omitempty"` +} + +type channelRoutesUpdateRequest struct { + RequestID string `json:"requestId"` + ChannelPolicies []installationChannelPolicy `json:"channelPolicies"` +} + +func writeAPIError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, slackGatewayErrorResponse{ + Status: "error", + Message: strings.TrimSpace(message), + }) +} + +func decodeJSONRequest(r *http.Request, target any) error { + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + return decoder.Decode(target) +} + +func (g *slackGateway) handleInstallTargetSelectionAPI(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + g.handleInstallTargetSelectionAPIGet(w, r) + case http.MethodPost: + g.handleInstallTargetSelectionAPIPost(w, r) + default: + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (g *slackGateway) handleInstallTargetSelectionAPIGet(w http.ResponseWriter, r *http.Request) { + state := strings.TrimSpace(r.URL.Query().Get("state")) + pendingInstall, err := g.state.parsePendingInstall(state) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "install state is invalid or expired") + return + } + targets, err := g.listInstallTargets(r.Context(), &pendingInstall.Installation, pendingInstall.RequestID) + if err != nil { + g.logger.ErrorContext( + r.Context(), + "install target selection api lookup failed", + "err", err, + "team_id", pendingInstall.Installation.TeamID, + "request_id", pendingInstall.RequestID, + ) + writeAPIError(w, http.StatusBadGateway, "install targets unavailable") + return + } + writeJSON(w, http.StatusOK, installSelectionResponse{ + Status: "resolved", + State: state, + RequestID: pendingInstall.RequestID, + TeamID: pendingInstall.Installation.TeamID, + Installation: pendingInstall.Installation, + Targets: targets, + }) +} + +func (g *slackGateway) handleInstallTargetSelectionAPIPost(w http.ResponseWriter, r *http.Request) { + var body installSelectionRequest + if err := decodeJSONRequest(r, &body); err != nil { + writeAPIError(w, http.StatusBadRequest, "invalid request body") + return + } + pendingInstall, err := g.state.parsePendingInstall(strings.TrimSpace(body.State)) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "install state is invalid or expired") + return + } + requestID := firstNonEmpty(strings.TrimSpace(body.RequestID), pendingInstall.RequestID) + if len(body.PresetInputs) == 0 { + writeAPIError(w, http.StatusBadRequest, "presetInputs is required") + return + } + installation := pendingInstall.Installation + if err := g.upsertInstallation(r.Context(), &installation, requestID, body.PresetInputs); err != nil { + g.logger.ErrorContext( + r.Context(), + "install target selection api upsert failed", + "err", err, + "team_id", installation.TeamID, + "request_id", requestID, + ) + writeAPIError(w, http.StatusBadGateway, "workspace install failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "installed", + "requestId": requestID, + "teamId": installation.TeamID, + }) +} + +func (g *slackGateway) handleInstallResultAPI(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()) + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": result.Status, + "code": result.Code, + "operation": result.Operation, + "provider": result.Provider, + "requestId": result.RequestID, + "teamId": result.TeamID, + "title": descriptor.Title, + "message": descriptor.Message, + "retryable": descriptor.Retryable, + "actionLabel": descriptor.ActionLabel, + "actionHref": descriptor.ActionHref, + }) +} + +func (g *slackGateway) handleWorkspaceManagementAPI(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + 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 api list failed", + "err", err, + "caller_auth_id", principal.ID, + ) + writeAPIError(w, http.StatusBadGateway, "workspace list unavailable") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "installations": installations, + }) +} + +func (g *slackGateway) handleWorkspaceTargetAPI(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + g.handleWorkspaceTargetAPIGet(w, r) + case http.MethodPost: + g.handleWorkspaceTargetAPIPost(w, r) + default: + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (g *slackGateway) handleWorkspaceTargetAPIGet(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 == "" { + writeAPIError(w, http.StatusBadRequest, "teamId is required") + return + } + requestID := newInstallRequestID() + targets, err := g.listInstallTargetsForOwnerAuthID(r.Context(), teamID, principal.ID, requestID, nil) + if err != nil { + g.logger.ErrorContext( + r.Context(), + "workspace target api lookup failed", + "err", err, + "caller_auth_id", principal.ID, + "team_id", teamID, + "request_id", requestID, + ) + writeAPIError(w, http.StatusBadGateway, "workspace targets unavailable") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "teamId": teamID, + "requestId": requestID, + "targets": targets, + }) +} + +func (g *slackGateway) handleWorkspaceTargetAPIPost(w http.ResponseWriter, r *http.Request) { + principal, ok := requireBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + var body workspaceTargetRequest + if err := decodeJSONRequest(r, &body); err != nil { + writeAPIError(w, http.StatusBadRequest, "invalid request body") + return + } + teamID := strings.TrimSpace(body.TeamID) + if teamID == "" { + writeAPIError(w, http.StatusBadRequest, "teamId is required") + return + } + if len(body.PresetInputs) == 0 { + writeAPIError(w, http.StatusBadRequest, "presetInputs is required") + return + } + requestID := firstNonEmpty(body.RequestID, newInstallRequestID()) + if err := g.updateManagedInstallationTarget(r.Context(), principal.ID, teamID, requestID, body.PresetInputs); err != nil { + g.logger.ErrorContext( + r.Context(), + "workspace target api update failed", + "err", err, + "caller_auth_id", principal.ID, + "team_id", teamID, + "request_id", requestID, + ) + writeAPIError(w, http.StatusBadGateway, "workspace target update failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "updated", + "teamId": teamID, + "requestId": requestID, + }) +} + +func (g *slackGateway) handleWorkspaceDisconnectAPI(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + principal, ok := requireBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + var body workspaceDisconnectRequest + if err := decodeJSONRequest(r, &body); err != nil { + writeAPIError(w, http.StatusBadRequest, "invalid request body") + return + } + teamID := strings.TrimSpace(body.TeamID) + if teamID == "" { + writeAPIError(w, http.StatusBadRequest, "teamId is required") + return + } + if err := g.disconnectManagedInstallation(r.Context(), principal.ID, teamID); err != nil { + g.logger.ErrorContext( + r.Context(), + "workspace disconnect api failed", + "err", err, + "caller_auth_id", principal.ID, + "team_id", teamID, + ) + writeAPIError(w, http.StatusBadGateway, "workspace disconnect failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "disconnected", + "teamId": teamID, + }) +} + +func (g *slackGateway) handleWorkspaceTestAPI(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + principal, ok := requireBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + var body workspaceTestRequest + if err := decodeJSONRequest(r, &body); err != nil { + writeAPIError(w, http.StatusBadRequest, "invalid request body") + return + } + teamID := strings.TrimSpace(body.TeamID) + if teamID == "" { + writeAPIError(w, http.StatusBadRequest, "teamId is required") + return + } + installation, err := g.lookupManagedWorkspace(r, principal.ID, teamID) + if err != nil { + g.logger.ErrorContext( + r.Context(), + "workspace test api lookup failed", + "err", err, + "caller_auth_id", principal.ID, + "team_id", teamID, + ) + writeAPIError(w, http.StatusBadGateway, "workspace lookup unavailable") + return + } + if installation == nil { + writeAPIError(w, http.StatusForbidden, "workspace not manageable") + return + } + envelope, err := inferSyntheticSlackEvent(body.ChannelID, body.Prompt, body.ThreadTS) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + envelope.TeamID = teamID + delivery, process, err := g.beginMessageEventDelivery(envelope) + if err != nil { + writeAPIError(w, http.StatusConflict, err.Error()) + return + } + if !process { + writeJSON(w, http.StatusOK, workspaceTestResponse{ + Status: "resolved", + Outcome: messageEventOutcomeIgnored, + }) + return + } + mode := strings.TrimSpace(body.Mode) + if mode == "" { + mode = "real" + } + result, err := g.processMessageEventWithDeliveryOptions( + r.Context(), + envelope, + delivery, + messageEventProcessOptions{ + Synthetic: true, + DryRun: mode == "dry-run", + }, + ) + if err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, workspaceTestResponse{ + Status: "resolved", + Outcome: result.Outcome, + Reply: result.Reply, + ConversationID: result.ConversationID, + PostedMessageTS: result.PostedMessageTS, + }) +} + +func (g *slackGateway) handleChannelSettingsAPI(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(), + "channel settings api list failed", + "err", err, + "caller_auth_id", principal.ID, + ) + writeAPIError(w, http.StatusBadGateway, "channel settings unavailable") + return + } + + relativePath := strings.TrimRight(g.relativeGatewayPath(r.URL.Path), "/") + relativePath = strings.TrimPrefix(relativePath, "/api") + if relativePath == "/settings/channels" { + if r.Method != http.MethodGet { + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "installations": installations, + }) + return + } + + if installationID, ok := channelSettingsInstallationPathID(relativePath); ok { + if r.Method != http.MethodGet { + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + for _, installation := range installations { + if strings.TrimSpace(installation.ID) == installationID { + writeJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "installation": installation, + }) + return + } + } + writeAPIError(w, http.StatusNotFound, "installation not found") + return + } + + installation, connection, found := g.matchChannelSettingsConnectionPath( + w, + r, + installations, + relativePath, + ) + if !found { + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "installation": installation, + "connection": connection, + }) + case http.MethodPut: + g.handleChannelSettingsAPIUpdate(w, r, principal.ID, installation, connection) + default: + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (g *slackGateway) handleChannelSettingsAPIUpdate( + w http.ResponseWriter, + r *http.Request, + callerAuthID string, + installation backendManagedInstallation, + connection backendManagedConnection, +) { + var body channelRoutesUpdateRequest + if err := decodeJSONRequest(r, &body); err != nil { + writeAPIError(w, http.StatusBadRequest, "invalid request body") + return + } + policiesByChannel := map[string]installationChannelPolicy{} + for _, policy := range body.ChannelPolicies { + channelID := strings.TrimSpace(policy.ExternalChannelID) + if channelID == "" { + continue + } + requireMention := true + if policy.RequireMention != nil { + requireMention = *policy.RequireMention + } + policiesByChannel[channelID] = installationChannelPolicy{ + ExternalChannelID: channelID, + ExternalChannelType: strings.TrimSpace(policy.ExternalChannelType), + RequireMention: &requireMention, + } + } + 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 := firstNonEmpty(body.RequestID, newInstallRequestID()) + if err := g.updateManagedInstallationRoutes( + r.Context(), + callerAuthID, + installation.ID, + connection.ID, + requestID, + policies, + ); err != nil { + g.logger.ErrorContext( + r.Context(), + "channel settings api update failed", + "err", err, + "caller_auth_id", callerAuthID, + "installation_id", installation.ID, + "connection_id", connection.ID, + "request_id", requestID, + ) + writeAPIError(w, http.StatusBadGateway, "channel routes update failed") + return + } + g.policies.forget(installation.Route.ExternalTenantID) + writeJSON(w, http.StatusOK, map[string]any{ + "status": "updated", + "installation": installation.ID, + "connection": connection.ID, + "requestId": requestID, + }) +} diff --git a/integrations/slack-gateway/react_routes.go b/integrations/slack-gateway/react_routes.go new file mode 100644 index 0000000..d086441 --- /dev/null +++ b/integrations/slack-gateway/react_routes.go @@ -0,0 +1,48 @@ +package main + +import ( + "net/http" + "net/url" + "strings" +) + +func reactSlackInstallResultPath() string { + return "/settings/slack/install/result" +} + +func reactSlackInstallSelectPath(state string) string { + target := url.URL{Path: "/settings/slack/install/select"} + query := target.Query() + query.Set("state", strings.TrimSpace(state)) + target.RawQuery = query.Encode() + return target.String() +} + +func reactSlackWorkspacesPath(query url.Values) string { + target := url.URL{Path: "/settings/slack/workspaces"} + target.RawQuery = query.Encode() + return target.String() +} + +func reactSlackWorkspaceTargetPath(query url.Values) string { + target := url.URL{Path: "/settings/slack/workspaces/target"} + target.RawQuery = query.Encode() + return target.String() +} + +func reactSlackWorkspaceTestPath(query url.Values) string { + target := url.URL{Path: "/settings/slack/workspaces/test"} + target.RawQuery = query.Encode() + return target.String() +} + +func reactSlackChannelSettingsPath(relativeGatewayPath string, query url.Values) string { + relative := strings.TrimPrefix(strings.TrimSpace(relativeGatewayPath), "/settings/channels") + target := url.URL{Path: "/settings/slack/channels" + relative} + target.RawQuery = query.Encode() + return target.String() +} + +func redirectToReactRoute(w http.ResponseWriter, r *http.Request, target string) { + http.Redirect(w, r, target, http.StatusSeeOther) +} diff --git a/integrations/slack-gateway/slack_oauth.go b/integrations/slack-gateway/slack_oauth.go index f9b7b9e..a6e03a2 100644 --- a/integrations/slack-gateway/slack_oauth.go +++ b/integrations/slack-gateway/slack_oauth.go @@ -215,7 +215,7 @@ func (g *slackGateway) handleOAuthCallback(w http.ResponseWriter, r *http.Reques }) return } - g.renderInstallTargetPicker(w, pendingState, requestID, targets) + redirectToReactRoute(w, r, reactSlackInstallSelectPath(pendingState)) return } if err := g.upsertInstallation(r.Context(), &installation, requestID, targets[0].PresetInputs); err != nil { diff --git a/integrations/slack-gateway/workspace_handlers.go b/integrations/slack-gateway/workspace_handlers.go index 54a510a..1dc0487 100644 --- a/integrations/slack-gateway/workspace_handlers.go +++ b/integrations/slack-gateway/workspace_handlers.go @@ -6,6 +6,15 @@ import ( ) func (g *slackGateway) handleWorkspaceManagement(w http.ResponseWriter, r *http.Request) { + principal, ok := requireBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + redirectToReactRoute(w, r, reactSlackWorkspacesPath(r.URL.Query())) + _ = principal +} + +func (g *slackGateway) renderLegacyWorkspaceManagement(w http.ResponseWriter, r *http.Request) { principal, ok := requireBrowserPrincipal(g.cfg, w, r) if !ok { return @@ -38,6 +47,15 @@ func (g *slackGateway) handleWorkspaceTarget(w http.ResponseWriter, r *http.Requ } func (g *slackGateway) handleWorkspaceTargetPicker(w http.ResponseWriter, r *http.Request) { + principal, ok := requireBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + redirectToReactRoute(w, r, reactSlackWorkspaceTargetPath(r.URL.Query())) + _ = principal +} + +func (g *slackGateway) renderLegacyWorkspaceTargetPicker(w http.ResponseWriter, r *http.Request) { principal, ok := requireBrowserPrincipal(g.cfg, w, r) if !ok { return diff --git a/integrations/slack-gateway/workspace_test_messages.go b/integrations/slack-gateway/workspace_test_messages.go index 091fe44..f938585 100644 --- a/integrations/slack-gateway/workspace_test_messages.go +++ b/integrations/slack-gateway/workspace_test_messages.go @@ -287,6 +287,15 @@ func (g *slackGateway) handleWorkspaceTest(w http.ResponseWriter, r *http.Reques } func (g *slackGateway) handleWorkspaceTestForm(w http.ResponseWriter, r *http.Request) { + principal, ok := requireBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + 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 diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a617d7b..fb720f6 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -5,6 +5,7 @@ import { NoticeProvider } from '@/components/notice-banner'; import { Layout } from '@/components/layout'; import { ChatPage } from '@/pages/chat'; import { CreatePage } from '@/pages/create'; +import { SettingsPage } from '@/pages/settings'; import { TerminalPage } from '@/pages/terminal'; import { chatCatchAllRoutePath } from '@/lib/urls'; @@ -56,6 +57,7 @@ export function App() { }> } /> } /> + } /> } /> } /> diff --git a/ui/src/lib/slack-management.ts b/ui/src/lib/slack-management.ts new file mode 100644 index 0000000..eebf76d --- /dev/null +++ b/ui/src/lib/slack-management.ts @@ -0,0 +1,144 @@ +export interface SlackInstallTargetProfile { + name: string; + imageUrl?: string; +} + +export interface SlackInstallTarget { + id: string; + profile: SlackInstallTargetProfile; + ownerLabel?: string; + presetInputs: Record; +} + +export interface SlackManagedInstallationRoute { + principalId: string; + provider: string; + externalScopeType: string; + externalTenantId: string; +} + +export interface SlackManagedChannelRoute { + id: string; + externalChannelId: string; + externalChannelType?: string; + requireMention?: boolean | null; + enabled?: boolean | null; +} + +export interface SlackManagedConnection { + id: string; + displayName?: string; + isDefault: boolean; + state: string; + routes?: SlackManagedChannelRoute[]; +} + +export interface SlackManagedInstallation { + id: string; + route: SlackManagedInstallationRoute; + state: string; + currentTarget?: { + id: string; + profile: SlackInstallTargetProfile; + ownerLabel?: string; + } | null; + installationConfig?: { + channelPolicies?: SlackManagedChannelRoute[]; + }; + connections?: SlackManagedConnection[]; + allowedActions?: string[]; + problemCode?: string; + disconnectedAt?: string; +} + +export interface SlackInstallSelection { + status: string; + state: string; + requestId: string; + teamId: string; + targets: SlackInstallTarget[]; +} + +export interface SlackInstallResult { + status: string; + code: string; + operation?: string; + provider?: string; + requestId?: string; + teamId?: string; + title: string; + message: string; + retryable?: boolean; + actionLabel?: string; + actionHref?: string; +} + +export interface SlackWorkspaceTestResult { + status: string; + outcome?: string; + reply?: string; + conversationId?: string; + postedMessageTs?: string; +} + +const SLACK_GATEWAY_BASE_PATH = '/slack-gateway'; + +async function parseGatewayResponse(res: Response): Promise { + const text = await res.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function gatewayErrorMessage(payload: unknown, fallback: string): string { + if (payload && typeof payload === 'object' && 'message' in payload) { + const message = String((payload as { message?: unknown }).message || '').trim(); + if (message) return message; + } + if (typeof payload === 'string' && payload.trim()) return payload.trim(); + return fallback; +} + +export async function slackGatewayRequest( + path: string, + options: RequestInit = {}, +): Promise { + const headers = new Headers(options.headers || {}); + if (options.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + const res = await fetch(`${SLACK_GATEWAY_BASE_PATH}${path}`, { + credentials: 'include', + ...options, + headers, + }); + const payload = await parseGatewayResponse(res); + if (!res.ok) { + throw new Error(gatewayErrorMessage(payload, res.statusText || 'Request failed')); + } + return payload as T; +} + +export function primaryConnection( + installation?: SlackManagedInstallation | null, +): SlackManagedConnection | null { + const connections = installation?.connections || []; + return ( + connections.find((connection) => connection.isDefault) || + connections[0] || + null + ); +} + +export function connectionRoutePath(installationId: string, connectionId: string): string { + return `/settings/slack/channels/installations/${encodeURIComponent( + installationId, + )}/connections/${encodeURIComponent(connectionId)}`; +} + +export function installationRoutePath(installationId: string): string { + return `/settings/slack/channels/installations/${encodeURIComponent(installationId)}`; +} diff --git a/ui/src/pages/chat.tsx b/ui/src/pages/chat.tsx index ad57503..0b40f26 100644 --- a/ui/src/pages/chat.tsx +++ b/ui/src/pages/chat.tsx @@ -1,7 +1,7 @@ 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 } from 'lucide-react'; +import { MenuIcon, RotateCwIcon, ExternalLinkIcon, SettingsIcon } from 'lucide-react'; import { request } from '@/lib/api'; import { cn } from '@/lib/utils'; import { useConfig } from '@/lib/config'; @@ -27,7 +27,6 @@ import { Composer } from '@/components/acp/composer'; import type { ComposerHandle } from '@/components/acp/composer'; import { PermissionDialog } from '@/components/acp/permission-dialog'; import { Skeleton } from '@/components/ui/skeleton'; -import { Button } from '@/components/ui/button'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import type { ConversationInfo } from '@/types/acp'; import type { Spritz } from '@/types/spritz'; @@ -571,6 +570,20 @@ export function ChatPage() { )}
+ + + } + > + + Settings + (load: () => Promise, deps: unknown[]) { + const [value, setValue] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(true); + + const reload = useCallback(async () => { + setLoading(true); + setError(''); + try { + setValue(await load()); + } catch (err) { + setError(err instanceof Error ? err.message : 'Request failed.'); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + useEffect(() => { + reload(); + }, [reload]); + + return { value, error, loading, reload }; +} + +function SettingsShell() { + const navItems = [ + { href: '/', label: 'Chat', icon: MessageSquareIcon }, + { href: '/settings/slack/workspaces', label: 'Slack workspaces', icon: PlugIcon }, + { href: '/settings/slack/channels', label: 'Channel routing', icon: SettingsIcon }, + ]; + + return ( +
+ +
+ +
+
+ ); +} + +function PageFrame({ + title, + description, + children, + action, +}: { + title: string; + description?: string; + children: ReactNode; + action?: ReactNode; +}) { + return ( +
+
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {action} +
+ {children} +
+ ); +} + +function LoadingRows() { + return ( +
+ + + +
+ ); +} + +function ErrorBanner({ message }: { message: string }) { + if (!message) return null; + return ( +
+
+ ); +} + +function StatusBadge({ value }: { value?: string }) { + const normalized = String(value || '').trim() || 'unknown'; + return ( + + {normalized} + + ); +} + +function targetName(installation: SlackManagedInstallation): string { + return installation.currentTarget?.profile?.name || 'No saved target'; +} + +function teamID(installation: SlackManagedInstallation): string { + return installation.route.externalTenantId; +} + +function WorkspaceListPage() { + const { value, error, loading, reload } = useAsyncValue( + () => slackGatewayRequest('/api/slack/workspaces'), + [], + ); + const installations = value?.installations || []; + + const disconnect = async (installation: SlackManagedInstallation) => { + if (!window.confirm(`Disconnect ${teamID(installation)}?`)) return; + await slackGatewayRequest('/api/slack/workspaces/disconnect', { + method: 'POST', + body: JSON.stringify({ teamId: teamID(installation) }), + }); + toast.success('Workspace disconnected.'); + reload(); + }; + + return ( + + + ); +} + +function EmptyState({ text }: { text: string }) { + return ( +
+ {text} +
+ ); +} + +function ChannelListPage() { + const { value, error, loading } = useAsyncValue( + () => slackGatewayRequest('/api/settings/channels'), + [], + ); + const installations = value?.installations || []; + + return ( + + + {loading ? ( + + ) : installations.length === 0 ? ( + + ) : ( +
+ {installations.map((installation) => { + const connections = installation.connections || []; + return connections.map((connection) => ( + + {teamID(installation)} + + {connection.routes?.length || 0} configured channel{(connection.routes?.length || 0) === 1 ? '' : 's'} + + + )); + })} +
+ )} +
+ ); +} + +function InstallationPage() { + const { installationId = '' } = useParams(); + const { value, error, loading } = useAsyncValue( + () => slackGatewayRequest( + `/api/settings/channels/installations/${encodeURIComponent(installationId)}`, + ), + [installationId], + ); + const installation = value?.installation; + + return ( + + + ); +} + +function routeRequireMention(route: SlackManagedChannelRoute): boolean { + return route.requireMention !== false; +} + +function ConnectionSettingsPage() { + const { installationId = '', connectionId = '' } = useParams(); + const loadPath = `/api/settings/channels/installations/${encodeURIComponent( + installationId, + )}/connections/${encodeURIComponent(connectionId)}`; + const { value, error, loading, reload } = useAsyncValue( + () => slackGatewayRequest(loadPath), + [loadPath], + ); + const [routes, setRoutes] = useState([]); + const [channelId, setChannelId] = useState(''); + const [channelType, setChannelType] = useState('channel'); + const [requireMention, setRequireMention] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (value?.connection) { + setRoutes([...(value.connection.routes || [])]); + } + }, [value?.connection]); + + const saveRoutes = async (nextRoutes: SlackManagedChannelRoute[]) => { + setSaving(true); + try { + await slackGatewayRequest(loadPath, { + method: 'PUT', + body: JSON.stringify({ + channelPolicies: nextRoutes.map((route) => ({ + externalChannelId: route.externalChannelId, + externalChannelType: route.externalChannelType || 'channel', + requireMention: routeRequireMention(route), + })), + }), + }); + toast.success('Channel settings saved.'); + reload(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save channel settings.'); + } finally { + setSaving(false); + } + }; + + const addRoute = async () => { + const normalized = channelId.trim(); + if (!normalized) return; + const nextRoutes = [ + ...routes.filter((route) => route.externalChannelId !== normalized), + { + id: normalized, + externalChannelId: normalized, + externalChannelType: channelType, + requireMention, + enabled: true, + }, + ].sort((left, right) => left.externalChannelId.localeCompare(right.externalChannelId)); + setChannelId(''); + await saveRoutes(nextRoutes); + }; + + const updateRoute = async (externalChannelId: string, nextRequireMention: boolean) => { + await saveRoutes( + routes.map((route) => + route.externalChannelId === externalChannelId + ? { ...route, requireMention: nextRequireMention } + : route, + ), + ); + }; + + const deleteRoute = async (externalChannelId: string) => { + await saveRoutes(routes.filter((route) => route.externalChannelId !== externalChannelId)); + }; + + const installation = value?.installation; + const connection = value?.connection; + + return ( + + + ); +} + +function WorkspaceTargetPage() { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const teamId = params.get('teamId') || ''; + const { value, error, loading } = useAsyncValue( + () => slackGatewayRequest(`/api/slack/workspaces/target?teamId=${encodeURIComponent(teamId)}`), + [teamId], + ); + const [selected, setSelected] = useState(''); + const [saving, setSaving] = useState(false); + const targets = value?.targets || []; + + useEffect(() => { + if (!selected && targets[0]) setSelected(targets[0].id); + }, [selected, targets]); + + const selectedTarget = targets.find((target) => target.id === selected); + const save = async () => { + if (!selectedTarget) return; + setSaving(true); + try { + await slackGatewayRequest('/api/slack/workspaces/target', { + method: 'POST', + body: JSON.stringify({ + teamId, + requestId: value?.requestId, + presetInputs: selectedTarget.presetInputs, + }), + }); + toast.success('Workspace target updated.'); + navigate('/settings/slack/workspaces'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update target.'); + } finally { + setSaving(false); + } + }; + + return ( + } + > + + {loading ? ( + + ) : targets.length === 0 ? ( + + ) : ( + + )} +
+ +
+
+ ); +} + +function TargetPicker({ + targets, + selected, + onSelect, +}: { + targets: SlackInstallTarget[]; + selected: string; + onSelect: (id: string) => void; +}) { + return ( +
+ {targets.map((target) => ( + + ))} +
+ ); +} + +function BackToWorkspaces() { + return ( + +