Add functionality to remove and recover identity (#3377)

* Lint some quotation marks

* Add BackupData table

* Add identity and removal functionality

* Test additional functionality

* Remove dependent destroy for backup data

* Add auth_data_dump column

* Add challenge to reserved words

* Add more shoulda matchers
This commit is contained in:
Andy Zhao 2019-07-03 12:01:16 -04:00 committed by Ben Halpern
parent 3d81753285
commit 22e76bee84
13 changed files with 175 additions and 10 deletions

View file

@ -75,6 +75,32 @@ class Internal::UsersController < Internal::ApplicationController
redirect_to "/internal/users/#{@user.id}/edit"
end
def remove_identity
identity = Identity.find(user_params[:identity_id])
@user = identity.user
begin
BackupData.backup!(identity)
identity.delete
@user.update("#{identity.provider}_username" => nil)
flash[:success] = "The #{identity.provider.capitalize} identity was successfully deleted and backed up."
rescue StandardError => e
flash[:error] = e.message
end
redirect_to "/internal/users/#{@user.id}/edit"
end
def recover_identity
backup = BackupData.find(user_params[:backup_data_id])
@user = backup.instance_user
begin
identity = backup.recover!
flash[:success] = "The #{identity.provider} identity was successfully recovered, and the backup was removed."
rescue StandardError => e
flash[:error] = e.message
end
redirect_to "/internal/users/#{@user.id}/edit"
end
private
def manage_credits
@ -121,7 +147,7 @@ class Internal::UsersController < Internal::ApplicationController
new_note note_for_current_role user_status
pro merge_user_id add_credits remove_credits
add_org_credits remove_org_credits ghostify
organization_id
organization_id identity_id backup_data_id
]
params.require(:user).permit(allowed_params)
end

15
app/models/backup_data.rb Normal file
View file

@ -0,0 +1,15 @@
class BackupData < ApplicationRecord
belongs_to :instance, polymorphic: true
belongs_to :instance_user, class_name: "User"
validates :instance_id, :instance_type, :json_data, presence: true
def self.backup!(instance)
BackupData.create!(instance_type: instance.class.name, instance_id: instance.id, instance_user_id: instance.user_id, json_data: instance.attributes)
end
def recover!
instance = instance_type.constantize.create!(json_data.to_h)
destroy!
instance
end
end

View file

@ -1,5 +1,6 @@
class Identity < ApplicationRecord
belongs_to :user
has_many :backup_data, as: :instance, class_name: "BackupData", dependent: :destroy
validates :uid, :provider, presence: true
validates :uid, uniqueness: { scope: :provider }, if: proc { |i| i.uid_changed? || i.provider_changed? }
validates :user_id, uniqueness: { scope: :provider }, if: proc { |i| i.user_id_changed? || i.provider_changed? }

View file

@ -44,6 +44,7 @@ class User < ApplicationRecord
has_many :classified_listings
has_many :poll_votes
has_many :poll_skips
has_many :backup_data, foreign_key: "instance_user_id", inverse_of: :instance_user, class_name: "BackupData"
mount_uploader :profile_image, ProfileImageUploader

View file

@ -39,6 +39,54 @@
<% end %>
</div>
<%= render "notes" %>
<div class="row">
<h2>Remove Identity</h2>
<p>Removing a social account identity can solve certain sign in issues, for example:</p>
<ul>
<li>
They created an account on dev.to, but deleted their original social account and recreated it with the same Twitter/GitHub username.
This will be true if their Twitter/GitHub account's UID does not match their identity's UID. You can use the following third party tools to check:
<ul>
<li>
<a href="https://tweeterid.com/?username=<%= @user.twitter_username %>" target="_blank" rel="noopener nofollow">Tweeter ID for Twitter (username is in URL for reference)</a>
</li>
<li>
<a href="https://caius.github.io/github_id/#<%= @user.github_username %>" target="_blank" rel="noopener nofollow">Caius's GitHub ID Lookup Tool</a>
</li>
</ul>
Steps to check:
<ol>
<li>Click one of the links to check their social account's UID.</li>
<li>Confirm whether or not it matches with the identity's UID.</li>
<li>If it doesn't match, delete the respective identity.</li>
<li>Ask the user to reauthorize their social account via https://dev.to/settings/account</li>
</ol>
</li>
<% @user.identities.each do |identity| %>
<%= form_for(@user, url: remove_identity_internal_user_path(@user), html: { method: :delete, onsubmit: "return confirm('Are you sure? This should only be done as a solution for the listed example(s).)" }) do |f| %>
<%= f.hidden_field :identity_id, value: identity.id %>
<p><b><%= identity.provider.capitalize %> UID: <%= identity.uid %> - Username: <%= identity.auth_data_dump["info"]["nickname"] %></b></p>
<%= f.submit "Delete #{identity.provider.capitalize} Identity" %>
<% end %>
<% end %>
<h3>This should be done only do this if you are certain the user is having the specific problem(s) listed above.</h3>
<hr>
<h2>Recover a deleted identity:</h2>
<% if @user.backup_data %>
<ul>
<% @user.backup_data.where(instance_type: "Identity").each do |data| %>
<li>
<%= "#{data.json_data['provider'].capitalize} #{data.instance_type} - UID: #{data.json_data['uid']}" %>
<%= form_for(@user, url: recover_identity_internal_user_path(@user), html: { method: :post, onsubmit: "return confirm('Please confirm you want to recover the #{data.json_data['provider'].capitalize} identity.')" }) do |f| %>
<%= f.hidden_field :backup_data_id, value: data.id %>
<%= f.submit "Recover #{data.json_data['provider'].capitalize} Identity" %>
<% end %>
</li>
<% end %>
</ul>
<% end %>
</ul>
</div>
<div class="row">
<h2>Merge User</h2>
<p>To merge a duplicate account, make sure you are currently on the page of the user you want to KEEP. Below, add the user id of the account to merge information from, and delete.</p>

