Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/controllers/home_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
57 changes: 57 additions & 0 deletions app/controllers/slack_onboarding_controller.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/jobs/invite_to_slack_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
9 changes: 6 additions & 3 deletions app/jobs/slack_code_of_conduct_accepted_job.rb
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
# 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

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]}"
Expand Down
1 change: 1 addition & 0 deletions app/jobs/sync_user_to_slack_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
45 changes: 43 additions & 2 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions app/serializers/user_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
15 changes: 14 additions & 1 deletion app/services/slack_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down
14 changes: 11 additions & 3 deletions app/services/slack_webhook_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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 = {}
Expand Down Expand Up @@ -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
)

Expand Down
2 changes: 1 addition & 1 deletion app/views/admin/oauth_applications/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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" %>
<p class="mt-2 text-sm text-ink-faint">
Space-separated list of scopes. Available: <code>profile</code>, <code>email</code>, <code>admin</code>.
Space-separated list of scopes. Available: <code>profile</code>, <code>email</code>, <code>admin</code>, <code>slack</code>.
Leave empty to use defaults.
</p>
</div>
Expand Down
3 changes: 2 additions & 1 deletion app/views/admin/oauth_applications/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@
Space-separated list of scopes. Available:
<code>profile</code> (basic user info),
<code>email</code> (user email address),
<code>admin</code> (administrative access).
<code>admin</code> (administrative access),
<code>slack</code> (Slack membership and Slack ID).
Leave empty to use defaults.
</p>
</div>
Expand Down
13 changes: 9 additions & 4 deletions app/views/admin/users/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,16 @@
<div>
<dt class="text-sm font-medium text-ink-faint">Slack membership</dt>
<dd class="text-sm text-ink">
<% if @user.slack_coc_accepted_at.present? %>
<% if @user.slack_member? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-teal-soft text-teal">Full member</span>
<% if @user.slack_coc_accepted_at.present? %>
<span class="text-ink-faint text-xs">accepted CoC <%= @user.slack_coc_accepted_at.strftime("%b %-d, %Y") %></span>
<% end %>
<% elsif @user.in_slack_workspace? && @user.slack_coc_accepted_at.present? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orchid-soft text-orchid">Guest · promotion pending</span>
<span class="text-ink-faint text-xs">accepted CoC <%= @user.slack_coc_accepted_at.strftime("%b %-d, %Y") %></span>
<% elsif @user.slack_member? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-sky-soft text-sky">In Slack · CoC pending</span>
<% elsif @user.in_slack_workspace? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-sky-soft text-sky">Guest · CoC pending</span>
<% if @user.slack_invited_at.present? %>
<span class="text-ink-faint text-xs">invited <%= @user.slack_invited_at.strftime("%b %-d, %Y") %></span>
<% end %>
Expand All @@ -241,7 +246,7 @@
<% else %>
<span class="text-ink-faint">Not invited yet</span>
<% end %>
<% unless @user.slack_member? %>
<% unless @user.in_slack_workspace? %>
<div class="mt-2">
<%= button_to(@user.slack_invited_at.present? ? "Re-invite to Slack" : "Invite to Slack",
invite_to_slack_admin_user_path(@user), method: :post,
Expand Down
3 changes: 2 additions & 1 deletion app/views/home/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@
<% end %>
</div>
<% else %>
<div class="mt-9">
<div class="mt-9 flex flex-wrap items-center justify-center gap-3">
<%= link_to "Sign in", login_path, class: "btn-stitch" %>
<%= link_to "Join the Slack", slack_onboarding_path, class: "btn-stitch-ghost" %>
</div>
<% end %>
</div>
Expand Down
Loading
Loading