diff --git a/app/jobs/code_of_conduct_request_job.rb b/app/jobs/code_of_conduct_request_job.rb index d824652..cc67b15 100644 --- a/app/jobs/code_of_conduct_request_job.rb +++ b/app/jobs/code_of_conduct_request_job.rb @@ -29,17 +29,24 @@ def perform(user_id, deadline: nil, reminder: false) # The opening of the message, as plain-text paragraphs. Shared by the Slack # DM and the email. - def self.paragraphs(user, deadline: nil) + # + # reason is :update for the first request, :demoted for a member who did not + # accept by the deadline and is now a guest, or :admin for a Slack admin who + # can't be demoted (see DemoteForCodeOfConductJob). + def self.paragraphs(user, deadline: nil, reason: :update) t = ->(key, **args) { I18n.t("code_of_conduct_request.#{key}", **args) } [ # Capitalized like User#full_name: many Slack-imported names are lowercase. t.call(:greeting, first_name: user.name_missing? ? "there" : user.first_name.gsub(/\b\p{L}/, &:upcase)), - *t.call(:update).split(/\n{2,}/), + *t.call(reason).split(/\n{2,}/), (t.call(:name_missing) if user.name_missing?), - (t.call(:deadline, deadline: deadline.strftime("%B %-d, %Y")) if deadline) + (t.call(:deadline, deadline: deadline.strftime("%B %-d, %Y")) if deadline && reason == :update) ].compact end + # The header of the Slack DM for a reason (see .paragraphs). + def self.slack_title(reason) = I18n.t("code_of_conduct_request.slack_titles.#{reason}") + private # A Slack failure must not stop the email. @@ -49,7 +56,7 @@ def send_slack_dm(user, deadline) service.post_code_of_conduct( user.slack_id, - title: I18n.t("code_of_conduct_request.slack_title"), + title: self.class.slack_title(:update), paragraphs: self.class.paragraphs(user, deadline:), form: user.name_missing? ) diff --git a/app/jobs/demote_for_code_of_conduct_job.rb b/app/jobs/demote_for_code_of_conduct_job.rb new file mode 100644 index 0000000..3d0f75f --- /dev/null +++ b/app/jobs/demote_for_code_of_conduct_job.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Moves a full member who joined before the code-of-conduct flow, and did not +# accept it by the deadline, back to a single-channel guest in the +# code-of-conduct channel. They then go through the same flow as a new member: +# a DM and an email ask them to accept, and accepting promotes them again (see +# SlackCodeOfConductAcceptedJob). +# +# Slack workspace admins and owners can't be demoted. They get their own +# request instead: a DM whose button opens the form with the box to check, +# and an email. Their apps already need the acceptance (see AppAccess). +# +# Enqueued by `bin/rails slack:coc:demote`. Skips anyone who accepted in the +# meantime, and exempt users. +class DemoteForCodeOfConductJob < ApplicationJob + queue_as :default + + def perform(user_id) + user = User.find_by(id: user_id) + return unless user&.slack_member? && user.slack_id.present? + return if user.slack_coc_accepted_at.present? || user.code_of_conduct_exempt + + service = SlackService.new + return unless service.configured? + + if service.workspace_admin?(user.slack_id) + Rails.logger.info "Not demoting Slack admin #{user.slack_id} for CoC; sending the admin request" + notify(service, user, reason: :admin, form: true) + return + end + + result = service.demote_to_guest(user.slack_id) + unless result[:ok] + Rails.logger.error "Failed to demote #{user.slack_id} for CoC: #{result[:error]}" + return + end + + user.update_columns(slack_membership: "pending", updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + notify(service, user, reason: :demoted, form: user.name_missing?) + end + + private + + # A Slack failure must not stop the email. + def notify(service, user, reason:, form:) + begin + service.post_code_of_conduct( + user.slack_id, + title: CodeOfConductRequestJob.slack_title(reason), + paragraphs: CodeOfConductRequestJob.paragraphs(user, reason:), + form: form + ) + rescue => e + Rails.logger.error "Failed to DM the CoC (#{reason}) to #{user.slack_id}: #{e.message}" + end + UserMailer.code_of_conduct_request(user, reason:).deliver_later + user.update_columns(slack_coc_requested_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index bd98bdf..627ee38 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -37,9 +37,9 @@ def slack_email_address_added(email_address) # Asks a member who joined the Slack before the code-of-conduct flow to # accept it (see CodeOfConductRequestJob). Signing in sends them to /slack, # which shows the accept step. - def code_of_conduct_request(user, deadline: nil) + def code_of_conduct_request(user, deadline: nil, reason: :update) @user = user - @paragraphs = CodeOfConductRequestJob.paragraphs(user, deadline:) + @paragraphs = CodeOfConductRequestJob.paragraphs(user, deadline:, reason:) @login_url = login_url @code_of_conduct_url = SlackService.code_of_conduct_url diff --git a/app/services/slack_service.rb b/app/services/slack_service.rb index cdd7825..6757176 100644 --- a/app/services/slack_service.rb +++ b/app/services/slack_service.rb @@ -154,6 +154,36 @@ def promote_to_member(slack_user_id) { ok: false, error: "request_failed", raw: nil } end + # Move a full member back to a single-channel guest in the code-of-conduct + # channel, via the undocumented `users.admin.setUltraRestricted` endpoint + # (same xoxc/xoxd auth as #promote_to_member, which undoes it). Used for + # members who joined before the code-of-conduct flow and did not accept it + # by the deadline. Returns { ok:, error:, raw: }. + def demote_to_guest(slack_user_id) + raise ConfigurationError, "Slack code-of-conduct channel (SLACK_COC_CHANNEL) not configured" if coc_channel.blank? + + body = admin_api_post("users.admin.setUltraRestricted", { + "user" => slack_user_id, + "channel" => coc_channel, + "team_id" => team_id, + "_x_reason" => "member-set-ultra-restricted" + }) + + Rails.logger.error "Slack demote failed for #{slack_user_id}: #{body["error"].inspect}" unless body["ok"] + { ok: !!body["ok"], error: body["error"], raw: body } + rescue Faraday::Error => e + Rails.logger.error "Slack demote request error for #{slack_user_id}: #{e.message}" + { ok: false, error: "request_failed", raw: nil } + end + + # True for workspace admins and owners. They are never demoted. + def workspace_admin?(slack_user_id) + raise ConfigurationError, "Slack client not configured" unless @client + + user = @client.users_info(user: slack_user_id)["user"] || {} + user["is_admin"] || user["is_owner"] || user["is_primary_owner"] || false + end + # DM someone the code of conduct with a button to accept it. Uses the bot # token (needs chat:write + im:write). Returns the Slack API response. The # blocks come from CodeOfConductSlackViews.request. diff --git a/config/locales/code_of_conduct_request.en.yml b/config/locales/code_of_conduct_request.en.yml index 3a23c26..85240d1 100644 --- a/config/locales/code_of_conduct_request.en.yml +++ b/config/locales/code_of_conduct_request.en.yml @@ -12,8 +12,17 @@ en: Here is a quick update on Patchwork Labs. We set up Weave, our new account system. Your Weave account is linked to your Slack account, and it is how you sign in to Patchwork Labs apps. New members now join the Slack through Weave, and they accept our Code of Conduct when they join. You joined before we added that step, so we ask you to accept it now. It takes a few seconds. + demoted: |- + We asked you to accept our Code of Conduct, and we have not seen your acceptance yet. Your Slack account is now a guest account, so you can only see the Code of Conduct channel. + + Accept the Code of Conduct to get full access back right away. + admin: |- + You are a Slack admin, so your account stays a full member. As an admin, please accept the Code of Conduct now, so that everyone in the Slack has accepted it. name_missing: "We also don't have your name yet. Please add it when you accept." deadline: "Please accept by %{deadline}. After that date, members who have not accepted move to guest access in the Slack until they accept." - slack_title: "📜 Please accept our Code of Conduct" + slack_titles: + update: "📜 Please accept our Code of Conduct" + demoted: "📜 Your Slack account is now a guest account" + admin: "📜 Slack admins: please accept our Code of Conduct" email_action: "Sign in to Weave with %{email} to accept." sign_off: "Thanks for being part of Patchwork Labs!" diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0079056 --- /dev/null +++ b/docs/CODE_OF_CONDUCT.md @@ -0,0 +1,40 @@ +# Code of Conduct acceptance + +Every Slack member accepts the Patchwork Labs Code of Conduct. New members accept it when they join (see `User#slack_onboarding_step`). This page is about members who joined before that step existed. + +## The flow + +```mermaid +flowchart TD + A[Full member, no acceptance] -->|slack:coc:request| B[Slack DM + email] + B -->|DM button or /slack| C[Accepted] + B -->|no answer by the deadline| D[slack:coc:demote] + D --> E[Single-channel guest in the CoC channel, DM + email] + E -->|DM button or /slack| F[Accepted, promoted to full member] +``` + +- The DM button accepts at once. When the Slack import saved a placeholder name (`NOTSET`, `Unknown`, `User`), the button opens a Slack form instead. The form has a required "I have read and accept" box, and name fields when the name is missing. +- The email links to sign-in. After sign-in, `/` sends the member to `/slack`, which shows the same accept step and name fields. +- The message text is in `config/locales/code_of_conduct_request.en.yml`. + +## Runbook + +Run these in the production container. + +1. Count the members: `bin/rails slack:coc:status`. +2. Preview the message: `bin/rails slack:coc:request DEADLINE=2026-11-01`. +3. Send it: `bin/rails slack:coc:request SEND=1 DEADLINE=2026-11-01`. +4. Optional: send a reminder: `bin/rails slack:coc:request SEND=1 REMIND=1 DEADLINE=2026-11-01`. +5. After the deadline, preview the demotions: `bin/rails slack:coc:demote ASKED_BEFORE=2026-10-15`. +6. Demote: `bin/rails slack:coc:demote CONFIRM=1 ASKED_BEFORE=2026-10-15`. Slack admins and owners cannot be demoted. They get a DM whose button opens the form with the box to check, and an email. + +## App access + +Every OAuth app requires the code of conduct (see `AppAccess`). A user who has not accepted cannot use the app. The authorize endpoint sends them to `/slack` to accept and then back to the app. Their existing tokens stop working until they accept. + +A superadmin can opt out: + +- one app, on the app's admin page (`oauth_applications.requires_code_of_conduct`), or +- one user, on the user's admin page (`users.code_of_conduct_exempt`). Exempt users are also not asked to accept and not demoted. + +The access policy of the app still applies in both cases. diff --git a/lib/tasks/slack.rake b/lib/tasks/slack.rake index 0f44331..8f8ca6b 100644 --- a/lib/tasks/slack.rake +++ b/lib/tasks/slack.rake @@ -113,5 +113,30 @@ namespace :slack do end puts "Enqueued #{users.size} requests." end + + # Moves members who were asked and still have not accepted back to + # single-channel guests. Nothing changes without CONFIRM=1. + # + # bin/rails slack:coc:demote # preview + # bin/rails slack:coc:demote CONFIRM=1 # asked 14 or more days ago + # bin/rails slack:coc:demote CONFIRM=1 ASKED_BEFORE=2026-10-15 + desc "Move members who did not accept the code of conduct back to guests (CONFIRM=1 to run)" + task demote: :environment do + asked_before = ENV["ASKED_BEFORE"].presence ? Date.iso8601(ENV["ASKED_BEFORE"]).beginning_of_day : 14.days.ago + users = User.code_of_conduct_pending.where(slack_coc_requested_at: ...asked_before).order(:id).to_a + + puts "Members asked before #{asked_before.to_date} who have not accepted: #{users.size}" + users.each { |user| puts " #{user.email} (#{user.slack_id}), asked #{user.slack_coc_requested_at.to_date}" } + + unless ENV["CONFIRM"] == "1" + puts "Preview only. Run again with CONFIRM=1 to demote them." + next + end + + users.each_with_index do |user, index| + DemoteForCodeOfConductJob.set(wait: (index * 3).seconds).perform_later(user.id) + end + puts "Enqueued #{users.size} demotions. Slack admins and owners are not demoted: they get a request with the form instead." + end end end diff --git a/spec/jobs/demote_for_code_of_conduct_job_spec.rb b/spec/jobs/demote_for_code_of_conduct_job_spec.rb new file mode 100644 index 0000000..5eb3959 --- /dev/null +++ b/spec/jobs/demote_for_code_of_conduct_job_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DemoteForCodeOfConductJob do + let(:slack) { instance_double(SlackService, configured?: true, workspace_admin?: false, post_code_of_conduct: true) } + let!(:user) do + create(:user, :verified, slack_id: "U1", slack_membership: "member", slack_coc_requested_at: 3.weeks.ago) + end + + before { allow(SlackService).to receive(:new).and_return(slack) } + + it "makes the member a guest and asks them again by DM and email" do + allow(slack).to receive(:demote_to_guest).with("U1").and_return({ ok: true }) + expect(slack).to receive(:post_code_of_conduct) + .with("U1", title: a_string_including("guest account"), paragraphs: include(a_string_including("now a guest account")), form: false) + + expect { described_class.perform_now(user.id) }.to have_enqueued_mail(UserMailer, :code_of_conduct_request) + + user.reload + expect(user).to be_slack_pending + expect(user.slack_onboarding_step).to eq(:accept_code_of_conduct) + end + + it "keeps the member when Slack refuses" do + allow(slack).to receive(:demote_to_guest).and_return({ ok: false, error: "not_allowed" }) + + expect { described_class.perform_now(user.id) }.not_to have_enqueued_mail(UserMailer, :code_of_conduct_request) + expect(user.reload).to be_slack_member + end + + it "skips someone who accepted in the meantime" do + user.update!(slack_coc_accepted_at: 1.hour.ago) + expect(slack).not_to receive(:demote_to_guest) + + described_class.perform_now(user.id) + end + + it "skips an exempt user" do + user.update!(code_of_conduct_exempt: true) + expect(slack).not_to receive(:demote_to_guest) + + described_class.perform_now(user.id) + end + + it "asks Slack admins and owners to accept through the form, without demoting them" do + allow(slack).to receive(:workspace_admin?).with("U1").and_return(true) + expect(slack).not_to receive(:demote_to_guest) + expect(slack).to receive(:post_code_of_conduct) + .with("U1", title: a_string_including("Slack admins"), paragraphs: include(a_string_including("You are a Slack admin")), form: true) + + expect { described_class.perform_now(user.id) }.to have_enqueued_mail(UserMailer, :code_of_conduct_request) + expect(user.reload).to be_slack_member + end +end diff --git a/spec/services/slack_service_invite_spec.rb b/spec/services/slack_service_invite_spec.rb index 3729cd2..a6ce1fe 100644 --- a/spec/services/slack_service_invite_spec.rb +++ b/spec/services/slack_service_invite_spec.rb @@ -79,4 +79,22 @@ def stub_faraday(response_body) expect(service.promote_to_member("U123")).to include(ok: false, error: "user_not_found") end end + + describe "#demote_to_guest" do + it "makes the member a single-channel guest in the CoC channel" do + stub_faraday("ok" => true) + + result = service.demote_to_guest("U1") + + expect(@url).to eq("https://patchworklabs.slack.com/api/users.admin.setUltraRestricted") + expect(@body).to include("U1", "C_COC", "T123") + expect(result[:ok]).to be true + end + + it "reports Slack's error" do + stub_faraday("ok" => false, "error" => "not_allowed") + + expect(service.demote_to_guest("U1")).to include(ok: false, error: "not_allowed") + end + end end