squashed identity proof updates (#10375)
This commit is contained in:
		
				
					committed by
					
						 Eugen Rochko
						Eugen Rochko
					
				
			
			
				
	
			
			
			
						parent
						
							026dd75208
						
					
				
				
					commit
					69141dca26
				
			| @@ -0,0 +1,19 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Api::V1::Accounts::IdentityProofsController < Api::BaseController | ||||
|   before_action :require_user! | ||||
|   before_action :set_account | ||||
|  | ||||
|   respond_to :json | ||||
|  | ||||
|   def index | ||||
|     @proofs = @account.identity_proofs.active | ||||
|     render json: @proofs, each_serializer: REST::IdentityProofSerializer | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def set_account | ||||
|     @account = Account.find(params[:account_id]) | ||||
|   end | ||||
| end | ||||
| @@ -18,7 +18,12 @@ class Settings::IdentityProofsController < Settings::BaseController | ||||
|       provider_username: params[:provider_username] | ||||
|     ) | ||||
|  | ||||
|     render layout: 'auth' | ||||
|     if current_account.username == params[:username] | ||||
|       render layout: 'auth' | ||||
|     else | ||||
|       flash[:alert] = I18n.t('identity_proofs.errors.wrong_user', proving: params[:username], current: current_account.username) | ||||
|       redirect_to settings_identity_proofs_path | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def create | ||||
| @@ -26,6 +31,7 @@ class Settings::IdentityProofsController < Settings::BaseController | ||||
|     @proof.token = resource_params[:token] | ||||
|  | ||||
|     if @proof.save | ||||
|       PostStatusService.new.call(current_user.account, text: post_params[:status_text]) if publish_proof? | ||||
|       redirect_to @proof.on_success_path(params[:user_agent]) | ||||
|     else | ||||
|       flash[:alert] = I18n.t('identity_proofs.errors.failed', provider: @proof.provider.capitalize) | ||||
| @@ -36,10 +42,22 @@ class Settings::IdentityProofsController < Settings::BaseController | ||||
|   private | ||||
|  | ||||
|   def check_required_params | ||||
|     redirect_to settings_identity_proofs_path unless [:provider, :provider_username, :token].all? { |k| params[k].present? } | ||||
|     redirect_to settings_identity_proofs_path unless [:provider, :provider_username, :username, :token].all? { |k| params[k].present? } | ||||
|   end | ||||
|  | ||||
|   def resource_params | ||||
|     params.require(:account_identity_proof).permit(:provider, :provider_username, :token) | ||||
|   end | ||||
|  | ||||
|   def publish_proof? | ||||
|     ActiveModel::Type::Boolean.new.cast(post_params[:post_status]) | ||||
|   end | ||||
|  | ||||
|   def post_params | ||||
|     params.require(:account_identity_proof).permit(:post_status, :status_text) | ||||
|   end | ||||
|  | ||||
|   def set_body_classes | ||||
|     @body_classes = '' | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										30
									
								
								app/javascript/mastodon/actions/identity_proofs.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								app/javascript/mastodon/actions/identity_proofs.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import api from '../api'; | ||||
