diff --git a/Gemfile b/Gemfile index 856d88d29..dfc766b16 100644 --- a/Gemfile +++ b/Gemfile @@ -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 Datadog’s 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 diff --git a/Gemfile.lock b/Gemfile.lock index 4a1a27b41..e38b1f254 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/assets/stylesheets/article-form-needed-legacy.scss b/app/assets/stylesheets/article-form-needed-legacy.scss index d6eaa2f45..7298318bf 100644 --- a/app/assets/stylesheets/article-form-needed-legacy.scss +++ b/app/assets/stylesheets/article-form-needed-legacy.scss @@ -476,7 +476,7 @@ } .new-article-pitch { - padding-top: calc(8% + 50px); + padding-top: calc(3% + 25px); width: 900px; max-width: 94%; margin: auto; diff --git a/app/controllers/api/v0/users_controller.rb b/app/controllers/api/v0/users_controller.rb index a49ad3c86..03d81f709 100644 --- a/app/controllers/api/v0/users_controller.rb +++ b/app/controllers/api/v0/users_controller.rb @@ -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 diff --git a/app/controllers/internal/feedback_messages_controller.rb b/app/controllers/internal/feedback_messages_controller.rb index 8802c7363..d57dc1b97 100644 --- a/app/controllers/internal/feedback_messages_controller.rb +++ b/app/controllers/internal/feedback_messages_controller.rb @@ -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 ). diff --git a/app/controllers/internal/invitations_controller.rb b/app/controllers/internal/invitations_controller.rb new file mode 100644 index 000000000..5f88f1f84 --- /dev/null +++ b/app/controllers/internal/invitations_controller.rb @@ -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 diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb new file mode 100644 index 000000000..54d2a0547 --- /dev/null +++ b/app/controllers/invitations_controller.rb @@ -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 diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 0d02e5741..e96588f71 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -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" diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0b3274a49..d42421de2 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/labor/badge_rewarder.rb b/app/labor/badge_rewarder.rb index 5a5ef36ee..c2b9fc9dc 100644 --- a/app/labor/badge_rewarder.rb +++ b/app/labor/badge_rewarder.rb @@ -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, diff --git a/app/labor/email_digest.rb b/app/labor/email_digest.rb index 362e7cb4b..e821f9776 100644 --- a/app/labor/email_digest.rb +++ b/app/labor/email_digest.rb @@ -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 diff --git a/app/liquid_tags/user_tag.rb b/app/liquid_tags/user_tag.rb index 0a1f801d4..0bc72b726 100644 --- a/app/liquid_tags/user_tag.rb +++ b/app/liquid_tags/user_tag.rb @@ -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) diff --git a/app/models/invitation.rb b/app/models/invitation.rb new file mode 100644 index 000000000..13e915082 --- /dev/null +++ b/app/models/invitation.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 0dd42ccec..23ac206fd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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) diff --git a/app/queries/internal/users_query.rb b/app/queries/internal/users_query.rb index 7f08a1209..a35654816 100644 --- a/app/queries/internal/users_query.rb +++ b/app/queries/internal/users_query.rb @@ -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 diff --git a/app/services/authentication/authenticator.rb b/app/services/authentication/authenticator.rb index dcd47d7aa..c8e18cc98 100644 --- a/app/services/authentication/authenticator.rb +++ b/app/services/authentication/authenticator.rb @@ -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 } diff --git a/app/services/mentions/create_all.rb b/app/services/mentions/create_all.rb index c184fefda..cfc1bc998 100644 --- a/app/services/mentions/create_all.rb +++ b/app/services/mentions/create_all.rb @@ -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) diff --git a/app/views/articles/_sidebar_additional.html.erb b/app/views/articles/_sidebar_additional.html.erb index 7b8a5da64..87775c4bd 100644 --- a/app/views/articles/_sidebar_additional.html.erb +++ b/app/views/articles/_sidebar_additional.html.erb @@ -1,7 +1,7 @@ -

Great to have you

+ <% if any_selfserve_auth? %> +

Great to have you

+ <% else %> +

Registration is by invitation only

+ <% end %> <% end %>

Open Source 😇

Free Forever ❤️

diff --git a/app/views/internal/growth/_results.html.erb b/app/views/internal/growth/_results.html.erb index 91a7679d1..5155c746f 100644 --- a/app/views/internal/growth/_results.html.erb +++ b/app/views/internal/growth/_results.html.erb @@ -1,4 +1,4 @@ -

