Project

General

Profile

Feature #12197 » 0002-Add-pages-to-change-the-avatar-for-users-and-adminis.patch

Go MAEDA, 2026-10-16 02:21

View differences:

app/assets/images/icons.svg
92 92
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--attachment">
93 93
      <path d="M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"/>
94 94
    </symbol>
95
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--avatar">
96
      <path d="M12 13a3 3 0 1 0 0 -6a3 3 0 0 0 0 6"/>
97
      <path d="M12 3c7.2 0 9 1.8 9 9c0 7.2 -1.8 9 -9 9c-7.2 0 -9 -1.8 -9 -9c0 -7.2 1.8 -9 9 -9"/>
98
      <path d="M6 20.05v-.05a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v.05"/>
99
    </symbol>
95 100
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--bookmark-add">
96 101
      <path d="M12 17l-6 4v-14a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v5"/>
97 102
      <path d="M16 19h6"/>
app/assets/stylesheets/application.css
2404 2404
  flex-shrink: 0;
2405 2405
}
2406 2406
.avatar.locked, .avatar.registered {opacity: .6;}
2407
.avatar-preview {display: block; margin-block-start: 8px;}
2407 2408
span[role="img"].avatar {
2408 2409
  font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif;
......
2456 2457
span[role="img"].avatar.s50  {
2457 2458
  font-size: calc(50px * .4);
2458 2459
}
2460
.avatar.s100 {
2461
  block-size: 100px;
2462
  inline-size: 100px;
2463
}
2464
span[role="img"].avatar.s100  {
2465
  font-size: calc(100px * .4);
2466
}
2459 2467
.avatar-color-0 {
2460 2468
  background-color: var(--oc-red-7);
app/controllers/avatars_controller.rb
18 18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19 19
class AvatarsController < ApplicationController
20
  before_action :find_avatar
21
  before_action :check_avatar_available
20
  layout :avatar_layout
21
  self.main_menu = false
22
  menu_item :users
23

  
24
  before_action :find_avatar, :only => :show
25
  before_action :find_user, :except => :show
26
  before_action :check_avatar_available, :except => :edit
22 27
  def show
23 28
    # The original image is never served since it may contain metadata
......
40 45
    end
41 46
  end
47
  def edit
48
  end
49

  
50
  def update
51
    file = params[:avatar]
52
    if !file.respond_to?(:original_filename)
53
      @user.errors.add(:avatar, :blank)
54
    elsif @user.update_avatar(file)
55
      flash[:notice] = l(:notice_successful_update)
56
      redirect_to @avatar_path
57
      return
58
    end
59

  
60
    render :edit, :status => :unprocessable_content
61
  end
62

  
63
  def destroy
64
    @user.delete_avatar
65
    flash[:notice] = l(:notice_successful_delete)
66
    redirect_to @avatar_path
67
  end
68

  
42 69
  private
43 70
  def find_avatar
......
49 76
    render_404 unless @avatar&.readable?
50 77
  end
78
  # /my/avatar is for the current user and /users/:user_id/avatar is for
79
  # administrators
80
  def find_user
81
    if params[:user_id]
82
      return unless require_admin
83

  
84
      @user = User.find_by(:id => params[:user_id])
85
      if @user
86
        @avatar_path = edit_user_avatar_path(@user)
87
      else
88
        render_404
89
      end
90
    elsif require_login
91
      @user = User.current
92
      @avatar_path = my_avatar_path
93
    end
94
  end
95

  
51 96
  def check_avatar_available
52 97
    render_404 unless User.avatar_available?
53 98
  end
99

  
100
  def avatar_layout
101
    params[:user_id] ? 'admin' : 'base'
102
  end
54 103
end
app/helpers/avatars_helper.rb
66 66
    end
67 67
  end
68
  # Returns a link to edit user's avatar if avatars are enabled
68
  # Returns true if the avatar set in Redmine is displayed for +user+
69
  def local_avatar?(user)
70
    user.is_a?(User) && user.avatar_id && User.avatar_available?
71
  end
72

  
73
  # Returns the path to the page to change the avatar of +user+, or nil if
74
  # the current user cannot change it
75
  def avatar_edit_path(user)
76
    if user == User.current
77
      my_avatar_path
78
    elsif User.current.admin? && user.logged?
79
      edit_user_avatar_path(user)
80
    end
81
  end
82

  
83
  # Returns the avatar of +user+ linked to the page to change it if the
84
  # current user can change it
69 85
  def avatar_edit_link(user, options={})
70
    if Setting.gravatar_enabled?
71
      url = Redmine::Configuration['avatar_server_url']
72
      link_to avatar(user, {:title => l(:button_edit)}.merge(options)), url, :target => '_blank'
86
    if (path = avatar_edit_path(user))
87
      link_to avatar(user, {:title => l(:button_change_avatar)}.merge(options)), path
88
    else
89
      avatar(user, options)
73 90
    end
74 91
  end
92
  # Returns a text that explains where the avatar of +user+ comes from
93
  def avatar_source_info(user)
94
    return l(:text_avatar_uploaded) if local_avatar?(user)
95

  
96
    source =
97
      if Setting.gravatar_enabled?
98
        t(:text_avatar_from_server_html, :url => Redmine::Configuration['avatar_server_url'])
99
      else
100
        l(:text_avatar_initials)
101
      end
102
    upload = User.avatar_available? ? l(:text_avatar_upload_instead) : l(:text_avatar_upload_disabled)
103
    safe_join([source, upload], ' ')
104
  end
105

  
75 106
  def anonymous_avatar(options={})
76 107
    options[:class] = ['anonymous-avatar', options[:class]].compact.join(' ')
77 108
    image_tag 'anonymous.png', GravatarHelper::DEFAULT_OPTIONS.except(:default, :rating, :ssl).merge(options)
......
86 117
  private
87
  def local_avatar?(user)
88
    user.is_a?(User) && user.avatar_id && User.avatar_available?
89
  end
90

  
91 118
  def local_avatar_tag(user, options)
92 119
    size = (options.delete(:size) || GravatarHelper::DEFAULT_OPTIONS[:size]).to_i
93 120
    options[:title] ||= user.name
app/views/avatars/edit.html.erb
1
<% if params[:user_id] %>
2
  <%= title [l(:label_user_plural), users_path], [@user.login, edit_user_path(@user)], l(:field_avatar) %>
3
<% else %>
4
  <%= title l(:button_change_avatar) %>
5
<% end %>
6

  
7
<%= error_messages_for 'user' %>
8

  
9
<div class="box tabular">
10
  <p>
11
    <label><%= l(:field_avatar) %></label>
12
    <%= avatar_source_info(@user) %>
13
    <span class="avatar-preview">
14
      <%= avatar(@user, :size => 100) %>
15
      <% if local_avatar?(@user) %>
16
        <span class="buttons">
17
          <%= link_to_attachment @user.avatar, :text => l(:button_download), :download => true,
18
                                 :class => 'icon icon-download', :icon => 'download' %>
19
          <%= link_to sprite_icon('del', l(:button_delete)), @avatar_path,
20
                      :method => :delete, :data => {:confirm => l(:text_are_you_sure)}, :class => 'icon icon-del' %>
21
        </span>
22
      <% end %>
23
    </span>
24
  </p>
25
  <% if User.avatar_available? %>
26
  <hr>
27
  <%= form_tag(@avatar_path, :method => :put, :multipart => true) do %>
28
  <p>
29
    <label for="avatar"><%= l(:label_attachment_new) %> <span class="required">*</span></label>
30
    <%= file_field_tag 'avatar', :required => true, :accept => User::AVATAR_CONTENT_TYPES.join(',') %>
31
    <%= submit_tag l(:button_update) %>
32
    <em class="info"><%= l(:label_max_size) %>: <%= number_to_human_size(Setting.attachment_max_size.to_i.kilobytes) %></em>
33
  </p>
34
  <% end %>
35
  <% end %>
36
</div>
37

  
38
<% unless params[:user_id] %>
39
  <% content_for :sidebar do %>
40
    <%= render :partial => 'my/sidebar' %>
41
  <% end %>
42
<% end %>
app/views/my/account.html.erb
1 1
<div class="contextual">
2 2
<%= additional_emails_link(@user) %>
3 3
<%= link_to(sprite_icon('key', l(:button_change_password)), { :action => 'password'}, :class => 'icon icon-passwd') if @user.change_password_allowed? %>
4
<%= link_to(sprite_icon('avatar', l(:button_change_avatar)), my_avatar_path, :class => 'icon icon-avatar') %>
4 5
<%= link_to sprite_icon('webhook', l(:label_webhook_plural)), webhooks_path, class: 'icon icon-webhook' if Webhook.enabled? && @user.allowed_to?(:use_webhooks, nil, global: true) %>
5 6
<%= link_to(sprite_icon('apps', l('label_oauth_authorized_application_plural')), oauth_authorized_applications_path, :class => 'icon icon-applications') if Setting.rest_api_enabled? %>
6 7
<%= call_hook(:view_my_account_contextual, :user => @user)%>
app/views/settings/_display.html.erb
25 25
<% end %></p>
26 26
<p><%= setting_check_box :gravatar_enabled, :data => {:enables => '#settings_gravatar_default'} %>
27
<em class="info"><%= t(:text_avatar_server_config_html, :url => Redmine::Configuration['avatar_server_url']) %></em></p>
27
<em class="info"><%= l(:text_setting_gravatar_enabled_fallback) %>
28
<%= t(:text_avatar_server_config_html, :url => Redmine::Configuration['avatar_server_url']) %></em></p>
28 29
<p>
29 30
  <%= setting_select :gravatar_default, gravatar_default_setting_options, :blank => :label_none %>
app/views/users/edit.html.erb
1 1
<div class="contextual">
2 2
<%= link_to sprite_icon('user', l(:label_profile)), user_path(@user), :class => 'icon icon-user' %>
3 3
<%= additional_emails_link(@user) %>
4
<%= link_to sprite_icon('avatar', l(:button_change_avatar)), edit_user_avatar_path(@user), :class => 'icon icon-avatar' %>
4 5
<%= change_status_link(@user) %>
5 6
<%= delete_link user_path(@user) if User.current != @user %>
6 7
</div>
app/views/users/show.html.erb
2 2
<%= link_to(sprite_icon('edit', l(:button_edit)), edit_user_path(@user), :class => 'icon icon-edit') if User.current.admin? && @user.logged? %>
3 3
</div>
4
<h2><%= avatar @user, :size => "50" %> <%= @user.name %></h2>
4
<h2><%= avatar_edit_link(@user, :size => "50") %> <%= @user.name %></h2>
5 5
<div class="splitcontent">
6 6
<div class="splitcontentleft">
config/icon_source.yml
69 69
  svg: news
70 70
- name: user
71 71
  svg: user
72
- name: avatar
73
  svg: user-square-rounded
72 74
- name: group
73 75
  svg: users-group
74 76
- name: bookmarked
config/locales/en.yml
1254 1254
  button_reset: Reset
1255 1255
  button_rename: Rename
1256 1256
  button_change_password: Change password
1257
  button_change_avatar: Change avatar
1257 1258
  button_copy: Copy
1258 1259
  button_copy_and_follow: Copy and follow
1259 1260
  button_copy_link: Copy link
......
1396 1397
  text_select_apply_tracker: "Select tracker"
1397 1398
  text_select_apply_issue_status: "Select issue status"
1398 1399
  text_avatar_server_config_html: The current avatar server is <a href="%{url}">%{url}</a>. You can configure it in config/configuration.yml.
1400
  text_avatar_uploaded: The uploaded image is currently used as the avatar.
1401
  text_avatar_from_server_html: The image from <a href="%{url}">%{url}</a> associated with the user's email address is currently used as the avatar. It can be changed on that site.
1402
  text_avatar_initials: The image generated from the user's name is currently used as the avatar.
1403
  text_avatar_upload_instead: You can upload an image to use instead.
1404
  text_avatar_upload_disabled: Uploading an avatar image to Redmine has been disabled by the administrator.
1399 1405
  text_no_subject: no subject
1400 1406
  text_allowed_queries_to_select: Public (to any users) queries only selectable
1401 1407
  text_setting_config_change: You can configure the behaviour in config/configuration.yml. Please restart the application after editing it.
1402 1408
  text_setting_gravatar_default_initials_html: Users' initials are sent to <a href="%{url}">%{url}</a> to generate their avatars.
1403 1409
  text_setting_avatar_enabled_imagemagick_unavailable: ImageMagick is not available, so users cannot set their avatar.
1410
  text_setting_gravatar_enabled_fallback: If enabled, Gravatar is used for users who have not set their avatar in Redmine.
1404 1411
  text_oauth_admin_permission: Full administrative access. When authorized by an Administrator, this application will be able to read and write all data and impersonate other users.
1405 1412
  text_oauth_admin_permission_info: This application requests full administrative access. If you are an Administrator (or become one in the future), it will be able to read and write all data and impersonate other users on your behalf. If you want to avoid this, authorize it as a user without Administrator privileges instead.
1406 1413
  text_oauth_copy_secret_now: Copy the secret to a safe place now, it will not be shown again.
config/routes.rb
102 102
  post 'my/api_key', :to => 'my#reset_api_key'
103 103
  post 'my/atom_key', :to => 'my#reset_atom_key', :as => 'my_atom_key'
104 104
  match 'my/password', :controller => 'my', :action => 'password', :via => [:get, :post]
105
  get 'my/avatar', :to => 'avatars#edit', :as => 'my_avatar', :format => false
106
  put 'my/avatar', :to => 'avatars#update', :format => false
107
  delete 'my/avatar', :to => 'avatars#destroy', :format => false
105 108
  match 'my/add_block', :controller => 'my', :action => 'add_block', :via => :post
106 109
  match 'my/remove_block', :controller => 'my', :action => 'remove_block', :via => :post
107 110
  match 'my/order_blocks', :controller => 'my', :action => 'order_blocks', :via => :post
......
130 133
    resources :email_addresses, :only => [:index, :create, :update, :destroy]
131 134
  end
132 135
  get 'users/:user_id/avatar/:key(/:size)', :to => 'avatars#show', :user_id => /\d+/, :key => /\h{16}/, :size => /\d+/, :as => 'user_avatar'
136
  get 'users/:user_id/avatar', :to => 'avatars#edit', :user_id => /\d+/, :as => 'edit_user_avatar', :format => false
137
  put 'users/:user_id/avatar', :to => 'avatars#update', :user_id => /\d+/, :format => false
138
  delete 'users/:user_id/avatar', :to => 'avatars#destroy', :user_id => /\d+/, :format => false
133 139
  post 'watchers/watch', :to => 'watchers#watch', :as => 'watch'
134 140
  delete 'watchers/watch', :to => 'watchers#unwatch'
test/functional/avatars_controller_test.rb
67 67
      assert_response :redirect
68 68
    end
69 69
  end
70

  
71
  def test_edit
72
    Redmine::Thumbnail.stubs(:convert_available?).returns(true)
73
    @request.session[:user_id] = 2
74
    get :edit
75
    assert_response :success
76
    assert_select 'img.avatar[src=?]', "/users/2/avatar/#{User.find(2).avatar_key}/100"
77
    assert_select 'a.icon-download[href=?]', '/attachments/download/16/testfile.png'
78
    assert_select 'a.icon-del[href="/my/avatar"][data-method="delete"]'
79
    assert_select 'form[action="/my/avatar"][enctype="multipart/form-data"] p' do
80
      assert_select 'input[type=file][name=avatar] + input[type=submit][value=Update]'
81
    end
82
  end
83

  
84
  def test_edit_with_avatars_disabled
85
    @request.session[:user_id] = 2
86
    with_settings :avatar_enabled => '0' do
87
      get :edit
88
    end
89
    assert_response :success
90
    assert_select 'p', :text => /disabled by the administrator/
91
    assert_select 'a.icon-del[href="/my/avatar"]', 0
92
    assert_select 'input[type=file]', 0
93
  end
94

  
95
  def test_update_should_return_404_with_avatars_disabled
96
    @request.session[:user_id] = 2
97
    with_settings :avatar_enabled => '0' do
98
      put :update, :params => {:avatar => fixture_file_upload('2010/11/101123161450_testfile_1.png', 'image/png')}
99
    end
100
    assert_response :not_found
101
  end
102

  
103
  def test_edit_with_user_id
104
    Redmine::Thumbnail.stubs(:convert_available?).returns(true)
105
    @request.session[:user_id] = 1
106
    get :edit, :params => {:user_id => 2}
107
    assert_response :success
108
    assert_select 'h2 a[href="/users/2/edit"]'
109
    assert_select 'form[action="/users/2/avatar"]'
110
  end
111

  
112
  def test_edit_with_user_id_should_require_admin
113
    @request.session[:user_id] = 2
114
    get :edit, :params => {:user_id => 2}
115
    assert_response :forbidden
116
  end
117

  
118
  def test_update
119
    Redmine::Thumbnail.stubs(:convert_available?).returns(true)
120
    set_tmp_attachments_directory
121
    @request.session[:user_id] = 2
122
    put :update, :params => {:avatar => fixture_file_upload('2010/11/101123161450_testfile_1.png', 'image/png')}
123
    assert_redirected_to '/my/avatar'
124
    assert_equal 'Successful update.', flash[:notice]
125

  
126
    avatar = User.find(2).avatar
127
    assert_equal '101123161450_testfile_1.png', avatar.filename
128
    assert_equal User.find(2), avatar.author
129
  end
130

  
131
  def test_destroy
132
    Redmine::Thumbnail.stubs(:convert_available?).returns(true)
133
    set_tmp_attachments_directory
134
    @request.session[:user_id] = 1
135
    delete :destroy, :params => {:user_id => 2}
136
    assert_redirected_to '/users/2/avatar'
137
    assert_equal 'Successful deletion.', flash[:notice]
138
    assert_nil User.find(2).avatar_id
139
  end
70 140
end
test/functional/my_controller_test.rb
423 423
    end
424 424
  end
425
  def test_my_account_with_avatar_enabled_should_link_to_edit_avatar
425
  def test_my_account_should_link_to_avatar_page
426 426
    with_settings :gravatar_enabled => '1' do
427
      Redmine::Configuration.with 'avatar_server_url' => 'https://gravatar.com' do
428
        get :account
429
        assert_response :success
430
        assert_select 'a[href=?] img.gravatar', 'https://gravatar.com'
431
      end
427
      get :account
428
      assert_response :success
429
      assert_select 'h2 a[href="/my/avatar"] img.gravatar'
432 430
    end
433 431
  end
test/functional/users_controller_test.rb
402 402
    get :show, :params => {:id => 'current'}
403 403
    assert_response :success
404 404
    assert_select 'h2', :text => /John Smith/
405
    assert_select 'h2 a[href="/my/avatar"] .avatar'
405 406
  end
406 407
  def test_show_issues_counts
test/helpers/avatars_helper_test.rb
50 50
    assert_include Digest::SHA256.hexdigest('jsmith@somenet.foo'), avatar(user)
51 51
  end
52
  def test_avatar_source_info
53
    Redmine::Thumbnail.stubs(:convert_available?).returns(true)
54
    user = User.find(2)
55
    assert_include 'It can be changed on that site. You can upload an image to use instead.', avatar_source_info(user)
56
    with_settings :gravatar_enabled => '0' do
57
      assert_include 'generated from the user', avatar_source_info(user)
58
    end
59
    user.avatar_id = 16
60
    assert_equal 'The uploaded image is currently used as the avatar.', avatar_source_info(user)
61
    with_settings :avatar_enabled => '0' do
62
      assert_include 'Uploading an avatar image to Redmine has been disabled by the administrator.', avatar_source_info(user)
63
    end
64
  end
65

  
52 66
  def test_avatar_with_email_string
53 67
    assert_include Digest::SHA256.hexdigest('jsmith@somenet.foo'), avatar('jsmith <jsmith@somenet.foo>')
54 68
  end
test/integration/routing/my_test.rb
38 38
    should_route 'GET /my/password' => 'my#password'
39 39
    should_route 'POST /my/password' => 'my#password'
40
    should_route 'GET /my/avatar' => 'avatars#edit'
41
    should_route 'PUT /my/avatar' => 'avatars#update'
42
    should_route 'DELETE /my/avatar' => 'avatars#destroy'
43

  
40 44
    should_route 'POST /my/add_block' => 'my#add_block'
41 45
    should_route 'POST /my/remove_block' => 'my#remove_block'
42 46
    should_route 'POST /my/order_blocks' => 'my#order_blocks'
test/integration/routing/users_test.rb
35 35
    should_route  'POST /users/bulk_lock' => 'users#bulk_lock'
36 36
    should_route  'POST /users/bulk_unlock' => 'users#bulk_unlock'
37
    should_route 'GET /users/44/avatar' => 'avatars#edit', :user_id => '44'
38
    should_route 'PUT /users/44/avatar' => 'avatars#update', :user_id => '44'
39
    should_route 'DELETE /users/44/avatar' => 'avatars#destroy', :user_id => '44'
37 40
    should_route 'GET /users/44/avatar/0123456789abcdef/48' => 'avatars#show', :user_id => '44', :key => '0123456789abcdef', :size => '48'
38 41
  end
39 42
end
(5-5/6)