From 6e1e89c04239054a2ad1f5afd972244f0ac0d065 Mon Sep 17 00:00:00 2001 From: Jasper Mayone Date: Fri, 9 Oct 2026 12:32:41 -0400 Subject: [PATCH 1/3] feat: demote members who miss the code of conduct deadline --- app/jobs/code_of_conduct_request_job.rb | 9 ++-- app/jobs/demote_for_code_of_conduct_job.rb | 50 +++++++++++++++++++ app/mailers/user_mailer.rb | 4 +- app/services/slack_service.rb | 30 +++++++++++ config/locales/code_of_conduct_request.en.yml | 4 ++ docs/CODE_OF_CONDUCT.md | 33 ++++++++++++ lib/tasks/slack.rake | 25 ++++++++++ .../demote_for_code_of_conduct_job_spec.rb | 45 +++++++++++++++++ spec/services/slack_service_invite_spec.rb | 18 +++++++ 9 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 app/jobs/demote_for_code_of_conduct_job.rb create mode 100644 docs/CODE_OF_CONDUCT.md create mode 100644 spec/jobs/demote_for_code_of_conduct_job_spec.rb diff --git a/app/jobs/code_of_conduct_request_job.rb b/app/jobs/code_of_conduct_request_job.rb index d824652..925266e 100644 --- a/app/jobs/code_of_conduct_request_job.rb +++ b/app/jobs/code_of_conduct_request_job.rb @@ -29,14 +29,17 @@ 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) + # + # demoted: true is for a member who did not accept by the deadline and is + # now a guest (see DemoteForCodeOfConductJob). + def self.paragraphs(user, deadline: nil, demoted: false) 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(demoted ? :demoted : :update).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 && !demoted) ].compact end 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..0dc7587 --- /dev/null +++ b/app/jobs/demote_for_code_of_conduct_job.rb @@ -0,0 +1,50 @@ +# 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). +# +# Enqueued by `bin/rails slack:coc:demote`. Skips anyone who accepted in the +# meantime, and Slack workspace admins and owners. +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? + + 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" + 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) + end + + private + + # A Slack failure must not stop the email. + def notify(service, user) + begin + intro = [*CodeOfConductRequestJob.paragraphs(user, demoted: true), I18n.t("code_of_conduct_request.slack_action")].join("\n\n") + service.post_code_of_conduct(user.slack_id, intro: intro, collect_name: user.name_missing?) + rescue => e + Rails.logger.error "Failed to DM the CoC to demoted member #{user.slack_id}: #{e.message}" + end + UserMailer.code_of_conduct_request(user, demoted: true).deliver_later + end + +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index bd98bdf..5188c36 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, demoted: false) @user = user - @paragraphs = CodeOfConductRequestJob.paragraphs(user, deadline:) + @paragraphs = CodeOfConductRequestJob.paragraphs(user, deadline:, demoted:) @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..290b9a9 100644 --- a/config/locales/code_of_conduct_request.en.yml +++ b/config/locales/code_of_conduct_request.en.yml @@ -12,6 +12,10 @@ 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. 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" diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..942e8fa --- /dev/null +++ b/docs/CODE_OF_CONDUCT.md @@ -0,0 +1,33 @@ +# 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 that also asks for the name. +- 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 are skipped. + +## App access + +When the Flipper flag `require_code_of_conduct` is on, a user who has not accepted cannot use any OAuth app (see `AppAccess`). The authorize endpoint sends them to `/slack` to accept and then back to the app. Their existing tokens stop working until they accept. The flag is off while it does not exist. diff --git a/lib/tasks/slack.rake b/lib/tasks/slack.rake index 0f44331..7a44846 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 skipped." + 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..69afa6d --- /dev/null +++ b/spec/jobs/demote_for_code_of_conduct_job_spec.rb @@ -0,0 +1,45 @@ +# 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", intro: a_string_including("now a guest account"), collect_name: 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 Slack admins and owners" do + allow(slack).to receive(:workspace_admin?).with("U1").and_return(true) + expect(slack).not_to receive(:demote_to_guest) + + described_class.perform_now(user.id) + 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 From d0d05e07c669a7a59aac4130ca8c95d3eaf76c90 Mon Sep 17 00:00:00 2001 From: Jasper Mayone Date: Fri, 9 Oct 2026 12:50:11 -0400 Subject: [PATCH 2/3] feat: send slack admins a code of conduct form instead of demoting --- app/jobs/code_of_conduct_request_job.rb | 16 +++++++----- app/jobs/demote_for_code_of_conduct_job.rb | 26 +++++++++++++------ app/mailers/user_mailer.rb | 4 +-- config/locales/code_of_conduct_request.en.yml | 7 ++++- docs/CODE_OF_CONDUCT.md | 8 +++--- lib/tasks/slack.rake | 2 +- .../demote_for_code_of_conduct_job_spec.rb | 9 ++++--- 7 files changed, 48 insertions(+), 24 deletions(-) diff --git a/app/jobs/code_of_conduct_request_job.rb b/app/jobs/code_of_conduct_request_job.rb index 925266e..cc67b15 100644 --- a/app/jobs/code_of_conduct_request_job.rb +++ b/app/jobs/code_of_conduct_request_job.rb @@ -30,19 +30,23 @@ 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. # - # demoted: true is for a member who did not accept by the deadline and is - # now a guest (see DemoteForCodeOfConductJob). - def self.paragraphs(user, deadline: nil, demoted: false) + # 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(demoted ? :demoted : :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 && !demoted) + (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. @@ -52,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 index 0dc7587..943a7df 100644 --- a/app/jobs/demote_for_code_of_conduct_job.rb +++ b/app/jobs/demote_for_code_of_conduct_job.rb @@ -6,8 +6,12 @@ # 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 Slack workspace admins and owners. +# meantime. class DemoteForCodeOfConductJob < ApplicationJob queue_as :default @@ -20,7 +24,8 @@ def perform(user_id) return unless service.configured? if service.workspace_admin?(user.slack_id) - Rails.logger.info "Not demoting Slack admin #{user.slack_id} for CoC" + 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 @@ -31,20 +36,25 @@ def perform(user_id) end user.update_columns(slack_membership: "pending", updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations - notify(service, user) + notify(service, user, reason: :demoted, form: user.name_missing?) end private # A Slack failure must not stop the email. - def notify(service, user) + def notify(service, user, reason:, form:) begin - intro = [*CodeOfConductRequestJob.paragraphs(user, demoted: true), I18n.t("code_of_conduct_request.slack_action")].join("\n\n") - service.post_code_of_conduct(user.slack_id, intro: intro, collect_name: user.name_missing?) + 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 to demoted member #{user.slack_id}: #{e.message}" + Rails.logger.error "Failed to DM the CoC (#{reason}) to #{user.slack_id}: #{e.message}" end - UserMailer.code_of_conduct_request(user, demoted: true).deliver_later + 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 5188c36..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, demoted: false) + def code_of_conduct_request(user, deadline: nil, reason: :update) @user = user - @paragraphs = CodeOfConductRequestJob.paragraphs(user, deadline:, demoted:) + @paragraphs = CodeOfConductRequestJob.paragraphs(user, deadline:, reason:) @login_url = login_url @code_of_conduct_url = SlackService.code_of_conduct_url diff --git a/config/locales/code_of_conduct_request.en.yml b/config/locales/code_of_conduct_request.en.yml index 290b9a9..85240d1 100644 --- a/config/locales/code_of_conduct_request.en.yml +++ b/config/locales/code_of_conduct_request.en.yml @@ -16,8 +16,13 @@ en: 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 index 942e8fa..762552e 100644 --- a/docs/CODE_OF_CONDUCT.md +++ b/docs/CODE_OF_CONDUCT.md @@ -13,7 +13,7 @@ flowchart TD 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 that also asks for the name. +- 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`. @@ -26,8 +26,10 @@ Run these in the production container. 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 are skipped. +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 -When the Flipper flag `require_code_of_conduct` is on, a user who has not accepted cannot use any OAuth app (see `AppAccess`). The authorize endpoint sends them to `/slack` to accept and then back to the app. Their existing tokens stop working until they accept. The flag is off while it does not exist. +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 one app out on its admin page (`requires_code_of_conduct`). The app's access policy still applies. diff --git a/lib/tasks/slack.rake b/lib/tasks/slack.rake index 7a44846..8f8ca6b 100644 --- a/lib/tasks/slack.rake +++ b/lib/tasks/slack.rake @@ -136,7 +136,7 @@ namespace :slack do 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 skipped." + 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 index 69afa6d..4f915f9 100644 --- a/spec/jobs/demote_for_code_of_conduct_job_spec.rb +++ b/spec/jobs/demote_for_code_of_conduct_job_spec.rb @@ -13,7 +13,7 @@ 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", intro: a_string_including("now a guest account"), collect_name: false) + .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) @@ -36,10 +36,13 @@ described_class.perform_now(user.id) end - it "skips Slack admins and owners" do + 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) - described_class.perform_now(user.id) + 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 From 5ab1f23c875467fc634cb7a4116e46833d6b337a Mon Sep 17 00:00:00 2001 From: Jasper Mayone Date: Fri, 9 Oct 2026 13:22:13 -0400 Subject: [PATCH 3/3] feat: skip exempt users when demoting --- app/jobs/demote_for_code_of_conduct_job.rb | 4 ++-- docs/CODE_OF_CONDUCT.md | 7 ++++++- spec/jobs/demote_for_code_of_conduct_job_spec.rb | 7 +++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/jobs/demote_for_code_of_conduct_job.rb b/app/jobs/demote_for_code_of_conduct_job.rb index 943a7df..3d0f75f 100644 --- a/app/jobs/demote_for_code_of_conduct_job.rb +++ b/app/jobs/demote_for_code_of_conduct_job.rb @@ -11,14 +11,14 @@ # 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. +# 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? + return if user.slack_coc_accepted_at.present? || user.code_of_conduct_exempt service = SlackService.new return unless service.configured? diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md index 762552e..0079056 100644 --- a/docs/CODE_OF_CONDUCT.md +++ b/docs/CODE_OF_CONDUCT.md @@ -32,4 +32,9 @@ Run these in the production container. 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 one app out on its admin page (`requires_code_of_conduct`). The app's access policy still applies. +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/spec/jobs/demote_for_code_of_conduct_job_spec.rb b/spec/jobs/demote_for_code_of_conduct_job_spec.rb index 4f915f9..5eb3959 100644 --- a/spec/jobs/demote_for_code_of_conduct_job_spec.rb +++ b/spec/jobs/demote_for_code_of_conduct_job_spec.rb @@ -36,6 +36,13 @@ 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)