Account deletion (#3728)

* Add form for account deletion

* If avatar or header are gone from source, remove them

* Add option to have SuspendAccountService remove user record, add tests

* Exclude suspended accounts from search
This commit is contained in:
Eugen Rochko 2017-06-14 18:01:27 +02:00 committed by GitHub
parent a208e7d655
commit 4a618908e8
15 changed files with 183 additions and 7 deletions

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
class Settings::DeletesController < ApplicationController
layout 'admin'
before_action :authenticate_user!
def show
@confirmation = Form::DeleteConfirmation.new
end
def destroy
if current_user.valid_password?(delete_params[:password])
Admin::SuspensionWorker.perform_async(current_user.account_id, true)
sign_out
redirect_to new_user_session_path, notice: I18n.t('deletes.success_msg')
else
redirect_to settings_delete_path, alert: I18n.t('deletes.bad_password_msg')
end
end
private
def delete_params
params.permit(:password)
end
end

View File

@ -96,6 +96,13 @@
margin-bottom: 40px; margin-bottom: 40px;
} }
h6 {
font-size: 16px;
color: $ui-primary-color;
line-height: 28px;
font-weight: 400;
}
& > p { & > p {
font-size: 14px; font-size: 14px;
line-height: 18px; line-height: 18px;
@ -114,6 +121,14 @@
background: transparent; background: transparent;
border-bottom: 1px solid $ui-base-color; border-bottom: 1px solid $ui-base-color;
} }
.muted-hint {
color: lighten($ui-base-color, 27%);
a {
color: $ui-primary-color;
}
}
} }
.simple_form { .simple_form {

View File

@ -303,7 +303,10 @@ code {
font-weight: 500; font-weight: 500;
} }
} }
}
.simple_form,
.table-form {
.warning { .warning {
max-width: 400px; max-width: 400px;
box-sizing: border-box; box-sizing: border-box;

View File

@ -177,6 +177,7 @@ class Account < ApplicationRecord
account_id IN (SELECT * FROM first_degree) account_id IN (SELECT * FROM first_degree)
AND target_account_id NOT IN (SELECT * FROM first_degree) AND target_account_id NOT IN (SELECT * FROM first_degree)
AND target_account_id NOT IN (:excluded_account_ids) AND target_account_id NOT IN (:excluded_account_ids)
AND accounts.suspended = false
GROUP BY target_account_id, accounts.id GROUP BY target_account_id, accounts.id
ORDER BY count(account_id) DESC ORDER BY count(account_id) DESC
OFFSET :offset OFFSET :offset
@ -199,6 +200,7 @@ class Account < ApplicationRecord
ts_rank_cd(#{textsearch}, #{query}, 32) AS rank ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
FROM accounts FROM accounts
WHERE #{query} @@ #{textsearch} WHERE #{query} @@ #{textsearch}
AND accounts.suspended = false
ORDER BY rank DESC ORDER BY rank DESC
LIMIT ? LIMIT ?
SQL SQL
@ -216,6 +218,7 @@ class Account < ApplicationRecord
FROM accounts FROM accounts
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?) LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)
WHERE #{query} @@ #{textsearch} WHERE #{query} @@ #{textsearch}
AND accounts.suspended = false
GROUP BY accounts.id GROUP BY accounts.id
ORDER BY rank DESC ORDER BY rank DESC
LIMIT ? LIMIT ?

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class Form::DeleteConfirmation
include ActiveModel::Model
attr_accessor :password
end

View File

@ -1,9 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
class SuspendAccountService < BaseService class SuspendAccountService < BaseService
def call(account) def call(account, remove_user = false)
@account = account @account = account
purge_user if remove_user
purge_content purge_content
purge_profile purge_profile
unsubscribe_push_subscribers unsubscribe_push_subscribers
@ -11,6 +12,10 @@ class SuspendAccountService < BaseService
private private
def purge_user
@account.user.destroy
end
def purge_content def purge_content
@account.statuses.reorder(nil).find_each do |status| @account.statuses.reorder(nil).find_each do |status|
# This federates out deletes to previous followers # This federates out deletes to previous followers

View File

@ -27,8 +27,19 @@ class UpdateRemoteProfileService < BaseService
account.locked = remote_profile.locked? account.locked = remote_profile.locked?
if !account.suspended? && !DomainBlock.find_by(domain: account.domain)&.reject_media? if !account.suspended? && !DomainBlock.find_by(domain: account.domain)&.reject_media?
account.avatar_remote_url = remote_profile.avatar if remote_profile.avatar.present? if remote_profile.avatar.present?
account.header_remote_url = remote_profile.header if remote_profile.header.present? account.avatar_remote_url = remote_profile.avatar
else
account.avatar_remote_url = ''
account.avatar.destroy
end
if remote_profile.header.present?
account.header_remote_url = remote_profile.header
else
account.header_remote_url = ''
account.header.destroy
end
end end
end end
end end

View File

@ -11,3 +11,8 @@
.actions .actions
= f.button :button, t('generic.save_changes'), type: :submit = f.button :button, t('generic.save_changes'), type: :submit
%hr/
%h6= t('auth.delete_account')
%p.muted-hint= t('auth.delete_account_html', path: settings_delete_path)

View File

@ -0,0 +1,16 @@
- content_for :page_title do
= t('settings.delete')
= simple_form_for @confirmation, url: settings_delete_path, method: :delete do |f|
.warning
%strong
= fa_icon('warning')
= t('deletes.warning_title')
= t('deletes.warning_html')
%p.hint= t('deletes.description_html')
= f.input :password, autocomplete: 'off', placeholder: t('simple_form.labels.defaults.current_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password') }, hint: t('deletes.confirm_password')
.actions
= f.button :button, t('deletes.proceed'), type: :submit, class: 'negative'

View File

@ -5,7 +5,7 @@ class Admin::SuspensionWorker
sidekiq_options queue: 'pull' sidekiq_options queue: 'pull'
def perform(account_id) def perform(account_id, remove_user = false)
SuspendAccountService.new.call(Account.find(account_id)) SuspendAccountService.new.call(Account.find(account_id), remove_user)
end end
end end

View File

@ -201,6 +201,8 @@ en:
invalid_url: The provided URL is invalid invalid_url: The provided URL is invalid
auth: auth:
change_password: Credentials change_password: Credentials
delete_account: Delete account
delete_account_html: If you wish to delete your account, you can <a href="%{path}">proceed here</a>. You will be asked for confirmation.
didnt_get_confirmation: Didn't receive confirmation instructions? didnt_get_confirmation: Didn't receive confirmation instructions?
forgot_password: Forgot your password? forgot_password: Forgot your password?
login: Log in login: Log in
@ -228,6 +230,14 @@ en:
x_minutes: "%{count}m" x_minutes: "%{count}m"
x_months: "%{count}mo" x_months: "%{count}mo"
x_seconds: "%{count}s" x_seconds: "%{count}s"
deletes:
bad_password_msg: Nice try, hackers! Incorrect password
confirm_password: Enter your current password to verify your identity
description_html: This will <strong>permanently, irreversibly</strong> remove content from your account and deactivate it. Your username will remain reserved to prevent future impersonations.
proceed: Delete account
success_msg: Your account was successfully deleted
warning_html: Only deletion of content from this particular instance is guaranteed. Content that has been widely shared is likely to leave traces. Offline servers and servers that have unsubscribed from your updates will not update their databases.
warning_title: Disseminated content availability
errors: errors:
'403': You don't have permission to view this page. '403': You don't have permission to view this page.
'404': The page you were looking for doesn't exist. '404': The page you were looking for doesn't exist.
@ -313,6 +323,7 @@ en:
settings: settings:
authorized_apps: Authorized apps authorized_apps: Authorized apps
back: Back to Mastodon back: Back to Mastodon
delete: Account deletion
edit_profile: Edit profile edit_profile: Edit profile
export: Data export export: Data export
followers: Authorized followers followers: Authorized followers

View File

@ -41,8 +41,8 @@ ru:
password: Пароль password: Пароль
setting_auto_play_gif: Автоматически проигрывать анимированные GIF setting_auto_play_gif: Автоматически проигрывать анимированные GIF
setting_boost_modal: Показывать диалог подтверждения перед продвижением setting_boost_modal: Показывать диалог подтверждения перед продвижением
setting_delete_modal: Показывать диалог подтверждения перед удалением
setting_default_privacy: Видимость постов setting_default_privacy: Видимость постов
setting_delete_modal: Показывать диалог подтверждения перед удалением
severity: Строгость severity: Строгость
type: Тип импорта type: Тип импорта
username: Имя пользователя username: Имя пользователя

View File

@ -7,7 +7,7 @@ SimpleNavigation::Configuration.run do |navigation|
primary.item :settings, safe_join([fa_icon('cog fw'), t('settings.settings')]), settings_profile_url do |settings| primary.item :settings, safe_join([fa_icon('cog fw'), t('settings.settings')]), settings_profile_url do |settings|
settings.item :profile, safe_join([fa_icon('user fw'), t('settings.edit_profile')]), settings_profile_url settings.item :profile, safe_join([fa_icon('user fw'), t('settings.edit_profile')]), settings_profile_url
settings.item :preferences, safe_join([fa_icon('sliders fw'), t('settings.preferences')]), settings_preferences_url settings.item :preferences, safe_join([fa_icon('sliders fw'), t('settings.preferences')]), settings_preferences_url
settings.item :password, safe_join([fa_icon('cog fw'), t('auth.change_password')]), edit_user_registration_url settings.item :password, safe_join([fa_icon('cog fw'), t('auth.change_password')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete}
settings.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_url, highlights_on: %r{/settings/two_factor_authentication} settings.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_url, highlights_on: %r{/settings/two_factor_authentication}
settings.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url settings.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url
settings.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url settings.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url

View File

@ -66,6 +66,7 @@ Rails.application.routes.draw do
end end
resource :follower_domains, only: [:show, :update] resource :follower_domains, only: [:show, :update]
resource :delete, only: [:show, :destroy]
end end
resources :media, only: [:show] resources :media, only: [:show]

View File

@ -0,0 +1,72 @@
require 'rails_helper'
describe Settings::DeletesController do
render_views
describe 'GET #show' do
context 'when signed in' do
let(:user) { Fabricate(:user) }
before do
sign_in user, scope: :user
end
it 'renders confirmation page' do
get :show
expect(response).to have_http_status(:success)
end
end
context 'when not signed in' do
it 'redirects' do
get :show
expect(response).to redirect_to '/auth/sign_in'
end
end
end
describe 'DELETE #destroy' do
context 'when signed in' do
let(:user) { Fabricate(:user, password: 'petsmoldoggos') }
before do
sign_in user, scope: :user
end
context 'with correct password' do
before do
delete :destroy, params: { password: 'petsmoldoggos' }
end
it 'redirects to sign in page' do
expect(response).to redirect_to '/auth/sign_in'
end
it 'removes user record' do
expect(User.find_by(id: user.id)).to be_nil
end
it 'marks account as suspended' do
expect(user.account.reload).to be_suspended
end
end
context 'with incorrect password' do
before do
delete :destroy, params: { password: 'blaze420' }
end
it 'redirects back to confirmation page' do
expect(response).to redirect_to settings_delete_path
end
end
end
context 'when not signed in' do
it 'redirects' do
delete :destroy
expect(response).to redirect_to '/auth/sign_in'
end
end
end
end