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 searchgh/stable
parent
a208e7d655
commit
4a618908e8
|
@ -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
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 ?
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Form::DeleteConfirmation
|
||||||
|
include ActiveModel::Model
|
||||||
|
|
||||||
|
attr_accessor :password
|
||||||
|
end
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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'
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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: Имя пользователя
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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
|
Reference in New Issue