Project

General

Profile

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

Go MAEDA, 2026-10-15 11:04

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
......
48 75
    render_404 unless @avatar&.readable?
49 76
  end
77
  # /my/avatar is for the current user and /users/:user_id/avatar is for
78
  # administrators
79
  def find_user
80
    if params[:user_id]
81
      return unless require_admin
82

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

  
50 95
  def check_avatar_available
51 96
    render_404 unless User.avatar_available?
52 97
  end
98

  
99
  def avatar_layout
100
    params[:user_id] ? 'admin' : 'base'
101
  end
53 102
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/:id(/:size)', :to => 'avatars#show', :user_id => /\d+/, :id => /\d+/, :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
65 65
      assert_response :redirect
66 66
    end
67 67
  end
68

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

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

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

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

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

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

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

  
129
  def test_destroy
130
    Redmine::Thumbnail.stubs(:convert_available?).returns(true)
131
    set_tmp_attachments_directory
132
    @request.session[:user_id] = 1
133
    delete :destroy, :params => {:user_id => 2}
134
    assert_redirected_to '/users/2/avatar'
135
    assert_equal 'Successful deletion.', flash[:notice]
136
    assert_nil User.find(2).avatar_id
137
  end
68 138
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/2/48' => 'avatars#show', :user_id => '44', :id => '2', :size => '48'
38 41
  end
39 42
end
(5-5/6)