[deploy] Invite users to join and create password (#9294)

* Initial invitation work

* Add more invitation code

* Add proper registration page

* Fix up tests

* Fix failings

* Fix spec

* Add self-serve auth config

* Add registered condition

* Change profile image call and registered_at test

* Change comment

* Fix copy test

* Stub emojipedia

* Linting

* Add registered at to factory

* Ensure registered for user tag

* Update app/views/internal/invitations/index.html.erb

Co-authored-by: Michael Kohl <citizen428@dev.to>

* Update app/controllers/internal/invitations_controller.rb

Co-authored-by: Michael Kohl <citizen428@dev.to>

* Slight changes

* Update recover password flow

* Update app/views/internal/invitations/index.html.erb

Co-authored-by: Michael Kohl <citizen428@dev.to>

* Update app/controllers/internal/invitations_controller.rb

Co-authored-by: Michael Kohl <citizen428@dev.to>

* Update db/schema.rb

Co-authored-by: Michael Kohl <citizen428@dev.to>
This commit is contained in:
Ben Halpern 2020-07-15 11:37:19 -04:00 committed by GitHub
parent 66c70d38e8
commit da6a6739e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 547 additions and 83 deletions

View file

@ -30,6 +30,7 @@ gem "cloudinary", "~> 1.16" # Client library for easily using the Cloudinary ser
gem "counter_culture", "~> 2.5" # counter_culture provides turbo-charged counter caches that are kept up-to-date
gem "ddtrace", "~> 0.37.0" # ddtrace is Datadogs tracing client for Ruby.
gem "devise", "~> 4.7" # Flexible authentication solution for Rails
gem "devise_invitable", "~> 2.0.0" # Allows invitations to be sent for joining
gem "dogstatsd-ruby", "~> 4.8" # A client for DogStatsD, an extension of the StatsD metric server for Datadog
gem "doorkeeper", "~> 5.4" # Oauth 2 provider
gem "dry-struct", "~> 1.2" # Typed structs and value objects

View file

@ -251,6 +251,9 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise_invitable (2.0.2)
actionmailer (>= 5.0)
devise (>= 4.6)
diff-lcs (1.3)
distribution (0.7.3)
docile (1.3.2)
@ -911,6 +914,7 @@ DEPENDENCIES
ddtrace (~> 0.37.0)
derailed_benchmarks (~> 1.7)
devise (~> 4.7)
devise_invitable (~> 2.0.0)
dogstatsd-ruby (~> 4.8)
doorkeeper (~> 5.4)
dry-struct (~> 1.2)

View file

@ -476,7 +476,7 @@
}
.new-article-pitch {
padding-top: calc(8% + 50px);
padding-top: calc(3% + 25px);
width: 900px;
max-width: 94%;
margin: auto;

View file

@ -6,7 +6,7 @@ module Api
SHOW_ATTRIBUTES_FOR_SERIALIZATION = %i[
id username name summary twitter_username github_username website_url
location created_at profile_image
location created_at profile_image registered
].freeze
private_constant :SHOW_ATTRIBUTES_FOR_SERIALIZATION
@ -18,6 +18,7 @@ module Api
else
relation.find(params[:id])
end
not_found unless @user.registered
end
def me

View file

@ -17,7 +17,7 @@ module Internal
where("score > ? AND score < ?", -10, 8).
limit(120)
@possible_spam_users = User.where(
@possible_spam_users = User.registered.where(
"github_created_at > ? OR twitter_created_at > ? OR length(name) > ?",
50.hours.ago, 50.hours.ago, 30
).

View file

@ -0,0 +1,23 @@
module Internal
class InvitationsController < Internal::ApplicationController
layout "internal"
def index
@invitations = User.where(registered: false).page(params[:page]).per(50)
end
def new; end
def create
email = params.dig(:user, :email)
name = params.dig(:user, :name)
username = "#{name.downcase.tr(' ', '_').gsub(/[^0-9a-z ]/i, '')}_#{rand(1000)}"
User.invite!(email: email,
name: name,
username: username,
remote_profile_image_url: Users::ProfileImageGenerator.call,
registered: false)
redirect_to internal_invitations_path
end
end
end

View file

@ -0,0 +1,29 @@
class InvitationsController < Devise::InvitationsController
def update
# Copied from https://github.com/scambra/devise_invitable/blob/master/app/controllers/devise/invitations_controller.rb
# And edited. This is a common devise pattern, similar to OmniauthCallbacksController.
raw_invitation_token = update_resource_params[:invitation_token]
self.resource = accept_resource
invitation_accepted = resource.errors.empty?
yield resource if block_given?
if invitation_accepted
resource.registered = true
resource.registered_at = Time.current
if resource.class.allow_insecure_sign_in_after_accept
flash_message = resource.active_for_authentication? ? :updated : :updated_not_active
set_flash_message :notice, flash_message if is_flashing_format?
resource.after_database_authentication
sign_in(resource_name, resource)
redirect_to onboarding_path
else
set_flash_message :notice, :updated_not_active if is_flashing_format?
respond_with resource, location: new_session_path(resource_name)
end
else
resource.invitation_token = raw_invitation_token
respond_with_navigational(resource) { render :edit }
end
end
end

View file

@ -218,6 +218,7 @@ class StoriesController < ApplicationController
return
end
not_found if @user.username.include?("spam_") && @user.decorate.fully_banished?
not_found unless @user.registered
assign_user_comments
assign_user_stories
@list_of = "articles"

View file

@ -101,6 +101,10 @@ module ApplicationHelper
end
end
def any_selfserve_auth?
authentication_enabled_providers.any?
end
def beautified_url(url)
url.sub(/\A((https?|ftp):\/)?\//, "").sub(/\?.*/, "").chomp("/")
rescue StandardError

View file

@ -14,7 +14,7 @@ module BadgeRewarder
message = "Happy #{ApplicationConfig['COMMUNITY_NAME']} birthday! " \
"Can you believe it's been #{i} #{'year'.pluralize(i)} already?!"
badge = Badge.find_by!(slug: "#{YEARS[i]}-year-club")
User.where("created_at < ? AND created_at > ?", i.year.ago, i.year.ago - 2.days).find_each do |user|
User.registered.where("created_at < ? AND created_at > ?", i.year.ago, i.year.ago - 2.days).find_each do |user|
achievement = BadgeAchievement.create(
user_id: user.id,
badge_id: badge.id,
@ -114,7 +114,7 @@ module BadgeRewarder
def self.award_badges(usernames, slug, message_markdown)
badge_id = Badge.find_by!(slug: slug).id
User.where(username: usernames).find_each do |user|
User.registered.where(username: usernames).find_each do |user|
BadgeAchievement.create(
user_id: user.id,
badge_id: badge_id,

View file

@ -27,6 +27,6 @@ class EmailDigest
private
def get_users
User.where(email_digest_periodic: true).where.not(email: "")
User.registered.where(email_digest_periodic: true).where.not(email: "")
end
end

View file

@ -25,7 +25,7 @@ class UserTag < LiquidTagBase
private
def parse_username_to_user(user)
User.find_by(username: user) || DELETED_USER
User.find_by(username: user, registered: true) || DELETED_USER
end
def path_to_profile(user)

7
app/models/invitation.rb Normal file
View file

@ -0,0 +1,7 @@
class Invitation < ApplicationRecord
# This class exists to take advantage of Rolify for limiting authorization
# on internal reports.
# NOTE: It is not backed by a database table and should not be expected to
# function like a traditional Rails model
resourcify
end

View file

@ -112,7 +112,7 @@ class User < ApplicationRecord
mount_uploader :profile_image, ProfileImageUploader
devise :omniauthable, :registerable, :database_authenticatable, :confirmable, :rememberable
devise :invitable, :omniauthable, :registerable, :database_authenticatable, :confirmable, :rememberable, :recoverable
validates :behance_url, length: { maximum: 100 }, allow_blank: true, format: BEHANCE_URL_REGEXP
validates :bg_color_hex, format: COLOR_HEX_REGEXP, allow_blank: true
@ -164,6 +164,7 @@ class User < ApplicationRecord
alias_attribute :subscribed_to_welcome_notifications?, :welcome_notifications
scope :eager_load_serialized_data, -> { includes(:roles) }
scope :registered, -> { where(registered: true) }
after_save :bust_cache
after_save :subscribe_to_mailchimp_newsletter
@ -385,6 +386,7 @@ class User < ApplicationRecord
end
def subscribe_to_mailchimp_newsletter
return unless registered
return unless email.present? && email.include?("@")
return if saved_changes["unconfirmed_email"] && saved_changes["confirmation_sent_at"]
return unless saved_changes.key?(:email) || saved_changes.key?(:email_newsletter)

View file

@ -1,6 +1,6 @@
module Internal
class UsersQuery
def self.call(relation: User.all, options: {})
def self.call(relation: User.registered, options: {})
role, search = options.values_at(:role, :search)
relation = relation.with_role(role, :any) if role.presence

View file

@ -116,6 +116,8 @@ module Authentication
{
password: Devise.friendly_token(20),
signup_cta_variant: cta_variant,
registered: true,
registered_at: Time.current,
saw_onboarding: false,
editor_version: :v2
}

View file

@ -28,7 +28,7 @@ module Mentions
end
def collect_existing_users(usernames)
User.where(username: usernames)
User.registered.where(username: usernames)
end
def create_mentions_for(users)

View file

@ -1,7 +1,7 @@
<div id="sidebar-wrapper-right" class="sidebar-wrapper sidebar-wrapper-right crayons-layout__sidebar-right">
<div class="sidebar-bg" id="sidebar-bg-right"></div>
<aside class="side-bar sidebar-additional showing grid gap-4" id="sidebar-additional">
<% unless user_signed_in? %>
<% unless user_signed_in? || !any_selfserve_auth? %>
<section class="crayons-card crayons-card--secondary signin-cta-widget">
<header class="crayons-card__header">
<h3 class="crayons-card__header__headline">Join <%= community_name %></h3>

View file

@ -0,0 +1,31 @@
<% title "Set your Password to Join" %>
<div class="crayons-layout crayons-layout--limited mb-10" style="max-width:640px">
<div class="crayons-notice mt-4">
<h1>Welcome to <%= community_name %></h1>
<h3><%= SiteConfig.tagline %></h3>
</div>
<div class="mt-3 crayons-card p-7 align-left">
<h2>Set a password to access your account</h2>
<%= form_for(resource, as: resource_name, url: invitation_path(resource_name), html: { method: :put }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<%= f.hidden_field :invitation_token, readonly: true %>
<% if f.object.class.require_password_on_accepting %>
<div class="crayons-field mt-5">
<%= f.label :password, class: "crayons-field__label" %>
<%= f.password_field :password, class: "crayons-textfield" %>
</div>
<div class="crayons-field">
<%= f.label :password_confirmation, class: "crayons-field__label" %>
<%= f.password_field :password_confirmation, class: "crayons-textfield" %>
</div>
<% end %>
<div class="mt-6">
<%= f.submit t("devise.invitations.edit.submit_button"), class: "crayons-btn" %>
</div>
<% end %>
</div>
</div>

View file

@ -0,0 +1,11 @@
<p><%= t("devise.mailer.invitation_instructions.hello", email: @resource.email) %></p>
<p><%= t("devise.mailer.invitation_instructions.someone_invited_you", url: root_url) %></p>
<p><%= link_to t("devise.mailer.invitation_instructions.accept"), accept_invitation_url(@resource, invitation_token: @token) %></p>
<% if @resource.invitation_due_at %>
<p><%= t("devise.mailer.invitation_instructions.accept_until", due_date: l(@resource.invitation_due_at, format: :'devise.mailer.invitation_instructions.accept_until_format')) %></p>
<% end %>
<p><%= t("devise.mailer.invitation_instructions.ignore") %></p>

View file

@ -0,0 +1,11 @@
<%= t("devise.mailer.invitation_instructions.hello", email: @resource.email) %>
<%= t("devise.mailer.invitation_instructions.someone_invited_you", url: root_url) %>
<%= accept_invitation_url(@resource, invitation_token: @token) %>
<% if @resource.invitation_due_at %>
<%= t("devise.mailer.invitation_instructions.accept_until", due_date: l(@resource.invitation_due_at, format: :'devise.mailer.invitation_instructions.accept_until_format')) %>
<% end %>
<%= t("devise.mailer.invitation_instructions.ignore") %>

View file

@ -1,25 +1,27 @@
<h2>Change your password</h2>
<%= devise_error_messages! %>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %>
<%= devise_error_messages! %>
<%= f.hidden_field :reset_password_token %>
<div class="mt-8 mb-10 crayons-card p-7 pt-3 align-left mx-auto" style="max-width:490px;">
<h2 class="pb-4">Change your password</h2>
<div class="field">
<%= f.label :password, "New password" %><br />
<% if @minimum_password_length %>
<em>(<%= @minimum_password_length %> characters minimum)</em><br />
<% end %>
<%= f.password_field :password, autofocus: true, autocomplete: "off" %>
</div>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %>
<%= devise_error_messages! %>
<%= f.hidden_field :reset_password_token %>
<div class="field">
<%= f.label :password_confirmation, "Confirm new password" %><br />
<%= f.password_field :password_confirmation, autocomplete: "off" %>
</div>
<div class="crayons-field">
<%= f.label :password, "New password", class: "crayons-field__label" %>
<% if @minimum_password_length %>
<em>(<%= @minimum_password_length %> characters minimum)</em><br />
<% end %>
<%= f.password_field :password, autofocus: true, autocomplete: "off", class: "crayons-textfield" %>
</div>
<div class="actions">
<%= f.submit "Change my password" %>
</div>
<% end %>
<div class="crayons-field">
<%= f.label :password_confirmation, "Confirm new password", class: "crayons-field__label" %>
<%= f.password_field :password_confirmation, autocomplete: "off", class: "crayons-textfield" %>
</div>
<%= render "devise/shared/links" %>
<div class="crayons-field pt-4">
<%= f.submit "Change my password", class: "crayons-btn" %>
</div>
<% end %>
</div>

View file

@ -1,16 +1,16 @@
<h2>Forgot your password?</h2>
<%= devise_error_messages! %>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %>
<%= devise_error_messages! %>
<div class="mt-8 mb-10 crayons-card p-7 pt-3 align-left mx-auto" style="max-width:490px;">
<h2 class="pb-4">Forgot your password?</h2>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true %>
</div>
<div class="crayons-field">
<%= f.label :email, class: "crayons-field__label" %>
<%= f.email_field :email, class: "crayons-textfield", placeholder: "you@email.com" %>
</div>
<div class="actions">
<%= f.submit "Send me reset password instructions" %>
</div>
<% end %>
<%= render "devise/shared/links" %>
<div class="crayons-field pt-6">
<%= f.submit "Send me reset password instructions", class: "crayons-btn" %>
</div>
<% end %>
</div>

View file

@ -17,14 +17,42 @@
<img class="secondary-logo" src="<%= cloudinary(safe_logo_url(SiteConfig.secondary_logo_url), 190) %>"
alt="Secondary <%= community_name %> logo" />
</div>
<h1>Great to have you</h1>
<% if any_selfserve_auth? %>
<h1>Great to have you</h1>
<% else %>
<h1>Registration is by invitation only</h1>
<% end %>
<% end %>
<div class="links">
<div class="flex flex-col m:flex-row justify-center">
<%= render partial: "shared/authentication/providers_registration_form" %>
</div>
<%= render partial: "shared/authentication/providers_registration_form" %>
<p class="pt-4"><em>We require social login to prevent abuse.</em></p>
<div id="sign-in-password-form" class="mt-1 crayons-card p-7 align-left mx-auto" style="max-width:490px;">
<p class="pb-4 fw-bold">If you have a password...</p>
<%= form_for(User.new, as: :user, url: session_path(:user)) do |f| %>
<div class="crayons-field">
<%= f.label :email, class: "crayons-field__label" %>
<%= f.email_field :email, autocomplete: "email", class: "crayons-textfield" %>
</div>
<div class="field">
<%= f.label :password, class: "crayons-field__label" %>
<%= f.password_field :password, autocomplete: "current-password", class: "crayons-textfield" %>
</div>
<div class="field">
<%= f.check_box :remember_me %>
<%= f.label :remember_me %>
</div>
<div class="actions pt-3">
<%= f.submit "Log in", class: "crayons-btn" %>
</div>
<% end %>
<% if any_selfserve_auth? %>
<p class="pt-6 fs-s"><em>Sign in by social auth if you don't have a password set.</em></p>
<% end %>
<p class="pt-6 fs-s"><em><a href="<%= new_password_path(:user) %>">Forgot password?</a></em></p>
</div>
</div>
<p>Open Source 😇</p>
<p>Free Forever ❤️</p>

View file

@ -1,4 +1,4 @@
<h3 class="m-4">Past <%= num_days %> day new users (<%= User.where("created_at > ?", num_days.day.ago).count %>)</h3>
<h3 class="m-4">Past <%= num_days %> day new users (<%= User.where("registered_at > ?", num_days.day.ago).count %>)</h3>
<div class="row">
<div class="col">
<div class="card">
@ -7,13 +7,13 @@
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
Average comments: <%= User.where("created_at > ?", num_days.day.ago).average(:comments_count) %>
Average comments: <%= User.where("registered_at > ?", num_days.day.ago).average(:comments_count) %>
</li>
<li class="list-group-item">
Average articles: <%= User.where("created_at > ?", num_days.day.ago).average(:articles_count) %>
Average articles: <%= User.where("registered_at > ?", num_days.day.ago).average(:articles_count) %>
</li>
<li class="list-group-item">
Average reactions: <%= User.where("created_at > ?", num_days.day.ago).average(:reactions_count) %>
Average reactions: <%= User.where("registered_at > ?", num_days.day.ago).average(:reactions_count) %>
</li>
</ul>
</div>

View file

@ -0,0 +1,35 @@
<div class="row my-3">
<div class="col">
<ul class="nav nav-pills">
<li class="nav-item">
<%= link_to "Registered Users", internal_users_path, class: "nav-link" %>
</li>
<li class="nav-item">
<%= link_to "New", new_internal_invitation_path, class: "nav-link" %>
</li>
</ul>
</div>
</div>
<table class="table table-hover">
<thead>
<tr>
<th scope="col">User</th>
<th scope="col">ID</th>
<th scope="col">Name</th>
<th scope="col">Email</th>
</tr>
</thead>
<tbody>
<% @invitations.each do |user| %>
<tr>
<td><%= link_to user.username, internal_user_path(user) %></td>
<td><%= user.id %></td>
<td><%= user.name %></td>
<td><%= user.email %></td>
</tr>
<% end %>
</tbody>
</table>
<%= paginate @invitations, theme: "internal" %>

View file

@ -0,0 +1,13 @@
<div class="row">
<div class="col-12">
<%= form_for(User.new, url: internal_invitations_path) do |f| %>
<div class="field">
<%= f.label "Email:" %>
<%= f.text_field :email, required: true, class: "form-control", placeholder: "Email of invitee" %>
<%= f.label "Name:" %>
<%= f.text_field :name, required: true, class: "form-control", placeholder: "Name of invitee" %>
</div>
<%= f.submit class: "btn btn-primary" %>
<% end %>
</div>
</div>

View file

@ -1,5 +1,5 @@
<div class="row my-3">
<div class="col">
<div class="col-8">
<ul class="nav nav-pills">
<li class="nav-item">
<%= link_to "All", internal_users_path, class: "nav-link #{'active' if params[:role].blank?}" %>
@ -16,6 +16,9 @@
<li class="nav-item">
<%= link_to "Tag Mods", internal_users_path(role: :tag_moderator), class: "nav-link #{'active' if params[:role] == 'tag_moderator'}" %>
</li>
<li class="nav-item">
<%= link_to "Invitations", internal_invitations_path, class: "nav-link" %>
</li>
</ul>
</div>
<div class="col">

View file

@ -21,7 +21,20 @@
</div>
<% else %>
<div class="border-0 border-b-1 border-solid border-base-20 p-2 grid gap-2">
<%= render partial: "shared/authentication/providers_nav_menu" %>
<% if any_selfserve_auth? %>
<%= render partial: "shared/authentication/providers_nav_menu" %>
<a
href="/enter"
class="crayons-btn crayons-btn--secondary">
More Sign In Options
</a>
<% else %>
<a
href="/enter"
class="crayons-btn crayons-btn--secondary">
Sign In
</a>
<% end %>
</div>
<div class="p-1">
<a href="<%= information_path %>" class="crayons-link crayons-link--block" id="last-nav-link">All about <%= community_name %></a>

View file

@ -9,19 +9,22 @@
class: "mascot-image",
alt: SiteConfig.mascot_image_description,
loading: "lazy") %>
<% if any_selfserve_auth? %>
<h1>Join our <%= community_qualified_name %> :)</h1>
<h1>Join our <%= community_qualified_name %> :)</h1>
<% if SiteConfig.tagline.present? %>
<p><%= SiteConfig.tagline %></p>
<% end %>
<% if SiteConfig.tagline.present? %>
<p><%= SiteConfig.tagline %></p>
<div class="flex flex-col l:flex-row justify-center">
<%= render partial: "shared/authentication/providers_signup_modal" %>
</div>
<p>
<em>We strive for transparency and don't collect excess data.</em>
</p>
<% else %>
<h1><a href="/enter" data-no-instant>Sign in</a> to <%= community_qualified_name %> :)</h1>
<% end %>
<div class="flex flex-col l:flex-row justify-center">
<%= render partial: "shared/authentication/providers_signup_modal" %>
</div>
<p>
<em>We strive for transparency and don't collect excess data.</em>
</p>
</div>
</div>

View file

@ -1,9 +1,15 @@
<% authentication_enabled_providers.each do |provider| %>
<a
href="<%= provider.sign_in_path(state: "navbar_basic") %>"
class="crayons-btn crayons-btn--xl crayons-btn--brand-<%= provider.provider_name %> crayons-btn--icon-left m-1 whitespace-nowrap"
data-no-instant>
<%= inline_svg_tag("#{provider.provider_name}.svg", aria: true, class: "crayons-icon", title: provider.provider_name) %>
Sign In with <%= provider.official_name %>
</a>
<% end %>
<div class="flex flex-col m:flex-row justify-center">
<% authentication_enabled_providers.each do |provider| %>
<a
href="<%= provider.sign_in_path(state: "navbar_basic") %>"
class="crayons-btn crayons-btn--xl crayons-btn--brand-<%= provider.provider_name %> crayons-btn--icon-left m-1 whitespace-nowrap"
data-no-instant>
<%= inline_svg_tag("#{provider.provider_name}.svg", aria: true, class: "crayons-icon", title: provider.provider_name) %>
Sign In with <%= provider.official_name %>
</a>
<% end %>
</div>
<% if authentication_enabled_providers.any? %>
<p class="pt-6 pb-8"><em>We require social registration to prevent abuse.</em></p>
<% end %>

View file

@ -5,7 +5,7 @@
<h2>
<a href="/"><%= community_name %></a> is a community of <br />
<%= number_with_delimiter User.estimated_count %> amazing <%= community_members_label %>
<%= number_with_delimiter User.registered.estimated_count %> amazing <%= community_members_label %>
</h2>
<h3>

View file

@ -1,5 +1,7 @@
<%= render "users/additional_authentication" %>
<%= render "users/account_set_password" %>
<%= render "users/account_providers_emails" %>
<div class="crayons-card mb-6 p-6 grid gap-6">

View file

@ -0,0 +1,15 @@
<div class="crayons-card mb-6 grid grid-flow-row gap-6 p-6">
<header>
<h2 class="mb-2">Set New Password</h2>
<p>As an alternative to signing in via linked social accounts, you may create a password.</p>
</header>
<%= form_for @user do |f| %>
<div class="crayons-field mb-5">
<%= f.label :password, class: "crayons-field__label" %>
<%= f.password_field :password, class: "crayons-textfield", autocomplete: "off" %>
<%= f.label :password_confirmation, "Confirm new password", class: "crayons-field__label" %><br />
<%= f.password_field :password_confirmation, autocomplete: "off", class: "crayons-textfield" %>
</div>
<button class="crayons-btn crayon-btn--secondary" id="new__api__secret__btn" type="submit">Set New Password</button>
<% end %>
</div>

View file

@ -27,7 +27,7 @@ module Metrics
DatadogStatsClient.count("articles.first_past_24h", first_articles_past_24h, tags: ["resource:articles"])
# Users who signed up in the past 24 hours who have made at least 1 comment so far
new_users_min_1_comment_past_24h = User.where("comments_count >= ? AND created_at > ?", 1, 24.hours.ago).size
new_users_min_1_comment_past_24h = User.where("comments_count >= ? AND registered_at > ?", 1, 24.hours.ago).size
DatadogStatsClient.count(
"users.new_min_1_comment_past_24h",
new_users_min_1_comment_past_24h,
@ -64,8 +64,8 @@ module Metrics
ids_by_day << id
end
flat_id_list = ids_by_day.flatten.uniq
non_new_user_ids = User.where("created_at < ?", 7.days.ago).where(id: flat_id_list).pluck(:id)
new_user_ids = User.where("created_at > ? AND created_at < ?", 8.days.ago,
non_new_user_ids = User.where("registered_at < ?", 7.days.ago).where(id: flat_id_list).pluck(:id)
new_user_ids = User.where("registered_at > ? AND registered_at < ?", 8.days.ago,
7.days.ago).where(id: flat_id_list).pluck(:id)
record_active_days_of_group(ids_by_day, non_new_user_ids, "established")
record_active_days_of_group(ids_by_day, new_user_ids, "new")

View file

@ -2,6 +2,6 @@ import querystring;
sub vcl_recv {
# return this URL with only the parameters that match this regular expression
if (req.url !~ "/internal/" && req.url !~ "/search/" && req.url !~ "/bulk_show") {
set req.url = querystring.regfilter_except(req.url, "^(a_id|args|article_id|article_ids|articles|asc|callback_url|category|chat_channel_id|client_id|code|collection_id|commentable_id|commentable_type|confirmation_token|created_at|end|filter|followable_id|followable_type|fork_id|i|key|message_offset|name|oauth_token|oauth_verifier|offset|org_id|organization_id|p|page|per_page|prefill|preview|purchaser|reactable_ids|redirect_uri|reported_url|reporter_username|response_type|scope|search|signature|sort|start|state|status|tag|tag_list|top|type_of|url|username|ut|verb)$");
set req.url = querystring.regfilter_except(req.url, "^(a_id|args|article_id|article_ids|articles|asc|callback_url|category|chat_channel_id|client_id|code|collection_id|commentable_id|commentable_type|confirmation_token|created_at|end|filter|followable_id|followable_type|fork_id|i|key|message_offset|name|oauth_token|oauth_verifier|offset|org_id|organization_id|p|page|per_page|prefill|preview|purchaser|reactable_ids|redirect_uri|reported_url|reporter_username|response_type|scope|search|signature|sort|start|state|status|tag|tag_list|top|type_of|url|username|invitation_token|reset_password_token|ut|verb)$");
}
}

View file

@ -100,6 +100,55 @@ Devise.setup do |config|
# Setup a pepper to generate the encrypted password.
# config.pepper = ''
# ==> Configuration for :invitable
# The period the generated invitation token is valid.
# After this period, the invited resource won't be able to accept the invitation.
# When invite_for is 0 (the default), the invitation won't expire.
# config.invite_for = 2.weeks
# Number of invitations users can send.
# - If invitation_limit is nil, there is no limit for invitations, users can
# send unlimited invitations, invitation_limit column is not used.
# - If invitation_limit is 0, users can't send invitations by default.
# - If invitation_limit n > 0, users can send n invitations.
# You can change invitation_limit column for some users so they can send more
# or less invitations, even with global invitation_limit = 0
# Default: nil
# config.invitation_limit = 5
# The key to be used to check existing users when sending an invitation
# and the regexp used to test it when validate_on_invite is not set.
# config.invite_key = { email: /\A[^@]+@[^@]+\z/ }
# config.invite_key = { email: /\A[^@]+@[^@]+\z/, username: nil }
# Ensure that invited record is valid.
# The invitation won't be sent if this check fails.
# Default: false
# config.validate_on_invite = true
# Resend invitation if user with invited status is invited again
# Default: true
# config.resend_invitation = false
# The class name of the inviting model. If this is nil,
# the #invited_by association is declared to be polymorphic.
# Default: nil
# config.invited_by_class_name = 'User'
# The foreign key to the inviting model (if invited_by_class_name is set)
# Default: :invited_by_id
# config.invited_by_foreign_key = :invited_by_id
# The column name used for counter_cache column. If this is nil,
# the #invited_by association is declared without counter_cache.
# Default: nil
# config.invited_by_counter_cache = :invitations_count
# Auto-login after the user accepts the invite. If this is false,
# the user will need to manually log in after accepting the invite.
# Default: true
# config.allow_insecure_sign_in_after_accept = false
# ==> Configuration for :confirmable
# A period that the user is allowed to access the website even without
# confirming their account. For instance, if set to 2.days, the user will be

View file

@ -0,0 +1,31 @@
en:
devise:
failure:
invited: "You have a pending invitation, accept it to finish creating your account."
invitations:
send_instructions: "An invitation email has been sent to %{email}."
invitation_token_invalid: "The invitation token provided is not valid!"
updated: "Your password was set successfully. You are now signed in."
updated_not_active: "Your password was set successfully."
no_invitations_remaining: "No invitations remaining"
invitation_removed: "Your invitation was removed."
new:
header: "Send invitation"
submit_button: "Send an invitation"
edit:
header: "Set your password"
submit_button: "Let's Get Started"
mailer:
invitation_instructions:
subject: "Invitation instructions"
hello: "Hello %{email}"
someone_invited_you: "Someone has invited you to %{url}, you can accept it through the link below."
accept: "Accept invitation"
accept_until: "This invitation will be due in %{due_date}."
ignore: "If you don't want to accept the invitation, please ignore this email. Your account won't be created until you access the link above and set your password."
time:
formats:
devise:
mailer:
invitation_instructions:
accept_until_format: "%B %d, %Y %I:%M %p"

View file

@ -7,7 +7,8 @@ Rails.application.routes.draw do
devise_for :users, controllers: {
omniauth_callbacks: "omniauth_callbacks",
registrations: "registrations"
registrations: "registrations",
invitations: "invitations"
}
devise_scope :user do
@ -52,6 +53,7 @@ Rails.application.routes.draw do
resources :comments, only: [:index]
resources :events, only: %i[index create update]
resources :feedback_messages, only: %i[index show]
resources :invitations, only: %i[index new create]
resources :pages, only: %i[index new create edit update destroy]
resources :mods, only: %i[index update]
resources :moderator_actions, only: %i[index]

View file

@ -0,0 +1,32 @@
class DeviseInvitableAddToUsers < ActiveRecord::Migration[6.0]
def up
safety_assured do
change_table :users, bulk: true do |t|
t.string :invitation_token
t.boolean :registered, default: true
t.datetime :registered_at
t.datetime :invitation_created_at
t.datetime :invitation_sent_at
t.datetime :invitation_accepted_at
t.integer :invitation_limit
t.references :invited_by, polymorphic: true
t.integer :invitations_count, default: 0
t.index :invitations_count
t.index :invitation_token, unique: true # for invitable
t.index :invited_by_id
end
end
end
def down
safety_assured do
change_table :users do |t|
t.remove_references :invited_by, polymorphic: true
t.remove :invitations_count, :invitation_limit,
:invitation_sent_at, :invitation_accepted_at,
:invitation_token, :invitation_created_at,
:registered, :registered_at
end
end
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_07_10_174257) do
ActiveRecord::Schema.define(version: 2020_07_12_150048) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -492,6 +492,7 @@ ActiveRecord::Schema.define(version: 2020_07_10_174257) do
t.datetime "updated_at", null: false
t.bigint "user_id"
t.datetime "verified_at"
t.index ["user_id"], name: "index_email_authorizations_on_user_id"
end
create_table "events", force: :cascade do |t|
@ -1244,6 +1245,14 @@ ActiveRecord::Schema.define(version: 2020_07_10_174257) do
t.string "inbox_guidelines"
t.string "inbox_type", default: "private"
t.string "instagram_url"
t.datetime "invitation_accepted_at"
t.datetime "invitation_created_at"
t.integer "invitation_limit"
t.datetime "invitation_sent_at"
t.string "invitation_token"
t.integer "invitations_count", default: 0
t.bigint "invited_by_id"
t.string "invited_by_type"
t.jsonb "language_settings", default: {}, null: false
t.datetime "last_article_at", default: "2017-01-01 05:00:00"
t.datetime "last_comment_at", default: "2017-01-01 05:00:00"
@ -1274,6 +1283,8 @@ ActiveRecord::Schema.define(version: 2020_07_10_174257) do
t.datetime "profile_updated_at", default: "2017-01-01 05:00:00"
t.integer "rating_votes_count", default: 0, null: false
t.integer "reactions_count", default: 0, null: false
t.boolean "registered", default: true
t.datetime "registered_at"
t.datetime "remember_created_at"
t.string "remember_token"
t.float "reputation_modifier", default: 1.0
@ -1308,6 +1319,10 @@ ActiveRecord::Schema.define(version: 2020_07_10_174257) do
t.index ["created_at"], name: "index_users_on_created_at"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["github_username"], name: "index_users_on_github_username", unique: true
t.index ["invitation_token"], name: "index_users_on_invitation_token", unique: true
t.index ["invitations_count"], name: "index_users_on_invitations_count"
t.index ["invited_by_id"], name: "index_users_on_invited_by_id"
t.index ["invited_by_type", "invited_by_id"], name: "index_users_on_invited_by_type_and_invited_by_id"
t.index ["language_settings"], name: "index_users_on_language_settings", using: :gin
t.index ["old_old_username"], name: "index_users_on_old_old_username"
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true

View file

@ -20,6 +20,7 @@ FactoryBot.define do
checked_code_of_conduct { true }
checked_terms_and_conditions { true }
display_announcements { true }
registered_at { Time.current }
signup_cta_variant { "navbar_basic" }
email_digest_periodic { false }
bg_color_hex { Faker::Color.hex_color }

View file

@ -26,6 +26,22 @@ RSpec.describe EmailDigest, type: :labor do
expect(mailer).to have_received(:digest_email)
expect(message_delivery).to have_received(:deliver_now)
end
it "does not send email when user does not have email_digest_periodic" do
articles = create_list(:article, 3, user_id: author.id, public_reactions_count: 20, score: 20)
user.update_column(:email_digest_periodic, false)
described_class.send_periodic_digest_email
expect(DigestMailer).not_to have_received(:with).with(user: user, articles: articles)
end
it "does not send email when user is not registered" do
articles = create_list(:article, 3, user_id: author.id, public_reactions_count: 20, score: 20)
user.update_column(:registered, false)
described_class.send_periodic_digest_email
expect(DigestMailer).not_to have_received(:with).with(user: user, articles: articles)
end
end
end
end

View file

@ -724,6 +724,12 @@ RSpec.describe User, type: :model do
end
end
it "does not enqueue with a non-registered user" do
sidekiq_assert_no_enqueued_jobs(only: Users::SubscribeToMailchimpNewsletterWorker) do
user.update(registered: false)
end
end
it "does not enqueue when the email address or subscription status has not changed" do
# The trait replaces the method with a dummy, but we need the actual method for this test.
user = described_class.find(create(:user, :ignore_mailchimp_subscribe_callback).id)
@ -901,6 +907,11 @@ RSpec.describe User, type: :model do
expect(identity.auth_data_dump.provider).to eq(identity.provider)
end
it "marks registered_at for newly registered user" do
new_user = user_from_authorization_service(:twitter, nil, "navbar_basic")
expect(new_user.registered_at).not_to be nil
end
it "persists extracts relevant identity data from new twitter user" do
new_user = user_from_authorization_service(:twitter, nil, "navbar_basic")
expect(new_user.twitter_followers_count).to eq(100)

View file

@ -138,6 +138,8 @@ RSpec.configure do |config|
config.before do
stub_request(:any, /res.cloudinary.com/).to_rack("dsdsdsds")
stub_request(:any, /emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/).to_rack("stubbed-emoji")
stub_request(:post, /api.fastly.com/).
to_return(status: 200, body: "".to_json, headers: {})

View file

@ -15,6 +15,12 @@ RSpec.describe "Api::V0::Users", type: :request do
expect(response).to have_http_status(:not_found)
end
it "returns 404 if the user is not registered" do
user.update_column(:registered, false)
get api_user_path(user.id)
expect(response).to have_http_status(:not_found)
end
it "returns 200 if the user username is found" do
get api_user_path("by_username"), params: { url: user.username }
expect(response).to have_http_status(:ok)

View file

@ -0,0 +1,33 @@
require "rails_helper"
RSpec.describe "/internal/invitations", type: :request do
let(:user) { create(:user) }
let(:admin) { create(:user, :super_admin) }
before do
sign_in(admin)
end
describe "GETS /internal/invitations" do
it "renders to appropriate page" do
user.update_column(:registered, false)
get "/internal/invitations"
expect(response.body).to include(user.username)
end
end
describe "GETS /internal/invitations/new" do
it "renders to appropriate page" do
get "/internal/invitations/new"
expect(response.body).to include("Email:")
end
end
describe "POST /internal/invitations" do
it "creates new invitation" do
post "/internal/invitations",
params: { user: { email: "hey#{rand(1000)}@email.co", name: "Roger #{rand(1000)}" } }
expect(User.last.registered).to be false
end
end
end

View file

@ -5,10 +5,22 @@ RSpec.describe "Registrations", type: :request do
describe "GET /enter" do
context "when not logged in" do
it "shows the sign in page" do
it "shows the sign in page (with self-serve auth)" do
get "/enter"
expect(response.body).to include "Great to have you"
end
it "shows the sign in text" do
get "/enter"
expect(response.body).to include "If you have a password"
end
it "shows invite-only text if no self-serve" do
SiteConfig.authentication_providers = []
get "/enter"
expect(response.body).to include "If you have a password"
expect(response.body).not_to include "Sign in by social auth"
end
end
context "when logged in" do

View file

@ -46,6 +46,11 @@ RSpec.describe "UserProfiles", type: :request do
expect { get "/#{banishable_user.reload.username}" }.to raise_error(ActiveRecord::RecordNotFound)
end
it "raises not found if user not registered" do
user.update_column(:registered, false)
expect { get "/#{user.username}" }.to raise_error(ActiveRecord::RecordNotFound)
end
it "renders noindex meta if banned" do
user.add_role(:banned)
get "/#{user.username}"

View file

@ -129,6 +129,8 @@ RSpec.describe Users::Delete, type: :service do
next
end
next if possible_factory_name == "invited_by"
record = create(possible_factory_name, inverse_of => user)
associations.push(record)
end

BIN
vendor/cache/devise_invitable-2.0.2.gem vendored Normal file

Binary file not shown.