View file

@ -1,4 +1,4 @@
<% unless @user.identities.exists?(provider: 'github') %>
<% unless @user.identities.exists?(provider: "github") %>
<div class="field">
<a href="/users/auth/github" class="big-button cta" data-no-instant>
<img src="<%= asset_path("github-logo.svg") %>" alt="github logo"> CONNECT GITHUB ACCOUNT
@ -7,7 +7,7 @@
<hr />
<% end %>
<% unless @user.identities.exists?(provider: 'twitter') %>
<% unless @user.identities.exists?(provider: "twitter") %>
<div class="field">
<a href="/users/auth/twitter?callback_url=<%= ApplicationConfig["APP_PROTOCOL"] %><%= ApplicationConfig["APP_DOMAIN"] %>/users/auth/twitter/callback" class="big-button cta" data-no-instant>
<img src="<%= asset_path("twitter-logo.svg") %> " alt="twitter logo"> CONNECT TWITTER ACCOUNT
@ -37,14 +37,14 @@
<% end %>
<% @user.api_secrets.order(created_at: :desc).each do |api_secret| %>
<div class='api__secret__container'>
<div class='api__secret__desc'>
<p class='title'><%= api_secret.description %></p>
<p class='content'><%= api_secret.secret %></p>
<p class='subtitle'>Created <%= api_secret.created_at.to_date.to_s %></p>
<div class="api__secret__container">
<div class="api__secret__desc">
<p class="title"><%= api_secret.description %></p>
<p class="content"><%= api_secret.secret %></p>
<p class="subtitle">Created <%= api_secret.created_at.to_date.to_s %></p>
</div>
<div class='api__secret__revoke'>
<div class="api__secret__revoke">
<%= form_tag users_api_secret_path(api_secret.id), method: :delete do %>
<%= button_tag "Revoke", class: "big-action danger-button" %>
<% end %>

View file

@ -32,6 +32,7 @@ class ReservedWords
rly
connect
chat_channels
challenge
email_subscriptions
social_previews
code-of-conduct

View file

@ -51,6 +51,8 @@ Rails.application.routes.draw do
post "full_delete"
patch "user_status"
post "merge"
delete "remove_identity"
post "recover_identity"
end
end
resources :welcome, only: %i[index create]

View file

@ -0,0 +1,12 @@
class CreateBackupDataTable < ActiveRecord::Migration[5.2]
def change
create_table :backup_data do |t|
t.bigint :instance_id, null: false
t.string :instance_type, null: false
t.bigint :instance_user_id
t.jsonb :json_data, null: false
t.timestamps null: false
end
end
end

View file

@ -12,7 +12,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_06_28_123548) do
ActiveRecord::Schema.define(version: 2019_07_03_003817) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -144,6 +144,15 @@ ActiveRecord::Schema.define(version: 2019_06_28_123548) do
t.index ["user_id"], name: "index_articles_on_user_id"
end
create_table "backup_data", force: :cascade do |t|
t.datetime "created_at", null: false
t.bigint "instance_id", null: false
t.string "instance_type", null: false
t.bigint "instance_user_id"
t.jsonb "json_data", null: false
t.datetime "updated_at", null: false
end
create_table "badge_achievements", force: :cascade do |t|
t.bigint "badge_id", null: false
t.datetime "created_at", null: false

View file

@ -6,5 +6,12 @@ FactoryBot.define do
provider { "github" }
token { rand(100_000) }
secret { rand(100_000) }
auth_data_dump do
{
"info" => {
"nickname" => "something"
}
}
end
end
end

View file

@ -0,0 +1,11 @@
require "rails_helper"
RSpec.describe BackupData, type: :model do
describe "validations" do
it { is_expected.to validate_presence_of(:instance_type) }
it { is_expected.to validate_presence_of(:instance_id) }
it { is_expected.to validate_presence_of(:json_data) }
it { is_expected.to belong_to(:instance) }
it { is_expected.to belong_to(:instance_user).class_name("User") }
end
end

View file

@ -45,4 +45,36 @@ RSpec.describe "internal/users", type: :request do
expect(user.reload.username).to include("spam")
end
end
describe "DELETE internal/users/:id/remove_identity" do
it "removes the given identity" do
identity = user.identities.first
delete "/internal/users/#{user.id}/remove_identity", params: { user: { identity_id: identity.id } }
expect { identity.reload }.to raise_error ActiveRecord::RecordNotFound
end
it "updates their social account's username to nil" do
identity = user.identities.first
delete "/internal/users/#{user.id}/remove_identity", params: { user: { identity_id: identity.id } }
expect(user.reload.github_username).to eq nil
end
end
describe "POST internal/users/:id/recover_identity" do
it "recovers a deleted identity" do
identity = user.identities.first
backup = BackupData.backup!(identity)
identity.delete
post "/internal/users/#{user.id}/recover_identity", params: { user: { backup_data_id: backup.id } }
expect(identity).to eq Identity.first
end
it "deletes the backup data" do
identity = user.identities.first
backup = BackupData.backup!(identity)
identity.delete
post "/internal/users/#{user.id}/recover_identity", params: { user: { backup_data_id: backup.id } }
expect { backup.reload }.to raise_error ActiveRecord::RecordNotFound
end
end
end