diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index a7cb7ce..ecacb05 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -8,11 +8,13 @@ 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). - # Joining the Slack is part of becoming a member, so anyone who hasn't - # finished lands on the next step of that instead. + # Joining the Slack (which includes accepting the code of conduct) 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) + onboarding = current_user.email_verified? && !current_user.slack_onboarding_complete? + redirect_to(onboarding ? slack_onboarding_path : profile_path) end end diff --git a/app/controllers/slack_onboarding_controller.rb b/app/controllers/slack_onboarding_controller.rb index b114c79..b05d6cf 100644 --- a/app/controllers/slack_onboarding_controller.rb +++ b/app/controllers/slack_onboarding_controller.rb @@ -42,16 +42,44 @@ def create 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. + # Slack DM and for full members who joined before the code-of-conduct flow. + # Also asks for a real name when the Slack import could not find one, and + # 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." + # "Try again" after an earlier acceptance needs no new check. + unless params[:accept] == "1" || current_user.slack_coc_accepted_at.present? + render_form_errors(accept: I18n.t("code_of_conduct_form.accept_error")) + return + end + + result = CodeOfConductAcceptance.call( + current_user, + names: params.slice(*CodeOfConductAcceptance::NAME_FIELDS).permit(*CodeOfConductAcceptance::NAME_FIELDS).to_h.symbolize_keys + ) + unless result.success? + render_form_errors(result.errors) + return + end + + notice = if current_user.slack_member? + "Thanks for accepting the Code of Conduct." + else + "Thanks for accepting the Code of Conduct. Your full Slack access is on its way." + end + redirect_to slack_onboarding_path, notice: notice + end + + private + + def render_form_errors(errors) + @step = current_user.slack_onboarding_step + @form_errors = errors + render :show, status: :unprocessable_content end end diff --git a/app/controllers/webhooks/slack_controller.rb b/app/controllers/webhooks/slack_controller.rb index ccb8e3e..388f0a2 100644 --- a/app/controllers/webhooks/slack_controller.rb +++ b/app/controllers/webhooks/slack_controller.rb @@ -57,26 +57,42 @@ def events render json: { status: "ok" }, status: :ok end - # Slack interactivity (Block Kit button clicks). Handles the "I accept the - # Code of Conduct" button, which promotes a single-channel guest to a full - # member. Slack posts the interaction as a form-encoded `payload` field. + # Slack interactivity. Handles the code-of-conduct DM (see + # SlackService#post_code_of_conduct): + # + # - "accept_coc" button: accepts at once, and promotes a single-channel + # guest to a full member. + # - "open_coc_form" button: opens the form with the box to check, which + # also asks for a missing name. + # - Submission of that form: saves the name and accepts. + # + # Slack posts the interaction as a form-encoded `payload` field. def interactions payload = JSON.parse(params[:payload].to_s.presence || "{}") + + if payload["type"] == "view_submission" + return submit_code_of_conduct_form(payload) if payload.dig("view", "callback_id") == SlackService::CODE_OF_CONDUCT_FORM + + head :ok + return + end + action = (payload["actions"] || []).first || {} - if action["action_id"] == "accept_coc" + case action["action_id"] + when "accept_coc" slack_user_id = action["value"].presence || payload.dig("user", "id") SlackCodeOfConductAcceptedJob.perform_later(slack_user_id) if slack_user_id.present? # Replace the original message so the button can't be used twice. - render json: { - replace_original: true, - text: ":white_check_mark: Thanks for accepting the Code of Conduct โ you now have full access to the Patchwork Labs Slack. Welcome! :tada:" - } - return + already_member = User.find_by(slack_id: slack_user_id)&.slack_member? || false + render json: { replace_original: true, text: SlackService.code_of_conduct_thanks(already_member:) } + when "open_coc_form" + open_code_of_conduct_form(payload) + head :ok + else + head :ok end - - head :ok rescue JSON::ParserError => e Rails.logger.error "[Slack Interactions] Invalid payload: #{e.message}" head :bad_request @@ -84,6 +100,50 @@ def interactions private + # Called while Slack waits for the answer to the click: the trigger_id + # expires after 3 seconds. + def open_code_of_conduct_form(payload) + SlackService.new.open_code_of_conduct_form( + trigger_id: payload["trigger_id"], + user: User.find_by(slack_id: payload.dig("user", "id")), + message: { channel: payload.dig("container", "channel_id"), ts: payload.dig("container", "message_ts") }.compact.presence + ) + rescue => e + Rails.logger.error "[Slack Interactions] Could not open CoC form: #{e.message}" + end + + # The person who submits the form is the one who accepts, so the user + # comes from the payload's user, never from a value in the form. + def submit_code_of_conduct_form(payload) + slack_user_id = payload.dig("user", "id") + values = payload.dig("view", "state", "values") || {} + message = JSON.parse(payload.dig("view", "private_metadata").presence || "{}").symbolize_keys.slice(:channel, :ts).presence + user = User.find_by(slack_id: slack_user_id) + + # Slack makes the box required, but check it here too. + accepted = Array(values.dig("accept", "value", "selected_options")).any? { |option| option["value"] == "accept" } + unless accepted + render json: { response_action: "errors", errors: { "accept" => I18n.t("code_of_conduct_form.accept_error") } } + return + end + + if user + result = CodeOfConductAcceptance.call( + user, + names: CodeOfConductAcceptance::NAME_FIELDS.index_with { |field| values.dig(field.to_s, "value", "value") }, + message: message + ) + unless result.success? + render json: { response_action: "errors", errors: result.errors.transform_keys(&:to_s) } + return + end + elsif slack_user_id.present? + SlackCodeOfConductAcceptedJob.perform_later(slack_user_id, **{ message: message }.compact) + end + + render json: { response_action: "clear" } + end + def devise_configured? defined?(Devise) end diff --git a/app/jobs/code_of_conduct_request_job.rb b/app/jobs/code_of_conduct_request_job.rb new file mode 100644 index 0000000..d824652 --- /dev/null +++ b/app/jobs/code_of_conduct_request_job.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Asks a full Slack member who joined before the code-of-conduct flow to +# accept it (User.code_of_conduct_pending). Sends the request two ways, +# because many members don't check Slack often: +# +# - a Slack DM with a button that accepts (or opens a form that also asks for +# a missing name), and +# - an email with a link to sign in to Weave and accept on /slack. +# +# The text is in config/locales/code_of_conduct_request.en.yml. Enqueued by +# `bin/rails slack:coc:request`. Idempotent: a member who has accepted is +# skipped, and so is one who was already asked, unless reminder is true. +class CodeOfConductRequestJob < ApplicationJob + queue_as :default + + # deadline is the date after which members who have not accepted move to + # guest access. nil leaves the deadline out of the message. + def perform(user_id, deadline: nil, reminder: false) + user = User.find_by(id: user_id) + return unless user&.can_authenticate? + return if user.slack_coc_accepted_at.present? + return if user.slack_coc_requested_at.present? && !reminder + + send_slack_dm(user, deadline) if user.slack_id.present? + UserMailer.code_of_conduct_request(user, deadline: deadline).deliver_later + user.update_columns(slack_coc_requested_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + # The opening of the message, as plain-text paragraphs. Shared by the Slack + # DM and the email. + def self.paragraphs(user, deadline: nil) + 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(:name_missing) if user.name_missing?), + (t.call(:deadline, deadline: deadline.strftime("%B %-d, %Y")) if deadline) + ].compact + end + + private + + # A Slack failure must not stop the email. + def send_slack_dm(user, deadline) + service = SlackService.new + return unless service.configured? + + service.post_code_of_conduct( + user.slack_id, + title: I18n.t("code_of_conduct_request.slack_title"), + paragraphs: self.class.paragraphs(user, deadline:), + form: user.name_missing? + ) + rescue => e + Rails.logger.error "Failed to DM the CoC request to #{user.slack_id}: #{e.message}" + end + +end diff --git a/app/jobs/push_slack_name_job.rb b/app/jobs/push_slack_name_job.rb new file mode 100644 index 0000000..383ba28 --- /dev/null +++ b/app/jobs/push_slack_name_job.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Sets the name on a user's Slack profile after they give it on the +# code-of-conduct form (see CodeOfConductAcceptance). With a nickname, only +# the Slack display name is set, so the preferred name stays out of Slack. +# Without one, the Slack first and last name are set to the preferred name. +# Slack errors and rate limits are retried. +class PushSlackNameJob < ApplicationJob + queue_as :default + + retry_on Slack::Web::Api::Errors::TooManyRequestsError, attempts: 10, + wait: ->(executions) { [executions * 30, 300].min } + retry_on Slack::Web::Api::Errors::SlackError, Slack::Web::Api::Errors::ServerError, + wait: :polynomially_longer, attempts: 5 + + def perform(user_id, nickname: nil) + user = User.find_by(id: user_id) + return if user&.slack_id.blank? + + profile = if nickname.present? + { display_name: nickname } + else + { first_name: user.first_name, last_name: user.last_name } + end + SlackService.new.update_slack_profile_name(user.slack_id, **profile) + end + +end diff --git a/app/jobs/slack_code_of_conduct_accepted_job.rb b/app/jobs/slack_code_of_conduct_accepted_job.rb index f7ade37..4286e1e 100644 --- a/app/jobs/slack_code_of_conduct_accepted_job.rb +++ b/app/jobs/slack_code_of_conduct_accepted_job.rb @@ -1,13 +1,20 @@ # frozen_string_literal: true -# 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. +# Runs when someone accepts the code of conduct: with the button in their Slack +# DM, with the Slack form, or on the /slack onboarding page. Records the +# acceptance, promotes a single-channel guest to full workspace member, and +# marks them a member in Weave once Slack confirms the promotion. +# +# A full member who joined before the code-of-conduct flow is already a member, +# so only the acceptance is recorded for them. +# +# message is the Slack DM that asked for the acceptance, as { channel:, ts: }. +# When it is given, the DM is replaced with a thank-you so its button can't be +# used again. class SlackCodeOfConductAcceptedJob < ApplicationJob queue_as :default - def perform(slack_user_id) + def perform(slack_user_id, message: nil) return if slack_user_id.blank? user = User.find_by(slack_id: slack_user_id) @@ -16,6 +23,10 @@ def perform(slack_user_id) service = SlackService.new return unless service.configured? + already_member = user&.slack_member? + replace_message(service, message, already_member:) if message.present? + return if already_member + result = service.promote_to_member(slack_user_id) if result[:ok] user&.update_columns(slack_membership: "member", updated_at: Time.current) @@ -27,4 +38,12 @@ def perform(slack_user_id) Rails.logger.error "Slack not configured for promotion: #{e.message}" end + private + + def replace_message(service, message, already_member:) + service.mark_code_of_conduct_accepted(channel: message[:channel], ts: message[:ts], already_member:) + rescue => e + Rails.logger.error "Failed to update CoC DM #{message.inspect}: #{e.message}" + end + end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 2fa917f..bd98bdf 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -34,4 +34,19 @@ def slack_email_address_added(email_address) ) end + # 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) + @user = user + @paragraphs = CodeOfConductRequestJob.paragraphs(user, deadline:) + @login_url = login_url + @code_of_conduct_url = SlackService.code_of_conduct_url + + mail( + to: @user.email, + subject: env_subject(I18n.t("code_of_conduct_request.subject")) + ) + end + end diff --git a/app/models/user.rb b/app/models/user.rb index a649427..315065a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -28,6 +28,7 @@ # slack_birthday :date # slack_city :string # slack_coc_accepted_at :datetime +# slack_coc_requested_at :datetime # slack_cost_center :string # slack_country :string # slack_department :string @@ -187,6 +188,15 @@ def self.generate_secure_password # as the `slack_member` claim. enum :slack_membership, { pending: "pending", member: "member" }, prefix: :slack + # Full Slack members who have not accepted the code of conduct. They joined + # before the code-of-conduct flow, so nothing has asked them yet (see + # CodeOfConductRequestJob). + scope :code_of_conduct_pending, -> { slack_member.where(slack_coc_accepted_at: nil).where.not(slack_id: nil) } + + # Names the Slack import saves when a Slack profile has no name. + PLACEHOLDER_NAMES = %w[NOTSET Unknown User].freeze + scope :name_missing, -> { where(first_name: PLACEHOLDER_NAMES).or(where(last_name: PLACEHOLDER_NAMES)) } + 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) } @@ -253,6 +263,9 @@ def self.p_id_format = /\A#{Weave.p_id_prefix}\d[A-F0-9]{9}\z/ end end + # True when the name is one the Slack import made up (see PLACEHOLDER_NAMES). + def name_missing? = PLACEHOLDER_NAMES.include?(first_name) || PLACEHOLDER_NAMES.include?(last_name) + def full_name # Display names capitalized (many Slack-imported names arrive lowercase). # Only upcase the first letter of each word so intentional casing like @@ -472,8 +485,11 @@ 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. + # + # Full members who joined before the code-of-conduct flow still have to + # accept it. They are not demoted for that, so they skip :awaiting_promotion. def slack_onboarding_step - return :member if slack_member? + return slack_coc_accepted_at.present? ? :member : :accept_code_of_conduct 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? @@ -481,6 +497,8 @@ def slack_onboarding_step :request_invite end + def slack_onboarding_complete? = slack_onboarding_step == :member + # 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. diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 9c2513e..bcef621 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -28,6 +28,7 @@ # slack_birthday :date # slack_city :string # slack_coc_accepted_at :datetime +# slack_coc_requested_at :datetime # slack_cost_center :string # slack_country :string # slack_department :string diff --git a/app/services/code_of_conduct_acceptance.rb b/app/services/code_of_conduct_acceptance.rb new file mode 100644 index 0000000..84e204f --- /dev/null +++ b/app/services/code_of_conduct_acceptance.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# Records that a person accepted the code of conduct, from the /slack page or +# the Slack form (see Webhooks::SlackController#interactions). +# +# When the Slack import gave the person a placeholder name (User#name_missing?), +# they must also give their preferred name. They can also give a legal name +# (only if it differs) and a Slack nickname (if they don't want their +# preferred name in Slack). The Slack profile then gets the nickname, or the +# preferred name (see PushSlackNameJob). +# +# The acceptance is recorded here, at once, so the next page they see is up +# to date. SlackCodeOfConductAcceptedJob then does the Slack side: it promotes +# a guest to full member, and it updates the Slack DM when one is given. +class CodeOfConductAcceptance + Result = Data.define(:user, :errors) do + def success? = errors.empty? + end + + NAME_FIELDS = %i[first_name last_name legal_first_name legal_last_name slack_name].freeze + + # names holds the NAME_FIELDS. They are read only when the user's name is + # missing. message is the Slack DM to update, as { channel:, ts: }. + def self.call(user, names: {}, message: nil) + new(user, names:, message:).call + end + + def initialize(user, names:, message:) + @user = user + @names = NAME_FIELDS.index_with { |field| names[field].to_s.strip } + @message = message + end + + def call + if @user.name_missing? + errors = name_errors + return Result.new(user: @user, errors:) if errors.any? + + save_names + end + + @user.update_columns(slack_coc_accepted_at: Time.current) if @user.slack_coc_accepted_at.nil? # rubocop:disable Rails/SkipsModelValidations + SlackCodeOfConductAcceptedJob.perform_later(@user.slack_id, **{ message: @message }.compact) if @user.slack_id.present? + + Result.new(user: @user, errors: {}) + end + + private + + # Keyed by field, so the Slack form can show each error next to its input. + def name_errors + errors = {} + errors[:first_name] = "Enter your preferred first name." if real_name_part(@names[:first_name]).nil? + errors[:last_name] = "Enter your preferred last name." if real_name_part(@names[:last_name]).nil? + if @names[:legal_first_name].present? ^ @names[:legal_last_name].present? + errors[@names[:legal_first_name].present? ? :legal_last_name : :legal_first_name] = "Enter both parts of your legal name, or leave both blank." + end + errors[:slack_name] = "Use 80 characters or fewer." if @names[:slack_name].length > 80 + errors + end + + def real_name_part(value) + value.presence unless User::PLACEHOLDER_NAMES.include?(value) + end + + def save_names + @user.update!( + first_name: @names[:first_name], + last_name: @names[:last_name], + legal_first_name: @names[:legal_first_name].presence, + legal_last_name: @names[:legal_last_name].presence + ) + PushSlackNameJob.perform_later(@user.id, nickname: @names[:slack_name].presence) if @user.slack_id.present? + end + +end diff --git a/app/services/code_of_conduct_slack_views.rb b/app/services/code_of_conduct_slack_views.rb new file mode 100644 index 0000000..f7f49f7 --- /dev/null +++ b/app/services/code_of_conduct_slack_views.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# Block Kit payloads for the code-of-conduct DM and form (see +# SlackService#post_code_of_conduct and #open_code_of_conduct_form). Plain +# hashes, as Slack's Block Kit reference documents them: +# https://docs.slack.dev/reference/block-kit/blocks +module CodeOfConductSlackViews + # callback_id of the form. The interactions webhook routes on it. + FORM_CALLBACK = "coc_form" + + # block_id of each field on the form. Each input's action_id is "value". + # A pair of fields shares one hint, under its second field. The /slack page + # uses the same list. + NAME_FIELDS = { + first_name: { label: "Preferred first name", hint_for: :last_name }, + last_name: { label: "Preferred last name", hint: :preferred_hint }, + legal_first_name: { label: "Legal first name", optional: true, hint_for: :legal_last_name }, + legal_last_name: { label: "Legal last name", hint: :legal_hint, optional: true }, + slack_name: { label: "Slack nickname", hint: :slack_name_hint, optional: true, max_length: 80 } + }.freeze + + module_function + + # The DM. button is { text:, action_id:, value: }. + def request(title:, paragraphs:, action_text:, button:, coc_url:, web_url:) + blocks = [header(title), *paragraphs.map { |paragraph| section(paragraph) }] + if coc_url.present? + blocks << section(":scroll: *Code of Conduct*\nIt explains how we treat each other in Patchwork Labs.", + accessory: link_button("Read it", coc_url)) + end + blocks << { type: "divider" } + blocks << section(action_text) + blocks << { + type: "actions", + elements: [{ type: "button", style: "primary", text: plain(button[:text]), action_id: button[:action_id], value: button[:value] }] + } + blocks << context("You can also accept on Weave: <#{web_url}|#{web_url.delete_prefix('https://')}>") + { blocks: blocks } + end + + # The form. ask_name adds the name fields. + def form(ask_name:, coc_url:, private_metadata:) + intro = t(:intro) + blocks = [coc_url.present? ? section(intro, accessory: link_button("Read it", coc_url)) : section(intro)] + + if ask_name + blocks << { type: "divider" } + blocks << section(t(:name_why)) + NAME_FIELDS.each { |block_id, field| blocks << text_input(block_id, **field) } + end + + blocks << { type: "divider" } + blocks << { + type: "input", + block_id: "accept", + label: plain("Code of Conduct"), + element: { + type: "checkboxes", + action_id: "value", + options: [{ text: plain(t(:accept_label)), value: "accept" }] + } + } + blocks << context(":lock: *#{t(:data_use_title)}*\n#{t(:data_use)}") + + { + type: "modal", + callback_id: FORM_CALLBACK, + private_metadata: private_metadata, + title: plain("Code of Conduct"), + submit: plain("I accept"), + close: plain("Not now"), + blocks: blocks + } + end + + def t(key) = I18n.t("code_of_conduct_form.#{key}") + def plain(text) = { type: "plain_text", text: text } + def header(text) = { type: "header", text: plain(text) } + def context(text) = { type: "context", elements: [{ type: "mrkdwn", text: text }] } + + def section(text, accessory: nil) + { type: "section", text: { type: "mrkdwn", text: text }, accessory: accessory }.compact + end + + # A URL button still sends a click to the interactions webhook, which + # ignores the action_id. + def link_button(text, url) = { type: "button", text: plain(text), action_id: "read_coc", url: url } + + def text_input(block_id, label:, hint: nil, optional: false, max_length: 100, hint_for: nil) + { + type: "input", + block_id: block_id.to_s, + optional: optional, + label: plain(label), + hint: (plain(t(hint)) if hint), + element: { type: "plain_text_input", action_id: "value", max_length: max_length } + }.compact + end +end diff --git a/app/services/slack_service.rb b/app/services/slack_service.rb index ffc2556..cdd7825 100644 --- a/app/services/slack_service.rb +++ b/app/services/slack_service.rb @@ -39,6 +39,9 @@ class ApiError < SlackError; end MANAGER_FIELD = "Manager" PWL_ID_FIELD = "pwl_idp_id" + # callback_id of the code-of-conduct form (see #open_code_of_conduct_form). + CODE_OF_CONDUCT_FORM = CodeOfConductSlackViews::FORM_CALLBACK + # How long a workspace's label -> field ID map is cached. CUSTOM_FIELD_IDS_TTL = 1.hour @@ -151,39 +154,84 @@ def promote_to_member(slack_user_id) { ok: false, error: "request_failed", raw: nil } end - # DM a newly-joined guest the code of conduct with an "I accept" button. Uses - # the bot token (needs chat:write + im:write). The button's action_id is - # "accept_coc" and its value is the Slack user id, handled by the interactions - # webhook. Returns the Slack API response. - def post_code_of_conduct(slack_user_id, coc_url: nil) + # 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. + # + # title and paragraphs (mrkdwn) open the message. They default to the + # welcome for a newly-joined guest. CodeOfConductRequestJob passes its own. + # + # With form: false, the button accepts at once: its action_id is + # "accept_coc" and its value is the Slack user id. With form: true, the + # button ("open_coc_form") opens a form with a box to check, and name fields + # when the name is missing (see #open_code_of_conduct_form). Use the form for + # people whose name the Slack import could not find. The interactions + # webhook handles both. + def post_code_of_conduct(slack_user_id, title: nil, paragraphs: nil, form: false) raise ConfigurationError, "Slack client not configured" unless @client - coc_url ||= self.class.code_of_conduct_url - intro = "Welcome to Patchwork Labs! :wave: Before you get full access to the community, " \ - "please read our Code of Conduct and accept it below." - coc_line = coc_url.present? ? "Read it here: #{coc_url}" : "Please review our Code of Conduct." + title ||= "Welcome to Patchwork Labs! ๐" + paragraphs ||= ["Before you get full access to the community, please read our Code of Conduct and accept it."] + button = if form + { text: "Review and accept", action_id: "open_coc_form", value: slack_user_id } + else + { text: "I accept the Code of Conduct", action_id: "accept_coc", value: slack_user_id } + end + + message = CodeOfConductSlackViews.request( + title: title, + paragraphs: paragraphs, + action_text: I18n.t(form ? "code_of_conduct_form.slack_form_action" : "code_of_conduct_form.slack_action"), + button: button, + coc_url: self.class.code_of_conduct_url, + web_url: Rails.application.routes.url_helpers.slack_onboarding_url(**Rails.application.config.action_mailer.default_url_options) + ) @client.chat_postMessage( channel: slack_user_id, - text: "Please review and accept the Patchwork Labs Code of Conduct to get full access.", - blocks: [ - { type: "section", text: { type: "mrkdwn", text: "*#{intro}*\n\n#{coc_line}" } }, - { - type: "actions", - elements: [ - { - type: "button", - style: "primary", - text: { type: "plain_text", text: "I accept the Code of Conduct", emoji: true }, - action_id: "accept_coc", - value: slack_user_id - } - ] - } - ] + text: "Please review and accept the Patchwork Labs Code of Conduct.", + blocks: message[:blocks] ) end + # Opens the code-of-conduct form from a button click. trigger_id comes from + # the click and expires after 3 seconds, so call this while answering it. + # The view comes from CodeOfConductSlackViews.form. + # + # The form has a required "I have read and accept" box, and says how Weave + # uses what it collects. It also asks for a preferred name, an optional + # legal name and an optional Slack nickname when the user's name is missing (or when there is no linked user, since nothing is + # known about them). message is the DM the button was in, as + # { channel:, ts: }. It goes into private_metadata, so the submission can + # replace the DM. + def open_code_of_conduct_form(trigger_id:, user:, message: nil) + raise ConfigurationError, "Slack client not configured" unless @client + + view = CodeOfConductSlackViews.form( + ask_name: user.nil? || user.name_missing?, + coc_url: self.class.code_of_conduct_url, + private_metadata: (message || {}).to_json + ) + + @client.views_open(trigger_id: trigger_id, view: view) + end + + # Replaces a code-of-conduct DM with a thank-you, so its button can't be + # used again. + def mark_code_of_conduct_accepted(channel:, ts:, already_member: false) + raise ConfigurationError, "Slack client not configured" unless @client + + @client.chat_update(channel: channel, ts: ts, text: self.class.code_of_conduct_thanks(already_member:), blocks: []) + end + + def self.code_of_conduct_thanks(already_member:) + if already_member + ":white_check_mark: Thanks for accepting the Code of Conduct! :tada:" + else + ":white_check_mark: Thanks for accepting the Code of Conduct โ you now have full access to the Patchwork Labs Slack. Welcome! :tada:" + end + end + # Where members open the workspace, e.g. https://patchworklabs.slack.com. # nil when the subdomain is not configured. def self.workspace_url @@ -413,6 +461,24 @@ def configured? # Uses user token (requires users.profile:write scope) # Returns false when there is nothing to do (no Slack ID or no user token). # Slack errors are raised, so PushPronounsToSlackJob can retry them. + # Sets name fields on a Slack profile (first_name, last_name, + # display_name). Uses the user token (users.profile:write). + def update_slack_profile_name(slack_user_id, **profile) + return false if slack_user_id.blank? || profile.empty? + + unless @user_client + Rails.logger.warn "Slack user token not configured, cannot update the profile name" + return false + end + + @user_client.users_profile_set(user: slack_user_id, profile: profile.to_json) + Rails.logger.info "Updated Slack profile name (#{profile.keys.join(', ')}) for user #{slack_user_id}" + true + rescue Slack::Web::Api::Errors::SlackError, Slack::Web::Api::Errors::TooManyRequestsError => e + Rails.logger.error "Error updating Slack profile name for #{slack_user_id}: #{e.message}" + raise + end + def update_slack_pronouns(slack_user_id, pronouns) return false if slack_user_id.blank? diff --git a/app/services/slack_webhook_service.rb b/app/services/slack_webhook_service.rb index 14ffc63..de5c181 100644 --- a/app/services/slack_webhook_service.rb +++ b/app/services/slack_webhook_service.rb @@ -143,7 +143,7 @@ def send_code_of_conduct(user) return if user&.slack_id.blank? return if user.slack_coc_accepted_at.present? - SlackService.new.post_code_of_conduct(user.slack_id) + SlackService.new.post_code_of_conduct(user.slack_id, form: user.name_missing?) rescue => e Rails.logger.error "[SlackWebhookService] Failed to post CoC to #{user&.slack_id}: #{e.message}" end diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index 8f96808..6a1e7d6 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -303,6 +303,11 @@ Full member <% if @user.slack_coc_accepted_at.present? %> accepted CoC <%= @user.slack_coc_accepted_at.strftime("%b %-d, %Y") %> + <% else %> + CoC pending + <% if @user.slack_coc_requested_at.present? %> + asked <%= @user.slack_coc_requested_at.strftime("%b %-d, %Y") %> + <% end %> <% end %> <% elsif @user.in_slack_workspace? && @user.slack_coc_accepted_at.present? %> Guest ยท promotion pending diff --git a/app/views/slack_onboarding/show.html.erb b/app/views/slack_onboarding/show.html.erb index eb5eaa7..ef10f24 100644 --- a/app/views/slack_onboarding/show.html.erb +++ b/app/views/slack_onboarding/show.html.erb @@ -66,18 +66,58 @@ <%= 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 current_user.slack_member? %> ++ Everyone in the Patchwork Labs Slack now accepts our Code of Conduct. + Please read it and accept it here. +
+ <% else %> ++ 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. +
+ <% end %> <% 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" %> + <%= form_with url: accept_code_of_conduct_slack_onboarding_path, method: :post, class: "space-y-4 text-left" do |form| %> + <% if current_user.name_missing? %> +<%= t("code_of_conduct_form.name_why") %>
+ <% CodeOfConductSlackViews::NAME_FIELDS.each do |field, options| %> +<%= t("code_of_conduct_form.#{options[:hint]}") %>
+ <% end %> + <% if @form_errors&.key?(field) %> +<%= @form_errors[field] %>
+ <% end %> +<%= @form_errors[:accept] %>
+ <% end %> +<%= t("code_of_conduct_form.data_use_title") %>
+<%= t("code_of_conduct_form.data_use") %>
+diff --git a/app/views/user_mailer/code_of_conduct_request.html.erb b/app/views/user_mailer/code_of_conduct_request.html.erb new file mode 100644 index 0000000..f47c78f --- /dev/null +++ b/app/views/user_mailer/code_of_conduct_request.html.erb @@ -0,0 +1,27 @@ +
<%= paragraph %>
+<% end %> + +<% if @code_of_conduct_url.present? %> + +<% end %> + +<%= t("code_of_conduct_request.email_action", email: @user.email) %>
+ +| + + Sign in and accept + + | +
<%= t("code_of_conduct_request.sign_off") %>
+ +We also sent this to you as a Slack DM. You only need to accept once.
+Finish joining the Patchwork Labs Slack
-You become a full member once you're in the Slack and have accepted the Code of Conduct.
+ <% if @user.slack_member? %> +Accept the Code of Conduct
+Everyone in the Patchwork Labs Slack now accepts the Code of Conduct. It takes a few seconds.
+ <% else %> +Finish joining the Patchwork Labs Slack
+You become a full member once you're in the Slack and have accepted the Code of Conduct.
+ <% end %>