Past <%= num_days %> day new users (<%= User.where("created_at > ?", num_days.day.ago).count %>)

+

Past <%= num_days %> day new users (<%= User.where("registered_at > ?", num_days.day.ago).count %>)

@@ -7,13 +7,13 @@
diff --git a/app/views/internal/invitations/index.html.erb b/app/views/internal/invitations/index.html.erb new file mode 100644 index 000000000..3da0966ec --- /dev/null +++ b/app/views/internal/invitations/index.html.erb @@ -0,0 +1,35 @@ +
+
+ +
+
+ + + + + + + + + + + + <% @invitations.each do |user| %> + + + + + + + <% end %> + +
UserIDNameEmail
<%= link_to user.username, internal_user_path(user) %><%= user.id %><%= user.name %><%= user.email %>
+ +<%= paginate @invitations, theme: "internal" %> diff --git a/app/views/internal/invitations/new.html.erb b/app/views/internal/invitations/new.html.erb new file mode 100644 index 000000000..d15b1559b --- /dev/null +++ b/app/views/internal/invitations/new.html.erb @@ -0,0 +1,13 @@ +
+
+ <%= form_for(User.new, url: internal_invitations_path) do |f| %> +
+ <%= 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" %> +
+ <%= f.submit class: "btn btn-primary" %> + <% end %> +
+
diff --git a/app/views/internal/users/index.html.erb b/app/views/internal/users/index.html.erb index 904c268fa..7adcf8c1a 100644 --- a/app/views/internal/users/index.html.erb +++ b/app/views/internal/users/index.html.erb @@ -1,5 +1,5 @@
-
+
diff --git a/app/views/layouts/_nav_menu.html.erb b/app/views/layouts/_nav_menu.html.erb index 9f5814d3e..f74d836ae 100644 --- a/app/views/layouts/_nav_menu.html.erb +++ b/app/views/layouts/_nav_menu.html.erb @@ -21,7 +21,20 @@
<% else %>
- <%= render partial: "shared/authentication/providers_nav_menu" %> + <% if any_selfserve_auth? %> + <%= render partial: "shared/authentication/providers_nav_menu" %> + + More Sign In Options + + <% else %> + + Sign In + + <% end %>
All about <%= community_name %> diff --git a/app/views/layouts/_signup_modal.html.erb b/app/views/layouts/_signup_modal.html.erb index 61535da1e..28120c49b 100644 --- a/app/views/layouts/_signup_modal.html.erb +++ b/app/views/layouts/_signup_modal.html.erb @@ -9,19 +9,22 @@ class: "mascot-image", alt: SiteConfig.mascot_image_description, loading: "lazy") %> + <% if any_selfserve_auth? %> +

Join our <%= community_qualified_name %> :)

-

Join our <%= community_qualified_name %> :)

+ <% if SiteConfig.tagline.present? %> +

<%= SiteConfig.tagline %>

+ <% end %> - <% if SiteConfig.tagline.present? %> -

<%= SiteConfig.tagline %>

+
+ <%= render partial: "shared/authentication/providers_signup_modal" %> +
+ +

+ We strive for transparency and don't collect excess data. +

+ <% else %> +

Sign in to <%= community_qualified_name %> :)

<% end %> - -
- <%= render partial: "shared/authentication/providers_signup_modal" %> -
- -

- We strive for transparency and don't collect excess data. -

diff --git a/app/views/shared/authentication/_providers_registration_form.html.erb b/app/views/shared/authentication/_providers_registration_form.html.erb index f3b5423d6..7e2ad9e7b 100644 --- a/app/views/shared/authentication/_providers_registration_form.html.erb +++ b/app/views/shared/authentication/_providers_registration_form.html.erb @@ -1,9 +1,15 @@ -<% authentication_enabled_providers.each do |provider| %> - " - 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 %> - -<% end %> + + +<% if authentication_enabled_providers.any? %> +

We require social registration to prevent abuse.

+<% end %> \ No newline at end of file diff --git a/app/views/stories/_sign_in_invitation.html.erb b/app/views/stories/_sign_in_invitation.html.erb index 43062b109..a21671972 100644 --- a/app/views/stories/_sign_in_invitation.html.erb +++ b/app/views/stories/_sign_in_invitation.html.erb @@ -5,7 +5,7 @@

<%= community_name %> is a community of
- <%= number_with_delimiter User.estimated_count %> amazing <%= community_members_label %> + <%= number_with_delimiter User.registered.estimated_count %> amazing <%= community_members_label %>

