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.
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 @@
The Slack is where the community lives. Here's how to get in.
++ 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 %> +Finish joining the Patchwork Labs Slack
+You become a full member once you're in the Slack and have accepted the Code of Conduct.
+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.