diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 81d90e5..a7cb7ce 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -8,7 +8,11 @@ class HomeController < ApplicationController def index # Signed-in members land on their account dashboard, not the # "you found the front door" splash (which is for anonymous visitors). - redirect_to profile_path if current_user + # Joining the Slack is part of becoming a member, so anyone who hasn't + # finished lands on the next step of that instead. + return unless current_user + + redirect_to(current_user.email_verified? && current_user.slack_pending? ? slack_onboarding_path : profile_path) end end diff --git a/app/controllers/slack_onboarding_controller.rb b/app/controllers/slack_onboarding_controller.rb new file mode 100644 index 0000000..b114c79 --- /dev/null +++ b/app/controllers/slack_onboarding_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# The way into the Patchwork Labs Slack (slack.patchworklabs.org lands here). +# +# Full Slack membership is part of being a member: see User#slack_membership. +# This page shows each person the next step they need to take and lets them +# ask for the next nudge: +# +# sign up -> confirm email -> accept Slack invite -> accept code of conduct -> member +class SlackOnboardingController < ApplicationController + skip_before_action :authenticate_user!, only: :show + + # Slack's invite endpoint answers `sent_recently` inside this window anyway, + # so there is no point asking it again. + INVITE_COOLDOWN = 10.minutes + + layout "sessions" + + def show + if current_user && !current_user.email_verified? + redirect_to email_confirmation_path + return + end + + @step = current_user ? current_user.slack_onboarding_step : :sign_up + end + + # Sends the Slack invite, or sends it again. + def create + unless %i[request_invite accept_invite].include?(current_user.slack_onboarding_step) + redirect_to slack_onboarding_path + return + end + + if current_user.slack_invited_at&.after?(INVITE_COOLDOWN.ago) + redirect_to slack_onboarding_path, alert: "We sent your invite a few minutes ago. Check your email, or try again in a few minutes." + return + end + + InviteToSlackJob.perform_later(current_user.id) + redirect_to slack_onboarding_path, notice: "Your Slack invite is on its way to #{current_user.email}." + end + + # Accepts the code of conduct from the web, for people who can't find the + # Slack DM. Also retries the promotion if it failed after an earlier + # acceptance. + def accept_code_of_conduct + unless %i[accept_code_of_conduct awaiting_promotion].include?(current_user.slack_onboarding_step) + redirect_to slack_onboarding_path + return + end + + SlackCodeOfConductAcceptedJob.perform_later(current_user.slack_id) + redirect_to slack_onboarding_path, notice: "Thanks for accepting the Code of Conduct. Your full Slack access is on its way." + end + +end diff --git a/app/jobs/invite_to_slack_job.rb b/app/jobs/invite_to_slack_job.rb index 5960043..2b44149 100644 --- a/app/jobs/invite_to_slack_job.rb +++ b/app/jobs/invite_to_slack_job.rb @@ -11,6 +11,7 @@ class InviteToSlackJob < ApplicationJob def perform(user_id, resend: false) user = User.find_by(id: user_id) return unless user + return if user.slack_member? service = SlackService.new unless service.configured? diff --git a/app/jobs/slack_code_of_conduct_accepted_job.rb b/app/jobs/slack_code_of_conduct_accepted_job.rb index 00c426d..f7ade37 100644 --- a/app/jobs/slack_code_of_conduct_accepted_job.rb +++ b/app/jobs/slack_code_of_conduct_accepted_job.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true -# Runs when a single-channel guest clicks "I accept the Code of Conduct" in Slack. -# Records the acceptance and promotes them from guest to full workspace member. +# Runs when a single-channel guest accepts the code of conduct, either with the +# button in their Slack DM or on the /slack onboarding page. Records the +# acceptance, promotes them from guest to full workspace member, and marks +# them a member in Weave once Slack confirms the promotion. class SlackCodeOfConductAcceptedJob < ApplicationJob queue_as :default @@ -9,13 +11,14 @@ def perform(slack_user_id) return if slack_user_id.blank? user = User.find_by(slack_id: slack_user_id) - user&.update_columns(slack_coc_accepted_at: Time.current) + user&.update_columns(slack_coc_accepted_at: Time.current) if user&.slack_coc_accepted_at.nil? service = SlackService.new return unless service.configured? result = service.promote_to_member(slack_user_id) if result[:ok] + user&.update_columns(slack_membership: "member", updated_at: Time.current) Rails.logger.info "Promoted #{slack_user_id} to full member after CoC acceptance" else Rails.logger.error "Failed to promote #{slack_user_id} after CoC acceptance: #{result[:error]}" diff --git a/app/jobs/sync_user_to_slack_job.rb b/app/jobs/sync_user_to_slack_job.rb index 75c6f9a..69a5a1d 100644 --- a/app/jobs/sync_user_to_slack_job.rb +++ b/app/jobs/sync_user_to_slack_job.rb @@ -22,6 +22,7 @@ def perform(user_id) ) Rails.logger.info "User #{user.email} found in Slack, updated slack_id" end + user.apply_slack_membership!(slack_user) # Sync PWL ID to Slack profile if user has p_id if user.p_id.present? diff --git a/app/models/user.rb b/app/models/user.rb index 0496f0b..1749a24 100644 --- a/app/models/user.rb +++ b/app/models/user.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 @@ -149,6 +150,13 @@ def self.generate_secure_password owner: 3 } + # Full membership of the Patchwork Labs Slack: in the workspace as a regular + # member, not a guest. Everyone starts `pending` and becomes a `member` once + # Slack reports them as one (see .slack_membership_for), which for new + # signups means after they accept the code of conduct. OAuth clients read it + # as the `slack_member` claim. + enum :slack_membership, { pending: "pending", member: "member" }, prefix: :slack + scope :last_seen_within, ->(ago) { joins(:user_sessions).where(user_sessions: { last_seen_at: ago.. }).distinct } scope :currently_online, -> { last_seen_within(15.minutes.ago) } scope :active, -> { last_seen_within(30.days.ago) } @@ -408,8 +416,41 @@ def community_member? = user? # In the Slack workspace already — a slack_id is only ever set by syncing # against a real workspace member (invite acceptance, team-join webhook, or # profile sync), so its presence means they joined, whether or not Weave - # sent the invite. - def slack_member? = slack_id.present? + # sent the invite. They can still be a guest: see #slack_member?. + def in_slack_workspace? = slack_id.present? + + # Where this person is in joining the Slack. Drives the /slack onboarding + # page. Each step is the next thing the person must do. + def slack_onboarding_step + return :member if slack_member? + return :awaiting_promotion if in_slack_workspace? && slack_coc_accepted_at.present? + return :accept_code_of_conduct if in_slack_workspace? + return :accept_invite if slack_invited_at.present? + + :request_invite + end + + # The membership a Slack user object (from users.info, users.lookupByEmail, + # users.list or a team_join/user_change event) entitles its owner to. Guests + # and deactivated accounts are not members. + def self.slack_membership_for(slack_user) + full = !slack_user["deleted"] && !slack_user["is_restricted"] && !slack_user["is_ultra_restricted"] + full ? "member" : "pending" + end + + # Records the membership Slack reports for this person. Skips the write when + # nothing changed, and bumps updated_at so the OIDC updated_at claim moves. + def apply_slack_membership!(slack_user) + # A lookup by email can find a different Slack account from the one + # linked here (e.g. an old account under a former address). Only the + # linked account decides membership. + return if slack_id.present? && slack_user["id"].present? && slack_user["id"] != slack_id + + membership = self.class.slack_membership_for(slack_user) + return if slack_membership == membership + + update_columns(slack_membership: membership, updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end # Human-facing membership label for profile/admin display. def membership_label diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 3fb0708..5422c16 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.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 @@ -101,6 +102,8 @@ def base_attributes role: @user.role, status: @user.status, email_verified: @user.email_verified?, + slack_id: @user.slack_id, + slack_member: @user.slack_member?, created_at: @user.created_at, updated_at: @user.updated_at } diff --git a/app/services/slack_service.rb b/app/services/slack_service.rb index 2bb53fb..2787fff 100644 --- a/app/services/slack_service.rb +++ b/app/services/slack_service.rb @@ -144,6 +144,13 @@ def post_code_of_conduct(slack_user_id, coc_url: nil) ) end + # Where members open the workspace, e.g. https://patchworklabs.slack.com. + # nil when the subdomain is not configured. + def self.workspace_url + subdomain = ENV["SLACK_WORKSPACE_SUBDOMAIN"] || Rails.application.credentials.dig(:slack, :workspace_subdomain) + "https://#{subdomain}.slack.com" if subdomain.present? + end + def self.code_of_conduct_url ENV["SLACK_COC_URL"] || Rails.application.credentials.dig(:slack, :coc_url) end @@ -271,10 +278,14 @@ def sync_slack_users_to_idp profile_attrs[:manager_id] = manager&.id if manager&.is_manager_or_manageable? end - if user.slack_id.blank? || user.slack_profile_synced_at.nil? || user.slack_profile_synced_at < 1.hour.ago + # list_members returns full members only, so everyone here is one. + # A guest who was promoted in Slack (not through Weave) gets picked + # up here even if their profile was synced recently. + if user.slack_id.blank? || !user.slack_member? || user.slack_profile_synced_at.nil? || user.slack_profile_synced_at < 1.hour.ago user.update!( slack_id: member["id"], slack_joined_at: Time.zone.at(member["updated"].to_i), + slack_membership: "member", **profile_attrs, slack_profile_synced_at: Time.current ) @@ -318,6 +329,7 @@ def sync_idp_users_to_slack slack_joined_at: Time.zone.at(slack_user["updated"].to_i) ) end + user.apply_slack_membership!(slack_user) # Sync API-editable fields and PWL ID to Slack fields_updated = false @@ -583,6 +595,7 @@ def create_user_from_slack_member(member) password_confirmation: password, slack_id: member["id"], slack_joined_at: Time.zone.at(member["updated"].to_i), + slack_membership: User.slack_membership_for(member), **profile_attrs, slack_profile_synced_at: Time.current ) diff --git a/app/services/slack_webhook_service.rb b/app/services/slack_webhook_service.rb index dd4b622..af0eb44 100644 --- a/app/services/slack_webhook_service.rb +++ b/app/services/slack_webhook_service.rb @@ -22,7 +22,8 @@ def process_team_join(slack_user_data) # Update existing user with Slack info user.update!( slack_id: slack_id, - slack_joined_at: Time.current + slack_joined_at: Time.current, + slack_membership: User.slack_membership_for(slack_user_data) ) Rails.logger.info "[SlackWebhookService] Updated existing user #{user.id} with Slack ID #{slack_id}" else @@ -46,8 +47,7 @@ def process_team_join(slack_user_data) # Process user_change event - user profile updated in Slack def process_user_change(slack_user_data) - # Skip bots and deleted users - return if slack_user_data["is_bot"] || slack_user_data["deleted"] + return if slack_user_data["is_bot"] slack_id = slack_user_data["id"] user = User.find_by(slack_id: slack_id) @@ -57,6 +57,13 @@ def process_user_change(slack_user_data) return end + # Slack sends user_change when a guest is promoted to member, when a + # member is made a guest, and when an account is deactivated. Record the + # membership before the deactivated check below, so a deactivated account + # stops being a member. + user.apply_slack_membership!(slack_user_data) + return if slack_user_data["deleted"] + # Extract updated profile data profile = slack_user_data["profile"] updates = {} @@ -146,6 +153,7 @@ def create_user_from_slack_member(member) last_name: last_name, slack_id: member["id"], slack_joined_at: Time.current, + slack_membership: User.slack_membership_for(member), password: User.generate_secure_password # Random password - user logs in via magic link ) diff --git a/app/views/admin/oauth_applications/edit.html.erb b/app/views/admin/oauth_applications/edit.html.erb index 89246e6..d6a3501 100644 --- a/app/views/admin/oauth_applications/edit.html.erb +++ b/app/views/admin/oauth_applications/edit.html.erb @@ -63,7 +63,7 @@ class: "mt-1 w-full px-4 py-3 rounded-lg border-2 border-neutral-300 bg-paper text-ink focus:ring-2 focus:ring-grape focus:border-grape transition-colors placeholder-ink-faint", placeholder: "profile email" %>

