@@ -138,7 +148,7 @@
<% else %>
Community profile
-
Your Patchwork Labs Slack profile isn't linked yet. Once you join the Slack, your community details will show up here automatically.
+
Your Patchwork Labs Slack profile isn't linked yet. Once you <%= link_to "join the Slack", slack_onboarding_path, class: "text-grape hover:text-ink font-medium underline decoration-dashed underline-offset-2" %>, your community details will show up here automatically.
<% end %>
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
index 8742af5..c4cc4e7 100644
--- a/config/initializers/doorkeeper.rb
+++ b/config/initializers/doorkeeper.rb
@@ -291,7 +291,7 @@
# documents, and each of the non-openid scopes maps to a claim block in
# config/initializers/doorkeeper_openid_connect.rb.
default_scopes :profile
- optional_scopes :openid, :email, :phone, :admin
+ optional_scopes :openid, :email, :phone, :admin, :slack
# Allows to restrict only certain scopes for grant_type.
# By default, all the scopes will be available for all the grant types.
diff --git a/config/initializers/doorkeeper_openid_connect.rb b/config/initializers/doorkeeper_openid_connect.rb
index 7ca695c..9a73357 100644
--- a/config/initializers/doorkeeper_openid_connect.rb
+++ b/config/initializers/doorkeeper_openid_connect.rb
@@ -165,5 +165,20 @@ def test_key
normal_claim :admin, scope: :admin, response: [:id_token, :user_info] do |user|
user.admin?
end
+
+ # Full membership of the Patchwork Labs Slack: in the workspace as a
+ # regular member (not a guest) with the code of conduct accepted. A user
+ # can sign in while this is false; each client decides what to allow.
+ normal_claim :slack_member, scope: :slack, response: [:id_token, :user_info] do |user|
+ user.slack_member?
+ end
+
+ # The member's Slack user ID (e.g. U0123ABCD). Omitted before they join,
+ # because the gem leaves out nil claims.
+ # Present for guests too, so check slack_member before inviting it to
+ # channels.
+ normal_claim :slack_id, scope: :slack, response: [:id_token, :user_info] do |user|
+ user.slack_id
+ end
end
end
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index af50857..0284aae 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -55,6 +55,14 @@ class Rack::Attack
end
end
+ # Throttle Slack invite and code-of-conduct requests by IP. Each one calls
+ # Slack's admin API, which rate-limits the whole workspace.
+ throttle("slack_onboarding/ip", limit: 5, period: 10.minutes) do |req|
+ if req.path.start_with?("/slack") && req.post?
+ req.ip
+ end
+ end
+
# Throttle API requests
# Limit to 100 requests per minute per IP for API endpoints
throttle("api/ip", limit: 100, period: 1.minute) do |req|
diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml
index 04a8970..68e538f 100644
--- a/config/locales/doorkeeper.en.yml
+++ b/config/locales/doorkeeper.en.yml
@@ -21,6 +21,7 @@ en:
doorkeeper:
scopes:
admin: 'Perform administrative actions on your behalf'
+ slack: 'See your Patchwork Labs Slack membership and Slack ID'
applications:
confirmations:
diff --git a/config/routes.rb b/config/routes.rb
index 675282f..d249f8a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -30,7 +30,11 @@
# oauth_userinfo GET /oauth/userinfo(.:format) oauth/userinfo#show
# oauth_oauth_metadata GET /oauth/.well-known/oauth-authorization-server(.:format) oauth/discovery#oauth_authorization_server
# oauth_openid_configuration GET /oauth/.well-known/openid-configuration(.:format) doorkeeper/openid_connect/discovery#provider
+# GET /(*path) redirect(302)
# root GET / home#index
+# accept_code_of_conduct_slack_onboarding POST /slack/code-of-conduct(.:format) slack_onboarding#accept_code_of_conduct
+# slack_onboarding GET /slack(.:format) slack_onboarding#show
+# POST /slack(.:format) slack_onboarding#create
# GET /.well-known/*path well_known#show
# GET /security.txt(.:format) redirect(301, /.well-known/security.txt)
# signup GET /signup(.:format) users#new
@@ -42,6 +46,7 @@
# POST /login(.:format) auth#login
# send_magic_link POST /auth/magic_link(.:format) auth#send_magic_link
# magic_link_login GET /auth/magic_link/:token(.:format) auth#magic_link_login
+# confirm_magic_link POST /auth/magic_link/:token(.:format) auth#confirm_magic_link
# check_password_login POST /auth/check_password_login(.:format) auth#check_password_login
# logout DELETE /logout(.:format) auth#logout
# me GET /auth/me(.:format) auth#me
@@ -54,6 +59,11 @@
# email_confirmation GET /email_confirmation(.:format) email_confirmations#show
# confirm_email GET /confirm_email/:token(.:format) email_confirmations#confirm
# resend_email_confirmation POST /email_confirmation/resend(.:format) email_confirmations#resend
+# make_primary_email_address PATCH /profile/emails/:id/make_primary(.:format) email_addresses#make_primary
+# resend_confirmation_email_address POST /profile/emails/:id/resend_confirmation(.:format) email_addresses#resend_confirmation
+# email_addresses POST /profile/emails(.:format) email_addresses#create
+# email_address DELETE /profile/emails/:id(.:format) email_addresses#destroy
+# confirm_email_address GET /emails/confirm/:token(.:format) email_addresses#confirm
# user_profile_photo GET /user/:p_id/pfp(.:format) profile_photos#show
# user_avatar GET /user/:p_id/avatar/:variant(.:format) profile_photos#avatar
# user_avatar_square GET /user/:p_id/avatar/:variant/square(.:format) profile_photos#avatar_square
@@ -310,10 +320,26 @@
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
+ # slack.patchworklabs.org is the public "join our Slack" address. It sends
+ # everyone to the onboarding page on the canonical host, where the session
+ # cookie lives. Declared before `root` because the first matching route wins.
+ constraints(host: /\Aslack\./i) do
+ get "(*path)", format: false, to: redirect(status: 302) { |_params, _request|
+ Rails.application.routes.url_helpers.slack_onboarding_url(**Rails.application.config.action_mailer.default_url_options)
+ }
+ end
+
# Defines the root path route ("/")
# root "articles#index"
root "home#index"
+ # Joining the Patchwork Labs Slack: shows each person the next step
+ # (sign up, accept the invite, accept the code of conduct) and lets them
+ # request or resend the invite.
+ resource :slack_onboarding, path: "slack", only: [:show, :create], controller: "slack_onboarding" do
+ post :accept_code_of_conduct, path: "code-of-conduct"
+ end
+
# Well-known routes for standard compliance
get ".well-known/*path", to: "well_known#show", format: false
# Legacy location -> canonical RFC 9116 location
diff --git a/db/migrate/20260928200000_add_slack_membership_to_users.rb b/db/migrate/20260928200000_add_slack_membership_to_users.rb
new file mode 100644
index 0000000..5b67a10
--- /dev/null
+++ b/db/migrate/20260928200000_add_slack_membership_to_users.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddSlackMembershipToUsers < ActiveRecord::Migration[8.1]
+ def change
+ add_column :users, :slack_membership, :string, default: "pending", null: false
+ end
+end
diff --git a/db/migrate/20260928200100_backfill_slack_membership.rb b/db/migrate/20260928200100_backfill_slack_membership.rb
new file mode 100644
index 0000000..105b1be
--- /dev/null
+++ b/db/migrate/20260928200100_backfill_slack_membership.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+# Best guess from local data. The Slack sync (every 6 hours) then corrects it
+# from what Slack reports for each account.
+#
+# Full members: everyone in the workspace whom Weave did not invite as a guest
+# (imported by the member sync, which skips guests), plus guests who accepted
+# the code of conduct.
+class BackfillSlackMembership < ActiveRecord::Migration[8.1]
+ def up
+ safety_assured do
+ execute <<~SQL.squish
+ UPDATE users
+ SET slack_membership = 'member'
+ WHERE slack_id IS NOT NULL
+ AND (slack_coc_accepted_at IS NOT NULL OR slack_invited_at IS NULL)
+ SQL
+ end
+ end
+
+ def down
+ safety_assured do
+ execute "UPDATE users SET slack_membership = 'pending'"
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c4b0114..bf5f7cd 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_07_29_150100) do
+ActiveRecord::Schema[8.1].define(version: 2026_09_28_200100) do
# These are extensions that must be enabled in order to support this database
enable_extension "fuzzystrmatch"
enable_extension "pg_catalog.plpgsql"
@@ -531,6 +531,7 @@
t.datetime "slack_joined_at"
t.string "slack_linkedin"
t.string "slack_manager_id"
+ t.string "slack_membership", default: "pending", null: false
t.string "slack_organization"
t.string "slack_phone"
t.string "slack_profile_image_url"
diff --git a/docs/OAUTH.md b/docs/OAUTH.md
index f862092..6d8a49c 100644
--- a/docs/OAUTH.md
+++ b/docs/OAUTH.md
@@ -72,6 +72,33 @@ The OAuth provider supports the following scopes:
- `profile` (default): Access to basic profile information (name, username)
- `email`: Access to email address and verification status
- `admin`: Administrative privileges (restricted)
+- `slack`: Patchwork Labs Slack membership (`slack_member`, `slack_id`)
+
+### Slack membership claims
+
+Signing up for Weave does not make someone a full member of the Patchwork Labs
+Slack. New users join Slack as single-channel guests and become full members
+only after they accept the Code of Conduct. Request the `slack` scope to tell
+the two apart:
+
+| Claim | Type | Meaning |
+|-------|------|---------|
+| `slack_member` | boolean | `true` when the user is a regular (non-guest) member of the Slack workspace. |
+| `slack_id` | string | The user's Slack user ID. Omitted until they join Slack. Guests have one too, so check `slack_member` before you invite it to a channel. |
+
+Users with `slack_member: false` can still sign in. Each client decides what to
+allow them to do. Send them to `https://slack.patchworklabs.org` to finish
+joining.
+
+```mermaid
+stateDiagram-v2
+ [*] --> pending: sign up
+ pending --> pending: confirm email, Slack invite sent
+ pending --> pending: accept invite (single-channel guest)
+ pending --> member: accept Code of Conduct (promoted)
+ member --> pending: made a guest or deactivated in Slack
+ [*] --> member: imported from Slack as a full member
+```
## Using the OAuth Provider
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index c0da847..a6b188a 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -37,6 +37,7 @@
# slack_invited_at :datetime
# slack_joined_at :datetime
# slack_linkedin :string
+# slack_membership :string default("pending"), not null
# slack_organization :string
# slack_phone :string
# slack_profile_image_url :string
diff --git a/spec/jobs/invite_to_slack_job_spec.rb b/spec/jobs/invite_to_slack_job_spec.rb
new file mode 100644
index 0000000..9bb6529
--- /dev/null
+++ b/spec/jobs/invite_to_slack_job_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe InviteToSlackJob do
+ let(:service) do
+ instance_double(SlackService, configured?: true, find_user_by_email: nil,
+ invite_to_workspace: { ok: true, already_member: false })
+ end
+
+ before { allow(SlackService).to receive(:new).and_return(service) }
+
+ it "invites a new member as a single-channel guest and records the invite" do
+ user = create(:user, :verified)
+ expect(service).to receive(:invite_to_workspace)
+ .with(hash_including(email: user.email, guest: :single_channel))
+
+ described_class.perform_now(user.id)
+
+ expect(user.reload.slack_invited_at).to be_present
+ end
+
+ it "doesn't invite someone who is already a full member" do
+ user = create(:user, :verified, slack_id: "U1", slack_membership: "member")
+ expect(service).not_to receive(:invite_to_workspace)
+
+ described_class.perform_now(user.id)
+ end
+end
diff --git a/spec/jobs/slack_code_of_conduct_accepted_job_spec.rb b/spec/jobs/slack_code_of_conduct_accepted_job_spec.rb
index 6ae9736..887d53e 100644
--- a/spec/jobs/slack_code_of_conduct_accepted_job_spec.rb
+++ b/spec/jobs/slack_code_of_conduct_accepted_job_spec.rb
@@ -16,6 +16,12 @@
expect(user.reload.slack_coc_accepted_at).to be_present
end
+ it "makes them a full member once Slack confirms the promotion" do
+ allow(service).to receive(:promote_to_member).and_return({ ok: true })
+
+ expect { described_class.perform_now("U123") }.to change { user.reload.slack_membership }.from("pending").to("member")
+ end
+
it "does nothing without a Slack user id" do
expect(service).not_to receive(:promote_to_member)
described_class.perform_now(nil)
@@ -25,5 +31,6 @@
allow(service).to receive(:promote_to_member).and_return({ ok: false, error: "user_not_found" })
described_class.perform_now("U123")
expect(user.reload.slack_coc_accepted_at).to be_present
+ expect(user).to be_slack_pending
end
end
diff --git a/spec/jobs/sync_user_to_slack_job_spec.rb b/spec/jobs/sync_user_to_slack_job_spec.rb
new file mode 100644
index 0000000..0894f89
--- /dev/null
+++ b/spec/jobs/sync_user_to_slack_job_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe SyncUserToSlackJob do
+ let(:service) { instance_double(SlackService) }
+
+ before do
+ allow(SlackService).to receive(:new).and_return(service)
+ allow(service).to receive(:send).with(:update_slack_profile_field, any_args)
+ end
+
+ it "links a guest found in Slack and keeps them pending" do
+ user = create(:user, :verified, email: "guest@example.com")
+ allow(service).to receive(:find_user_by_email).and_return("id" => "U1", "updated" => Time.now.to_i, "is_ultra_restricted" => true)
+
+ described_class.perform_now(user.id)
+
+ expect(user.reload.slack_id).to eq("U1")
+ expect(user).to be_slack_pending
+ end
+
+ it "makes someone Slack reports as a regular member a member" do
+ user = create(:user, :verified, email: "full@example.com")
+ allow(service).to receive(:find_user_by_email).and_return("id" => "U2", "updated" => Time.now.to_i)
+
+ described_class.perform_now(user.id)
+
+ expect(user.reload).to be_slack_member
+ end
+end
diff --git a/spec/models/user_slack_membership_spec.rb b/spec/models/user_slack_membership_spec.rb
new file mode 100644
index 0000000..0f660ce
--- /dev/null
+++ b/spec/models/user_slack_membership_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe User do
+ describe "default state" do
+ it "starts a new signup as pending" do
+ expect(create(:user)).to be_slack_pending
+ end
+ end
+
+ describe ".slack_membership_for" do
+ it "makes a regular workspace account a member" do
+ expect(described_class.slack_membership_for("id" => "U1")).to eq("member")
+ end
+
+ it "keeps a single-channel guest pending" do
+ expect(described_class.slack_membership_for("id" => "U1", "is_ultra_restricted" => true)).to eq("pending")
+ end
+
+ it "keeps a multi-channel guest pending" do
+ expect(described_class.slack_membership_for("id" => "U1", "is_restricted" => true)).to eq("pending")
+ end
+
+ it "does not count a deactivated account" do
+ expect(described_class.slack_membership_for("id" => "U1", "deleted" => true)).to eq("pending")
+ end
+ end
+
+ describe "#apply_slack_membership!" do
+ it "promotes a guest that Slack reports as a regular member" do
+ user = create(:user, slack_id: "U1")
+
+ expect { user.apply_slack_membership!("id" => "U1") }.to change { user.reload.slack_membership }.to("member")
+ end
+
+ it "demotes a member that Slack reports as deactivated" do
+ user = create(:user, slack_id: "U1", slack_membership: "member")
+
+ user.apply_slack_membership!("id" => "U1", "deleted" => true)
+
+ expect(user.reload).to be_slack_pending
+ end
+
+ it "ignores a Slack account other than the linked one" do
+ user = create(:user, slack_id: "U1")
+
+ user.apply_slack_membership!("id" => "U2")
+
+ expect(user.reload).to be_slack_pending
+ end
+ end
+
+ describe "#slack_onboarding_step" do
+ it "asks a new account to request an invite" do
+ expect(build(:user).slack_onboarding_step).to eq(:request_invite)
+ end
+
+ it "asks an invited account to accept the invite" do
+ expect(build(:user, slack_invited_at: 1.hour.ago).slack_onboarding_step).to eq(:accept_invite)
+ end
+
+ it "asks a guest to accept the code of conduct" do
+ expect(build(:user, slack_invited_at: 1.hour.ago, slack_id: "U1").slack_onboarding_step).to eq(:accept_code_of_conduct)
+ end
+
+ it "waits on the promotion after the guest accepts" do
+ user = build(:user, slack_id: "U1", slack_coc_accepted_at: 1.minute.ago)
+
+ expect(user.slack_onboarding_step).to eq(:awaiting_promotion)
+ end
+
+ it "is done for a full member" do
+ expect(build(:user, slack_id: "U1", slack_membership: "member").slack_onboarding_step).to eq(:member)
+ end
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 4661660..670b3cd 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -37,6 +37,7 @@
# slack_invited_at :datetime
# slack_joined_at :datetime
# slack_linkedin :string
+# slack_membership :string default("pending"), not null
# slack_organization :string
# slack_phone :string
# slack_profile_image_url :string
diff --git a/spec/requests/oauth/openid_connect_spec.rb b/spec/requests/oauth/openid_connect_spec.rb
index 5fc6456..a0646b4 100644
--- a/spec/requests/oauth/openid_connect_spec.rb
+++ b/spec/requests/oauth/openid_connect_spec.rb
@@ -16,7 +16,7 @@
Doorkeeper::Application.create!(
name: "Test Client",
redirect_uri: redirect_uri,
- scopes: "openid profile email phone admin",
+ scopes: "openid profile email phone admin slack",
confidential: false
)
end
@@ -159,7 +159,14 @@ def header = decoded.last
"email" => user.email,
"email_verified" => true
)
- expect(claims).not_to include("phone_number", "phone_number_verified", "admin")
+ expect(claims).not_to include("phone_number", "phone_number_verified", "admin", "slack_member", "slack_id")
+ end
+
+ it "carries the Slack membership claims when the slack scope is granted" do
+ user.update!(slack_id: "U0MEMBER", slack_membership: "member")
+ slack_claims = verify_id_token(obtain_tokens(scope: "openid slack")["id_token"]).first
+
+ expect(slack_claims).to include("slack_member" => true, "slack_id" => "U0MEMBER")
end
it "rejects a token whose signature does not match the JWKS" do
@@ -217,6 +224,25 @@ def userinfo(scope:)
expect(userinfo(scope: "openid admin")).to include("admin" => true)
end
+ it "tells a user who hasn't finished joining Slack apart from a full member" do
+ user.update!(slack_id: "U0GUEST")
+
+ expect(userinfo(scope: "openid slack")).to include("slack_member" => false, "slack_id" => "U0GUEST")
+ end
+
+ it "reports full Slack members through the slack scope" do
+ user.update!(slack_id: "U0MEMBER", slack_membership: "member")
+
+ expect(userinfo(scope: "openid slack")).to include("slack_member" => true, "slack_id" => "U0MEMBER")
+ end
+
+ it "omits slack_id for a user who is not in Slack yet" do
+ body = userinfo(scope: "openid slack")
+
+ expect(body).to include("slack_member" => false)
+ expect(body).not_to have_key("slack_id")
+ end
+
it "does not leak claims from scopes that were not granted" do
body = userinfo(scope: "openid profile")
@@ -228,7 +254,7 @@ def userinfo(scope:)
it "does not leak email or phone into a profile-only response" do
body = userinfo(scope: "openid profile")
- expect(body).not_to include("email", "email_verified", "phone_number", "admin")
+ expect(body).not_to include("email", "email_verified", "phone_number", "admin", "slack_member", "slack_id")
end
# Weave served userinfo to any valid access token long before OIDC existed
diff --git a/spec/requests/slack_membership_flow_spec.rb b/spec/requests/slack_membership_flow_spec.rb
new file mode 100644
index 0000000..105c852
--- /dev/null
+++ b/spec/requests/slack_membership_flow_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# The whole way from signup to full Slack membership, and what OAuth clients
+# see at each end of it. Slack itself is stubbed; everything in Weave is real.
+RSpec.describe "Slack membership flow", type: :request do
+ include ActiveJob::TestHelper
+
+ let(:slack) do
+ instance_double(
+ SlackService,
+ configured?: true,
+ find_user_by_email: nil,
+ invite_to_workspace: { ok: true, already_member: false },
+ post_code_of_conduct: true,
+ promote_to_member: { ok: true }
+ )
+ end
+
+ let(:application) do
+ Doorkeeper::Application.create!(
+ name: "Krater", redirect_uri: "https://krater.example.com/callback", scopes: "openid profile slack"
+ )
+ end
+
+ before { allow(SlackService).to receive(:new).and_return(slack) }
+
+ def slack_claims(user)
+ token = Doorkeeper::AccessToken.create!(application: application, resource_owner_id: user.id, scopes: "openid slack")
+ get oauth_userinfo_path, headers: { "Authorization" => "Bearer #{token.plaintext_token}" }
+ expect(response).to have_http_status(:ok)
+ response.parsed_body.slice("slack_member", "slack_id")
+ end
+
+ it "takes a signup through invite and code of conduct to full membership" do # rubocop:disable RSpec/ExampleLength, RSpec/MultipleExpectations
+ post signup_path, params: { user: { first_name: "Ada", last_name: "Lovelace", email: "ada@example.com" } }
+ user = User.find_by!(email: "ada@example.com")
+ expect(slack_claims(user)).to eq("slack_member" => false)
+
+ # Confirming the email sends the Slack invite as a single-channel guest.
+ expect(slack).to receive(:invite_to_workspace)
+ .with(hash_including(email: "ada@example.com", guest: :single_channel))
+ get confirm_email_path(token: user.reload.confirmation_token)
+ perform_enqueued_jobs(only: InviteToSlackJob)
+ expect(user.reload.slack_onboarding_step).to eq(:accept_invite)
+
+ # They accept the invite and land in Slack as a guest.
+ SlackWebhookService.process_team_join(
+ "id" => "U0ADA", "is_restricted" => true, "is_ultra_restricted" => true, "profile" => { "email" => "ada@example.com" }
+ )
+ expect(user.reload.slack_onboarding_step).to eq(:accept_code_of_conduct)
+ expect(slack_claims(user)).to eq("slack_member" => false, "slack_id" => "U0ADA")
+
+ # They accept the code of conduct and are promoted.
+ expect(slack).to receive(:promote_to_member).with("U0ADA")
+ SlackCodeOfConductAcceptedJob.perform_now("U0ADA")
+ expect(user.reload).to be_slack_member
+ expect(slack_claims(user)).to eq("slack_member" => true, "slack_id" => "U0ADA")
+ end
+
+ it "makes a full member imported from Slack a member straight away" do
+ service = SlackService.allocate
+ allow(service).to receive_messages(configured?: true, list_members: [
+ { "id" => "U0OLD", "updated" => 1.year.ago.to_i, "profile" => { "email" => "old@example.com", "real_name" => "Old Timer" } }
+ ])
+
+ service.sync_slack_users_to_idp
+
+ user = User.find_by!(email: "old@example.com")
+ expect(user).to be_slack_member
+ expect(slack_claims(user)).to eq("slack_member" => true, "slack_id" => "U0OLD")
+ end
+
+ it "makes an existing account a member when the Slack sync finds it as a full member" do
+ user = create(:user, :verified, email: "known@example.com", slack_id: "U0KNOWN")
+ service = SlackService.allocate
+ allow(service).to receive_messages(configured?: true, list_members: [
+ { "id" => "U0KNOWN", "updated" => 1.year.ago.to_i, "profile" => { "email" => "known@example.com" } }
+ ])
+
+ service.sync_slack_users_to_idp
+
+ expect(user.reload).to be_slack_member
+ end
+end
diff --git a/spec/requests/slack_onboarding_spec.rb b/spec/requests/slack_onboarding_spec.rb
new file mode 100644
index 0000000..83c9f34
--- /dev/null
+++ b/spec/requests/slack_onboarding_spec.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# /slack is the way into the Patchwork Labs Slack, and slack.patchworklabs.org
+# points at it. Each visitor sees the next step they need to take.
+RSpec.describe "Slack onboarding", type: :request do
+ let(:user) { create(:user, :verified) }
+
+ describe "slack.patchworklabs.org" do
+ it "sends visitors to the onboarding page on the canonical host" do
+ host! "slack.patchworklabs.org"
+
+ get "/"
+
+ expect(response).to have_http_status(:found)
+ expect(response.location).to eq("http://example.com/slack")
+ end
+
+ it "sends any path there too" do
+ host! "slack.patchworklabs.org"
+
+ get "/join"
+
+ expect(response.location).to eq("http://example.com/slack")
+ end
+ end
+
+ describe "GET /slack" do
+ it "asks an anonymous visitor to sign up for an invite" do
+ get slack_onboarding_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Request an invite", signup_path)
+ end
+
+ it "sends an unconfirmed account to confirm its email first" do
+ sign_in_via_magic_link(user)
+ user.reload.update!(email_confirmed_at: nil)
+
+ get slack_onboarding_path
+
+ expect(response).to redirect_to(email_confirmation_path)
+ end
+
+ it "offers the invite to a confirmed account that doesn't have one yet" do
+ sign_in_via_magic_link(user)
+
+ get slack_onboarding_path
+
+ expect(response.body).to include("Send my invite")
+ end
+
+ it "offers to accept the code of conduct to a guest" do
+ user.update!(slack_id: "U1", slack_invited_at: 1.hour.ago)
+ sign_in_via_magic_link(user)
+
+ get slack_onboarding_path
+
+ expect(response.body).to include("I accept the Code of Conduct")
+ end
+
+ it "welcomes a full member" do
+ user.update!(slack_id: "U1", slack_membership: "member")
+ sign_in_via_magic_link(user)
+
+ get slack_onboarding_path
+
+ expect(response.body).to include("You're a full member")
+ end
+ end
+
+ describe "POST /slack" do
+ before { sign_in_via_magic_link(user) }
+
+ it "sends the invite" do
+ expect { post slack_onboarding_path }.to have_enqueued_job(InviteToSlackJob).with(user.id)
+
+ expect(response).to redirect_to(slack_onboarding_path)
+ end
+
+ it "resends an invite sent a while ago" do
+ user.update!(slack_invited_at: 1.day.ago)
+
+ expect { post slack_onboarding_path }.to have_enqueued_job(InviteToSlackJob).with(user.id)
+ end
+
+ it "doesn't resend an invite sent a few minutes ago" do
+ user.update!(slack_invited_at: 2.minutes.ago)
+
+ expect { post slack_onboarding_path }.not_to have_enqueued_job(InviteToSlackJob)
+ expect(flash[:alert]).to include("a few minutes ago")
+ end
+
+ it "doesn't invite someone who is already in the Slack" do
+ user.update!(slack_id: "U1")
+
+ expect { post slack_onboarding_path }.not_to have_enqueued_job(InviteToSlackJob)
+ end
+
+ it "requires a signed-in account" do
+ delete logout_path
+
+ expect { post slack_onboarding_path }.not_to have_enqueued_job(InviteToSlackJob)
+ expect(response).to redirect_to("/login")
+ end
+ end
+
+ describe "POST /slack/code-of-conduct" do
+ before { sign_in_via_magic_link(user) }
+
+ it "accepts the code of conduct for a guest" do
+ user.update!(slack_id: "U1")
+
+ expect { post accept_code_of_conduct_slack_onboarding_path }
+ .to have_enqueued_job(SlackCodeOfConductAcceptedJob).with("U1")
+ end
+
+ it "retries the promotion after an earlier acceptance" do
+ user.update!(slack_id: "U1", slack_coc_accepted_at: 1.hour.ago)
+
+ expect { post accept_code_of_conduct_slack_onboarding_path }
+ .to have_enqueued_job(SlackCodeOfConductAcceptedJob).with("U1")
+ end
+
+ it "does nothing for someone who isn't in the Slack yet" do
+ expect { post accept_code_of_conduct_slack_onboarding_path }.not_to have_enqueued_job(SlackCodeOfConductAcceptedJob)
+ end
+
+ it "does nothing for a full member" do
+ user.update!(slack_id: "U1", slack_membership: "member")
+
+ expect { post accept_code_of_conduct_slack_onboarding_path }.not_to have_enqueued_job(SlackCodeOfConductAcceptedJob)
+ end
+ end
+
+ describe "GET /" do
+ it "sends a member who hasn't finished joining the Slack to onboarding" do
+ sign_in_via_magic_link(user)
+
+ get root_path
+
+ expect(response).to redirect_to(slack_onboarding_path)
+ end
+
+ it "sends a full member to their account" do
+ user.update!(slack_id: "U1", slack_membership: "member")
+ sign_in_via_magic_link(user)
+
+ get root_path
+
+ expect(response).to redirect_to(profile_path)
+ end
+ end
+end
diff --git a/spec/services/slack_webhook_service_spec.rb b/spec/services/slack_webhook_service_spec.rb
index c0926d4..b11f69e 100644
--- a/spec/services/slack_webhook_service_spec.rb
+++ b/spec/services/slack_webhook_service_spec.rb
@@ -88,5 +88,65 @@
expect(user.email).to eq("mine@example.com")
expect(user.first_name).to eq("New")
end
+
+ it "makes a guest a member when Slack reports the promotion" do
+ user = create(:user, slack_id: "U500", email: "guest@example.com")
+
+ described_class.process_user_change("id" => "U500", "profile" => { "email" => "guest@example.com" })
+
+ expect(user.reload).to be_slack_member
+ end
+
+ it "keeps a guest pending while Slack still reports them as a guest" do
+ user = create(:user, slack_id: "U501", email: "guest@example.com")
+
+ described_class.process_user_change(
+ "id" => "U501", "is_ultra_restricted" => true, "profile" => { "email" => "guest@example.com" }
+ )
+
+ expect(user.reload).to be_slack_pending
+ end
+
+ it "ends the membership of a deactivated Slack account" do
+ user = create(:user, slack_id: "U502", slack_membership: "member")
+
+ described_class.process_user_change("id" => "U502", "deleted" => true, "profile" => {})
+
+ expect(user.reload).to be_slack_pending
+ end
+ end
+
+ describe ".process_team_join" do
+ before { allow(SlackService).to receive(:new).and_return(instance_double(SlackService, post_code_of_conduct: true)) }
+
+ it "links an invited signup who joins as a guest, and keeps them pending" do
+ user = create(:user, :verified, email: "new@example.com", slack_invited_at: 1.hour.ago)
+
+ described_class.process_team_join(
+ "id" => "U600", "is_restricted" => true, "is_ultra_restricted" => true, "profile" => { "email" => "new@example.com" }
+ )
+
+ user.reload
+ expect(user.slack_id).to eq("U600")
+ expect(user).to be_slack_pending
+ expect(user.slack_onboarding_step).to eq(:accept_code_of_conduct)
+ end
+
+ it "makes someone who joins as a regular member a member" do
+ user = create(:user, :verified, email: "full@example.com")
+
+ described_class.process_team_join("id" => "U601", "profile" => { "email" => "full@example.com" })
+
+ expect(user.reload).to be_slack_member
+ end
+
+ it "creates a pending account for a guest who joins without a Weave account" do
+ user = described_class.process_team_join(
+ "id" => "U602", "is_ultra_restricted" => true,
+ "profile" => { "email" => "walkin@example.com", "real_name" => "Walk In" }
+ )
+
+ expect(user).to be_slack_pending
+ end
end
end
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
index d8becb1..5f82704 100644
--- a/test/fixtures/users.yml
+++ b/test/fixtures/users.yml
@@ -41,6 +41,7 @@
# slack_invited_at :datetime
# slack_joined_at :datetime
# slack_linkedin :string
+# slack_membership :string default("pending"), not null
# slack_organization :string
# slack_phone :string
# slack_profile_image_url :string
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index c6be7ce..55893b9 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -37,6 +37,7 @@
# slack_invited_at :datetime
# slack_joined_at :datetime
# slack_linkedin :string
+# slack_membership :string default("pending"), not null
# slack_organization :string
# slack_phone :string
# slack_profile_image_url :string
@@ -95,17 +96,22 @@ class UserTest < ActiveSupport::TestCase
assert user.valid?
end
- test "slack_member? is true when a slack_id is set, even without an invite" do
+ test "in_slack_workspace? is true when a slack_id is set, even without an invite" do
user = User.new(slack_id: "U09E8DF4QBA")
- assert user.slack_member?
+ assert user.in_slack_workspace?
assert_nil user.slack_invited_at
end
- test "slack_member? is false without a slack_id" do
+ test "in_slack_workspace? is false without a slack_id" do
user = User.new(slack_invited_at: Time.current)
- assert_not user.slack_member?
+ assert_not user.in_slack_workspace?
+ end
+
+ test "slack_member? needs full membership, not only a slack_id" do
+ assert_not User.new(slack_id: "U09E8DF4QBA").slack_member?
+ assert User.new(slack_id: "U09E8DF4QBA", slack_membership: "member").slack_member?
end
end