You have been added as a contributor to the #{contributorship.universe.name} universe.
",
- icon: Universe.icon,
- icon_color: Universe.color,
- happened_at: DateTime.current,
- passthrough_link: Rails.application.routes.url_helpers.universe_path(contributorship.universe),
- reference_code: 'contributor-added'
- )
- end
- end
- end
+ UserOnboardingService.link_pending_contributor_invites(resource)
- # If the user was created in the last 60 seconds, report it to Slack
- if resource.persisted?
- if params[:user].key? :referral_code
- referral_code = ReferralCode.where(code: params[:user][:referral_code]).first
-
- Referral.create(
- referrer_id: referral_code.user.id,
- referred_id: resource.id,
- associated_code_id: referral_code.id
- ) if referral_code.present?
- end
+ if params[:user].key? :referral_code
+ UserOnboardingService.record_referral(resource, params[:user][:referral_code])
end
end
diff --git a/app/controllers/user_authentications_controller.rb b/app/controllers/user_authentications_controller.rb
new file mode 100644
index 000000000..5ebe59b72
--- /dev/null
+++ b/app/controllers/user_authentications_controller.rb
@@ -0,0 +1,26 @@
+##
+# Lets a signed-in user disconnect a linked OAuth provider from their account.
+# (Connecting happens through the OmniAuth flow in OmniauthCallbacksController.)
+class UserAuthenticationsController < ApplicationController
+ before_action :authenticate_user!
+
+ def destroy
+ authentication = current_user.user_authentications.find_by(id: params[:id])
+
+ if authentication.nil?
+ redirect_to user_more_actions_path(current_user), alert: "We couldn't find that linked account."
+ return
+ end
+
+ # Don't let users strand themselves: their last linked account can only be
+ # removed once they've set a password they actually know.
+ if current_user.oauth_only? && current_user.user_authentications.count == 1
+ redirect_to user_more_actions_path(current_user),
+ alert: "You haven't set a password yet, so this linked account is your only way to log in. Set a password first, then disconnect it."
+ return
+ end
+
+ authentication.destroy
+ redirect_to user_more_actions_path(current_user), notice: "Disconnected. You can no longer use that account to log in."
+ end
+end
diff --git a/app/helpers/devise_helper.rb b/app/helpers/devise_helper.rb
index ca61f0d23..73212a373 100644
--- a/app/helpers/devise_helper.rb
+++ b/app/helpers/devise_helper.rb
@@ -2,7 +2,7 @@ module DeviseHelper
def devise_error_messages!
resource.errors.full_messages.map { |msg| content_tag(:li, msg + '.') }.join.html_safe
end
-
+
def resource_name
:user
end
@@ -14,4 +14,13 @@ def resource
def devise_mapping
@devise_mapping ||= Devise.mappings[:user]
end
+
+ # OAuth providers that have credentials configured, so login/signup pages
+ # only show buttons that will actually work.
+ def configured_oauth_providers
+ {
+ google_oauth2: ENV['GOOGLE_OAUTH_CLIENT_ID'],
+ discord: ENV['DISCORD_CLIENT_ID']
+ }.select { |_provider, client_id| client_id.present? }.keys
+ end
end
\ No newline at end of file
diff --git a/app/models/users/user.rb b/app/models/users/user.rb
index 642efc56a..653291a9a 100644
--- a/app/models/users/user.rb
+++ b/app/models/users/user.rb
@@ -6,7 +6,8 @@ class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
- :recoverable, :rememberable, :trackable, :validatable
+ :recoverable, :rememberable, :trackable, :validatable,
+ :omniauthable, omniauth_providers: [:google_oauth2, :discord]
include HasContent
include Authority::UserAbilities
@@ -43,6 +44,59 @@ def on_premium_plan?
has_many :contributors, dependent: :destroy
+ has_many :user_authentications, dependent: :destroy
+
+ # Providers that guarantee verified email addresses, so an OAuth login can
+ # be safely matched to an existing account by email alone.
+ EMAIL_VERIFIED_OAUTH_PROVIDERS = %w(google_oauth2 discord).freeze
+
+ # Set when from_omniauth creates a brand-new account, so callers can run
+ # new-account onboarding. (previously_new_record? can't be used for this:
+ # after_create hooks like initialize_secure_code update the row again,
+ # which resets it.)
+ attr_accessor :new_oauth_signup
+
+ # Finds (or creates) the user for an OmniAuth callback. Returns a persisted
+ # user with the authentication linked, or an unpersisted user (with errors)
+ # when account creation fails (e.g. the provider sent no email).
+ def self.from_omniauth(auth)
+ authentication = UserAuthentication.find_by(provider: auth.provider, uid: auth.uid)
+ return authentication.user if authentication
+
+ email = auth.info.email&.downcase
+ user = nil
+
+ if email.present? && EMAIL_VERIFIED_OAUTH_PROVIDERS.include?(auth.provider.to_s)
+ user = User.find_by(email: email)
+ end
+
+ if user.nil?
+ user = User.new(
+ email: email,
+ name: auth.info.name,
+ password: Devise.friendly_token[0, 20],
+ password_automatically_set: true
+ )
+ user.new_oauth_signup = user.save
+ end
+
+ user.user_authentications.create(provider: auth.provider, uid: auth.uid) if user.persisted?
+ user
+ end
+
+ # True when the user signed up through OAuth and has never chosen their own
+ # password (so their only way into the account is a linked provider).
+ def oauth_only?
+ password_automatically_set? && user_authentications.any?
+ end
+
+ # Once a user sets a real password (e.g. through the reset-password email),
+ # they're no longer dependent on their linked providers to log in.
+ after_update :clear_password_automatically_set, if: :saved_change_to_encrypted_password?
+ def clear_password_automatically_set
+ update_column(:password_automatically_set, false) if password_automatically_set?
+ end
+
has_one :referral_code, dependent: :destroy
has_many :referrals, foreign_key: :referrer_id, dependent: :destroy
def referrer
diff --git a/app/models/users/user_authentication.rb b/app/models/users/user_authentication.rb
new file mode 100644
index 000000000..6f516e6bc
--- /dev/null
+++ b/app/models/users/user_authentication.rb
@@ -0,0 +1,9 @@
+##
+# Links a User to an external OAuth identity (e.g. Google, Discord).
+# A user may have multiple authentications, one per provider account.
+class UserAuthentication < ApplicationRecord
+ belongs_to :user
+
+ validates :provider, presence: true
+ validates :uid, presence: true, uniqueness: { scope: :provider }
+end
diff --git a/app/services/user_onboarding_service.rb b/app/services/user_onboarding_service.rb
new file mode 100644
index 000000000..0e0448299
--- /dev/null
+++ b/app/services/user_onboarding_service.rb
@@ -0,0 +1,43 @@
+##
+# Post-signup housekeeping shared by every account-creation path
+# (email/password registration and OAuth signups alike).
+class UserOnboardingService < Service
+ # Tie any universe contributor invites with this email to this user
+ def self.link_pending_contributor_invites(user)
+ return unless user.persisted?
+
+ # Load the records up front: after update_all below, the user_id: nil
+ # condition no longer matches them, so re-running the query would find
+ # nothing to notify about.
+ potential_contributor_records = Contributor.where(email: user.email.downcase, user_id: nil).to_a
+ return unless potential_contributor_records.any?
+
+ Contributor.where(id: potential_contributor_records.map(&:id)).update_all(user_id: user.id)
+
+ # Create a notification letting the user know about each collaboration!
+ potential_contributor_records.each do |contributorship|
+ user.notifications.create(
+ message_html: "
You have been added as a contributor to the #{contributorship.universe.name} universe.
",
+ icon: Universe.icon,
+ icon_color: Universe.color,
+ happened_at: DateTime.current,
+ passthrough_link: Rails.application.routes.url_helpers.universe_path(contributorship.universe),
+ reference_code: 'contributor-added'
+ )
+ end
+ end
+
+ # Credit the referrer when the new user signed up through a referral link
+ def self.record_referral(user, code)
+ return unless user.persisted? && code.present?
+
+ referral_code = ReferralCode.where(code: code).first
+ return if referral_code.nil?
+
+ Referral.create(
+ referrer_id: referral_code.user.id,
+ referred_id: user.id,
+ associated_code_id: referral_code.id
+ )
+ end
+end
diff --git a/app/views/devise/registrations/more_actions.html.erb b/app/views/devise/registrations/more_actions.html.erb
index b36d1eb09..903c513af 100644
--- a/app/views/devise/registrations/more_actions.html.erb
+++ b/app/views/devise/registrations/more_actions.html.erb
@@ -37,6 +37,10 @@
+ <% if configured_oauth_providers.any? %>
+ <%= render partial: 'devise/registrations/panes/connected_accounts' %>
+ <% end %>
+
<%= form_for(current_user, as: :user, url: registration_path(:user), html: { method: :put, id: "settings-form" }) do |f| %>
<% if current_user.errors.any? %>
+ <%= render 'devise/shared/oauth_buttons' %>
+
<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
<% if resource.errors.any? %>
diff --git a/app/views/devise/registrations/panes/_connected_accounts.html.erb b/app/views/devise/registrations/panes/_connected_accounts.html.erb
new file mode 100644
index 000000000..bfa7f1347
--- /dev/null
+++ b/app/views/devise/registrations/panes/_connected_accounts.html.erb
@@ -0,0 +1,47 @@
+
+
+
Connected accounts
+
+ Link an external account to log into Notebook.ai with a single click.
+
+
+
+ <% if current_user.oauth_only? %>
+
+ You signed up with a linked account, so you haven't set a Notebook.ai password yet.
+ To add one (or before disconnecting your only linked account), use
+ <%= link_to 'Forgot your password?', new_user_password_path, class: 'font-medium underline' %>
+ to set a password for <%= current_user.email %>.
+