mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-05 16:02:10 +00:00
Merge pull request #837 from warmbly/devin/1791175783-slack-link-auto-and-oidc
feat: auto-link Slack members by email, Continue with Slack for mismatched emails, standalone /slack/link page
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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://<backend>/api/v1/integrations/slack/interactivity` |
|
||||
| **OAuth & Permissions** redirect URL | `https://<backend>/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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Callout type="info" title="Connections made before email matching need a reconnect">
|
||||
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.
|
||||
<Callout type="info" title="Automatic linking needs Slack emails">
|
||||
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.
|
||||
</Callout>
|
||||
|
||||
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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -679,6 +679,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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../lib/proof.ts";
|
||||
|
||||
// Slack isn't configured in the local stack; only these synthetic codes are stubbed.
|
||||
const preview = {
|
||||
organization_id: "11111111-1111-4111-8111-111111111111",
|
||||
organization_name: "Acme Labs",
|
||||
is_member: true,
|
||||
slack_team_id: "TQA123",
|
||||
slack_team_name: "Acme Slack",
|
||||
slack_user_id: "UQA123",
|
||||
slack_user_name: "Alex Morgan",
|
||||
slack_user_avatar: "",
|
||||
user_email: "dev@warmbly.com",
|
||||
email_matches: true,
|
||||
verify_available: true,
|
||||
expires_at: "2030-01-01T15:45:00Z",
|
||||
};
|
||||
|
||||
async function mockPreview(page: Page, code: string, overrides: Partial<typeof preview> = {}) {
|
||||
await page.route(`**/v1/integrations/slack/link/${code}`, async (route) => {
|
||||
expect(route.request().method()).toBe("GET");
|
||||
await route.fulfill({ json: { ...preview, ...overrides } });
|
||||
});
|
||||
}
|
||||
|
||||
async function reviewVisible(page: Page) {
|
||||
await expect(page.getByRole("heading", { name: "Connect Slack to Warmbly" })).toBeVisible();
|
||||
await expect(page.getByText("Alex Morgan", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Acme Slack", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("dev@warmbly.com", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Back to dashboard" })).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("signed out", () => {
|
||||
test.use({ signedIn: false });
|
||||
test("Slack links send signed-out users to login and preserve the destination", async ({ page, proof }) => {
|
||||
await page.goto("/slack/link?code=proof-signed-out");
|
||||
await expect(page).toHaveURL((url) =>
|
||||
url.pathname === "/auth/login" && url.searchParams.get("next") === "/slack/link?code=proof-signed-out");
|
||||
await expect(page.getByPlaceholder("name@company.com")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeVisible();
|
||||
await proof.chapter("Sign in first", "Real local routing preserves the Slack link as the next destination");
|
||||
await proof.dwell();
|
||||
await proof.shot("signed-out-redirect", { caption: "Real redirect to login; next retains /slack/link?code=proof-signed-out" });
|
||||
});
|
||||
});
|
||||
|
||||
test("matching Slack email connects the account on the standalone page", async ({ page, proof }) => {
|
||||
await mockPreview(page, "proof-matching");
|
||||
const confirmations: unknown[] = [];
|
||||
await page.route("**/v1/integrations/slack/link", async (route) => {
|
||||
expect(route.request().method()).toBe("POST");
|
||||
confirmations.push(route.request().postDataJSON());
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
json: {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
organization_id: preview.organization_id,
|
||||
connection_id: "33333333-3333-4333-8333-333333333333",
|
||||
slack_team_id: preview.slack_team_id,
|
||||
slack_user_id: preview.slack_user_id,
|
||||
user_id: "44444444-4444-4444-8444-444444444444",
|
||||
dm_notifications: true,
|
||||
created_at: "2030-01-01T15:30:00Z",
|
||||
updated_at: "2030-01-01T15:30:00Z",
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto("/slack/link?code=proof-matching");
|
||||
await reviewVisible(page);
|
||||
await expect(page.getByRole("button", { name: "Connect account", exact: true })).toBeEnabled();
|
||||
await expect(page.getByText("Your Slack and Warmbly emails match.")).toBeVisible();
|
||||
await proof.chapter("Matching emails", "Fixture preview: check both identities, then connect");
|
||||
await proof.dwell();
|
||||
await proof.shot("review-matching-email", { caption: "Stubbed Slack preview: matching email permits Connect account" });
|
||||
await page.getByRole("button", { name: "Connect account", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "You're connected", exact: true })).toBeVisible();
|
||||
expect(confirmations).toEqual([{ code: "proof-matching" }]);
|
||||
await expect(page.getByRole("link", { name: "Return to Slack" })).toHaveAttribute("href", "https://app.slack.com/client/TQA123");
|
||||
await expect(page.getByRole("link", { name: "Slack settings", exact: true })).toHaveAttribute("href", "/app/integrations");
|
||||
await proof.chapter("Connected", "Stubbed confirmation response; no real Slack message was sent");
|
||||
await proof.dwell();
|
||||
await proof.shot("done-connected", { caption: "After Connect account: stubbed 201 shows success and both return links" });
|
||||
});
|
||||
|
||||
test("mismatched Slack email offers verification and adapts to mobile", async ({ page, proof }) => {
|
||||
await mockPreview(page, "proof-mismatch", { email_matches: false });
|
||||
await mockPreview(page, "proof-unavailable", { email_matches: false, verify_available: false });
|
||||
await page.goto("/slack/link?code=proof-mismatch");
|
||||
await reviewVisible(page);
|
||||
const verify = page.getByRole("button", { name: "Continue with Slack", exact: true });
|
||||
await expect(verify).toBeEnabled();
|
||||
await expect(page.getByRole("button", { name: "Connect account", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Your Slack email is different, so Slack will confirm this account is yours. It takes one click.")).toBeVisible();
|
||||
await proof.chapter("Different emails", "Fixture preview: Continue with Slack; live OAuth not exercised");
|
||||
await proof.dwell();
|
||||
await proof.shot("review-mismatched-email", { caption: "Stubbed mismatch preview offers Continue with Slack" });
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await reviewVisible(page);
|
||||
await expect(verify).toBeInViewport();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
|
||||
await proof.dwell();
|
||||
await proof.shot("review-mobile", { caption: "375×812 mobile review using stubbed mismatch preview" });
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
await page.goto("/slack/link?code=proof-unavailable");
|
||||
await reviewVisible(page);
|
||||
await expect(page.getByRole("button", { name: "Connect account", exact: true })).toBeDisabled();
|
||||
await expect(page.getByText("Your Slack email is not the one you use for Warmbly. Sign in to Warmbly with the email on your Slack profile to link.")).toBeVisible();
|
||||
await proof.chapter("Verification unavailable", "Fixture preview: disabled action explains how to proceed");
|
||||
await proof.dwell();
|
||||
await proof.shot("review-verification-unavailable", { caption: "Stubbed unavailable verification: disabled Connect account and recovery instructions" });
|
||||
});
|
||||
|
||||
test("Slack links explain invalid access and forward legacy URLs", async ({ page, proof }) => {
|
||||
await mockPreview(page, "proof-not-member", { is_member: false });
|
||||
await mockPreview(page, "x");
|
||||
await page.goto("/slack/link?code=proof-not-member");
|
||||
await expect(page.getByRole("heading", { name: "You're not in Acme Labs", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Connect account|Continue with Slack/ })).toHaveCount(0);
|
||||
await proof.chapter("Workspace membership", "Fixture preview: this account is not a member");
|
||||
await proof.dwell();
|
||||
await proof.shot("not-member", { caption: "Stubbed non-member preview explains the required workspace membership" });
|
||||
const response = page.waitForResponse((res) => new URL(res.url()).pathname === "/v1/integrations/slack/link/proof-invalid");
|
||||
await page.goto("/slack/link?code=proof-invalid");
|
||||
const invalid = await response;
|
||||
expect(invalid.status()).toBe(404);
|
||||
expect(await invalid.json()).toMatchObject({ code: "slack_link_invalid" });
|
||||
await expect(page.getByRole("heading", { name: "This link has expired", exact: true })).toBeVisible();
|
||||
await proof.chapter("Expired link", "Real local API: 404 slack_link_invalid for an unknown code");
|
||||
await proof.dwell();
|
||||
await proof.shot("expired-link", { caption: "Real local 404 slack_link_invalid renders the expired-link state" });
|
||||
const previewRequests: string[] = [];
|
||||
const trackPreview = (req: { url(): string }) => {
|
||||
if (req.url().includes("/v1/integrations/slack/link/")) previewRequests.push(req.url());
|
||||
};
|
||||
page.on("request", trackPreview);
|
||||
await page.goto("/slack/link");
|
||||
await expect(page.getByRole("heading", { name: "This link is missing its code", exact: true })).toBeVisible();
|
||||
await proof.dwell();
|
||||
expect(previewRequests).toEqual([]);
|
||||
page.off("request", trackPreview);
|
||||
await proof.shot("missing-code", { caption: "Real missing-code UI; no preview request was made" });
|
||||
await page.goto("/app/slack/link?code=x");
|
||||
await expect(page).toHaveURL((url) => url.pathname === "/slack/link" && url.search === "?code=x");
|
||||
await reviewVisible(page);
|
||||
await proof.chapter("Old links still work", "Real /app/slack/link?code=x forward; stubbed review data");
|
||||
await proof.dwell();
|
||||
await proof.shot("legacy-route-forward", { caption: "Legacy route preserves ?code=x and renders the standalone page (stubbed preview)" });
|
||||
});
|
||||
@@ -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<SlackUserLink | null>(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 = (
|
||||
<Problem
|
||||
title="This link is missing its code"
|
||||
body="Open the link from the message the Warmbly app sent you in Slack, or mention @Warmbly to get a new one."
|
||||
/>
|
||||
);
|
||||
} else if (linked) {
|
||||
body = (
|
||||
<Linked
|
||||
link={linked}
|
||||
orgName={preview.data?.organization_name}
|
||||
slackName={preview.data?.slack_user_name || linked.slack_user_id}
|
||||
teamName={preview.data?.slack_team_name}
|
||||
/>
|
||||
);
|
||||
} else if (preview.isPending) {
|
||||
body = (
|
||||
<div className="py-16 flex justify-center">
|
||||
<Loader2Icon className="w-4 h-4 animate-spin text-slate-400" />
|
||||
</div>
|
||||
);
|
||||
} 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 = (
|
||||
<Problem
|
||||
title={expired ? "This link has expired or was already used" : "Could not open this link"}
|
||||
body={
|
||||
expired
|
||||
? "Links from Slack work once and only for a short time. Mention @Warmbly in Slack to get a new one."
|
||||
: errorMessage(e, "Try again in a moment.")
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const p = preview.data;
|
||||
body = (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-[15px] font-semibold text-slate-900">Link your Slack account</h1>
|
||||
<p className="text-[12px] text-slate-500 leading-relaxed">
|
||||
The Warmbly assistant will act as you in Slack, with your permissions in this workspace, and
|
||||
can send your notifications as DMs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-stretch gap-2">
|
||||
<Side
|
||||
label="Slack"
|
||||
title={p.slack_user_name || p.slack_user_id}
|
||||
sub={p.slack_team_name || "Slack workspace"}
|
||||
glyph={
|
||||
p.slack_user_avatar ? (
|
||||
<img
|
||||
src={p.slack_user_avatar}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
className="size-7 rounded-md object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<ProviderGlyph provider="slack" name="Slack" size={7} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-center text-slate-300 shrink-0 rotate-90 sm:rotate-0">
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<Side
|
||||
label="Warmbly"
|
||||
title={p.organization_name}
|
||||
sub="Workspace"
|
||||
glyph={
|
||||
<span className="size-7 rounded-md bg-sky-50 text-sky-600 flex items-center justify-center">
|
||||
<BuildingIcon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!p.is_member && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2.5 flex items-start gap-2 text-[11.5px] text-amber-900 leading-relaxed">
|
||||
<TriangleAlertIcon className="w-3.5 h-3.5 mt-0.5 shrink-0 text-amber-500" />
|
||||
<span>
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{p.is_member && !p.email_matches && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2.5 flex items-start gap-2 text-[11.5px] text-amber-900 leading-relaxed">
|
||||
<TriangleAlertIcon className="w-3.5 h-3.5 mt-0.5 shrink-0 text-amber-500" />
|
||||
<span>
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] text-slate-400">
|
||||
Expires {new Date(p.expires_at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onConfirm()}
|
||||
disabled={!p.is_member || !p.email_matches || confirm.isPending}
|
||||
className="h-8 px-3.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12.5px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{confirm.isPending && <Loader2Icon className="w-3.5 h-3.5 animate-spin" />}
|
||||
Link account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageTopbar eyebrow="Slack" subtitle="Link your account" />
|
||||
<PageBody>
|
||||
<div className="px-5 py-10 flex justify-center">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={linked ? "done" : "review"}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-full max-w-[460px] rounded-lg border border-slate-200 bg-white p-5 shadow-[0_8px_24px_-12px_rgba(15,23,42,0.18)]"
|
||||
>
|
||||
{body}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function Side({ label, title, sub, glyph }: { label: string; title: string; sub: React.ReactNode; glyph: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex-1 min-w-0 rounded-md border border-slate-200 px-3 py-2.5 flex items-center gap-2.5">
|
||||
{glyph}
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{label}</div>
|
||||
<div className="text-[12.5px] font-medium text-slate-900 truncate">{title}</div>
|
||||
<div className="text-[11px] text-slate-500 truncate">{sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Linked({
|
||||
link,
|
||||
orgName,
|
||||
slackName,
|
||||
teamName,
|
||||
}: {
|
||||
link: SlackUserLink;
|
||||
orgName?: string;
|
||||
slackName: string;
|
||||
teamName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto size-10 rounded-full bg-emerald-50 text-emerald-600 flex items-center justify-center">
|
||||
<CheckIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-[15px] font-semibold text-slate-900">Your Slack account is linked</h1>
|
||||
<p className="text-[12px] text-slate-700">
|
||||
Linked <span className="font-medium">{slackName}</span>
|
||||
{teamName ? ` in ${teamName}` : ""}
|
||||
</p>
|
||||
<p className="text-[12px] text-slate-500 leading-relaxed">
|
||||
{orgName ? `Ask Warmbly anything about ${orgName} from Slack.` : "Ask Warmbly anything from Slack."}{" "}
|
||||
The app has sent you a confirmation there.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<a
|
||||
href={`https://app.slack.com/client/${encodeURIComponent(link.slack_team_id)}`}
|
||||
className="h-8 px-3.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<ExternalLinkIcon className="w-3.5 h-3.5" />
|
||||
Return to Slack
|
||||
</a>
|
||||
<Link
|
||||
to="/app/integrations"
|
||||
className="h-8 px-3 rounded-md border border-slate-200 hover:border-slate-300 text-[12.5px] text-slate-700 inline-flex items-center transition-colors"
|
||||
>
|
||||
Slack settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Problem({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="space-y-3 text-center">
|
||||
<div className="mx-auto size-10 rounded-full bg-amber-50 text-amber-600 flex items-center justify-center">
|
||||
<TriangleAlertIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-[15px] font-semibold text-slate-900">{title}</h1>
|
||||
<p className="text-[12px] text-slate-500 leading-relaxed">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <Navigate to="/auth/login" search={{ next }} replace />;
|
||||
}
|
||||
return <SlackLinkInner />;
|
||||
}
|
||||
|
||||
function SlackLinkInner() {
|
||||
const [params] = useSearchParams();
|
||||
const code = (params.get("code") ?? "").trim();
|
||||
const preview = useSlackLinkPreview(code);
|
||||
const confirm = useConfirmSlackLink();
|
||||
const [linked, setLinked] = React.useState<SlackUserLink | null>(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 = <Problem title="This link is missing its code" body="Open the link from the message Warmbly sent you in Slack, or mention @Warmbly to get a new one." />;
|
||||
} else if (linked) {
|
||||
key = "done";
|
||||
content = <Done link={linked} p={preview.data} />;
|
||||
} else if (preview.isPending) {
|
||||
key = "loading";
|
||||
content = (
|
||||
<div className="py-20 flex justify-center">
|
||||
<Loader2Icon className="w-5 h-5 animate-spin text-slate-400" />
|
||||
</div>
|
||||
);
|
||||
} 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 = (
|
||||
<Problem
|
||||
title={expired ? "This link has expired" : "Could not open this link"}
|
||||
body={expired ? "Links from Slack work once, for 15 minutes. Mention @Warmbly in Slack and it will send you a fresh one." : errorMessage(e, "Try again in a moment.")}
|
||||
/>
|
||||
);
|
||||
} else if (!preview.data.is_member) {
|
||||
const p = preview.data;
|
||||
key = "not-member";
|
||||
content = (
|
||||
<Problem
|
||||
title={`You're not in ${p.organization_name || "this workspace"}`}
|
||||
body={`Only members of ${p.organization_name || "the workspace"} can link a Slack account to it${p.user_email ? `, and you're signed in as ${p.user_email}` : ""}. Ask an admin to invite you, or sign in with the account that belongs to it.`}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
key = "review";
|
||||
content = <Review p={preview.data} connecting={confirm.isPending && !verifying} verifying={verifying} onConnect={() => void onConnect()} onVerify={() => void onVerify()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-dvh w-full overflow-hidden flex flex-col items-center justify-center px-4 py-8 sm:py-10">
|
||||
<div className="absolute inset-0" aria-hidden="true">
|
||||
<div className="sky-base" />
|
||||
<div className="sky-breathe" />
|
||||
<div className="sun-glow" />
|
||||
<img src="/backdrops/cloud-3.webp" alt="" decoding="async" className="cloud-drift cloud-1 absolute select-none" style={{ top: "6%", left: "-10%", width: 360, opacity: 0.55, height: "auto" }} />
|
||||
<img src="/backdrops/cloud-4.webp" alt="" decoding="async" className="cloud-drift cloud-2 absolute select-none" style={{ bottom: "8%", right: "-8%", width: 320, opacity: 0.5, height: "auto" }} />
|
||||
</div>
|
||||
<div className="relative z-10 w-full max-w-[460px]">
|
||||
<BrandMark className="mb-5 flex w-fit items-center gap-2.5 mx-auto" />
|
||||
<motion.div
|
||||
initial={{ y: 14, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="animate-card-float rounded-3xl border border-slate-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04),0_30px_70px_-32px_rgba(15,23,42,0.32)] overflow-hidden"
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={key}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="px-6 py-8 sm:px-9 sm:py-9"
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
<div className="theme-solid mt-5 flex items-center justify-center gap-3 text-[12px] text-white/90">
|
||||
<Link to="/app/integrations" className="hover:text-white transition-colors">
|
||||
Back to dashboard
|
||||
</Link>
|
||||
<span className="text-white/60">·</span>
|
||||
<a href="https://docs.warmbly.com/guides/slack/#link-your-slack-account" target="_blank" rel="noreferrer" className="hover:text-white transition-colors">
|
||||
How linking works
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pair({ avatar }: { avatar?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="size-14 rounded-2xl border border-slate-200 bg-white shadow-sm flex items-center justify-center overflow-hidden">
|
||||
{avatar ? <img src={avatar} alt="" referrerPolicy="no-referrer" className="size-full object-cover" /> : <ProviderGlyph provider="slack" name="Slack" size={10} />}
|
||||
</span>
|
||||
<span className="relative w-16 flex items-center">
|
||||
<span className="w-full border-t border-dashed border-slate-300" />
|
||||
<span className="absolute left-1/2 -translate-x-1/2 size-7 rounded-full bg-sky-50 text-sky-600 ring-4 ring-white flex items-center justify-center">
|
||||
<LinkIcon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="size-14 rounded-2xl bg-sky-600 shadow-sm flex items-center justify-center">
|
||||
<Logo className="w-7 text-white" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Review({ p, connecting, verifying, onConnect, onVerify }: { p: SlackLinkPreview; connecting: boolean; verifying: boolean; onConnect: () => void; onVerify: () => void }) {
|
||||
const busy = connecting || verifying;
|
||||
const expires = new Date(p.expires_at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
return (
|
||||
<div>
|
||||
<Pair avatar={p.slack_user_avatar} />
|
||||
<h1 className="mt-6 text-center text-[22px] sm:text-[24px] font-semibold tracking-[-0.03em] leading-[1.15] text-slate-900">Connect Slack to Warmbly</h1>
|
||||
<p className="mt-2 text-center text-[13.5px] text-slate-500 leading-relaxed max-w-sm mx-auto">
|
||||
Warmbly will answer you in Slack as yourself, with your permissions in <span className="font-medium text-slate-700">{p.organization_name}</span>.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 rounded-xl border border-slate-200 divide-y divide-slate-100">
|
||||
<Account
|
||||
glyph={
|
||||
p.slack_user_avatar ? (
|
||||
<img src={p.slack_user_avatar} alt="" referrerPolicy="no-referrer" className="size-7 rounded-md object-cover" />
|
||||
) : (
|
||||
<ProviderGlyph provider="slack" name="Slack" size={7} />
|
||||
)
|
||||
}
|
||||
label="Slack"
|
||||
title={p.slack_user_name || "Your Slack account"}
|
||||
sub={p.slack_team_name || "Slack workspace"}
|
||||
/>
|
||||
<Account
|
||||
glyph={
|
||||
<span className="size-7 rounded-md bg-sky-600 flex items-center justify-center">
|
||||
<Logo className="w-4 text-white" />
|
||||
</span>
|
||||
}
|
||||
label="Warmbly"
|
||||
title={p.user_email || "Your Warmbly account"}
|
||||
sub={p.organization_name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{p.email_matches || !p.verify_available ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnect}
|
||||
disabled={busy || !p.email_matches}
|
||||
className="w-full h-11 rounded-lg bg-sky-600 hover:bg-sky-700 text-white text-[14px] font-medium inline-flex items-center justify-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{connecting && <Loader2Icon className="w-4 h-4 animate-spin" />}
|
||||
Connect account
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVerify}
|
||||
disabled={busy}
|
||||
className="w-full h-11 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 hover:border-slate-300 text-slate-900 text-[14px] font-medium inline-flex items-center justify-center gap-2.5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span aria-hidden="true" className="inline-flex">{verifying ? <Loader2Icon className="w-4 h-4 animate-spin text-slate-500" /> : <ProviderGlyph provider="slack" name="Slack" size={7} />}</span>
|
||||
Continue with Slack
|
||||
</button>
|
||||
)}
|
||||
<p className="mt-3 text-center text-[12px] text-slate-500 leading-relaxed">
|
||||
{p.email_matches
|
||||
? "Your Slack and Warmbly emails match."
|
||||
: p.verify_available
|
||||
? "Your Slack email is different, so Slack will confirm this account is yours. It takes one click."
|
||||
: "Your Slack email is not the one you use for Warmbly. Sign in to Warmbly with the email on your Slack profile to link."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-[11.5px] text-slate-500">This link works once and expires at {expires}.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Account({ glyph, label, title, sub }: { glyph: React.ReactNode; label: string; title: string; sub: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<span className="shrink-0">{glyph}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13.5px] font-medium text-slate-900 truncate">{title}</div>
|
||||
<div className="text-[12px] text-slate-500 truncate">{sub}</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10.5px] uppercase tracking-[0.14em] font-medium text-slate-500">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Done({ link, p }: { link: SlackUserLink; p?: SlackLinkPreview }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center py-2">
|
||||
<motion.span
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 20, delay: 0.1 }}
|
||||
className="size-16 rounded-full bg-emerald-50 text-emerald-600 inline-flex items-center justify-center"
|
||||
>
|
||||
<CheckIcon className="w-8 h-8" />
|
||||
</motion.span>
|
||||
<h1 className="mt-5 text-[22px] sm:text-[24px] font-semibold tracking-[-0.03em] leading-[1.15] text-slate-900">You're connected</h1>
|
||||
<p className="mt-2.5 text-[13.5px] text-slate-500 leading-relaxed max-w-sm">
|
||||
<span className="font-medium text-slate-700">{p?.slack_user_name || "Your Slack account"}</span> is linked to{" "}
|
||||
<span className="font-medium text-slate-700">{p?.organization_name || "your Warmbly workspace"}</span>. Warmbly sent you a confirmation in Slack and will answer
|
||||
anything you asked while linking.
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap items-center justify-center gap-2">
|
||||
<a
|
||||
href={`https://app.slack.com/client/${encodeURIComponent(link.slack_team_id)}`}
|
||||
className="h-10 px-4 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[13.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
Return to Slack <ExternalLinkIcon className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<Link to="/app/integrations" className="h-10 px-4 rounded-md border border-slate-200 hover:border-slate-300 text-slate-800 text-[13.5px] font-medium inline-flex items-center transition-colors">
|
||||
Slack settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Problem({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center py-2">
|
||||
<span className="size-14 rounded-full bg-amber-50 text-amber-600 inline-flex items-center justify-center">
|
||||
<TriangleAlertIcon className="w-6 h-6" />
|
||||
</span>
|
||||
<h1 className="mt-5 text-[20px] font-semibold tracking-[-0.02em] text-slate-900">{title}</h1>
|
||||
<p className="mt-2 text-[13.5px] text-slate-500 leading-relaxed max-w-sm">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,10 +48,20 @@ export async function getSlackLinkPreview(code: string): Promise<SlackLinkPrevie
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmSlackLink(code: string): Promise<SlackUserLink> {
|
||||
// slack_code and state are a Sign in with Slack result, for a link whose emails differ.
|
||||
export async function confirmSlackLink(input: { code: string; slack_code?: string; state?: string }): Promise<SlackUserLink> {
|
||||
return await Request<SlackUserLink>({
|
||||
method: "POST",
|
||||
url: "/integrations/slack/link",
|
||||
data: input,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function startSlackLinkVerify(code: string): Promise<{ url: string }> {
|
||||
return await Request<{ url: string }>({
|
||||
method: "POST",
|
||||
url: "/integrations/slack/link/verify",
|
||||
data: { code },
|
||||
authorization: true,
|
||||
});
|
||||
|
||||
@@ -66,7 +66,11 @@ export interface SlackLinkPreview {
|
||||
// The Slack account's name and avatar as Slack reports them; either may be empty.
|
||||
slack_user_name: string;
|
||||
slack_user_avatar: string;
|
||||
// The Slack account's email is the signed-in user's Warmbly email, which linking requires.
|
||||
// The signed-in Warmbly account the link would be made for.
|
||||
user_email: string;
|
||||
// The Slack account's email is the signed-in user's Warmbly email, so one click links.
|
||||
email_matches: boolean;
|
||||
// Sign in with Slack can confirm the link when the emails differ.
|
||||
verify_available: boolean;
|
||||
expires_at: Date;
|
||||
}
|
||||
|
||||
+6
-4
@@ -82,7 +82,6 @@ export const dashboardPages = {
|
||||
automations: () => import("./app/app/automations/page"),
|
||||
automation: () => import("./app/app/automations/[id]/page"),
|
||||
audit: () => import("./app/app/audit/page"),
|
||||
slackLink: () => import("./app/app/slack/link/page"),
|
||||
unibox: () => import("./app/app/unibox/page"),
|
||||
settingsLayout: () => import("./app/app/settings/layout"),
|
||||
profile: () => import("./app/app/settings/profile/page"),
|
||||
@@ -236,6 +235,8 @@ const connectRoute = standalone("connect", () => import("./app/connect/page"), "
|
||||
const cliRoute = standalone("cli", () => import("./app/cli/page"), "Authorize CLI");
|
||||
// Where Warmbly Cloud sends the Google/Microsoft popup back to on a linked instance.
|
||||
const cloudOAuthDoneRoute = standalone("cloud-oauth/done", () => import("./app/cloud-oauth/done/page"), "Mailbox connected");
|
||||
// Where the Warmbly app in Slack sends a member to link their account.
|
||||
const slackLinkRoute = standalone("slack/link", () => import("./app/slack/link/page"), "Link Slack");
|
||||
// First-run claim link printed by the backend on an empty database.
|
||||
const setupRoute = standalone("setup", () => import("./app/setup/page"), "Set up Warmbly");
|
||||
|
||||
@@ -348,8 +349,8 @@ const salesforceRoute = dash(appRoute, "integrations/salesforce/$id", dashboardP
|
||||
const automationsRoute = dash(appRoute, "automations", dashboardPages.automations, "Automations", loaders.automationsLoader);
|
||||
const automationRoute = dash(appRoute, "automations/$id", dashboardPages.automation, "Automation", loaders.automationLoader);
|
||||
const auditRoute = dash(appRoute, "audit", dashboardPages.audit, "Audit log", loaders.auditLoader);
|
||||
// Where the Warmbly app in Slack sends a member to link their account.
|
||||
const slackLinkRoute = dash(appRoute, "slack/link", dashboardPages.slackLink, "Link Slack");
|
||||
// Link buttons sent before the link page moved out of the dashboard.
|
||||
const appSlackLinkRoute = forward(appRoute, "slack/link", "/slack/link", true);
|
||||
// The breadcrumb above the link page points here.
|
||||
const slackRoute = forward(appRoute, "slack", "/app/integrations");
|
||||
|
||||
@@ -408,6 +409,7 @@ const routeTree = rootRoute.addChildren([
|
||||
selectOrgRoute,
|
||||
inviteRoute,
|
||||
connectRoute,
|
||||
slackLinkRoute,
|
||||
cliRoute,
|
||||
cloudOAuthDoneRoute,
|
||||
setupRoute,
|
||||
@@ -441,7 +443,7 @@ const routeTree = rootRoute.addChildren([
|
||||
automationsRoute,
|
||||
automationRoute,
|
||||
auditRoute,
|
||||
slackLinkRoute,
|
||||
appSlackLinkRoute,
|
||||
slackRoute,
|
||||
settingsRoute.addChildren(settingsChildren),
|
||||
billingLegacy,
|
||||
|
||||
Reference in New Issue
Block a user