|  | ||||
| export const IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST = 'IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST'; | ||||
| export const IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS = 'IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS'; | ||||
| export const IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL    = 'IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL'; | ||||
|  | ||||
| export const fetchAccountIdentityProofs = accountId => (dispatch, getState) => { | ||||
|   dispatch(fetchAccountIdentityProofsRequest(accountId)); | ||||
|  | ||||
|   api(getState).get(`/api/v1/accounts/${accountId}/identity_proofs`) | ||||
|     .then(({ data }) => dispatch(fetchAccountIdentityProofsSuccess(accountId, data))) | ||||
|     .catch(err => dispatch(fetchAccountIdentityProofsFail(accountId, err))); | ||||
| }; | ||||
|  | ||||
| export const fetchAccountIdentityProofsRequest = id => ({ | ||||
|   type: IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST, | ||||
|   id, | ||||
| }); | ||||
|  | ||||
| export const fetchAccountIdentityProofsSuccess = (accountId, identity_proofs) => ({ | ||||
|   type: IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS, | ||||
|   accountId, | ||||
|   identity_proofs, | ||||
| }); | ||||
|  | ||||
| export const fetchAccountIdentityProofsFail = (accountId, err) => ({ | ||||
|   type: IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL, | ||||
|   accountId, | ||||
|   err, | ||||
| }); | ||||
| @@ -62,6 +62,7 @@ class Header extends ImmutablePureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     account: ImmutablePropTypes.map, | ||||
|     identity_props: ImmutablePropTypes.list, | ||||
|     onFollow: PropTypes.func.isRequired, | ||||
|     onBlock: PropTypes.func.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
| @@ -81,7 +82,7 @@ class Header extends ImmutablePureComponent { | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { account, intl, domain } = this.props; | ||||
|     const { account, intl, domain, identity_proofs } = this.props; | ||||
|  | ||||
|     if (!account) { | ||||
|       return null; | ||||
| @@ -234,8 +235,20 @@ class Header extends ImmutablePureComponent { | ||||
|  | ||||
|           <div className='account__header__extra'> | ||||
|             <div className='account__header__bio'> | ||||
|               {fields.size > 0 && ( | ||||
|               { (fields.size > 0 || identity_proofs.size > 0) && ( | ||||
|                 <div className='account__header__fields'> | ||||
|                   {identity_proofs.map((proof, i) => ( | ||||
|                     <dl key={i}> | ||||
|                       <dt dangerouslySetInnerHTML={{ __html: proof.get('provider') }} /> | ||||
|  | ||||
|                       <dd className='verified'> | ||||
|                         <a href={proof.get('proof_url')} target='_blank' rel='noopener'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}> | ||||
|                           <Icon id='check' className='verified__mark' /> | ||||
|                         </span></a> | ||||
|                         <a href={proof.get('profile_url')} target='_blank' rel='noopener'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} /></a> | ||||
|                       </dd> | ||||
|                     </dl> | ||||
|                   ))} | ||||
|                   {fields.map((pair, i) => ( | ||||
|                     <dl key={i}> | ||||
|                       <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} /> | ||||
|   | ||||
| @@ -12,6 +12,7 @@ export default class Header extends ImmutablePureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     account: ImmutablePropTypes.map, | ||||
|     identity_proofs: ImmutablePropTypes.list, | ||||
|     onFollow: PropTypes.func.isRequired, | ||||
|     onBlock: PropTypes.func.isRequired, | ||||
|     onMention: PropTypes.func.isRequired, | ||||
| @@ -84,7 +85,7 @@ export default class Header extends ImmutablePureComponent { | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { account, hideTabs } = this.props; | ||||
|     const { account, hideTabs, identity_proofs } = this.props; | ||||
|  | ||||
|     if (account === null) { | ||||
|       return <MissingIndicator />; | ||||
| @@ -96,6 +97,7 @@ export default class Header extends ImmutablePureComponent { | ||||
|  | ||||
|         <InnerHeader | ||||
|           account={account} | ||||
|           identity_proofs={identity_proofs} | ||||
|           onFollow={this.handleFollow} | ||||
|           onBlock={this.handleBlock} | ||||
|           onMention={this.handleMention} | ||||
|   | ||||
| @@ -21,6 +21,7 @@ import { openModal } from '../../../actions/modal'; | ||||
| import { blockDomain, unblockDomain } from '../../../actions/domain_blocks'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import { unfollowModal } from '../../../initial_state'; | ||||
| import { List as ImmutableList } from 'immutable'; | ||||
|  | ||||
| const messages = defineMessages({ | ||||
|   unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, | ||||
| @@ -35,6 +36,7 @@ const makeMapStateToProps = () => { | ||||
|   const mapStateToProps = (state, { accountId }) => ({ | ||||
|     account: getAccount(state, accountId), | ||||
|     domain: state.getIn(['meta', 'domain']), | ||||
|     identity_proofs: state.getIn(['identity_proofs', accountId], ImmutableList()), | ||||
|   }); | ||||
|  | ||||
|   return mapStateToProps; | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import ColumnBackButton from '../../components/column_back_button'; | ||||
| import { List as ImmutableList } from 'immutable'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import { fetchAccountIdentityProofs } from '../../actions/identity_proofs'; | ||||
|  | ||||
| const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => { | ||||
|   const path = withReplies ? `${accountId}:with_replies` : accountId; | ||||
| @@ -42,6 +43,7 @@ class AccountTimeline extends ImmutablePureComponent { | ||||
|     const { params: { accountId }, withReplies } = this.props; | ||||
|  | ||||
|     this.props.dispatch(fetchAccount(accountId)); | ||||
|     this.props.dispatch(fetchAccountIdentityProofs(accountId)); | ||||
|     if (!withReplies) { | ||||
|       this.props.dispatch(expandAccountFeaturedTimeline(accountId)); | ||||
|     } | ||||
| @@ -51,6 +53,7 @@ class AccountTimeline extends ImmutablePureComponent { | ||||
|   componentWillReceiveProps (nextProps) { | ||||
|     if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) { | ||||
|       this.props.dispatch(fetchAccount(nextProps.params.accountId)); | ||||
|       this.props.dispatch(fetchAccountIdentityProofs(nextProps.params.accountId)); | ||||
|       if (!nextProps.withReplies) { | ||||
|         this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId)); | ||||
|       } | ||||
|   | ||||
							
								
								
									
										25
									
								
								app/javascript/mastodon/reducers/identity_proofs.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/javascript/mastodon/reducers/identity_proofs.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| import { Map as ImmutableMap, fromJS } from 'immutable'; | ||||
| import { | ||||
|   IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST, | ||||
|   IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS, | ||||
|   IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL, | ||||
| } from '../actions/identity_proofs'; | ||||
|  | ||||
| const initialState = ImmutableMap(); | ||||
|  | ||||
| export default function identityProofsReducer(state = initialState, action) { | ||||
|   switch(action.type) { | ||||
|   case IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST: | ||||
|     return state.set('isLoading', true); | ||||
|   case IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL: | ||||
|     return state.set('isLoading', false); | ||||
|   case IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS: | ||||
|     return state.update(identity_proofs => identity_proofs.withMutations(map => { | ||||
|       map.set('isLoading', false); | ||||
|       map.set('loaded', true); | ||||
|       map.set(action.accountId, fromJS(action.identity_proofs)); | ||||
|     })); | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
| }; | ||||
| @@ -30,6 +30,7 @@ import filters from './filters'; | ||||
| import conversations from './conversations'; | ||||
| import suggestions from './suggestions'; | ||||
| import polls from './polls'; | ||||
| import identity_proofs from './identity_proofs'; | ||||
|  | ||||
| const reducers = { | ||||
|   dropdown_menu, | ||||
| @@ -56,6 +57,7 @@ const reducers = { | ||||
|   notifications, | ||||
|   height_cache, | ||||
|   custom_emojis, | ||||
|   identity_proofs, | ||||
|   lists, | ||||
|   listEditor, | ||||
|   listAdder, | ||||
|   | ||||
| @@ -10,12 +10,10 @@ | ||||
| } | ||||
|  | ||||
| .logo-container { | ||||
|   margin: 100px auto; | ||||
|   margin-bottom: 50px; | ||||
|   margin: 100px auto 50px; | ||||
|  | ||||
|   @media screen and (max-width: 400px) { | ||||
|     margin: 30px auto; | ||||
|     margin-bottom: 20px; | ||||
|   @media screen and (max-width: 500px) { | ||||
|     margin: 40px auto 0; | ||||
|   } | ||||
|  | ||||
|   h1 { | ||||
|   | ||||
| @@ -854,13 +854,19 @@ code { | ||||
|     flex: 1; | ||||
|     flex-direction: column; | ||||
|     flex-shrink: 1; | ||||
|     max-width: 50%; | ||||
|  | ||||
|     &-sep { | ||||
|       align-self: center; | ||||
|       flex-grow: 0; | ||||
|       overflow: visible; | ||||
|       position: relative; | ||||
|       z-index: 1; | ||||
|     } | ||||
|  | ||||
|     p { | ||||
|       word-break: break-word; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .account__avatar { | ||||
| @@ -882,12 +888,13 @@ code { | ||||
|       height: 100%; | ||||
|       left: 50%; | ||||
|       position: absolute; | ||||
|       top: 0; | ||||
|       width: 1px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &__row { | ||||
|     align-items: center; | ||||
|     align-items: flex-start; | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|   } | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class ProofProvider::Keybase | ||||
|   BASE_URL = 'https://keybase.io' | ||||
|   BASE_URL = ENV.fetch('KEYBASE_BASE_URL', 'https://keybase.io') | ||||
|   DOMAIN = ENV.fetch('KEYBASE_DOMAIN', Rails.configuration.x.local_domain) | ||||
|  | ||||
|   class Error < StandardError; end | ||||
|  | ||||
|   | ||||
| @@ -14,7 +14,7 @@ class ProofProvider::Keybase::ConfigSerializer < ActiveModel::Serializer | ||||
|   end | ||||
|  | ||||
|   def domain | ||||
|     Rails.configuration.x.local_domain | ||||
|     ProofProvider::Keybase::DOMAIN | ||||
|   end | ||||
|  | ||||
|   def display_name | ||||
| @@ -66,6 +66,6 @@ class ProofProvider::Keybase::ConfigSerializer < ActiveModel::Serializer | ||||
|   end | ||||
|  | ||||
|   def contact | ||||
|     [Setting.site_contact_email.presence].compact | ||||
|     [Setting.site_contact_email.presence || 'unknown'].compact | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -49,14 +49,10 @@ class ProofProvider::Keybase::Verifier | ||||
|  | ||||
|   def query_params | ||||
|     { | ||||
|       domain: domain, | ||||
|       domain: ProofProvider::Keybase::DOMAIN, | ||||
|       kb_username: @provider_username, | ||||
|       username: @local_username, | ||||
|       sig_hash: @token, | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   def domain | ||||
|     Rails.configuration.x.local_domain | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -26,7 +26,7 @@ class AccountIdentityProof < ApplicationRecord | ||||
|  | ||||
|   scope :active, -> { where(verified: true, live: true) } | ||||
|  | ||||
|   after_create_commit :queue_worker | ||||
|   after_commit :queue_worker, if: :saved_change_to_token? | ||||
|  | ||||
|   delegate :refresh!, :on_success_path, :badge, to: :provider_instance | ||||
|  | ||||
|   | ||||
							
								
								
									
										17
									
								
								app/serializers/rest/identity_proof_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/serializers/rest/identity_proof_serializer.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class REST::IdentityProofSerializer < ActiveModel::Serializer | ||||
|   attributes :provider, :provider_username, :updated_at, :proof_url, :profile_url | ||||
|  | ||||
|   def proof_url | ||||
|     object.badge.proof_url | ||||
|   end | ||||
|  | ||||
|   def profile_url | ||||
|     object.badge.profile_url | ||||
|   end | ||||
|  | ||||
|   def provider | ||||
|     object.provider.capitalize | ||||
|   end | ||||
| end | ||||
| @@ -27,5 +27,10 @@ | ||||
|  | ||||
|           %p= t('identity_proofs.i_am_html', username: content_tag(:strong, @proof.provider_username), service: @proof.provider.capitalize) | ||||
|  | ||||
|     .connection-prompt__post | ||||
|       = f.input :post_status, label: t('identity_proofs.publicize_checkbox'), as: :boolean, wrapper: :with_label, :input_html => { checked: true } | ||||
|  | ||||
|       = f.input :status_text, as: :text, input_html: { value: t('identity_proofs.publicize_toot', username: @proof.provider_username, service: @proof.provider.capitalize, url: @proof.badge.proof_url), rows: 4 } | ||||
|  | ||||
|     = f.button :button, t('identity_proofs.authorize'), type: :submit | ||||
|     = link_to t('simple_form.no'), settings_identity_proofs_url, class: 'button negative' | ||||
|   | ||||
| @@ -648,10 +648,13 @@ en: | ||||
|       keybase: | ||||
|         invalid_token: Keybase tokens are hashes of signatures and must be 66 hex characters | ||||
|         verification_failed: Keybase does not recognize this token as a signature of Keybase user %{kb_username}. Please retry from Keybase. | ||||
|       wrong_user: Cannot create a proof for %{proving} while logged in as %{current}. Log in as %{proving} and try again. | ||||
|     explanation_html: Here you can cryptographically connect your other identities, such as a Keybase profile. This lets other people send you encrypted messages and trust content you send them. | ||||
|     i_am_html: I am %{username} on %{service}. | ||||
|     identity: Identity | ||||
|     inactive: Inactive | ||||
|     publicize_checkbox: 'And toot this:' | ||||
|     publicize_toot: 'It is proven! I am %{username} on %{service}: %{url}' | ||||
|     status: Verification status | ||||
|     view_proof: View proof | ||||
|   imports: | ||||
|   | ||||
| @@ -354,6 +354,7 @@ Rails.application.routes.draw do | ||||
|         resources :followers, only: :index, controller: 'accounts/follower_accounts' | ||||
|         resources :following, only: :index, controller: 'accounts/following_accounts' | ||||
|         resources :lists, only: :index, controller: 'accounts/lists' | ||||
|         resources :identity_proofs, only: :index, controller: 'accounts/identity_proofs' | ||||
|  | ||||
|         member do | ||||
|           post :follow | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| describe Settings::IdentityProofsController do | ||||
|   include RoutingHelper | ||||
|   render_views | ||||
|  | ||||
|   let(:user) { Fabricate(:user) } | ||||
| @@ -9,8 +10,15 @@ describe Settings::IdentityProofsController do | ||||
|   let(:provider) { 'keybase' } | ||||
|   let(:findable_id) { Faker::Number.number(5) } | ||||
|   let(:unfindable_id) { Faker::Number.number(5) } | ||||
|   let(:new_proof_params) do | ||||
|     { provider: provider, provider_username: kbname, token: valid_token, username: user.account.username } | ||||
|   end | ||||
|   let(:status_text) { "i just proved that i am also #{kbname} on #{provider}." } | ||||
|   let(:status_posting_params) do | ||||
|     { post_status: '0', status_text: status_text } | ||||
|   end | ||||
|   let(:postable_params) do | ||||
|     { account_identity_proof: { provider: provider, provider_username: kbname, token: valid_token } } | ||||
|     { account_identity_proof: new_proof_params.merge(status_posting_params) } | ||||
|   end | ||||
|  | ||||
|   before do | ||||
| @@ -19,10 +27,32 @@ describe Settings::IdentityProofsController do | ||||
|   end | ||||
|  | ||||
|   describe 'new proof creation' do | ||||
|     context 'GET #new with no existing proofs' do | ||||
|       it 'redirects to :index' do | ||||
|         get :new | ||||
|         expect(response).to redirect_to settings_identity_proofs_path | ||||
|     context 'GET #new' do | ||||
|       context 'with all of the correct params' do | ||||
|         before do | ||||
|           allow_any_instance_of(ProofProvider::Keybase::Badge).to receive(:avatar_url) { full_pack_url('media/images/void.png') } | ||||
|         end | ||||
|  | ||||
|         it 'renders the template' do | ||||
|           get :new, params: new_proof_params | ||||
|           expect(response).to render_template(:new) | ||||
|         end | ||||
|       end | ||||
|  | ||||
|       context 'without any params' do | ||||
|         it 'redirects to :index' do | ||||
|           get :new, params: {} | ||||
|           expect(response).to redirect_to settings_identity_proofs_path | ||||
|         end | ||||
|       end | ||||
|  | ||||
|       context 'with params to prove a different, not logged-in user' do | ||||
|         let(:wrong_user_params) { new_proof_params.merge(username: 'someone_else') } | ||||
|  | ||||
|         it 'shows a helpful alert' do | ||||
|           get :new, params: wrong_user_params | ||||
|           expect(flash[:alert]).to eq I18n.t('identity_proofs.errors.wrong_user', proving: 'someone_else', current: user.account.username) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|  | ||||
| @@ -44,6 +74,23 @@ describe Settings::IdentityProofsController do | ||||
|           post :create, params: postable_params | ||||
|           expect(response).to redirect_to root_url | ||||
|         end | ||||
|  | ||||
|         it 'does not post a status' do | ||||
|           expect(PostStatusService).not_to receive(:new) | ||||
|           post :create, params: postable_params | ||||
|         end | ||||
|  | ||||
|         context 'and the user has requested to post a status' do | ||||
|           let(:postable_params_with_status) do | ||||
|             postable_params.tap { |p| p[:account_identity_proof][:post_status] = '1' } | ||||
|           end | ||||
|  | ||||
|           it 'posts a status' do | ||||
|             expect_any_instance_of(PostStatusService).to receive(:call).with(user.account, text: status_text) | ||||
|  | ||||
|             post :create, params: postable_params_with_status | ||||
|           end | ||||
|         end | ||||
|       end | ||||
|  | ||||
|       context 'when saving fails' do | ||||
|   | ||||
		Reference in New Issue
	
	Block a user