Skip to content

Commit 6e56f3f

Browse files
Merge pull request #146 from graphql-devise/alternate-reset-password-flow
Alternate reset password flow, only 2 steps, no redirect
2 parents da3013a + 2afb805 commit 6e56f3f

File tree

10 files changed

+306
-9
lines changed

10 files changed

+306
-9
lines changed

app/views/graphql_devise/mailer/reset_password_instructions.html.erb

+7-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
<p><%= t('.request_reset_link_msg') %></p>
44

5-
<p><%= link_to t('.password_change_link'), "#{message['schema_url']}?#{password_reset_query(token: @token, redirect_url: message['redirect-url'], resource_name: @resource.class.to_s).to_query}" %></p>
5+
<p>
6+
<% if message['schema_url'].present? %>
7+
<%= link_to t('.password_change_link'), "#{message['schema_url']}?#{password_reset_query(token: @token, redirect_url: message['redirect-url'], resource_name: @resource.class.to_s).to_query}" %>
8+
<% else %>
9+
<%= link_to t('.password_change_link'), "#{message['redirect-url'].to_s}?#{{ reset_password_token: @token }.to_query}" %>
10+
<% end %>
11+
</p>
612

713
<p><%= t('.ignore_mail_msg') %></p>
814
<p><%= t('.no_changes_msg') %></p>

config/locales/en.yml

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ en:
99
registrations:
1010
missing_confirm_redirect_url: "Missing 'confirm_success_url' parameter. Required when confirmable module is enabled."
1111
passwords:
12+
password_recovery_disabled: "You must enable password recovery for this model."
1213
update_password_error: "Unable to update user password"
1314
missing_passwords: "You must fill out the fields labeled 'Password' and 'Password confirmation'."
1415
password_not_required: "This account does not require a password. Sign in using your '%{provider}' account instead."

lib/graphql_devise/default_operations/mutations.rb

+10-6
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,22 @@
55
require 'graphql_devise/mutations/logout'
66
require 'graphql_devise/mutations/resend_confirmation'
77
require 'graphql_devise/mutations/send_password_reset'
8+
require 'graphql_devise/mutations/send_password_reset_with_token'
89
require 'graphql_devise/mutations/sign_up'
910
require 'graphql_devise/mutations/update_password'
11+
require 'graphql_devise/mutations/update_password_with_token'
1012

1113
module GraphqlDevise
1214
module DefaultOperations
1315
MUTATIONS = {
14-
login: { klass: GraphqlDevise::Mutations::Login, authenticatable: true },
15-
logout: { klass: GraphqlDevise::Mutations::Logout, authenticatable: true },
16-
sign_up: { klass: GraphqlDevise::Mutations::SignUp, authenticatable: true },
17-
update_password: { klass: GraphqlDevise::Mutations::UpdatePassword, authenticatable: true },
18-
send_password_reset: { klass: GraphqlDevise::Mutations::SendPasswordReset, authenticatable: false },
19-
resend_confirmation: { klass: GraphqlDevise::Mutations::ResendConfirmation, authenticatable: false }
16+
login: { klass: GraphqlDevise::Mutations::Login, authenticatable: true },
17+
logout: { klass: GraphqlDevise::Mutations::Logout, authenticatable: true },
18+
sign_up: { klass: GraphqlDevise::Mutations::SignUp, authenticatable: true },
19+
update_password: { klass: GraphqlDevise::Mutations::UpdatePassword, authenticatable: true },
20+
update_password_with_token: { klass: GraphqlDevise::Mutations::UpdatePasswordWithToken, authenticatable: true },
21+
send_password_reset: { klass: GraphqlDevise::Mutations::SendPasswordReset, authenticatable: false },
22+
send_password_reset_with_token: { klass: GraphqlDevise::Mutations::SendPasswordResetWithToken, authenticatable: false },
23+
resend_confirmation: { klass: GraphqlDevise::Mutations::ResendConfirmation, authenticatable: false }
2024
}.freeze
2125
end
2226
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
module GraphqlDevise
4+
module Mutations
5+
class SendPasswordResetWithToken < Base
6+
argument :email, String, required: true
7+
argument :redirect_url, String, required: true
8+
9+
field :message, String, null: false
10+
11+
def resolve(email:, redirect_url:)
12+
check_redirect_url_whitelist!(redirect_url)
13+
14+
resource = find_resource(:email, get_case_insensitive_field(:email, email))
15+
16+
if resource
17+
yield resource if block_given?
18+
19+
resource.send_reset_password_instructions(
20+
email: email,
21+
provider: 'email',
22+
redirect_url: redirect_url,
23+
template_path: ['graphql_devise/mailer']
24+
)
25+
26+
if resource.errors.empty?
27+
{ message: I18n.t('graphql_devise.passwords.send_instructions') }
28+
else
29+
raise_user_error_list(I18n.t('graphql_devise.invalid_resource'), errors: resource.errors.full_messages)
30+
end
31+
else
32+
raise_user_error(I18n.t('graphql_devise.user_not_found'))
33+
end
34+
end
35+
end
36+
end
37+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# frozen_string_literal: true
2+
3+
module GraphqlDevise
4+
module Mutations
5+
class UpdatePasswordWithToken < Base
6+
argument :password, String, required: true
7+
argument :password_confirmation, String, required: true
8+
argument :reset_password_token, String, required: true
9+
10+
field :credentials,
11+
GraphqlDevise::Types::CredentialType,
12+
null: true,
13+
description: 'Authentication credentials. Resource must be signed_in for credentials to be returned.'
14+
15+
def resolve(reset_password_token:, **attrs)
16+
raise_user_error(I18n.t('graphql_devise.passwords.password_recovery_disabled')) unless recoverable_enabled?
17+
18+
resource = resource_class.with_reset_password_token(reset_password_token)
19+
raise_user_error(I18n.t('graphql_devise.passwords.reset_token_not_found')) if resource.blank?
20+
raise_user_error(I18n.t('graphql_devise.passwords.reset_token_expired')) unless resource.reset_password_period_valid?
21+
22+
if resource.update(attrs)
23+
yield resource if block_given?
24+
25+
response_payload = { authenticatable: resource }
26+
response_payload[:credentials] = set_auth_headers(resource) if controller.signed_in?(resource_name)
27+
28+
response_payload
29+
else
30+
raise_user_error_list(
31+
I18n.t('graphql_devise.passwords.update_password_error'),
32+
errors: resource.errors.full_messages
33+
)
34+
end
35+
end
36+
end
37+
end
38+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
module Mutations
4+
class ResetAdminPasswordWithToken < GraphqlDevise::Mutations::UpdatePasswordWithToken
5+
field :authenticatable, Types::AdminType, null: false
6+
7+
def resolve(reset_password_token:, **attrs)
8+
super do |admin|
9+
controller.sign_in(admin)
10+
end
11+
end
12+
end
13+
end

