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 @@
-We require social login to prevent abuse.
+If you have a password...
+ <%= form_for(User.new, as: :user, url: session_path(:user)) do |f| %> +Sign in by social auth if you don't have a password set.
+ <% 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 @@ -| User | +ID | +Name | +|
|---|---|---|---|
| <%= link_to user.username, internal_user_path(user) %> | +<%= user.id %> | +<%= user.name %> | +<%= user.email %> | +
<%= SiteConfig.tagline %>
+ <% end %> - <% if SiteConfig.tagline.present? %> -<%= SiteConfig.tagline %>
++ We strive for transparency and don't collect excess data. +
+ <% else %> +- We strive for transparency and don't collect excess data. -
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 @@As an alternative to signing in via linked social accounts, you may create a password.
+