From 480db404b03e6cfbd1b108140ea73d9036cdc85f Mon Sep 17 00:00:00 2001 From: MAEDA Go Date: Thu, 15 Oct 2026 00:03:52 +0900 Subject: [PATCH 2/3] Add pages to change the avatar for users and administrators --- app/assets/images/icons.svg | 5 ++ app/assets/stylesheets/application.css | 8 +++ app/controllers/avatars_controller.rb | 53 +++++++++++++++- app/helpers/avatars_helper.rb | 43 ++++++++++--- app/views/avatars/edit.html.erb | 42 +++++++++++++ app/views/my/account.html.erb | 1 + app/views/settings/_display.html.erb | 3 +- app/views/users/edit.html.erb | 1 + app/views/users/show.html.erb | 2 +- config/icon_source.yml | 2 + config/locales/en.yml | 7 +++ config/routes.rb | 6 ++ test/functional/avatars_controller_test.rb | 70 ++++++++++++++++++++++ test/functional/my_controller_test.rb | 10 ++-- test/functional/users_controller_test.rb | 1 + test/helpers/avatars_helper_test.rb | 14 +++++ test/integration/routing/my_test.rb | 4 ++ test/integration/routing/users_test.rb | 3 + 18 files changed, 257 insertions(+), 18 deletions(-) create mode 100644 app/views/avatars/edit.html.erb diff --git a/app/assets/images/icons.svg b/app/assets/images/icons.svg index 721cfc301..111096d3b 100644 --- a/app/assets/images/icons.svg +++ b/app/assets/images/icons.svg @@ -92,6 +92,11 @@ + + + + + diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 3dfb0efb0..c08e84cb8 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -2404,6 +2404,7 @@ img.filecontent.image {background-image: url(/transparent.png);} flex-shrink: 0; } .avatar.locked, .avatar.registered {opacity: .6;} +.avatar-preview {display: block; margin-block-start: 8px;} span[role="img"].avatar { font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif; @@ -2456,6 +2457,13 @@ span[role="img"].avatar.s40 { span[role="img"].avatar.s50 { font-size: calc(50px * .4); } +.avatar.s100 { + block-size: 100px; + inline-size: 100px; +} +span[role="img"].avatar.s100 { + font-size: calc(100px * .4); +} .avatar-color-0 { background-color: var(--oc-red-7); diff --git a/app/controllers/avatars_controller.rb b/app/controllers/avatars_controller.rb index 4c1a28ccf..47eb5c1bb 100644 --- a/app/controllers/avatars_controller.rb +++ b/app/controllers/avatars_controller.rb @@ -18,8 +18,13 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. class AvatarsController < ApplicationController - before_action :find_avatar - before_action :check_avatar_available + layout :avatar_layout + self.main_menu = false + menu_item :users + + before_action :find_avatar, :only => :show + before_action :find_user, :except => :show + before_action :check_avatar_available, :except => :edit def show # The original image is never served since it may contain metadata @@ -40,6 +45,28 @@ class AvatarsController < ApplicationController end end + def edit + end + + def update + file = params[:avatar] + if !file.respond_to?(:original_filename) + @user.errors.add(:avatar, :blank) + elsif @user.update_avatar(file) + flash[:notice] = l(:notice_successful_update) + redirect_to @avatar_path + return + end + + render :edit, :status => :unprocessable_content + end + + def destroy + @user.delete_avatar + flash[:notice] = l(:notice_successful_delete) + redirect_to @avatar_path + end + private def find_avatar @@ -48,7 +75,29 @@ class AvatarsController < ApplicationController render_404 unless @avatar&.readable? end + # /my/avatar is for the current user and /users/:user_id/avatar is for + # administrators + def find_user + if params[:user_id] + return unless require_admin + + @user = User.find_by(:id => params[:user_id]) + if @user + @avatar_path = edit_user_avatar_path(@user) + else + render_404 + end + elsif require_login + @user = User.current + @avatar_path = my_avatar_path + end + end + def check_avatar_available render_404 unless User.avatar_available? end + + def avatar_layout + params[:user_id] ? 'admin' : 'base' + end end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 47a70bd1a..3c7c14ccb 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -66,14 +66,45 @@ module AvatarsHelper end end - # Returns a link to edit user's avatar if avatars are enabled + # Returns true if the avatar set in Redmine is displayed for +user+ + def local_avatar?(user) + user.is_a?(User) && user.avatar_id && User.avatar_available? + end + + # Returns the path to the page to change the avatar of +user+, or nil if + # the current user cannot change it + def avatar_edit_path(user) + if user == User.current + my_avatar_path + elsif User.current.admin? && user.logged? + edit_user_avatar_path(user) + end + end + + # Returns the avatar of +user+ linked to the page to change it if the + # current user can change it def avatar_edit_link(user, options={}) - if Setting.gravatar_enabled? - url = Redmine::Configuration['avatar_server_url'] - link_to avatar(user, {:title => l(:button_edit)}.merge(options)), url, :target => '_blank' + if (path = avatar_edit_path(user)) + link_to avatar(user, {:title => l(:button_change_avatar)}.merge(options)), path + else + avatar(user, options) end end + # Returns a text that explains where the avatar of +user+ comes from + def avatar_source_info(user) + return l(:text_avatar_uploaded) if local_avatar?(user) + + source = + if Setting.gravatar_enabled? + t(:text_avatar_from_server_html, :url => Redmine::Configuration['avatar_server_url']) + else + l(:text_avatar_initials) + end + upload = User.avatar_available? ? l(:text_avatar_upload_instead) : l(:text_avatar_upload_disabled) + safe_join([source, upload], ' ') + end + def anonymous_avatar(options={}) options[:class] = ['anonymous-avatar', options[:class]].compact.join(' ') image_tag 'anonymous.png', GravatarHelper::DEFAULT_OPTIONS.except(:default, :rating, :ssl).merge(options) @@ -86,10 +117,6 @@ module AvatarsHelper private - def local_avatar?(user) - user.is_a?(User) && user.avatar_id && User.avatar_available? - end - def local_avatar_tag(user, options) size = (options.delete(:size) || GravatarHelper::DEFAULT_OPTIONS[:size]).to_i options[:title] ||= user.name diff --git a/app/views/avatars/edit.html.erb b/app/views/avatars/edit.html.erb new file mode 100644 index 000000000..37284704d --- /dev/null +++ b/app/views/avatars/edit.html.erb @@ -0,0 +1,42 @@ +<% if params[:user_id] %> + <%= title [l(:label_user_plural), users_path], [@user.login, edit_user_path(@user)], l(:field_avatar) %> +<% else %> + <%= title l(:button_change_avatar) %> +<% end %> + +<%= error_messages_for 'user' %> + +
+

+ + <%= avatar_source_info(@user) %> + + <%= avatar(@user, :size => 100) %> + <% if local_avatar?(@user) %> + + <%= link_to_attachment @user.avatar, :text => l(:button_download), :download => true, + :class => 'icon icon-download', :icon => 'download' %> + <%= link_to sprite_icon('del', l(:button_delete)), @avatar_path, + :method => :delete, :data => {:confirm => l(:text_are_you_sure)}, :class => 'icon icon-del' %> + + <% end %> + +

+ <% if User.avatar_available? %> +
+ <%= form_tag(@avatar_path, :method => :put, :multipart => true) do %> +

+ + <%= file_field_tag 'avatar', :required => true, :accept => User::AVATAR_CONTENT_TYPES.join(',') %> + <%= submit_tag l(:button_update) %> + <%= l(:label_max_size) %>: <%= number_to_human_size(Setting.attachment_max_size.to_i.kilobytes) %> +

+ <% end %> + <% end %> +
+ +<% unless params[:user_id] %> + <% content_for :sidebar do %> + <%= render :partial => 'my/sidebar' %> + <% end %> +<% end %> diff --git a/app/views/my/account.html.erb b/app/views/my/account.html.erb index 44e4b0f86..a5e3638fb 100644 --- a/app/views/my/account.html.erb +++ b/app/views/my/account.html.erb @@ -1,6 +1,7 @@
<%= additional_emails_link(@user) %> <%= link_to(sprite_icon('key', l(:button_change_password)), { :action => 'password'}, :class => 'icon icon-passwd') if @user.change_password_allowed? %> +<%= link_to(sprite_icon('avatar', l(:button_change_avatar)), my_avatar_path, :class => 'icon icon-avatar') %> <%= 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) %> <%= link_to(sprite_icon('apps', l('label_oauth_authorized_application_plural')), oauth_authorized_applications_path, :class => 'icon icon-applications') if Setting.rest_api_enabled? %> <%= call_hook(:view_my_account_contextual, :user => @user)%> diff --git a/app/views/settings/_display.html.erb b/app/views/settings/_display.html.erb index 56912f854..14e11e2af 100644 --- a/app/views/settings/_display.html.erb +++ b/app/views/settings/_display.html.erb @@ -25,7 +25,8 @@ <% end %>

<%= setting_check_box :gravatar_enabled, :data => {:enables => '#settings_gravatar_default'} %> -<%= t(:text_avatar_server_config_html, :url => Redmine::Configuration['avatar_server_url']) %>

+<%= l(:text_setting_gravatar_enabled_fallback) %> +<%= t(:text_avatar_server_config_html, :url => Redmine::Configuration['avatar_server_url']) %>

<%= setting_select :gravatar_default, gravatar_default_setting_options, :blank => :label_none %> diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 354d31cb9..9790700d7 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -1,6 +1,7 @@

<%= link_to sprite_icon('user', l(:label_profile)), user_path(@user), :class => 'icon icon-user' %> <%= additional_emails_link(@user) %> +<%= link_to sprite_icon('avatar', l(:button_change_avatar)), edit_user_avatar_path(@user), :class => 'icon icon-avatar' %> <%= change_status_link(@user) %> <%= delete_link user_path(@user) if User.current != @user %>
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 784c49139..a2219277f 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -2,7 +2,7 @@ <%= link_to(sprite_icon('edit', l(:button_edit)), edit_user_path(@user), :class => 'icon icon-edit') if User.current.admin? && @user.logged? %>
-

<%= avatar @user, :size => "50" %> <%= @user.name %>

+

<%= avatar_edit_link(@user, :size => "50") %> <%= @user.name %>

diff --git a/config/icon_source.yml b/config/icon_source.yml index d4527c412..99e8b5318 100644 --- a/config/icon_source.yml +++ b/config/icon_source.yml @@ -69,6 +69,8 @@ svg: news - name: user svg: user +- name: avatar + svg: user-square-rounded - name: group svg: users-group - name: bookmarked diff --git a/config/locales/en.yml b/config/locales/en.yml index 4c5e402b8..8f9917b3b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1254,6 +1254,7 @@ en: button_reset: Reset button_rename: Rename button_change_password: Change password + button_change_avatar: Change avatar button_copy: Copy button_copy_and_follow: Copy and follow button_copy_link: Copy link @@ -1396,11 +1397,17 @@ en: text_select_apply_tracker: "Select tracker" text_select_apply_issue_status: "Select issue status" text_avatar_server_config_html: The current avatar server is %{url}. You can configure it in config/configuration.yml. + text_avatar_uploaded: The uploaded image is currently used as the avatar. + text_avatar_from_server_html: The image from %{url} associated with the user's email address is currently used as the avatar. It can be changed on that site. + text_avatar_initials: The image generated from the user's name is currently used as the avatar. + text_avatar_upload_instead: You can upload an image to use instead. + text_avatar_upload_disabled: Uploading an avatar image to Redmine has been disabled by the administrator. text_no_subject: no subject text_allowed_queries_to_select: Public (to any users) queries only selectable text_setting_config_change: You can configure the behaviour in config/configuration.yml. Please restart the application after editing it. text_setting_gravatar_default_initials_html: Users' initials are sent to %{url} to generate their avatars. text_setting_avatar_enabled_imagemagick_unavailable: ImageMagick is not available, so users cannot set their avatar. + text_setting_gravatar_enabled_fallback: If enabled, Gravatar is used for users who have not set their avatar in Redmine. 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. 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. text_oauth_copy_secret_now: Copy the secret to a safe place now, it will not be shown again. diff --git a/config/routes.rb b/config/routes.rb index 66de196e1..f6343dd03 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -102,6 +102,9 @@ Rails.application.routes.draw do post 'my/api_key', :to => 'my#reset_api_key' post 'my/atom_key', :to => 'my#reset_atom_key', :as => 'my_atom_key' match 'my/password', :controller => 'my', :action => 'password', :via => [:get, :post] + get 'my/avatar', :to => 'avatars#edit', :as => 'my_avatar', :format => false + put 'my/avatar', :to => 'avatars#update', :format => false + delete 'my/avatar', :to => 'avatars#destroy', :format => false match 'my/add_block', :controller => 'my', :action => 'add_block', :via => :post match 'my/remove_block', :controller => 'my', :action => 'remove_block', :via => :post match 'my/order_blocks', :controller => 'my', :action => 'order_blocks', :via => :post @@ -130,6 +133,9 @@ Rails.application.routes.draw do resources :email_addresses, :only => [:index, :create, :update, :destroy] end get 'users/:user_id/avatar/:id(/:size)', :to => 'avatars#show', :user_id => /\d+/, :id => /\d+/, :size => /\d+/, :as => 'user_avatar' + get 'users/:user_id/avatar', :to => 'avatars#edit', :user_id => /\d+/, :as => 'edit_user_avatar', :format => false + put 'users/:user_id/avatar', :to => 'avatars#update', :user_id => /\d+/, :format => false + delete 'users/:user_id/avatar', :to => 'avatars#destroy', :user_id => /\d+/, :format => false post 'watchers/watch', :to => 'watchers#watch', :as => 'watch' delete 'watchers/watch', :to => 'watchers#unwatch' diff --git a/test/functional/avatars_controller_test.rb b/test/functional/avatars_controller_test.rb index 170d3b0ee..97e7571b9 100644 --- a/test/functional/avatars_controller_test.rb +++ b/test/functional/avatars_controller_test.rb @@ -65,4 +65,74 @@ class AvatarsControllerTest < Redmine::ControllerTest assert_response :redirect end end + + def test_edit + Redmine::Thumbnail.stubs(:convert_available?).returns(true) + @request.session[:user_id] = 2 + get :edit + assert_response :success + assert_select 'img.avatar[src=?]', '/users/2/avatar/16/100' + assert_select 'a.icon-download[href=?]', '/attachments/download/16/testfile.png' + assert_select 'a.icon-del[href="/my/avatar"][data-method="delete"]' + assert_select 'form[action="/my/avatar"][enctype="multipart/form-data"] p' do + assert_select 'input[type=file][name=avatar] + input[type=submit][value=Update]' + end + end + + def test_edit_with_avatars_disabled + @request.session[:user_id] = 2 + with_settings :avatar_enabled => '0' do + get :edit + end + assert_response :success + assert_select 'p', :text => /disabled by the administrator/ + assert_select 'a.icon-del[href="/my/avatar"]', 0 + assert_select 'input[type=file]', 0 + end + + def test_update_should_return_404_with_avatars_disabled + @request.session[:user_id] = 2 + with_settings :avatar_enabled => '0' do + put :update, :params => {:avatar => fixture_file_upload('2010/11/101123161450_testfile_1.png', 'image/png')} + end + assert_response :not_found + end + + def test_edit_with_user_id + Redmine::Thumbnail.stubs(:convert_available?).returns(true) + @request.session[:user_id] = 1 + get :edit, :params => {:user_id => 2} + assert_response :success + assert_select 'h2 a[href="/users/2/edit"]' + assert_select 'form[action="/users/2/avatar"]' + end + + def test_edit_with_user_id_should_require_admin + @request.session[:user_id] = 2 + get :edit, :params => {:user_id => 2} + assert_response :forbidden + end + + def test_update + Redmine::Thumbnail.stubs(:convert_available?).returns(true) + set_tmp_attachments_directory + @request.session[:user_id] = 2 + put :update, :params => {:avatar => fixture_file_upload('2010/11/101123161450_testfile_1.png', 'image/png')} + assert_redirected_to '/my/avatar' + assert_equal 'Successful update.', flash[:notice] + + avatar = User.find(2).avatar + assert_equal '101123161450_testfile_1.png', avatar.filename + assert_equal User.find(2), avatar.author + end + + def test_destroy + Redmine::Thumbnail.stubs(:convert_available?).returns(true) + set_tmp_attachments_directory + @request.session[:user_id] = 1 + delete :destroy, :params => {:user_id => 2} + assert_redirected_to '/users/2/avatar' + assert_equal 'Successful deletion.', flash[:notice] + assert_nil User.find(2).avatar_id + end end diff --git a/test/functional/my_controller_test.rb b/test/functional/my_controller_test.rb index 92fbb9643..f1798cc38 100644 --- a/test/functional/my_controller_test.rb +++ b/test/functional/my_controller_test.rb @@ -423,13 +423,11 @@ class MyControllerTest < Redmine::ControllerTest end end - def test_my_account_with_avatar_enabled_should_link_to_edit_avatar + def test_my_account_should_link_to_avatar_page with_settings :gravatar_enabled => '1' do - Redmine::Configuration.with 'avatar_server_url' => 'https://gravatar.com' do - get :account - assert_response :success - assert_select 'a[href=?] img.gravatar', 'https://gravatar.com' - end + get :account + assert_response :success + assert_select 'h2 a[href="/my/avatar"] img.gravatar' end end diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb index 6ec7f2bb5..8ce8e8f27 100644 --- a/test/functional/users_controller_test.rb +++ b/test/functional/users_controller_test.rb @@ -402,6 +402,7 @@ class UsersControllerTest < Redmine::ControllerTest get :show, :params => {:id => 'current'} assert_response :success assert_select 'h2', :text => /John Smith/ + assert_select 'h2 a[href="/my/avatar"] .avatar' end def test_show_issues_counts diff --git a/test/helpers/avatars_helper_test.rb b/test/helpers/avatars_helper_test.rb index 8354efc0f..af533f4e9 100644 --- a/test/helpers/avatars_helper_test.rb +++ b/test/helpers/avatars_helper_test.rb @@ -50,6 +50,20 @@ class AvatarsHelperTest < Redmine::HelperTest assert_include Digest::SHA256.hexdigest('jsmith@somenet.foo'), avatar(user) end + def test_avatar_source_info + Redmine::Thumbnail.stubs(:convert_available?).returns(true) + user = User.find(2) + assert_include 'It can be changed on that site. You can upload an image to use instead.', avatar_source_info(user) + with_settings :gravatar_enabled => '0' do + assert_include 'generated from the user', avatar_source_info(user) + end + user.avatar_id = 16 + assert_equal 'The uploaded image is currently used as the avatar.', avatar_source_info(user) + with_settings :avatar_enabled => '0' do + assert_include 'Uploading an avatar image to Redmine has been disabled by the administrator.', avatar_source_info(user) + end + end + def test_avatar_with_email_string assert_include Digest::SHA256.hexdigest('jsmith@somenet.foo'), avatar('jsmith ') end diff --git a/test/integration/routing/my_test.rb b/test/integration/routing/my_test.rb index ff428c1dc..4367aa294 100644 --- a/test/integration/routing/my_test.rb +++ b/test/integration/routing/my_test.rb @@ -38,6 +38,10 @@ class RoutingMyTest < Redmine::RoutingTest should_route 'GET /my/password' => 'my#password' should_route 'POST /my/password' => 'my#password' + should_route 'GET /my/avatar' => 'avatars#edit' + should_route 'PUT /my/avatar' => 'avatars#update' + should_route 'DELETE /my/avatar' => 'avatars#destroy' + should_route 'POST /my/add_block' => 'my#add_block' should_route 'POST /my/remove_block' => 'my#remove_block' should_route 'POST /my/order_blocks' => 'my#order_blocks' diff --git a/test/integration/routing/users_test.rb b/test/integration/routing/users_test.rb index 6ca38a758..720be7425 100644 --- a/test/integration/routing/users_test.rb +++ b/test/integration/routing/users_test.rb @@ -35,6 +35,9 @@ class RoutingUsersTest < Redmine::RoutingTest should_route 'POST /users/bulk_lock' => 'users#bulk_lock' should_route 'POST /users/bulk_unlock' => 'users#bulk_unlock' + should_route 'GET /users/44/avatar' => 'avatars#edit', :user_id => '44' + should_route 'PUT /users/44/avatar' => 'avatars#update', :user_id => '44' + should_route 'DELETE /users/44/avatar' => 'avatars#destroy', :user_id => '44' should_route 'GET /users/44/avatar/2/48' => 'avatars#show', :user_id => '44', :id => '2', :size => '48' end end -- 2.55.0