spec/dummy/config/routes.rb

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
authenticatable_type: Types::CustomAdminType,
1616
skip: [:sign_up, :check_password_token],
1717
operations: {
18-
confirm_account: Resolvers::ConfirmAdminAccount
18+
confirm_account: Resolvers::ConfirmAdminAccount,
19+
update_password_with_token: Mutations::ResetAdminPasswordWithToken
1920
},
2021
at: '/api/v1/admin/graphql_auth'
2122
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe 'Send Password Reset Requests' do
6+
include_context 'with graphql query request'
7+
8+
let!(:user) { create(:user, :confirmed, email: '[email protected]') }
9+
let(:email) { user.email }
10+
let(:redirect_url) { 'https://google.com' }
11+
let(:query) do
12+
<<-GRAPHQL
13+
mutation {
14+
userSendPasswordResetWithToken(
15+
email: "#{email}",
16+
redirectUrl: "#{redirect_url}"
17+
) {
18+
message
19+
}
20+
}
21+
GRAPHQL
22+
end
23+
24+
context 'when redirect_url is not whitelisted' do
25+
let(:redirect_url) { 'https://not-safe.com' }
26+
27+
it 'returns a not whitelisted redirect url error' do
28+
expect { post_request }.to not_change(ActionMailer::Base.deliveries, :count)
29+
30+
expect(json_response[:errors]).to containing_exactly(
31+
hash_including(
32+
message: "Redirect to '#{redirect_url}' not allowed.",
33+
extensions: { code: 'USER_ERROR' }
34+
)
35+
)
36+
end
37+
end
38+
39+
context 'when params are correct' do
40+
context 'when using the gem schema' do
41+
it 'sends password reset email' do
42+
expect { post_request }.to change(ActionMailer::Base.deliveries, :count).by(1)
43+
44+
expect(json_response[:data][:userSendPasswordResetWithToken]).to include(
45+
message: 'You will receive an email with instructions on how to reset your password in a few minutes.'
46+
)
47+
48+
email = Nokogiri::HTML(ActionMailer::Base.deliveries.last.body.encoded)
49+
link = email.css('a').first
50+
51+
expect(link['href']).to include(redirect_url + '?reset_password_token')
52+
end
53+
end
54+
end
55+
56+
context 'when email address uses different casing' do
57+
let(:email) { '[email protected]' }
58+
59+
it 'honors devise configuration for case insensitive fields' do
60+
expect { post_request }.to change(ActionMailer::Base.deliveries, :count).by(1)
61+
expect(json_response[:data][:userSendPasswordResetWithToken]).to include(
62+
message: 'You will receive an email with instructions on how to reset your password in a few minutes.'
63+
)
64+
end
65+
end
66+
67+
context 'when user email is not found' do
68+
let(:email) { '[email protected]' }
69+
70+
before { post_request }
71+
72+
it 'returns an error' do
73+
expect(json_response[:errors]).to contain_exactly(
74+
hash_including(message: 'User was not found or was not logged in.', extensions: { code: 'USER_ERROR' })
75+
)
76+
end
77+
end
78+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe 'Update Password With Token' do
6+
include_context 'with graphql query request'
7+
8+
let(:password) { '12345678' }
9+
let(:password_confirmation) { password }
10+
11+
context 'when using the user model' do
12+
let(:user) { create(:user, :confirmed) }
13+
let(:query) do
14+
<<-GRAPHQL
15+
mutation {
16+
userUpdatePasswordWithToken(
17+
resetPasswordToken: "#{token}",
18+
password: "#{password}",
19+
passwordConfirmation: "#{password_confirmation}"
20+
) {
21+
authenticatable { email }
22+
credentials { accessToken }
23+
}
24+
}
25+
GRAPHQL
26+
end
27+
28+
context 'when reset password token is valid' do
29+
let(:token) { user.send(:set_reset_password_token) }
30+
31+
it 'updates the password' do
32+
expect do
33+
post_request
34+
user.reload
35+
end.to change(user, :encrypted_password)
36+
37+
expect(user).to be_valid_password(password)
38+
expect(json_response[:data][:userUpdatePasswordWithToken][:credentials]).to be_nil
39+
expect(json_response[:data][:userUpdatePasswordWithToken][:authenticatable]).to include(email: user.email)
40+
end
41+
42+
context 'when token has expired' do
43+
it 'returns an expired token error' do
44+
travel_to 10.hours.ago do
45+
token
46+
end
47+
48+
post_request
49+
50+
expect(json_response[:errors]).to contain_exactly(
51+
hash_including(message: 'Reset password token is no longer valid.', extensions: { code: 'USER_ERROR' })
52+
)
53+
end
54+
end
55+
56+
context 'when password confirmation does not match' do
57+
let(:password_confirmation) { 'does not match' }
58+
59+
it 'returns an error' do
60+
post_request
61+
62+
expect(json_response[:errors]).to contain_exactly(
63+
hash_including(
64+
message: 'Unable to update user password',
65+
extensions: { code: 'USER_ERROR', detailed_errors: ["Password confirmation doesn't match Password"] }
66+
)
67+
)
68+
end
69+
end
70+
end
71+
72+
context 'when reset password token is not found' do
73+
let(:token) { user.send(:set_reset_password_token) + 'invalid' }
74+
75+
it 'returns an error' do
76+
post_request
77+
78+
expect(json_response[:errors]).to contain_exactly(
79+
hash_including(message: 'No user found for the specified reset token.', extensions: { code: 'USER_ERROR' })
80+
)
81+
end
82+
end
83+
end
84+
85+
context 'when using the admin model' do
86+
let(:admin) { create(:admin, :confirmed) }
87+
let(:query) do
88+
<<-GRAPHQL
89+
mutation {
90+
adminUpdatePasswordWithToken(
91+
resetPasswordToken: "#{token}",
92+
password: "#{password}",
93+
passwordConfirmation: "#{password_confirmation}"
94+
) {
95+
authenticatable { email }
96+
credentials { uid }
97+
}
98+
}
99+
GRAPHQL
100+
end
101+
102+
context 'when reset password token is valid' do
103+
let(:token) { admin.send(:set_reset_password_token) }
104+
105+
it 'updates the password' do
106+
expect do
107+
post_request
108+
admin.reload
109+
end.to change(admin, :encrypted_password)
110+
111+
expect(admin).to be_valid_password(password)
112+
expect(json_response[:data][:adminUpdatePasswordWithToken]).to include(
113+
credentials: { uid: admin.email },
114+
authenticatable: { email: admin.email }
115+
)
116+
end
117+
end
118+
end
119+
end

spec/requests/queries/check_password_token_spec.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
context 'when reset password token is not found' do
9090
let(:token) { user.send(:set_reset_password_token) + 'invalid' }
9191

92-
it 'redirects to redirect url' do
92+
it 'returns an error message' do
9393
get_request
9494

9595
expect(json_response[:errors]).to contain_exactly(

0 commit comments

Comments
 (0)