From 55d1b9a634ca4f565067a04b1e1737087054bc40 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 5 Oct 2026 05:00:22 +0000 Subject: [PATCH 1/2] feat: link Slack members to Warmbly automatically by matching email, add Continue with Slack (Sign in with Slack) for mismatched emails, and move the Slack link page to a standalone /slack/link screen Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/content/docs/api/endpoints.mdx | 5 +- docs/content/docs/api/error-codes.mdx | 9 +- docs/content/docs/development/slack-app.mdx | 2 + docs/content/docs/guides/slack.mdx | 18 +- internal/api/handler/slack.go | 31 +- internal/api/routes.go | 1 + internal/app/integration/oauth.go | 10 + internal/app/integration/service.go | 3 + internal/app/integration/slack.go | 4 + internal/app/slackapp/client.go | 25 +- internal/app/slackapp/events.go | 7 +- internal/app/slackapp/home.go | 3 + internal/app/slackapp/inbox.go | 1 + internal/app/slackapp/inbox_actions.go | 2 +- internal/app/slackapp/interactivity.go | 2 +- internal/app/slackapp/links.go | 115 ++++++- internal/app/slackapp/service.go | 15 +- internal/app/slackapp/verify.go | 185 +++++++++++ internal/app/slackapp/verify_test.go | 69 ++++ internal/repository/pg_slack.go | 33 +- web/src/app/app/slack/link/page.tsx | 260 --------------- web/src/app/slack/link/page.tsx | 302 ++++++++++++++++++ .../lib/api/client/app/integrations/slack.ts | 12 +- .../lib/api/models/app/integrations/Slack.ts | 6 +- web/src/router.tsx | 10 +- 25 files changed, 819 insertions(+), 311 deletions(-) create mode 100644 internal/app/slackapp/verify.go create mode 100644 internal/app/slackapp/verify_test.go delete mode 100644 web/src/app/app/slack/link/page.tsx create mode 100644 web/src/app/slack/link/page.tsx diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 3db2e1ae8..3c2c84e49 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -686,8 +686,9 @@ The [Slack](/guides/slack/) panel's routes. They are session-only: linking binds | GET | `/integrations/slack/status` | organization member. `app_configured`, `interactive_configured` and `my_link` (always the caller's own) for everyone; `connection`, `settings` and `missing_scopes` only for `manage_settings` or `use_integrations` (otherwise absent, `{}` and `[]`); `links` (every member's link) only for `manage_settings` | | GET | `/integrations/slack/channels` | `manage_settings` or `use_integrations`. Public channels and the private channels the bot is in, filtered by `q`, at most 200, as `data` plus `pagination` | | PUT | `/integrations/slack/settings` | `manage_settings`. Default `channel`, per-category `routes`, `assistant_disabled`, `assistant_dm_only`, `inbox_channel` (a channel id from the list; empty turns the [inbox channel](/guides/slack/#inbox-in-slack) off) and `inbox_scope` (`replies`, the default, or `all`). The inbox channel must be one the bot can see and not a Slack Connect channel, else `400`. Idempotent: the whole settings object is replaced | -| GET | `/integrations/slack/link/:code` | signed in, any workspace. Previews the link a bot button carries: `organization_id`, `organization_name`, `is_member`, `slack_team_id`, `slack_team_name`, `slack_user_id`, `slack_user_name` and `slack_user_avatar` (as Slack reports them now; either may be empty), `email_matches` (the Slack account's email is the caller's Warmbly email) and `expires_at`. An unknown or expired code is `404` `slack_link_invalid` | -| POST | `/integrations/slack/link` | signed in, member of the code's workspace (else `403` `forbidden`), whose Warmbly email is the email on the code's Slack account (else `403` `slack_link_email_mismatch`, and the code stays usable). `code` from the bot's button; answers `201` with the link and replaces any earlier link of that Slack account. The code is single-use, so a repeat is refused with `404` `slack_link_invalid` | +| GET | `/integrations/slack/link/:code` | signed in, any workspace. Previews the link a bot button carries: `organization_id`, `organization_name`, `is_member`, `slack_team_id`, `slack_team_name`, `slack_user_id`, `slack_user_name` and `slack_user_avatar` (as Slack reports them now; either may be empty), `user_email` (the caller's Warmbly email), `email_matches` (the Slack account's email is the caller's Warmbly email), `verify_available` (Sign in with Slack can confirm the link) and `expires_at`. An unknown or expired code is `404` `slack_link_invalid` | +| POST | `/integrations/slack/link/verify` | signed in, member of the code's workspace (else `403` `forbidden`). `code` from the bot's button; answers `200` with `url`, a Sign in with Slack page for the code's Slack workspace. Slack returns to the integrations OAuth callback, which hands `code` and `state` to the opener. `503` `slack_verify_unavailable` when the Slack app has no client credentials | +| POST | `/integrations/slack/link` | signed in, member of the code's workspace (else `403` `forbidden`). `code` from the bot's button, plus `slack_code` and `state` from Sign in with Slack when the caller's Warmbly email is not the email on the code's Slack account (without them that is `403` `slack_link_email_mismatch`, and the code stays usable). A Sign in with Slack result must be this caller's, for this code, and for the code's Slack account (`403` `slack_verify_failed` or `slack_verify_wrong_account`). Answers `201` with the link and replaces any earlier link of that Slack account. The code is single-use, so a repeat is refused with `404` `slack_link_invalid` | | PATCH | `/integrations/slack/link` | organization member, own link. `dm_notifications`. `404` `slack_not_linked` when the caller has no link in this workspace | | DELETE | `/integrations/slack/link` | organization member, own link. Answers `204`, also when there was no link, so a repeat is safe | | DELETE | `/integrations/slack/links/:id` | `manage_settings`. Removes any member's link. Answers `204` | diff --git a/docs/content/docs/api/error-codes.mdx b/docs/content/docs/api/error-codes.mdx index b72adf1fd..d224919fc 100644 --- a/docs/content/docs/api/error-codes.mdx +++ b/docs/content/docs/api/error-codes.mdx @@ -510,12 +510,12 @@ Turning on two-factor authentication (`POST /auth/2fa/enroll/start` and `/confir #### `slack_link_email_mismatch` -A `403` whose `code` is `slack_link_email_mismatch` comes from `POST /integrations/slack/link`. Confirming a [Slack link](/guides/slack/#link-your-slack-account) needs the email on the Slack account's profile to be the signed-in user's Warmbly email, compared without regard to case. Warmbly reads the Slack email when the link is confirmed, and a Slack connection that does not share member emails (one made before Warmbly asked for `users:read.email`) never matches. The code stays usable, so the same button works once the emails match. `GET /integrations/slack/link/:code` reports the same check ahead of time as `email_matches`. +A `403` whose `code` is `slack_link_email_mismatch` comes from `POST /integrations/slack/link` sent without a Sign in with Slack result. Confirming a [Slack link](/guides/slack/#link-your-slack-account) that way needs the email on the Slack account's profile to be the signed-in user's Warmbly email, compared without regard to case. The code stays usable: confirm it again with `slack_code` and `state` from `POST /integrations/slack/link/verify`. `GET /integrations/slack/link/:code` reports the check ahead of time as `email_matches`, and whether Sign in with Slack is available as `verify_available`. ```json { "error": "Forbidden", - "message": "Your Slack account's email address must be the one you sign in to Warmbly with. Sign in with that address, or ask a Warmbly admin to reconnect Slack if Slack is not sharing your email.", + "message": "This Slack account's email is not the one you sign in to Warmbly with. Continue with Slack to confirm the account is yours.", "code": "slack_link_email_mismatch", "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e" } @@ -559,6 +559,9 @@ Returned when the requested resource doesn't exist. | `lead_cc_contact_not_found` | [Set a lead's CC](/api/reference/campaigns/#set-a-leads-cc) named a contact that is not in the workspace | | `slack_not_connected` | A [Slack](/guides/slack/) route was called for a workspace that has not connected Slack, or whose connection was removed. Connect it under **Integrations > Slack** | | `slack_link_invalid` | The Slack link code is unknown, expired or already used. Mention or message the bot in Slack for a new link button | +| `slack_verify_failed` | `POST /integrations/slack/link` with a Sign in with Slack result that is unknown, expired, already used, started by someone else or for another code. Start again from `POST /integrations/slack/link/verify` | +| `slack_verify_wrong_account` | `POST /integrations/slack/link` after Sign in with Slack signed in a different Slack account than the one the link code names | +| `slack_verify_unavailable` | `POST /integrations/slack/link/verify` on an instance whose Slack app has no client credentials | | `slack_not_linked` | `PATCH /integrations/slack/link` from a member who has not linked a Slack account in this workspace | ### 409 Conflict @@ -723,7 +726,7 @@ A `503` whose `code` is `slack_not_configured` is not transient and retrying wil - Create the instance's Slack app and set `SLACK_OAUTH_CLIENT_ID`, `SLACK_OAUTH_CLIENT_SECRET` and `SLACK_SIGNING_SECRET` on the backend and the consumer, then restart. See [Slack app](/development/slack-app/) - In a client, read `app_configured` and `interactive_configured` from `GET /integrations/slack/status` and hide the Slack affordances rather than retrying -Those request URLs answer `401` `unauthorized` to a request whose Slack signature does not verify. Linking a Slack account to a workspace you are not a member of is `403` `forbidden`, and linking one whose email is not yours is `403` [`slack_link_email_mismatch`](#slack_link_email_mismatch). +Those request URLs answer `401` `unauthorized` to a request whose Slack signature does not verify. Linking a Slack account to a workspace you are not a member of is `403` `forbidden`, and linking one whose email is not yours without signing in to it with Slack is `403` [`slack_link_email_mismatch`](#slack_link_email_mismatch). #### `mailbox_allowance_reached` diff --git a/docs/content/docs/development/slack-app.mdx b/docs/content/docs/development/slack-app.mdx index f189f1894..fb5fe8994 100644 --- a/docs/content/docs/development/slack-app.mdx +++ b/docs/content/docs/development/slack-app.mdx @@ -80,6 +80,8 @@ Every URL is on the backend's public URL, `API_PUBLIC_URL` (or `BACKEND_PUBLIC_U | **Interactivity & Shortcuts** request URL | `https:///api/v1/integrations/slack/interactivity` | | **OAuth & Permissions** redirect URL | `https:///integrations/oauth/callback`, or `INTEGRATIONS_OAUTH_REDIRECT_URL` when set | +The same redirect URL serves Sign in with Slack, which members use to link a Slack account whose email is not their Warmbly email. It needs no extra scope or setting. + Each request Slack sends is checked against the signing secret: the signature must match and its timestamp must be within five minutes, and the body is capped at 1 MiB. A request that fails is answered `401` and nothing in it is parsed. Each one is answered within Slack's three-second limit, and the work it starts runs afterwards. If the backend's public URL changes, update these URLs in the Slack app (or create the app again from the manifest with the new host and replace the credentials), and update `API_PUBLIC_URL`. diff --git a/docs/content/docs/guides/slack.mdx b/docs/content/docs/guides/slack.mdx index f55b5c900..66a759094 100644 --- a/docs/content/docs/guides/slack.mdx +++ b/docs/content/docs/guides/slack.mdx @@ -25,18 +25,21 @@ On a self-hosted instance the Slack card stays unavailable until the operator cr The assistant and the inbox buttons do things in Warmbly on your behalf, so Warmbly has to know which Warmbly member you are. Linking ties your Slack account to your Warmbly account in this workspace. Until you link, the bot can post notifications and inbox replies but will not act for you. -The member who connects Slack is linked automatically to the Slack account that approved the install, when the two use the same email. Everyone else links once: +Most members never see a link step. When the email on your Slack profile is the email of a Warmbly account that is a member of the connected workspace, Warmbly links the two the first time you mention `@Warmbly`, message it, press one of its buttons or open its Home tab. It answers straight away and sends you a DM saying it linked you. The member who connects Slack is linked the same way to the Slack account that approved the install. + +When the emails differ, or Slack does not share yours, you link once: 1. Ask the bot something: mention `@Warmbly` or message it. The bot sends you a **Link your Warmbly account** button in a DM. In a channel it also replies in the thread to say it is waiting for you, and the button itself is only ever sent to you. -2. The button opens Warmbly. Sign in if you need to, check the workspace and the Slack account it shows (its name and picture), and confirm. -3. Warmbly shows which Slack account it linked. The bot confirms in that account's DM and answers the question you asked, where you asked it. You do not have to ask again. +2. The button opens Warmbly. Sign in if you need to, and check the Slack account and the Warmbly account it shows. +3. Select **Continue with Slack**. Slack asks you to sign in to the Slack account the button was made for, which proves the account is yours whatever email it uses. When your Slack and Warmbly emails already match, the button is **Connect account** instead and needs no Slack sign-in. +4. The bot confirms in that account's DM and answers the question you asked, where you asked it. You do not have to ask again. The link button works once and expires after 15 minutes; ask the bot for a new one if it lapses. A question waiting for the link is dropped with it. You can only link to a Warmbly workspace you are a member of. -The email address on your Slack profile has to be the email you sign in to Warmbly with (capitalization does not matter). Warmbly reads it from Slack when you confirm and refuses the link when the two differ, so a link button only ever links the Slack account it was made for to the Warmbly account with the same email. If they differ, sign in to Warmbly with the address your Slack profile uses, or change one of them so they match. +A link button only ever links the Slack account it was made for: either its Slack email is your Warmbly email (capitalization does not matter), or you signed in to that Slack account with **Continue with Slack**. Signing in to a different Slack account is refused. - -Reading a Slack member's email needs the `users:read` and `users:read.email` permissions. A connection made before Warmbly asked for them lists them as missing under **Integrations > Slack**, and linking is refused until someone with **Manage settings** selects **Reconnect**. Links made before then keep working. + +Matching by email needs the `users:read` and `users:read.email` permissions. A connection made before Warmbly asked for them lists them as missing under **Integrations > Slack**. Until someone with **Manage settings** selects **Reconnect**, members link with **Continue with Slack**. Links made before then keep working. A Slack account links to one Warmbly account at a time. Linking again replaces the previous link. Unlink from the app's Home tab or from **Integrations > Slack** in Warmbly. If your membership in the workspace ends, the link stops working and the bot asks you to link again. @@ -189,7 +192,8 @@ Slack delivers other messages from channels the bot is in, including the team's | Notifications post, but the bot never answers and buttons do nothing | The Slack app is only partly configured: posting works but Slack cannot reach Warmbly. On a self-hosted instance the operator needs to set the signing secret ([Slack app](/development/slack-app/#environment-variables)) | | The Slack panel lists missing permissions | Select **Reconnect**. A feature needing a permission the connection lacks stays off until then | | The bot says it sent you a link, or replies with a **Link your Warmbly account** button | Your Slack account is not linked yet, or the link was removed. Follow the button in your DM with Warmbly; the bot then answers what you asked | -| Linking says your Slack email must match your Warmbly email | Sign in to Warmbly with the email on your Slack profile. If they already match, ask someone with **Manage settings** to select **Reconnect** under **Integrations > Slack** so Warmbly can read Slack email addresses | +| Warmbly did not link you automatically | Your Slack email is not the email of a Warmbly member of the workspace, or the connection predates email matching. Follow the link button and select **Continue with Slack** | +| **Continue with Slack** says you signed in as someone else | Slack signed you in to a different account than the one the button was made for. Sign in to Slack with that account, or ask the bot for a new link from the account you want to link | | The bot says the thread is someone else's conversation | The thread belongs to a member of a different Warmbly workspace. Start a new thread | | The bot does not answer in a channel | Check that it is a member of the channel, that the channel is not a Slack Connect channel, and that the assistant is not set to **DMs only** or **Off** | | An inbox button says you need inbox access | Your role lacks **Use unified inbox**. Ask a workspace admin | diff --git a/internal/api/handler/slack.go b/internal/api/handler/slack.go index 541050ad2..a78786803 100644 --- a/internal/api/handler/slack.go +++ b/internal/api/handler/slack.go @@ -166,12 +166,20 @@ func (h *Handler) ConfirmSlackLink(c *gin.Context) { } var req struct { Code string `json:"code" binding:"required"` + // SlackCode and State are a Sign in with Slack result, from + // POST /integrations/slack/link/verify. + SlackCode string `json:"slack_code"` + State string `json:"state"` } if err := c.ShouldBindJSON(&req); err != nil { errx.JSON(c, errx.InvalidBody(err)) return } - link, xerr := h.SlackService.ConfirmLink(c.Request.Context(), userID, req.Code) + var proof *slackapp.LinkProof + if req.SlackCode != "" { + proof = &slackapp.LinkProof{Code: req.SlackCode, State: req.State} + } + link, xerr := h.SlackService.ConfirmLink(c.Request.Context(), userID, req.Code, proof) if xerr != nil { errx.JSON(c, xerr) return @@ -184,6 +192,27 @@ func (h *Handler) ConfirmSlackLink(c *gin.Context) { c.JSON(http.StatusCreated, link) } +// StartSlackLinkVerify — POST /v1/integrations/slack/link/verify +func (h *Handler) StartSlackLinkVerify(c *gin.Context) { + _, userID, ok := h.slackCaller(c, false) + if !ok { + return + } + var req struct { + Code string `json:"code" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + errx.JSON(c, errx.InvalidBody(err)) + return + } + u, xerr := h.SlackService.StartLinkVerify(c.Request.Context(), userID, req.Code) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, gin.H{"url": u}) +} + // UpdateMySlackLink — PATCH /v1/integrations/slack/link func (h *Handler) UpdateMySlackLink(c *gin.Context) { orgID, userID, ok := h.slackCaller(c, true) diff --git a/internal/api/routes.go b/internal/api/routes.go index 244232338..bea5e9ff2 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -674,6 +674,7 @@ func Run( slackPanel.PUT("/settings", m.RequireOrganization(), slackWrite, h.UpdateSlackSettings) slackPanel.GET("/link/:code", h.PreviewSlackLink) slackPanel.POST("/link", h.ConfirmSlackLink) + slackPanel.POST("/link/verify", h.StartSlackLinkVerify) slackPanel.PATCH("/link", m.RequireOrganization(), h.UpdateMySlackLink) slackPanel.DELETE("/link", m.RequireOrganization(), h.DeleteMySlackLink) slackPanel.DELETE("/links/:id", m.RequireOrganization(), slackWrite, h.RemoveSlackLink) diff --git a/internal/app/integration/oauth.go b/internal/app/integration/oauth.go index a0c558dc6..33b6c2b76 100644 --- a/internal/app/integration/oauth.go +++ b/internal/app/integration/oauth.go @@ -158,6 +158,16 @@ func (m *OAuthManager) Configured(p models.IntegrationProvider) bool { // RedirectURL is the shared OAuth callback every provider redirects to. func (m *OAuthManager) RedirectURL() string { return m.redirectURL } +// ClientCredentials returns the provider's client id and secret, empty when +// the provider is not configured. +func (m *OAuthManager) ClientCredentials(p models.IntegrationProvider) (string, string) { + op, ok := m.providers[p] + if !ok || op.config == nil { + return "", "" + } + return op.config.ClientID, op.config.ClientSecret +} + // Scopes returns the requested scopes for a provider (empty if none/unknown). func (m *OAuthManager) Scopes(p models.IntegrationProvider) []string { if op, ok := m.providers[p]; ok { diff --git a/internal/app/integration/service.go b/internal/app/integration/service.go index ddec33181..03c5b0d43 100644 --- a/internal/app/integration/service.go +++ b/internal/app/integration/service.go @@ -185,6 +185,9 @@ type Service interface { MarkSlackTeamRevoked(ctx context.Context, teamID string, status models.IntegrationStatus, detail string) ([]uuid.UUID, error) SlackOAuthConfigured() bool SlackOAuthRedirectURL() string + // SlackOAuthClient is the Slack app's client id and secret, for Sign in + // with Slack. + SlackOAuthClient() (clientID, clientSecret string) // VerificationProviderFor and ReportVerificationProviderError implement // emailverify.ProviderSource: the org's paid verification backend, if any. diff --git a/internal/app/integration/slack.go b/internal/app/integration/slack.go index 5fdc7dba4..f7420bd7b 100644 --- a/internal/app/integration/slack.go +++ b/internal/app/integration/slack.go @@ -172,3 +172,7 @@ func (s *service) SlackOAuthConfigured() bool { func (s *service) SlackOAuthRedirectURL() string { return s.oauth.RedirectURL() } + +func (s *service) SlackOAuthClient() (string, string) { + return s.oauth.ClientCredentials(models.IntegrationSlack) +} diff --git a/internal/app/slackapp/client.go b/internal/app/slackapp/client.go index cf0916eec..7213b770b 100644 --- a/internal/app/slackapp/client.go +++ b/internal/app/slackapp/client.go @@ -94,7 +94,9 @@ func (c *Client) do(ctx context.Context, token, method, contentType string, body if err != nil { return err } - req.Header.Set("Authorization", "Bearer "+token) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } req.Header.Set("Content-Type", contentType) resp, err := c.http.Do(req) if err != nil { @@ -333,3 +335,24 @@ func (c *Client) AuthTest(ctx context.Context, token string) (*authTest, error) } return &out, nil } + +// OpenIDToken exchanges a Sign in with Slack authorization code for the +// signed-in member's OpenID Connect id_token. +func (c *Client) OpenIDToken(ctx context.Context, clientID, clientSecret, code, redirectURI string) (string, error) { + form := url.Values{ + "client_id": {clientID}, + "client_secret": {clientSecret}, + "code": {code}, + "redirect_uri": {redirectURI}, + } + var out struct { + IDToken string `json:"id_token"` + } + if err := c.callForm(ctx, "", "openid.connect.token", form, &out); err != nil { + return "", err + } + if out.IDToken == "" { + return "", &APIError{Method: "openid.connect.token", Code: "no_id_token"} + } + return out.IDToken, nil +} diff --git a/internal/app/slackapp/events.go b/internal/app/slackapp/events.go index f4d363c70..e7f6a4948 100644 --- a/internal/app/slackapp/events.go +++ b/internal/app/slackapp/events.go @@ -312,6 +312,11 @@ func (s *Service) handleAsk(ctx context.Context, a *actor, q ask) { return } if a.link == nil { + if s.autoLink(ctx, a) { + // Routing depends on who the author is, so it runs again. + s.handleAsk(ctx, a, q) + return + } s.promptLink(ctx, a, &q) return } @@ -349,7 +354,7 @@ func (s *Service) onAssistantThreadStarted(ctx context.Context, env *eventEnvelo if err := s.client.SetSuggestedPrompts(ctx, a.token, at.ChannelID, at.ThreadTS, "Try asking", suggestedPrompts); err != nil { log.Warn().Err(err).Msg("slack: suggested prompts failed") } - if a.link == nil { + if a.link == nil && !s.autoLink(ctx, a) { s.promptLink(ctx, a, &ask{Channel: at.ChannelID, ThreadTS: at.ThreadTS, DM: true}) } } diff --git a/internal/app/slackapp/home.go b/internal/app/slackapp/home.go index daf5fde21..726e20170 100644 --- a/internal/app/slackapp/home.go +++ b/internal/app/slackapp/home.go @@ -22,6 +22,9 @@ func (s *Service) publishHome(ctx context.Context, teamID, appID, slackUserID st if a == nil { return } + if a.link == nil { + s.autoLink(ctx, a) + } if err := s.client.PublishView(ctx, a.token, slackUserID, s.homeView(ctx, a, appID)); err != nil { log.Warn().Err(err).Msg("slack: publishing the home tab failed") } diff --git a/internal/app/slackapp/inbox.go b/internal/app/slackapp/inbox.go index b3dc46709..a72bae07b 100644 --- a/internal/app/slackapp/inbox.go +++ b/internal/app/slackapp/inbox.go @@ -58,6 +58,7 @@ type CampaignLookup interface { // UserLookup names the member who sent a reply; satisfied by the user repository. type UserLookup interface { GetUser(ctx context.Context, id uuid.UUID) (*models.User, error) + GetUserByEmail(ctx context.Context, email string) (*models.User, error) } // InboxDeps builds an InboxPoster. Only Integrations and Repo are required. diff --git a/internal/app/slackapp/inbox_actions.go b/internal/app/slackapp/inbox_actions.go index fb28233a5..1fb8e6ea0 100644 --- a/internal/app/slackapp/inbox_actions.go +++ b/internal/app/slackapp/inbox_actions.go @@ -150,7 +150,7 @@ func (s *Service) submitReply(ctx context.Context, p *interaction) any { return viewErrors("body", "Replying from Slack is not available on this Warmbly instance.") } a := s.resolveActor(ctx, p.teamID(), p.User.ID) - if a == nil || a.link == nil { + if a == nil || (a.link == nil && !s.autoLink(ctx, a)) { return viewErrors("body", "Link your Warmbly account first: mention @Warmbly or message it, and follow the link it sends you.") } if !a.member.Permissions.HasPermission(models.PermAccessUnibox) { diff --git a/internal/app/slackapp/interactivity.go b/internal/app/slackapp/interactivity.go index 9e1eea000..7194fc5e9 100644 --- a/internal/app/slackapp/interactivity.go +++ b/internal/app/slackapp/interactivity.go @@ -160,7 +160,7 @@ func (s *Service) requireLinked(ctx context.Context, p *interaction) *actor { if a == nil { return nil } - if a.link == nil { + if a.link == nil && !s.autoLink(ctx, a) { channel := p.channelID() dm := strings.HasPrefix(channel, "D") if channel == "" { diff --git a/internal/app/slackapp/links.go b/internal/app/slackapp/links.go index fce5e627c..e26e07e69 100644 --- a/internal/app/slackapp/links.go +++ b/internal/app/slackapp/links.go @@ -55,7 +55,7 @@ func (s *Service) mintLink(ctx context.Context, conn *models.IntegrationConnecti log.Warn().Err(err).Msg("slack: storing a link code failed") return "", "" } - return appURL("/app/slack/link?code=" + url.QueryEscape(code)), code + return appURL("/slack/link?code=" + url.QueryEscape(code)), code } // LinkPreview is GET /v1/integrations/slack/link/:code. @@ -63,6 +63,11 @@ type LinkPreview struct { OrganizationID uuid.UUID `json:"organization_id"` OrganizationName string `json:"organization_name"` IsMember bool `json:"is_member"` + // UserEmail is the signed-in Warmbly account the link would be made for. + UserEmail string `json:"user_email"` + // VerifyAvailable: Sign in with Slack can confirm the link when the + // emails do not match. + VerifyAvailable bool `json:"verify_available"` models.SlackLinkPreview } @@ -103,9 +108,11 @@ func (s *Service) PreviewLink(ctx context.Context, userID uuid.UUID, code string } if s.users != nil { if u, err := s.users.GetUser(ctx, userID); err == nil && u != nil { + out.UserEmail = u.Email out.EmailMatches = models.SlackLinkEmailMatches(prof.Email, u.Email) } } + out.VerifyAvailable = s.VerifyAvailable() if org, xerr := s.orgs.Get(ctx, c.OrganizationID); xerr == nil && org != nil { out.OrganizationName = org.Name } @@ -168,9 +175,10 @@ func (s *Service) slackProfile(ctx context.Context, c *models.SlackLinkCode) (li } // ConfirmLink redeems a code for the signed-in user, who must be an accepted -// member of the code's workspace whose email is the Slack account's email. -// The code is spent in the same transaction. -func (s *Service) ConfirmLink(ctx context.Context, userID uuid.UUID, code string) (*models.SlackUserLink, *errx.Error) { +// member of the code's workspace, and either carries a Sign in with Slack +// proof for the code's Slack account or has that account's email. The code +// is spent in the same transaction. +func (s *Service) ConfirmLink(ctx context.Context, userID uuid.UUID, code string, proof *LinkProof) (*models.SlackUserLink, *errx.Error) { code = strings.TrimSpace(code) if !validCode(code) { return nil, ErrSlackLinkInvalid @@ -182,16 +190,25 @@ func (s *Service) ConfirmLink(ctx context.Context, userID uuid.UUID, code string if c == nil { return nil, ErrSlackLinkInvalid } - prof, xerr := s.slackProfile(ctx, c) - if xerr != nil { - return nil, xerr + verified := proof != nil && proof.Code != "" + email := "" + if verified { + if xerr := s.verifyProof(ctx, userID, code, c, *proof); xerr != nil { + return nil, xerr + } + } else { + prof, xerr := s.slackProfile(ctx, c) + if xerr != nil { + return nil, xerr + } + email = prof.Email } - link, err := s.repo.ConsumeLinkCode(ctx, hashLinkCode(code), userID, prof.Email) + link, err := s.repo.ConsumeLinkCode(ctx, hashLinkCode(code), userID, email, verified) switch { case errors.Is(err, repository.ErrSlackLinkCodeInvalid): return nil, ErrSlackLinkInvalid case errors.Is(err, repository.ErrSlackLinkNotMember): - return nil, errx.New(errx.Forbidden, "You are not a member of the Warmbly workspace this link belongs to.") + return nil, errSlackLinkNotMember case errors.Is(err, repository.ErrSlackLinkEmailMismatch): return nil, ErrSlackLinkEmailMismatch case err != nil || link == nil: @@ -248,6 +265,86 @@ func (s *Service) linkInstaller(ctx context.Context, conn *models.IntegrationCon s.sendLinkConfirmation(ctx, link, false) } +// autoLinkMissTTL is how long a Slack member with no matching Warmbly +// account is left alone before their email is looked up again. +const autoLinkMissTTL = 10 * time.Minute + +// autoLink links an unlinked Slack member to the Warmbly account with their +// Slack email in any workspace connected to the team, so a member whose +// emails match never sees a link page. A miss is remembered for a while. +func (s *Service) autoLink(ctx context.Context, a *actor) bool { + if a == nil || a.link != nil || a.unknown || s.users == nil { + return false + } + missKey := "slack:autolink:miss:" + a.teamID + ":" + a.userID + if s.guard.get(ctx, missKey) != "" { + return false + } + miss := func() bool { + s.guard.put(ctx, missKey, "1", autoLinkMissTTL) + return false + } + u, err := s.client.UserInfo(ctx, a.token, a.userID) + if err != nil { + if IsAPIError(err, "missing_scope", "user_not_found") { + return miss() + } + return false + } + email := profileFrom(u).Email + if !strings.Contains(email, "@") { + return miss() + } + wu, err := s.users.GetUserByEmail(ctx, email) + if err != nil || wu == nil { + return miss() + } + conns, err := s.integ.SlackConnectionsForTeam(ctx, a.teamID) + if err != nil { + return false + } + for i := range conns { + conn := &conns[i] + link, err := s.repo.LinkInstaller(ctx, conn.OrganizationID, conn.ID, a.teamID, a.userID, email, wu.ID) + if err != nil { + log.Warn().Err(err).Msg("slack: linking by email failed") + return false + } + if link == nil { + continue + } + token := a.token + if conn.ID != a.conn.ID { + if token, err = s.integ.SlackBotToken(ctx, conn.OrganizationID, conn.ID); err != nil { + return false + } + } + m, st := s.membership(ctx, link) + if st != memberOK { + return false + } + a.conn, a.token, a.link, a.member, a.gone = conn, token, link, m, false + if s.audit != nil { + s.audit.LogAction(ctx, link.OrganizationID, link.UserID, models.AuditActionCreate, models.AuditEntityIntegration, + &link.ConnectionID, "", "Slack", nil, map[string]string{"slack_link": "linked_by_email"}) + } + s.sendAutoLinkNotice(ctx, a) + return true + } + return miss() +} + +func (s *Service) sendAutoLinkNotice(ctx context.Context, a *actor) { + dm, err := s.client.OpenDM(ctx, a.token, a.userID) + if err != nil { + return + } + text := s.linkedLine(ctx, a) + " Your Slack email matches your Warmbly account, so I linked them for you. Unlink any time from my Home tab." + if _, err := s.client.PostMessage(ctx, a.token, Message{Channel: dm, Text: "You're linked to Warmbly", Blocks: blocks(sectionBlock(text))}); err != nil { + log.Warn().Err(err).Msg("slack: auto-link notice DM failed") + } +} + // resumeAsk answers the question that was held while its author linked. func (s *Service) resumeAsk(ctx context.Context, link *models.SlackUserLink, q *ask) { a := s.resolveActor(ctx, link.SlackTeamID, link.SlackUserID) diff --git a/internal/app/slackapp/service.go b/internal/app/slackapp/service.go index a52dddbe3..d40377171 100644 --- a/internal/app/slackapp/service.go +++ b/internal/app/slackapp/service.go @@ -23,10 +23,14 @@ import ( // Stable error codes the dashboard branches on. var ( - ErrSlackNotConfigured = errx.NewWithIdentifier(errx.ServiceUnavailable, "slack_not_configured", "Slack is not set up on this Warmbly instance.") - ErrSlackNotConnected = errx.NewWithIdentifier(errx.NotFound, "slack_not_connected", "This workspace has not connected Slack.") - ErrSlackLinkInvalid = errx.NewWithIdentifier(errx.NotFound, "slack_link_invalid", "This link has expired or was already used. Ask Warmbly in Slack for a new one.") - ErrSlackLinkEmailMismatch = errx.NewWithIdentifier(errx.Forbidden, "slack_link_email_mismatch", "Your Slack account's email address must be the one you sign in to Warmbly with. Sign in with that address, or ask a Warmbly admin to reconnect Slack if Slack is not sharing your email.") + ErrSlackNotConfigured = errx.NewWithIdentifier(errx.ServiceUnavailable, "slack_not_configured", "Slack is not set up on this Warmbly instance.") + ErrSlackNotConnected = errx.NewWithIdentifier(errx.NotFound, "slack_not_connected", "This workspace has not connected Slack.") + ErrSlackLinkInvalid = errx.NewWithIdentifier(errx.NotFound, "slack_link_invalid", "This link has expired or was already used. Ask Warmbly in Slack for a new one.") + ErrSlackLinkEmailMismatch = errx.NewWithIdentifier(errx.Forbidden, "slack_link_email_mismatch", "This Slack account's email is not the one you sign in to Warmbly with. Continue with Slack to confirm the account is yours.") + ErrSlackVerifyUnavailable = errx.NewWithIdentifier(errx.ServiceUnavailable, "slack_verify_unavailable", "Signing in with Slack is not set up on this Warmbly instance.") + ErrSlackVerifyFailed = errx.NewWithIdentifier(errx.Forbidden, "slack_verify_failed", "Slack could not confirm your account. Try again.") + ErrSlackVerifyWrongAccount = errx.NewWithIdentifier(errx.Forbidden, "slack_verify_wrong_account", "You signed in to Slack as a different person than the one this link was made for. Sign in to Slack with that account and try again.") + errSlackLinkNotMember = errx.New(errx.Forbidden, "You are not a member of the Warmbly workspace this link belongs to.") ) // Timeouts for work done after Slack has been answered. @@ -50,6 +54,9 @@ type Integrations interface { MarkSlackTeamRevoked(ctx context.Context, teamID string, status models.IntegrationStatus, detail string) ([]uuid.UUID, error) SlackOAuthConfigured() bool SlackOAuthRedirectURL() string + // SlackOAuthClient is the Slack app's client id and secret, for Sign in + // with Slack. + SlackOAuthClient() (clientID, clientSecret string) } // Organizations resolves workspaces and live membership. diff --git a/internal/app/slackapp/verify.go b/internal/app/slackapp/verify.go new file mode 100644 index 000000000..5740b85f6 --- /dev/null +++ b/internal/app/slackapp/verify.go @@ -0,0 +1,185 @@ +package slackapp + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "net/url" + "strings" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" +) + +// Sign in with Slack (OpenID Connect) proves which Slack account the person on +// the link page controls, so a link can be made when the Slack and Warmbly +// emails differ or Slack does not share the email. + +const ( + oidcAuthorizeURL = "https://slack.com/openid/connect/authorize" + oidcIssuer = "https://slack.com" + verifyStateTTL = 10 * time.Minute +) + +// LinkProof is the Sign in with Slack result the link page sends back. +type LinkProof struct { + Code string + State string +} + +type verifyState struct { + CodeHash string `json:"h"` + UserID uuid.UUID `json:"u"` + Nonce string `json:"n"` +} + +func verifyKey(state string) string { return "slack:link:verify:" + state } + +func randToken() (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// VerifyAvailable reports whether Sign in with Slack can confirm a link. +func (s *Service) VerifyAvailable() bool { + id, secret := s.integ.SlackOAuthClient() + return id != "" && secret != "" && s.integ.SlackOAuthRedirectURL() != "" +} + +// StartLinkVerify returns the Sign in with Slack URL that proves the caller +// controls the Slack account a pending link code names. +func (s *Service) StartLinkVerify(ctx context.Context, userID uuid.UUID, code string) (string, *errx.Error) { + code = strings.TrimSpace(code) + if !validCode(code) { + return "", ErrSlackLinkInvalid + } + if !s.VerifyAvailable() { + return "", ErrSlackVerifyUnavailable + } + c, err := s.repo.PreviewLinkCode(ctx, hashLinkCode(code)) + if err != nil { + return "", errx.InternalError() + } + if c == nil { + return "", ErrSlackLinkInvalid + } + if m, xerr := s.orgs.GetMembership(ctx, c.OrganizationID, userID); xerr != nil || m == nil || m.AcceptedAt == nil { + return "", errSlackLinkNotMember + } + state, err := randToken() + if err != nil { + return "", errx.InternalError() + } + nonce, err := randToken() + if err != nil { + return "", errx.InternalError() + } + blob, _ := json.Marshal(verifyState{CodeHash: hex.EncodeToString(hashLinkCode(code)), UserID: userID, Nonce: nonce}) + s.guard.put(ctx, verifyKey(state), string(blob), verifyStateTTL) + + clientID, _ := s.integ.SlackOAuthClient() + q := url.Values{ + "response_type": {"code"}, + "scope": {"openid"}, + "client_id": {clientID}, + "redirect_uri": {s.integ.SlackOAuthRedirectURL()}, + "state": {state}, + "nonce": {nonce}, + "team": {c.SlackTeamID}, + } + return oidcAuthorizeURL + "?" + q.Encode(), nil +} + +// verifyProof checks a Sign in with Slack result against the link code: the +// flow was started by this user for this code, and Slack signed in the code's +// Slack account. +func (s *Service) verifyProof(ctx context.Context, userID uuid.UUID, code string, c *models.SlackLinkCode, p LinkProof) *errx.Error { + key := verifyKey(p.State) + raw := s.guard.get(ctx, key) + if p.State == "" || raw == "" { + return ErrSlackVerifyFailed + } + s.guard.del(ctx, key) + var st verifyState + if json.Unmarshal([]byte(raw), &st) != nil || st.UserID != userID || st.CodeHash != hex.EncodeToString(hashLinkCode(code)) { + return ErrSlackVerifyFailed + } + clientID, secret := s.integ.SlackOAuthClient() + tok, err := s.client.OpenIDToken(ctx, clientID, secret, p.Code, s.integ.SlackOAuthRedirectURL()) + if err != nil { + log.Warn().Err(err).Msg("slack: Sign in with Slack token exchange failed") + return ErrSlackVerifyFailed + } + claims, err := parseIDToken(tok) + if err != nil || !claims.valid(clientID, st.Nonce, time.Now()) { + return ErrSlackVerifyFailed + } + if claims.TeamID != c.SlackTeamID || claims.UserID != c.SlackUserID { + return ErrSlackVerifyWrongAccount + } + return nil +} + +type idTokenClaims struct { + Iss string `json:"iss"` + Aud audience `json:"aud"` + Exp int64 `json:"exp"` + Nonce string `json:"nonce"` + TeamID string `json:"https://slack.com/team_id"` + UserID string `json:"https://slack.com/user_id"` +} + +// audience is an id_token aud claim, a string or a list of them. +type audience []string + +func (a *audience) UnmarshalJSON(b []byte) error { + var one string + if json.Unmarshal(b, &one) == nil { + *a = audience{one} + return nil + } + var many []string + if err := json.Unmarshal(b, &many); err != nil { + return err + } + *a = many + return nil +} + +// parseIDToken reads an id_token's claims. The signature is not checked: the +// token comes straight from Slack's token endpoint over TLS, in exchange for +// this app's client secret (OpenID Connect Core 3.1.3.7). +func parseIDToken(tok string) (*idTokenClaims, error) { + parts := strings.Split(tok, ".") + if len(parts) != 3 { + return nil, errors.New("malformed id_token") + } + body, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "=")) + if err != nil { + return nil, err + } + var c idTokenClaims + if err := json.Unmarshal(body, &c); err != nil { + return nil, err + } + return &c, nil +} + +func (c *idTokenClaims) valid(clientID, nonce string, now time.Time) bool { + aud := false + for _, v := range c.Aud { + aud = aud || (v == clientID && v != "") + } + return aud && c.Iss == oidcIssuer && nonce != "" && c.Nonce == nonce && + c.Exp > now.Unix() && c.TeamID != "" && c.UserID != "" +} diff --git a/internal/app/slackapp/verify_test.go b/internal/app/slackapp/verify_test.go new file mode 100644 index 000000000..fd04c269e --- /dev/null +++ b/internal/app/slackapp/verify_test.go @@ -0,0 +1,69 @@ +package slackapp + +import ( + "encoding/base64" + "encoding/json" + "testing" + "time" +) + +func idToken(t *testing.T, claims map[string]any) string { + t.Helper() + b, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + return "e30." + base64.RawURLEncoding.EncodeToString(b) + ".sig" +} + +func TestIDTokenClaims(t *testing.T) { + now := time.Now() + base := func() map[string]any { + return map[string]any{ + "iss": oidcIssuer, + "aud": "client-1", + "exp": now.Add(time.Minute).Unix(), + "nonce": "n1", + "https://slack.com/team_id": "T1", + "https://slack.com/user_id": "U1", + } + } + c, err := parseIDToken(idToken(t, base())) + if err != nil { + t.Fatal(err) + } + if !c.valid("client-1", "n1", now) || c.TeamID != "T1" || c.UserID != "U1" { + t.Fatalf("a well-formed token must be valid, got %+v", c) + } + + list := base() + list["aud"] = []string{"other", "client-1"} + if c, err := parseIDToken(idToken(t, list)); err != nil || !c.valid("client-1", "n1", now) { + t.Fatal("an aud list containing the client must be accepted") + } + + bad := map[string]func(map[string]any){ + "issuer": func(m map[string]any) { m["iss"] = "https://evil.example" }, + "aud": func(m map[string]any) { m["aud"] = "other" }, + "expired": func(m map[string]any) { m["exp"] = now.Add(-time.Second).Unix() }, + "nonce": func(m map[string]any) { m["nonce"] = "n2" }, + "no team": func(m map[string]any) { delete(m, "https://slack.com/team_id") }, + "no user": func(m map[string]any) { delete(m, "https://slack.com/user_id") }, + } + for name, mutate := range bad { + m := base() + mutate(m) + c, err := parseIDToken(idToken(t, m)) + if err == nil && c.valid("client-1", "n1", now) { + t.Errorf("%s: token must be refused", name) + } + } + if c.valid("client-1", "", now) { + t.Error("an empty expected nonce must never match") + } + for _, tok := range []string{"", "a.b", "a.!!!.c", "a." + base64.RawURLEncoding.EncodeToString([]byte("not json")) + ".c"} { + if _, err := parseIDToken(tok); err == nil { + t.Errorf("parseIDToken(%q) must fail", tok) + } + } +} diff --git a/internal/repository/pg_slack.go b/internal/repository/pg_slack.go index c0007fdf6..6403f2afb 100644 --- a/internal/repository/pg_slack.go +++ b/internal/repository/pg_slack.go @@ -43,14 +43,15 @@ type SlackRepository interface { // PreviewLinkCode returns an unexpired code without consuming it. PreviewLinkCode(ctx context.Context, codeHash []byte) (*models.SlackLinkCode, error) // ConsumeLinkCode redeems a code for userID in one transaction: the code is - // deleted and the link written only when userID is an accepted member - // whose email is slackEmail, the code's Slack account's email. - ConsumeLinkCode(ctx context.Context, codeHash []byte, userID uuid.UUID, slackEmail string) (*models.SlackUserLink, error) + // deleted and the link written only when userID is an accepted member and + // either verified (Sign in with Slack proved the Slack account) or the + // member's email is slackEmail, the code's Slack account's email. + ConsumeLinkCode(ctx context.Context, codeHash []byte, userID uuid.UUID, slackEmail string, verified bool) (*models.SlackUserLink, error) PurgeExpiredLinkCodes(ctx context.Context) (int64, error) - // LinkInstaller links the member who connected Slack to the Slack account - // that approved the install, when neither is linked yet, the member is an - // accepted member of the org, and slackEmail is the member's email (the - // rule ConsumeLinkCode applies). Nil when nothing was written. + // LinkInstaller links a Warmbly member to a Slack account (the installer, + // or a member matched by email) when neither is linked yet, the member is + // an accepted member of the org, and slackEmail is the member's email. + // Nil when nothing was written. LinkInstaller(ctx context.Context, orgID, connectionID uuid.UUID, teamID, slackUserID, slackEmail string, userID uuid.UUID) (*models.SlackUserLink, error) GetAgentThread(ctx context.Context, connectionID uuid.UUID, channelID, threadTS string) (*models.SlackAgentThread, error) @@ -208,7 +209,7 @@ func (r *slackRepository) PreviewLinkCode(ctx context.Context, codeHash []byte) return &c, nil } -func (r *slackRepository) ConsumeLinkCode(ctx context.Context, codeHash []byte, userID uuid.UUID, slackEmail string) (*models.SlackUserLink, error) { +func (r *slackRepository) ConsumeLinkCode(ctx context.Context, codeHash []byte, userID uuid.UUID, slackEmail string, verified bool) (*models.SlackUserLink, error) { tx, err := r.DB.Begin(ctx) if err != nil { return nil, err @@ -240,13 +241,15 @@ func (r *slackRepository) ConsumeLinkCode(ctx context.Context, codeHash []byte, // Rolled back: the code stays redeemable by a real member. return nil, ErrSlackLinkNotMember } - var userEmail string - if err := tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&userEmail); err != nil && !isNoRows(err) { - return nil, err - } - if !models.SlackLinkEmailMatches(slackEmail, userEmail) { - // Rolled back, like a non-member. - return nil, ErrSlackLinkEmailMismatch + if !verified { + var userEmail string + if err := tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&userEmail); err != nil && !isNoRows(err) { + return nil, err + } + if !models.SlackLinkEmailMatches(slackEmail, userEmail) { + // Rolled back, like a non-member. + return nil, ErrSlackLinkEmailMismatch + } } // One Slack member per workspace and one link per member per connection. diff --git a/web/src/app/app/slack/link/page.tsx b/web/src/app/app/slack/link/page.tsx deleted file mode 100644 index 13a8be1f8..000000000 --- a/web/src/app/app/slack/link/page.tsx +++ /dev/null @@ -1,260 +0,0 @@ -// /app/slack/link?code=…: where the Warmbly bot in Slack sends a member to -// bind their Slack account to their Warmbly account. Shows both sides, then -// links on an explicit confirm. - -import React from "react"; -import { Link } from "@tanstack/react-router"; -import { useSearchParams } from "@/hooks/useSearchParams"; -import { AnimatePresence, motion } from "framer-motion"; -import { ArrowRightIcon, BuildingIcon, CheckIcon, ExternalLinkIcon, Loader2Icon, TriangleAlertIcon } from "lucide-react"; -import toast from "react-hot-toast"; - -import { Page, PageBody, PageTopbar } from "@/components/layout/Page"; -import ProviderGlyph from "@/app/app/integrations/_components/ProviderGlyph"; -import { useConfirmSlackLink, useSlackLinkPreview } from "@/lib/api/hooks/app/integrations/useSlack"; -import type { SlackUserLink } from "@/lib/api/models/app/integrations/Slack"; -import type { AppError } from "@/lib/api/client/normalizeError"; -import { errorMessage } from "@/lib/errors/message"; - -export default function SlackLinkPage() { - const [params] = useSearchParams(); - const code = (params.get("code") ?? "").trim(); - const preview = useSlackLinkPreview(code); - const confirm = useConfirmSlackLink(); - const [linked, setLinked] = React.useState(null); - - async function onConfirm() { - try { - setLinked(await confirm.mutateAsync(code)); - } catch (err) { - const e = err as AppError; - if (e.code === "slack_link_email_mismatch") { - toast.error("Your Slack email must match your Warmbly email"); - void preview.refetch(); - } else if (e.status === 403) { - toast.error("You are not a member of that workspace"); - } else if (e.status === 404 || e.code === "slack_link_invalid") { - toast.error("This link has expired. Ask for a new one in Slack."); - void preview.refetch(); - } else { - toast.error(errorMessage(err, "Could not link your Slack account")); - } - } - } - - let body: React.ReactNode; - if (!code) { - body = ( - - ); - } else if (linked) { - body = ( - - ); - } else if (preview.isPending) { - body = ( -
- -
- ); - } else if (preview.isError || !preview.data) { - const e = preview.error as unknown as AppError | null; - const expired = e?.status === 404 || e?.code === "slack_link_invalid"; - body = ( - - ); - } else { - const p = preview.data; - body = ( -
-
-

Link your Slack account

-

- The Warmbly assistant will act as you in Slack, with your permissions in this workspace, and - can send your notifications as DMs. -

-
- -
- - ) : ( - - ) - } - /> -
- -
- - - - } - /> -
- - {!p.is_member && ( -
- - - You are not a member of {p.organization_name}. Only its members can link a Slack account - to it. Ask someone who manages the team to invite you, or sign in with the account that - belongs to it. - -
- )} - - {p.is_member && !p.email_matches && ( -
- - - This Slack account's email is not the address you sign in to Warmbly with, and the two - have to match. Sign in to Warmbly with the email your Slack profile uses. If they already - match, ask a Warmbly admin to reconnect Slack so Warmbly can read Slack email addresses. - -
- )} - -
- - Expires {new Date(p.expires_at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} - - -
-
- ); - } - - return ( - - - -
- - - {body} - - -
-
-
- ); -} - -function Side({ label, title, sub, glyph }: { label: string; title: string; sub: React.ReactNode; glyph: React.ReactNode }) { - return ( -
- {glyph} -
-
{label}
-
{title}
-
{sub}
-
-
- ); -} - -function Linked({ - link, - orgName, - slackName, - teamName, -}: { - link: SlackUserLink; - orgName?: string; - slackName: string; - teamName?: string; -}) { - return ( -
-
- -
-
-

Your Slack account is linked

-

- Linked {slackName} - {teamName ? ` in ${teamName}` : ""} -

-

- {orgName ? `Ask Warmbly anything about ${orgName} from Slack.` : "Ask Warmbly anything from Slack."}{" "} - The app has sent you a confirmation there. -

-
-
- - - Return to Slack - - - Slack settings - -
-
- ); -} - -function Problem({ title, body }: { title: string; body: string }) { - return ( -
-
- -
-
-

{title}

-

{body}

-
-
- ); -} diff --git a/web/src/app/slack/link/page.tsx b/web/src/app/slack/link/page.tsx new file mode 100644 index 000000000..2cfface91 --- /dev/null +++ b/web/src/app/slack/link/page.tsx @@ -0,0 +1,302 @@ +// /slack/link?code=…: where the Warmbly app in Slack sends a member to link +// their Slack account. Standalone on the auth screen's sky, like /connect. + +import React from "react"; +import { Link, Navigate } from "@tanstack/react-router"; +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon, ExternalLinkIcon, LinkIcon, Loader2Icon, TriangleAlertIcon } from "lucide-react"; +import toast from "react-hot-toast"; + +import BrandMark from "@/components/shared/BrandMark"; +import { Logo } from "@/components/svg"; +import ProviderGlyph from "@/app/app/integrations/_components/ProviderGlyph"; +import { useSearchParams } from "@/hooks/useSearchParams"; +import getToken from "@/lib/helper/getToken"; +import { authorizeInPopup } from "@/lib/integrations/oauthPopup"; +import { startSlackLinkVerify } from "@/lib/api/client/app/integrations/slack"; +import { useConfirmSlackLink, useSlackLinkPreview } from "@/lib/api/hooks/app/integrations/useSlack"; +import type { SlackLinkPreview, SlackUserLink } from "@/lib/api/models/app/integrations/Slack"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import { errorMessage } from "@/lib/errors/message"; + +export default function SlackLinkPage() { + if (!getToken()) { + const next = window.location.pathname + window.location.search; + return ; + } + return ; +} + +function SlackLinkInner() { + const [params] = useSearchParams(); + const code = (params.get("code") ?? "").trim(); + const preview = useSlackLinkPreview(code); + const confirm = useConfirmSlackLink(); + const [linked, setLinked] = React.useState(null); + const [verifying, setVerifying] = React.useState(false); + + function onError(err: unknown) { + const e = err as AppError; + if (e?.status === 404 || e?.code === "slack_link_invalid") { + toast.error("This link has expired. Ask Warmbly in Slack for a new one."); + void preview.refetch(); + return; + } + if (err instanceof Error && err.message === "access_denied") { + toast.error("Slack sign-in was cancelled."); + return; + } + if (e?.code === "slack_link_email_mismatch") void preview.refetch(); + toast.error(errorMessage(err, "Could not link your Slack account")); + } + + async function onConnect() { + try { + setLinked(await confirm.mutateAsync({ code })); + } catch (err) { + onError(err); + } + } + + async function onVerify() { + setVerifying(true); + try { + const res = await authorizeInPopup(() => startSlackLinkVerify(code).then((r) => r.url)); + setLinked(await confirm.mutateAsync({ code, slack_code: res.code, state: res.state })); + } catch (err) { + onError(err); + } finally { + setVerifying(false); + } + } + + let key: string; + let content: React.ReactNode; + if (!code) { + key = "missing"; + content = ; + } else if (linked) { + key = "done"; + content = ; + } else if (preview.isPending) { + key = "loading"; + content = ( +
+ +
+ ); + } else if (preview.isError || !preview.data) { + const e = preview.error as unknown as AppError | null; + const expired = e?.status === 404 || e?.code === "slack_link_invalid"; + key = "error"; + content = ( + + ); + } else if (!preview.data.is_member) { + const p = preview.data; + key = "not-member"; + content = ( + + ); + } else { + key = "review"; + content = void onConnect()} onVerify={() => void onVerify()} />; + } + + return ( +
+ ); }