diff --git a/app/views/users/_account.html.erb b/app/views/users/_account.html.erb index af486327c..d9c0225fb 100644 --- a/app/views/users/_account.html.erb +++ b/app/views/users/_account.html.erb @@ -1,5 +1,7 @@ <%= render "users/additional_authentication" %> +<%= render "users/account_set_password" %> + <%= render "users/account_providers_emails" %>
diff --git a/app/views/users/_account_set_password.html.erb b/app/views/users/_account_set_password.html.erb new file mode 100644 index 000000000..5581f5948 --- /dev/null +++ b/app/views/users/_account_set_password.html.erb @@ -0,0 +1,15 @@ +
+
+

Set New Password

+

As an alternative to signing in via linked social accounts, you may create a password.

+
+ <%= form_for @user do |f| %> +
+ <%= 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" %>
+ <%= f.password_field :password_confirmation, autocomplete: "off", class: "crayons-textfield" %> +
+ + <% end %> +
diff --git a/app/workers/metrics/record_daily_usage_worker.rb b/app/workers/metrics/record_daily_usage_worker.rb index 94f383173..5cd0a22ab 100644 --- a/app/workers/metrics/record_daily_usage_worker.rb +++ b/app/workers/metrics/record_daily_usage_worker.rb @@ -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") diff --git a/config/fastly/snippets/safe_params_list.vcl b/config/fastly/snippets/safe_params_list.vcl index 65a9d9e82..30c3f0c65 100644 --- a/config/fastly/snippets/safe_params_list.vcl +++ b/config/fastly/snippets/safe_params_list.vcl @@ -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)$"); } } diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 1dc2a53eb..6ac411614 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -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 diff --git a/config/locales/devise_invitable.en.yml b/config/locales/devise_invitable.en.yml new file mode 100644 index 000000000..63a0c5bd8 --- /dev/null +++ b/config/locales/devise_invitable.en.yml @@ -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" diff --git a/config/routes.rb b/config/routes.rb index 858ab79d4..02fa49fd1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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] diff --git a/db/migrate/20200712150048_devise_invitable_add_to_users.rb b/db/migrate/20200712150048_devise_invitable_add_to_users.rb new file mode 100644 index 000000000..4c04ded67 --- /dev/null +++ b/db/migrate/20200712150048_devise_invitable_add_to_users.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 4b95f6408..edf5de1c1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 5aaaaa74d..e11365331 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -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 } diff --git a/spec/labor/email_digest_spec.rb b/spec/labor/email_digest_spec.rb index 92b3c38b4..dd49df813 100644 --- a/spec/labor/email_digest_spec.rb +++ b/spec/labor/email_digest_spec.rb @@ -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 diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 37cb0a63c..70baf135c 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -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) diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index f711699d8..dd25b9bb9 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -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: {}) diff --git a/spec/requests/api/v0/users_spec.rb b/spec/requests/api/v0/users_spec.rb index d9679bf73..5fa2a383b 100644 --- a/spec/requests/api/v0/users_spec.rb +++ b/spec/requests/api/v0/users_spec.rb @@ -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) diff --git a/spec/requests/internal/invitations_spec.rb b/spec/requests/internal/invitations_spec.rb new file mode 100644 index 000000000..ff91a1ca6 --- /dev/null +++ b/spec/requests/internal/invitations_spec.rb @@ -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 diff --git a/spec/requests/registrations_spec.rb b/spec/requests/registrations_spec.rb index b890ad1ee..b59f9e550 100644 --- a/spec/requests/registrations_spec.rb +++ b/spec/requests/registrations_spec.rb @@ -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 diff --git a/spec/requests/user/user_profile_spec.rb b/spec/requests/user/user_profile_spec.rb index 8b1397f5e..bb0c43b29 100644 --- a/spec/requests/user/user_profile_spec.rb +++ b/spec/requests/user/user_profile_spec.rb @@ -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}" diff --git a/spec/services/users/delete_spec.rb b/spec/services/users/delete_spec.rb index 0d188db38..4863d2981 100644 --- a/spec/services/users/delete_spec.rb +++ b/spec/services/users/delete_spec.rb @@ -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 diff --git a/vendor/cache/devise_invitable-2.0.2.gem b/vendor/cache/devise_invitable-2.0.2.gem new file mode 100644 index 000000000..406f75f3d Binary files /dev/null and b/vendor/cache/devise_invitable-2.0.2.gem differ