Commit f4b31e0c authored by Bob Van Landuyt's avatar Bob Van Landuyt

Allow users to set their profile private

This will hide activities, events, member since from the profile page
or the API.
parent d714754e
......@@ -99,7 +99,8 @@ class ProfilesController < Profiles::ApplicationController
:username,
:website_url,
:organization,
:preferred_language
:preferred_language,
:private_profile
)
end
end
......@@ -13,6 +13,8 @@ class UsersController < ApplicationController
skip_before_action :authenticate_user!
before_action :user, except: [:exists]
before_action :authorize_read_user_profile!,
only: [:calendar, :calendar_activities, :groups, :projects, :contributed_projects, :snippets]
def show
respond_to do |format|
......@@ -148,4 +150,8 @@ class UsersController < ApplicationController
def build_canonical_path(user)
url_for(safe_params.merge(username: user.to_param))
end
def authorize_read_user_profile!
access_denied! unless can?(current_user, :read_user_profile, user)
end
end
class PersonalProjectsFinder < UnionFinder
include Gitlab::Allowable
def initialize(user, params = {})
@user = user
@params = params
......@@ -14,6 +16,8 @@ class PersonalProjectsFinder < UnionFinder
#
# Returns an ActiveRecord::Relation.
def execute(current_user = nil)
return Project.none unless can?(current_user, :read_user_profile, @user)
segments = all_projects(current_user)
find_union(segments, Project).includes(:namespace).order_updated_desc
......
......@@ -7,6 +7,7 @@
class UserRecentEventsFinder
prepend FinderWithCrossProjectAccess
include FinderMethods
include Gitlab::Allowable
requires_cross_project_access
......@@ -21,6 +22,8 @@ class UserRecentEventsFinder
end
def execute
return Event.none unless can?(current_user, :read_user_profile, target_user)
recent_events(params[:offset] || 0)
.joins(:project)
.with_associations
......
......@@ -42,7 +42,13 @@ module UsersHelper
private
def get_profile_tabs
[:activity, :groups, :contributed, :projects, :snippets]
tabs = []
if can?(current_user, :read_user_profile, @user)
tabs += [:activity, :groups, :contributed, :projects, :snippets]
end
tabs
end
def get_current_user_menu_items
......
......@@ -5,6 +5,9 @@ class UserPolicy < BasePolicy
desc "This is the ghost user"
condition(:subject_ghost, scope: :subject, score: 0) { @subject.ghost? }
desc "The profile is private"
condition(:private_profile, scope: :subject, score: 0) { @subject.private_profile? }
rule { ~restricted_public_level }.enable :read_user
rule { ~anonymous }.enable :read_user
......@@ -12,4 +15,7 @@ class UserPolicy < BasePolicy
enable :destroy_user
enable :update_user
end
rule { default }.enable :read_user_profile
rule { private_profile & ~(user_is_self | admin) }.prevent :read_user_profile
end
......@@ -66,7 +66,8 @@ module Users
:theme_id,
:twitter,
:username,
:website_url
:website_url,
:private_profile
]
end
......
......@@ -69,6 +69,12 @@
= f.text_field :location
= f.text_field :organization
= f.text_area :bio, rows: 4, maxlength: 250, help: 'Tell us about yourself in fewer than 250 characters.'
%hr
%h5 Private profile
- private_profile_label = capture do
Don't display activity-related personal information on your profile
= link_to icon('question-circle'), help_page_path('user/profile/index.md', anchor: 'private-profile')
= f.check_box :private_profile, label: private_profile_label
.prepend-top-default.append-bottom-default
= f.submit 'Update profile settings', class: 'btn btn-success'
= link_to 'Cancel', user_path(current_user), class: 'btn btn-cancel'
......
......@@ -23,6 +23,7 @@
= link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: 'btn',
title: 'Report abuse', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= icon('exclamation-circle')
- if can?(current_user, :read_user_profile, @user)
= link_to user_path(@user, rss_url_options), class: 'btn btn-default has-tooltip', title: 'Subscribe', 'aria-label': 'Subscribe' do
= icon('rss')
- if current_user && current_user.admin?
......@@ -40,8 +41,10 @@
= @user.name
.cover-desc.member-date
%p
%span.middle-dot-divider
@#{@user.username}
- if can?(current_user, :read_user_profile, @user)
%span.middle-dot-divider
Member since #{@user.created_at.to_date.to_s(:long)}
......@@ -78,6 +81,7 @@
%p.profile-user-bio
= @user.bio
- unless profile_tabs.empty?
.scrolling-tabs-container
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
......@@ -137,3 +141,13 @@
.loading-status
= spinner
- if profile_tabs.empty?
.row
.col-12
.svg-content
= image_tag 'illustrations/profile_private_mode.svg'
.col-12.text-center
.text-content
%h4
This user has a private profile
---
title: Add an option to have a private profile on GitLab.
merge_request: 20387
author: jxterry
type: added
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddPrivateProfileToUsers < ActiveRecord::Migration
DOWNTIME = false
def change
add_column :users, :private_profile, :boolean
end
end
......@@ -2710,6 +2710,7 @@ ActiveRecord::Schema.define(version: 20180718100455) do
t.integer "theme_id", limit: 2
t.integer "accepted_term_id"
t.string "feed_token"
t.boolean "private_profile"
end
add_index "users", ["admin"], name: "index_users_on_admin", using: :btree
......
......@@ -107,7 +107,8 @@ GET /users
"can_create_group": true,
"can_create_project": true,
"two_factor_enabled": true,
"external": false
"external": false,
"private_profile": false
},
{
"id": 2,
......@@ -137,7 +138,8 @@ GET /users
"can_create_group": true,
"can_create_project": true,
"two_factor_enabled": true,
"external": false
"external": false,
"private_profile": false
}
]
```
......@@ -251,6 +253,7 @@ Parameters:
"can_create_project": true,
"two_factor_enabled": true,
"external": false,
"private_profile": false,
"shared_runners_minutes_limit": 133
}
```
......@@ -291,6 +294,7 @@ Parameters:
- `skip_confirmation` (optional) - Skip confirmation - true or false (default)
- `external` (optional) - Flags the user as external - true or false(default)
- `avatar` (optional) - Image file for user's avatar
- `private_profile` (optional) - User's profile is private - true or false
- `shared_runners_minutes_limit` (optional) - Pipeline minutes quota for this user
## User modification
......@@ -323,6 +327,7 @@ Parameters:
- `external` (optional) - Flags the user as external - true or false(default)
- `shared_runners_minutes_limit` (optional) - Pipeline minutes quota for this user
- `avatar` (optional) - Image file for user's avatar
- `private_profile` (optional) - User's profile is private - true or false
On password update, user will be forced to change it upon next login.
Note, at the moment this method does only return a `404` error,
......@@ -387,7 +392,8 @@ GET /user
"can_create_group": true,
"can_create_project": true,
"two_factor_enabled": true,
"external": false
"external": false,
"private_profile": false
}
```
......@@ -434,7 +440,8 @@ GET /user
"can_create_group": true,
"can_create_project": true,
"two_factor_enabled": true,
"external": false
"external": false,
"private_profile": false
}
```
......
......@@ -68,6 +68,28 @@ Alternatively, you can follow [this detailed procedure from the GitLab Team Hand
which also covers the case where you have projects hosted with
[GitLab Pages](../project/pages/index.md).
## Private profile
The following information will be hidden from the user profile page (https://gitlab.example.com/username) if this feature is enabled:
- Atom feed
- Date when account is created
- Activity tab
- Groups tab
- Contributed projects tab
- Personal projects tab
- Snippets tab
To enable private profile:
1. Navigate to your personal [profile settings](#profile-settings).
1. Check the "Private profile" option.
1. Hit **Update profile settings**.
NOTE: **Note:**
You and GitLab admins can see your the abovementioned information on your profile even if it is private.
## Troubleshooting
### Why do I keep getting signed out?
......
......@@ -30,7 +30,7 @@ module API
end
class User < UserBasic
expose :created_at
expose :created_at, if: ->(user, opts) { Ability.allowed?(opts[:current_user], :read_user_profile, user) }
expose :bio, :location, :skype, :linkedin, :twitter, :website_url, :organization
end
......@@ -55,6 +55,7 @@ module API
expose :can_create_project?, as: :can_create_project
expose :two_factor_enabled?, as: :two_factor_enabled
expose :external
expose :private_profile
end
class UserWithAdmin < UserPublic
......
......@@ -12,7 +12,7 @@ module API
key = Key.find(params[:id])
present key, with: Entities::SSHKeyWithUser
present key, with: Entities::SSHKeyWithUser, current_user: current_user
end
end
end
......
......@@ -42,6 +42,7 @@ module API
optional :can_create_group, type: Boolean, desc: 'Flag indicating the user can create groups'
optional :external, type: Boolean, desc: 'Flag indicating the user is an external user'
optional :avatar, type: File, desc: 'Avatar image for user'
optional :private_profile, type: Boolean, desc: 'Flag indicating the user has a private profile'
optional :min_access_level, type: Integer, values: Gitlab::Access.all_values, desc: 'Limit by minimum access level of authenticated user'
all_or_none_of :extern_uid, :provider
......@@ -103,7 +104,7 @@ module API
entity = current_user&.admin? ? Entities::UserWithAdmin : Entities::UserBasic
users = users.preload(:identities, :u2f_registrations) if entity == Entities::UserWithAdmin
users, options = with_custom_attributes(users, with: entity)
users, options = with_custom_attributes(users, { with: entity, current_user: current_user })
present paginate(users), options
end
......@@ -120,7 +121,7 @@ module API
user = User.find_by(id: params[:id])
not_found!('User') unless user && can?(current_user, :read_user, user)
opts = current_user&.admin? ? { with: Entities::UserWithAdmin } : { with: Entities::User }
opts = { with: current_user&.admin? ? Entities::UserWithAdmin : Entities::User, current_user: current_user }
user, opts = with_custom_attributes(user, opts)
present user, opts
......@@ -146,7 +147,7 @@ module API
user = ::Users::CreateService.new(current_user, params).execute(skip_authorization: true)
if user.persisted?
present user, with: Entities::UserPublic
present user, with: Entities::UserPublic, current_user: current_user
else
conflict!('Email has already been taken') if User
.where(email: user.email)
......@@ -205,7 +206,7 @@ module API
result = ::Users::UpdateService.new(current_user, user_params.except(:extern_uid, :provider).merge(user: user)).execute
if result[:status] == :success
present user, with: Entities::UserPublic
present user, with: Entities::UserPublic, current_user: current_user
else
render_validation_error!(user)
end
......@@ -552,7 +553,7 @@ module API
Entities::UserPublic
end
present current_user, with: entity
present current_user, with: entity, current_user: current_user
end
end
......
......@@ -2,6 +2,8 @@ require 'spec_helper'
describe UsersController do
let(:user) { create(:user) }
let(:private_user) { create(:user, private_profile: true) }
let(:public_user) { create(:user) }
describe 'GET #show' do
context 'with rendered views' do
......@@ -98,17 +100,48 @@ describe UsersController do
expect(assigns(:events)).to be_empty
end
it 'hides events if the user has a private profile' do
Gitlab::DataBuilder::Push.build_sample(project, private_user)
get :show, username: private_user.username, format: :json
expect(assigns(:events)).to be_empty
end
end
end
describe 'GET #calendar' do
it 'renders calendar' do
context 'for user' do
let(:project) { create(:project) }
before do
sign_in(user)
project.add_developer(user)
end
get :calendar, username: user.username, format: :json
context 'with public profile' do
it 'renders calendar' do
push_data = Gitlab::DataBuilder::Push.build_sample(project, public_user)
EventCreateService.new.push(project, public_user, push_data)
get :calendar, username: public_user.username, format: :json
expect(response).to have_gitlab_http_status(200)
end
end
context 'with private profile' do
it 'does not render calendar' do
push_data = Gitlab::DataBuilder::Push.build_sample(project, private_user)
EventCreateService.new.push(project, private_user, push_data)
get :calendar, username: private_user.username, format: :json
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
context 'forked project' do
let(:project) { create(:project) }
......@@ -150,9 +183,26 @@ describe UsersController do
expect(assigns(:calendar_date)).to eq(Date.parse('2014-07-31'))
end
context 'for user' do
context 'with public profile' do
it 'renders calendar_activities' do
get :calendar_activities, username: user.username
expect(response).to render_template('calendar_activities')
push_data = Gitlab::DataBuilder::Push.build_sample(project, public_user)
EventCreateService.new.push(project, public_user, push_data)
get :calendar_activities, username: public_user.username
expect(assigns[:events]).not_to be_empty
end
end
context 'with private profile' do
it 'does not render calendar_activities' do
push_data = Gitlab::DataBuilder::Push.build_sample(project, private_user)
EventCreateService.new.push(project, private_user, push_data)
get :calendar_activities, username: private_user.username
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
......
......@@ -3,6 +3,7 @@ require 'spec_helper'
describe 'User page' do
let(:user) { create(:user) }
context 'with public profile' do
it 'shows all the tabs' do
visit(user_path(user))
......@@ -15,6 +16,43 @@ describe 'User page' do
end
end
it 'does not show private profile message' do
visit(user_path(user))
expect(page).not_to have_content("This user has a private profile")
end
end
context 'with private profile' do
let(:user) { create(:user, private_profile: true) }
it 'shows no tab' do
visit(user_path(user))
expect(page).to have_css("div.profile-header")
expect(page).not_to have_css("ul.nav-links")
end
it 'shows private profile message' do
visit(user_path(user))
expect(page).to have_content("This user has a private profile")
end
it 'shows own tabs' do
sign_in(user)
visit(user_path(user))
page.within '.nav-links' do
expect(page).to have_link('Activity')
expect(page).to have_link('Groups')
expect(page).to have_link('Contributed projects')
expect(page).to have_link('Personal projects')
expect(page).to have_link('Snippets')
end
end
end
context 'signup disabled' do
it 'shows the sign in link' do
stub_application_setting(signup_enabled: false)
......
......@@ -29,11 +29,22 @@ describe UserRecentEventsFinder do
public_project.add_developer(current_user)
end
context 'when profile is public' do
it 'returns all the events' do
expect(finder.execute).to include(private_event, internal_event, public_event)
end
end
context 'when profile is private' do
it 'returns no event' do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, project_owner).and_return(false)
expect(finder.execute).to be_empty
end
end
it 'does not include the events if the user cannot read cross project' do
expect(Ability).to receive(:allowed?).and_call_original
expect(Ability).to receive(:allowed?).with(current_user, :read_cross_project) { false }
expect(finder.execute).to be_empty
end
......
......@@ -25,11 +25,23 @@ describe UsersHelper do
allow(helper).to receive(:can?).and_return(true)
end
context 'with public profile' do
it 'includes all the expected tabs' do
expect(tabs).to include(:activity, :groups, :contributed, :projects, :snippets)
end
end
context 'with private profile' do
before do
allow(helper).to receive(:can?).with(user, :read_user_profile, nil).and_return(false)
end
it 'is empty' do
expect(tabs).to be_empty
end
end
end
describe '#current_user_menu_items' do
subject(:items) { helper.current_user_menu_items }
......
......@@ -11,6 +11,7 @@ describe API::Users do
let(:ldap_blocked_user) { create(:omniauth_user, provider: 'ldapmain', state: 'ldap_blocked') }
let(:not_existing_user_id) { (User.maximum('id') || 0 ) + 10 }
let(:not_existing_pat_id) { (PersonalAccessToken.maximum('id') || 0 ) + 10 }
let(:private_user) { create(:user, private_profile: true) }
describe 'GET /users' do
context "when unauthenticated" do
......@@ -265,6 +266,13 @@ describe API::Users do
expect(response).to match_response_schema('public_api/v4/user/admin')
expect(json_response['is_admin']).to be(false)
end
it "includes the `created_at` field for private users" do
get api("/users/#{private_user.id}", admin)
expect(response).to match_response_schema('public_api/v4/user/admin')
expect(json_response.keys).to include 'created_at'
end
end
context 'for an anonymous user' do
......@@ -283,6 +291,20 @@ describe API::Users do
expect(response).to have_gitlab_http_status(404)
end
it "returns the `created_at` field for public users" do
get api("/users/#{user.id}")
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response.keys).to include 'created_at'
end
it "does not return the `created_at` field for private users" do
get api("/users/#{private_user.id}")
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response.keys).not_to include 'created_at'
end
end
it "returns a 404 error if user id not found" do
......@@ -385,6 +407,18 @@ describe API::Users do
expect(new_user.recently_sent_password_reset?).to eq(true)
end
it "creates user with private profile" do
post api('/users', admin), attributes_for(:user, private_profile: true)
expect(response).to have_gitlab_http_status(201)
user_id = json_response['id']
new_user = User.find(user_id)
expect(new_user).not_to eq(nil)
expect(new_user.private_profile?).to eq(true)
end
it "does not create user with invalid email" do
post api('/users', admin),
email: 'invalid email',
......@@ -597,6 +631,13 @@ describe API::Users do
expect(user.reload.external?).to be_truthy
end
it "updates private profile" do
put api("/users/#{user.id}", admin), { private_profile: true }
expect(response).to have_gitlab_http_status(200)
expect(user.reload.private_profile).to eq(true)
end
# EE
it "updates shared_runners_minutes_limit" do
expect do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment