diff --git a/lib/graphql_devise/default_operations/mutations.rb b/lib/graphql_devise/default_operations/mutations.rb index 4eb3281c..4c7b322a 100644 --- a/lib/graphql_devise/default_operations/mutations.rb +++ b/lib/graphql_devise/default_operations/mutations.rb @@ -4,6 +4,7 @@ require 'graphql_devise/mutations/login' require 'graphql_devise/mutations/logout' require 'graphql_devise/mutations/resend_confirmation' +require 'graphql_devise/mutations/resend_confirmation_with_token' require 'graphql_devise/mutations/send_password_reset' require 'graphql_devise/mutations/send_password_reset_with_token' require 'graphql_devise/mutations/sign_up' @@ -24,6 +25,7 @@ module DefaultOperations send_password_reset: { klass: GraphqlDevise::Mutations::SendPasswordReset, authenticatable: false }, send_password_reset_with_token: { klass: GraphqlDevise::Mutations::SendPasswordResetWithToken, authenticatable: false }, resend_confirmation: { klass: GraphqlDevise::Mutations::ResendConfirmation, authenticatable: false }, + resend_confirmation_with_token: { klass: GraphqlDevise::Mutations::ResendConfirmationWithToken, authenticatable: false }, confirm_registration_with_token: { klass: GraphqlDevise::Mutations::ConfirmRegistrationWithToken, authenticatable: true } }.freeze end diff --git a/lib/graphql_devise/mutations/resend_confirmation_with_token.rb b/lib/graphql_devise/mutations/resend_confirmation_with_token.rb new file mode 100644 index 00000000..32c517a9 --- /dev/null +++ b/lib/graphql_devise/mutations/resend_confirmation_with_token.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module GraphqlDevise + module Mutations + class ResendConfirmationWithToken < Base + argument :email, String, required: true + argument :confirm_url, String, required: true + + field :message, String, null: false + + def resolve(email:, confirm_url:) + check_redirect_url_whitelist!(confirm_url) + + resource = find_confirmable_resource(email) + + if resource + yield resource if block_given? + + if resource.confirmed? && !resource.pending_reconfirmation? + raise_user_error(I18n.t('graphql_devise.confirmations.already_confirmed')) + end + + resource.send_confirmation_instructions( + redirect_url: confirm_url, + template_path: ['graphql_devise/mailer'] + ) + + { message: I18n.t('graphql_devise.confirmations.send_instructions', email: email) } + else + raise_user_error(I18n.t('graphql_devise.confirmations.user_not_found', email: email)) + end + end + + private + + def find_confirmable_resource(email) + email_insensitive = get_case_insensitive_field(:email, email) + resource = find_resource(:unconfirmed_email, email_insensitive) if resource_class.reconfirmable + resource ||= find_resource(:email, email_insensitive) + resource + end + end + end +end diff --git a/spec/dummy/app/graphql/dummy_schema.rb b/spec/dummy/app/graphql/dummy_schema.rb index 1e9a82d2..896deda4 100644 --- a/spec/dummy/app/graphql/dummy_schema.rb +++ b/spec/dummy/app/graphql/dummy_schema.rb @@ -7,17 +7,18 @@ class DummySchema < GraphQL::Schema public_introspection: true, resource_loaders: [ GraphqlDevise::ResourceLoader.new( - 'User', + User, only: [ :login, :confirm_account, :send_password_reset, :resend_confirmation, + :resend_confirmation_with_token, :check_password_token ] ), - GraphqlDevise::ResourceLoader.new('Guest', only: [:logout]), - GraphqlDevise::ResourceLoader.new('SchemaUser') + GraphqlDevise::ResourceLoader.new(Guest, only: [:logout]), + GraphqlDevise::ResourceLoader.new(SchemaUser) ] ) diff --git a/spec/requests/mutations/resend_confirmation_with_token_spec.rb b/spec/requests/mutations/resend_confirmation_with_token_spec.rb new file mode 100644 index 00000000..dc247f77 --- /dev/null +++ b/spec/requests/mutations/resend_confirmation_with_token_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Resend confirmation with token' do + include_context 'with graphql query request' + + let(:confirmed_at) { nil } + let!(:user) { create(:user, confirmed_at: nil, email: 'mwallace@wallaceinc.com') } + let(:email) { user.email } + let(:id) { user.id } + let(:confirm_url) { 'https://google.com' } + let(:query) do + <<-GRAPHQL + mutation { + userResendConfirmationWithToken( + email:"#{email}", + confirmUrl:"#{confirm_url}" + ) { + message + } + } + GRAPHQL + end + + context 'when confirm_url is not whitelisted' do + let(:confirm_url) { 'https://not-safe.com' } + + it 'returns a not whitelisted confirm url error' do + expect { post_request }.to not_change(ActionMailer::Base.deliveries, :count) + + expect(json_response[:errors]).to containing_exactly( + hash_including( + message: "Redirect to '#{confirm_url}' not allowed.", + extensions: { code: 'USER_ERROR' } + ) + ) + end + end + + context 'when params are correct' do + context 'when using the gem schema' do + it 'sends an email to the user with confirmation url and returns a success message' do + expect { post_request }.to change(ActionMailer::Base.deliveries, :count).by(1) + expect(json_response[:data][:userResendConfirmationWithToken]).to include( + message: 'You will receive an email with instructions for how to confirm your email address in a few minutes.' + ) + + email = Nokogiri::HTML(ActionMailer::Base.deliveries.last.body.encoded) + confirm_link = email.css('a').first['href'] + confirm_token = confirm_link.match(/\?confirmationToken\=(?.+)\z/)[:token] + + expect(User.confirm_by_token(confirm_token)).to eq(user) + end + end + + context 'when using a custom schema' do + let(:custom_path) { '/api/v1/graphql' } + + it 'sends an email to the user with confirmation url and returns a success message' do + expect { post_request(custom_path) }.to change(ActionMailer::Base.deliveries, :count).by(1) + expect(json_response[:data][:userResendConfirmationWithToken]).to include( + message: 'You will receive an email with instructions for how to confirm your email address in a few minutes.' + ) + + email = Nokogiri::HTML(ActionMailer::Base.deliveries.last.body.encoded) + confirm_link = email.css('a').first['href'] + confirm_token = confirm_link.match(/\?confirmationToken\=(?.+)\z/)[:token] + + expect(User.confirm_by_token(confirm_token)).to eq(user) + end + end + + context 'when email address uses different casing' do + let(:email) { 'mWallace@wallaceinc.com' } + + it 'honors devise configuration for case insensitive fields' do + expect { post_request }.to change(ActionMailer::Base.deliveries, :count).by(1) + expect(json_response[:data][:userResendConfirmationWithToken]).to include( + message: 'You will receive an email with instructions for how to confirm your email address in a few minutes.' + ) + end + end + + context 'when the user has already been confirmed' do + before { user.confirm } + + it 'does *NOT* send an email and raises an error' do + expect { post_request }.to not_change(ActionMailer::Base.deliveries, :count) + expect(json_response[:data][:userResendConfirmationWithToken]).to be_nil + expect(json_response[:errors]).to contain_exactly( + hash_including( + message: 'Email was already confirmed, please try signing in', + extensions: { code: 'USER_ERROR' } + ) + ) + end + end + end + + context 'when the email was changed' do + let(:confirmed_at) { 2.seconds.ago } + let(:email) { 'new-email@wallaceinc.com' } + let(:new_email) { email } + + before do + user.update_with_email( + email: new_email, + schema_url: 'http://localhost/test', + confirmation_success_url: 'https://google.com' + ) + end + + it 'sends new confirmation email' do + expect { post_request }.to change(ActionMailer::Base.deliveries, :count).by(1) + expect(ActionMailer::Base.deliveries.first.to).to contain_exactly(new_email) + expect(json_response[:data][:userResendConfirmationWithToken]).to include( + message: 'You will receive an email with instructions for how to confirm your email address in a few minutes.' + ) + end + end + + context "when the email isn't in the system" do + let(:email) { 'notthere@gmail.com' } + + it 'does *NOT* send an email and raises an error' do + expect { post_request }.to not_change(ActionMailer::Base.deliveries, :count) + expect(json_response[:data][:userResendConfirmationWithToken]).to be_nil + expect(json_response[:errors]).to contain_exactly( + hash_including( + message: "Unable to find user with email '#{email}'.", + extensions: { code: 'USER_ERROR' } + ) + ) + end + end +end