@@ -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 |
@@ -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)); | |||
} | |||
@@ -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 | |||
@@ -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 | |||