- Space-separated list of scopes. Available: profile, email, admin. + Space-separated list of scopes. Available: profile, email, admin, slack. Leave empty to use defaults.

diff --git a/app/views/admin/oauth_applications/new.html.erb b/app/views/admin/oauth_applications/new.html.erb index a7022c2..af3854d 100644 --- a/app/views/admin/oauth_applications/new.html.erb +++ b/app/views/admin/oauth_applications/new.html.erb @@ -68,7 +68,8 @@ Space-separated list of scopes. Available: profile (basic user info), email (user email address), - admin (administrative access). + admin (administrative access), + slack (Slack membership and Slack ID). Leave empty to use defaults.

diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index c0e2404..e89f039 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -227,11 +227,16 @@
Slack membership
- <% if @user.slack_coc_accepted_at.present? %> + <% if @user.slack_member? %> Full member + <% if @user.slack_coc_accepted_at.present? %> + accepted CoC <%= @user.slack_coc_accepted_at.strftime("%b %-d, %Y") %> + <% end %> + <% elsif @user.in_slack_workspace? && @user.slack_coc_accepted_at.present? %> + Guest · promotion pending accepted CoC <%= @user.slack_coc_accepted_at.strftime("%b %-d, %Y") %> - <% elsif @user.slack_member? %> - In Slack · CoC pending + <% elsif @user.in_slack_workspace? %> + Guest · CoC pending <% if @user.slack_invited_at.present? %> invited <%= @user.slack_invited_at.strftime("%b %-d, %Y") %> <% end %> @@ -241,7 +246,7 @@ <% else %> Not invited yet <% end %> - <% unless @user.slack_member? %> + <% unless @user.in_slack_workspace? %>
<%= button_to(@user.slack_invited_at.present? ? "Re-invite to Slack" : "Invite to Slack", invite_to_slack_admin_user_path(@user), method: :post, diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index 8761517..c78a0bb 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -43,8 +43,9 @@ <% end %>
<% else %> -
+
<%= link_to "Sign in", login_path, class: "btn-stitch" %> + <%= link_to "Join the Slack", slack_onboarding_path, class: "btn-stitch-ghost" %>
<% end %>
diff --git a/app/views/slack_onboarding/show.html.erb b/app/views/slack_onboarding/show.html.erb new file mode 100644 index 0000000..eb5eaa7 --- /dev/null +++ b/app/views/slack_onboarding/show.html.erb @@ -0,0 +1,105 @@ +<%# The way into the Patchwork Labs Slack (slack.patchworklabs.org redirects here). + @step is the next thing this person must do; see User#slack_onboarding_step. %> +<% content_for :title, "Join the Slack" %> +<% + steps = [ + [:sign_up, "Create your Weave account"], + [:request_invite, "Get your Slack invite"], + [:accept_invite, "Accept the invite from Slack"], + [:accept_code_of_conduct, "Accept the Code of Conduct"], + [:member, "You're a full member"] + ] + # awaiting_promotion is the Code of Conduct step, finishing up. + current_index = steps.index { |key, _| key == (@step == :awaiting_promotion ? :accept_code_of_conduct : @step) } + link_class = "text-grape hover:text-ink font-medium underline decoration-dashed underline-offset-2" +%> + +
+
+
+ +
+

Join the Patchwork Labs Slack

+

The Slack is where the community lives. Here's how to get in.

+
+ +
+
    + <% steps.each_with_index do |(_key, label), index| %> + <% state = index < current_index ? :done : (index == current_index ? :current : :todo) %> + <%= tag.li class: "flex items-center gap-3", aria: { current: ("step" if state == :current) } do %> + + <%= state == :done ? "✓" : index + 1 %> + + + <%= label %> + + <% end %> + <% end %> +
+ +
+ <% case @step %> + <% when :sign_up %> +

+ Membership starts with a Weave account. Sign up with your email and + we'll send your Slack invite as soon as you confirm it. +

+ <%= link_to "Request an invite", signup_path, class: "btn-stitch w-full justify-center" %> +

+ Already have an account? <%= link_to "Sign in", login_path, class: link_class %> +

+ + <% when :request_invite %> +

+ We'll invite <%= current_user.email %> to the Slack. +

+ <%= button_to "Send my invite", slack_onboarding_path, class: "btn-stitch w-full justify-center" %> + + <% when :accept_invite %> +

+ Slack sent an invite to <%= current_user.email %>. + Open it and join the workspace. This page moves on once you're in. +

+

Can't find it? Check your spam folder first.

+ <%= button_to "Resend my invite", slack_onboarding_path, class: "btn-stitch-ghost w-full justify-center" %> + + <% when :accept_code_of_conduct %> +

+ You're in the Slack as a guest. To get full access, accept our Code of + Conduct. We sent it to you as a Slack DM, or you can accept it here. +

+ <% if SlackService.code_of_conduct_url.present? %> +

+ <%= link_to "Read the Code of Conduct", SlackService.code_of_conduct_url, + target: "_blank", rel: "noopener noreferrer", class: link_class %> +

+ <% end %> + <%= button_to "I accept the Code of Conduct", accept_code_of_conduct_slack_onboarding_path, + class: "btn-stitch w-full justify-center" %> + + <% when :awaiting_promotion %> +

+ You accepted the Code of Conduct. We're upgrading your Slack account to + full access. This usually takes a few seconds, so refresh the page soon. +

+ <%= button_to "Try again", accept_code_of_conduct_slack_onboarding_path, + class: "btn-stitch-ghost w-full justify-center" %> + + <% when :member %> +

You're a full member of the Patchwork Labs Slack. Welcome!

+ <% if SlackService.workspace_url.present? %> + <%= link_to "Open Slack", SlackService.workspace_url, class: "btn-stitch w-full justify-center" %> + <% end %> + <% end %> +
+ + <% if current_user %> +
+ <%= link_to "Go to your account", profile_path, class: "text-sm #{link_class}" %> +
+ <% end %> +
+
+
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 762b1d3..709b441 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -2,6 +2,16 @@
+ <% if @user.slack_pending? %> +
+
+

Finish joining the Patchwork Labs Slack

+

You become a full member once you're in the Slack and have accepted the Code of Conduct.

+
+ <%= link_to "Continue", slack_onboarding_path, class: "btn-stitch shrink-0 justify-center" %> +
+ <% end %> +
@@ -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