diff options
Diffstat (limited to 'app')
22 files changed, 190 insertions, 89 deletions
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index 9f97ff622..a431e3557 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true class ActivityPub::OutboxesController < Api::BaseController + include SignatureVerification + before_action :set_account def show - @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) + @statuses = @account.statuses.permitted_for(@account, signed_request_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses, Status) render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 276c6b012..7534b5375 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -15,6 +15,7 @@ class ApplicationController < ActionController::Base helper_method :current_flavour helper_method :current_skin helper_method :single_user_mode? + helper_method :use_pam? rescue_from ActionController::RoutingError, with: :not_found rescue_from ActiveRecord::RecordNotFound, with: :not_found @@ -145,6 +146,10 @@ class ApplicationController < ActionController::Base @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.exists? end + def use_pam? + Devise.pam_authentication + end + def current_account @current_account ||= current_user.try(:account) end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 2b6a1bdbc..9b3ea4f27 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -15,6 +15,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController protected + def update_resource(resource, params) + params[:password] = nil if Devise.pam_authentication && resource.encrypted_password.blank? + super + end + def build_resource(hash = nil) super(hash) diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index f45d77b88..ce9cf98d7 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -29,7 +29,11 @@ class Auth::SessionsController < Devise::SessionsController if session[:otp_user_id] User.find(session[:otp_user_id]) elsif user_params[:email] - User.find_for_authentication(email: user_params[:email]) + if use_pam? && Devise.check_at_sign && user_params[:email].index('@').nil? + User.joins(:account).find_by(accounts: { username: user_params[:email] }) + else + User.find_for_authentication(email: user_params[:email]) + end end end diff --git a/app/controllers/concerns/signature_authentication.rb b/app/controllers/concerns/signature_authentication.rb new file mode 100644 index 000000000..beec93223 --- /dev/null +++ b/app/controllers/concerns/signature_authentication.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module SignatureAuthentication + extend ActiveSupport::Concern + + include SignatureVerification + + def current_account + super || signed_request_account + end +end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index d67fac0e5..61ffb97d9 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class StatusesController < ApplicationController + include SignatureAuthentication include Authorization layout 'public' diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index b597ba4bb..e2ea45c83 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -10,6 +10,7 @@ class StreamEntriesController < ApplicationController before_action :set_stream_entry before_action :set_link_headers before_action :check_account_suspension + before_action :set_cache_headers def show respond_to do |format| @@ -20,6 +21,10 @@ class StreamEntriesController < ApplicationController end format.atom do + unless @stream_entry.hidden? + skip_session! + expires_in 3.minutes, public: true + end render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(@stream_entry, true)) end end diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index c300db89b..6b79ec02d 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -133,9 +133,7 @@ export default class ColumnHeader extends React.PureComponent { <h1 className={buttonClassName}> <button onClick={this.handleTitleClick}> <i className={`fa fa-fw fa-${icon} column-header__icon`} /> - <span className='column-header__title'> - {title} - </span> + {title} </button> <div className='column-header__buttons'> diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index 84e3a2338..138bc4e2e 100644 --- a/app/javascript/mastodon/features/compose/index.js +++ b/app/javascript/mastodon/features/compose/index.js @@ -12,6 +12,7 @@ import Motion from '../ui/util/optional_motion'; import spring from 'react-motion/lib/spring'; import SearchResultsContainer from './containers/search_results_container'; import { changeComposing } from '../../actions/compose'; +import elephantUIPlane from '../../../images/elephant_ui_plane.svg'; const messages = defineMessages({ start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, @@ -94,7 +95,11 @@ export default class Compose extends React.PureComponent { <div className='drawer__inner' onFocus={this.onFocus}> <NavigationContainer onClose={this.onBlur} /> <ComposeFormContainer /> - {multiColumn && <div className='mastodon' />} + {multiColumn && ( + <div className='drawer__inner__mastodon'> + <img alt='' src={elephantUIPlane} /> + </div> + )} </div> <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index b023b208d..91f9739d7 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1766,7 +1766,7 @@ position: absolute; top: 0; left: 0; - background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-color)}"/></svg>') no-repeat bottom / 100% auto; + background: lighten($ui-base-color, 13%); box-sizing: border-box; padding: 0; display: flex; @@ -1779,10 +1779,19 @@ &.darker { background: $ui-base-color; } +} - > .mastodon { - background: url('~images/elephant_ui_plane.svg') no-repeat left bottom / contain; - flex: 1; +.drawer__inner__mastodon { + background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-color)}"/></svg>') no-repeat bottom / 100% auto; + flex: 1; + min-height: 47px; + + > img { + display: block; + object-fit: contain; + object-position: bottom left; + width: 100%; + height: 100%; } } @@ -1913,7 +1922,7 @@ font-family: inherit; color: $ui-highlight-color; cursor: pointer; - flex: 0 0 auto; + white-space: nowrap; font-size: 16px; padding: 0 5px 0 0; z-index: 3; @@ -2403,15 +2412,17 @@ overflow: hidden; & > button { - display: flex; - flex: auto; margin: 0; border: none; - padding: 15px; + padding: 15px 0 15px 15px; color: inherit; background: transparent; font: inherit; text-align: left; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex: 1; } &.active { @@ -2432,7 +2443,6 @@ .column-header__buttons { height: 48px; display: flex; - margin-left: 0; } .column-header__links .text-btn { @@ -2512,14 +2522,6 @@ } } -.column-header__title { - display: inline-block; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - flex: 1; -} - .text-btn { display: inline-block; padding: 0; diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index abf2b9b80..c8a358195 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -15,7 +15,8 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity account: @account, reblog: original_status, uri: @json['id'], - created_at: @options[:override_timestamps] ? nil : @json['published'] + created_at: @options[:override_timestamps] ? nil : @json['published'], + visibility: original_status.visibility ) distribute(status) @@ -35,6 +36,6 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity end def announceable?(status) - status.public_visibility? || status.unlisted_visibility? + status.account_id == @account.id || status.public_visibility? || status.unlisted_visibility? end end diff --git a/app/models/user.rb b/app/models/user.rb index 603b72e2b..6ef6db915 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -34,6 +34,7 @@ # disabled :boolean default(FALSE), not null # moderator :boolean default(FALSE), not null # invite_id :integer +# remember_token :string # class User < ApplicationRecord @@ -50,6 +51,8 @@ class User < ApplicationRecord devise :registerable, :recoverable, :rememberable, :trackable, :validatable, :confirmable + devise :pam_authenticatable + belongs_to :account, inverse_of: :user belongs_to :invite, counter_cache: :uses, optional: true accepts_nested_attributes_for :account @@ -84,6 +87,33 @@ class User < ApplicationRecord attr_accessor :invite_code + def pam_conflict(_) + # block pam login tries on traditional account + nil + end + + def pam_conflict? + return false unless Devise.pam_authentication + encrypted_password.present? && is_pam_account? + end + + def pam_get_name + return account.username if account.present? + super + end + + def pam_setup(_attributes) + acc = Account.new(username: pam_get_name) + acc.save!(validate: false) + + self.email = "#{acc.username}@#{find_pam_suffix}" if email.nil? && find_pam_suffix + self.confirmed_at = Time.now.utc + self.admin = false + self.account = acc + + acc.destroy! unless save + end + def confirmed? confirmed_at.present? end @@ -213,6 +243,45 @@ class User < ApplicationRecord @invite_code = code end + def password_required? + return false if Devise.pam_authentication + super + end + + def send_reset_password_instructions + return false if encrypted_password.blank? && Devise.pam_authentication + super + end + + def reset_password!(new_password, new_password_confirmation) + return false if encrypted_password.blank? && Devise.pam_authentication + super + end + + def self.pam_get_user(attributes = {}) + if attributes[:email] + resource = + if Devise.check_at_sign && !attributes[:email].index('@') + joins(:account).find_by(accounts: { username: attributes[:email] }) + else + find_by(email: attributes[:email]) + end + + if resource.blank? + resource = new(email: attributes[:email]) + if Devise.check_at_sign && !resource[:email].index('@') + resource[:email] = "#{attributes[:email]}@#{resource.find_pam_suffix}" + end + end + resource + end + end + + def self.authenticate_with_pam(attributes = {}) + return nil unless Devise.pam_authentication + super + end + protected def send_devise_notification(notification, *args) diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index d0472a1d7..3e31a4145 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -91,13 +91,13 @@ class FetchLinkCardService < BaseService case @card.type when 'link' - @card.image = URI.parse(embed.thumbnail_url) if embed.respond_to?(:thumbnail_url) + @card.image_remote_url = embed.thumbnail_url if embed.respond_to?(:thumbnail_url) when 'photo' return false unless embed.respond_to?(:url) - @card.embed_url = embed.url - @card.image = URI.parse(embed.url) - @card.width = embed.width.presence || 0 - @card.height = embed.height.presence || 0 + @card.embed_url = embed.url + @card.image_remote_url = embed.url + @card.width = embed.width.presence || 0 + @card.height = embed.height.presence || 0 when 'video' @card.width = embed.width.presence || 0 @card.height = embed.height.presence || 0 diff --git a/app/validators/unreserved_username_validator.rb b/app/validators/unreserved_username_validator.rb index 44ea4359b..c2311a89a 100644 --- a/app/validators/unreserved_username_validator.rb +++ b/app/validators/unreserved_username_validator.rb @@ -8,7 +8,13 @@ class UnreservedUsernameValidator < ActiveModel::Validator private + def pam_controlled?(value) + return false unless Devise.pam_authentication && Devise.pam_controlled_service + Rpam2.account(Devise.pam_controlled_service, value).present? + end + def reserved_username?(value) + return true if pam_controlled?(value) return false unless Setting.reserved_usernames Setting.reserved_usernames.include?(value.downcase) end diff --git a/app/views/about/_links.html.haml b/app/views/about/_links.html.haml new file mode 100644 index 000000000..ccf4f08b9 --- /dev/null +++ b/app/views/about/_links.html.haml @@ -0,0 +1,16 @@ +.container.links + .brand + = link_to root_url do + = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' + + %ul.nav + %li + - if user_signed_in? + = link_to t('settings.back'), root_url, class: 'webapp-btn' + - else + = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn' + %li= link_to t('about.about_this'), about_more_path + %li + = link_to 'https://joinmastodon.org/' do + = "#{t('about.other_instances')}" + %i.fa.fa-external-link{ style: 'padding-left: 5px;' } diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index d92362bd7..84daadba8 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -7,22 +7,7 @@ .landing-page .header-wrapper.compact .header - .container.links - .brand - = link_to root_url do - = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' - - %ul.nav - %li - - if user_signed_in? - = link_to t('settings.back'), root_url, class: 'webapp-btn' - - else - = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn' - %li= link_to t('about.about_this'), about_more_path - %li - = link_to 'https://joinmastodon.org/' do - = "#{t('about.other_instances')}" - %i.fa.fa-external-link{ style: 'padding-left: 5px;' } + = render 'links' .container.hero .heading diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml index 4f5b53470..487c8429b 100644 --- a/app/views/about/show.html.haml +++ b/app/views/about/show.html.haml @@ -11,22 +11,7 @@ = image_tag asset_pack_path('elephant-fren.png'), alt: '', role: 'presentation', class: 'mascot' .header - .container.links - .brand - = link_to root_url do - = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' - - %ul.nav - %li - - if user_signed_in? - = link_to t('settings.back'), root_url, class: 'webapp-btn' - - else - = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn' - %li= link_to t('about.about_this'), about_more_path - %li - = link_to 'https://joinmastodon.org/' do - = "#{t('about.other_instances')}" - %i.fa.fa-external-link{ style: 'padding-left: 5px;' } + = render 'links' .container.hero .floats diff --git a/app/views/about/terms.html.haml b/app/views/about/terms.html.haml index 7004cb0b1..ba780759c 100644 --- a/app/views/about/terms.html.haml +++ b/app/views/about/terms.html.haml @@ -4,19 +4,7 @@ .landing-page .header-wrapper.compact .header - .container.links - .brand - = link_to root_url do - = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' - - %ul.nav - %li - - if user_signed_in? - = link_to t('settings.back'), root_url, class: 'webapp-btn' - - else - = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn' - %li= link_to t('about.about_this'), about_more_path - %li= link_to t('about.other_instances'), 'https://joinmastodon.org/' + = render 'links' .extended-description .container diff --git a/app/views/auth/passwords/edit.html.haml b/app/views/auth/passwords/edit.html.haml index 5ef3de976..d8fed9e77 100644 --- a/app/views/auth/passwords/edit.html.haml +++ b/app/views/auth/passwords/edit.html.haml @@ -1,14 +1,18 @@ - content_for :page_title do = t('auth.set_new_password') -= simple_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| - = render 'shared/error_messages', object: resource - = f.input :reset_password_token, as: :hidden + = simple_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| + = render 'shared/error_messages', object: resource - = f.input :password, autofocus: true, placeholder: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off' } - = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' } + - if use_pam? || current_user.encrypted_password.present? + = f.input :reset_password_token, as: :hidden - .actions - = f.button :button, t('auth.set_new_password'), type: :submit + = f.input :password, autofocus: true, placeholder: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off' } + = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' } + + .actions + = f.button :button, t('auth.set_new_password'), type: :submit + - else + = t('simple_form.labels.defaults.pam_account') .form-footer= render 'auth/shared/links' diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml index 145f5cd9e..102199f81 100644 --- a/app/views/auth/registrations/edit.html.haml +++ b/app/views/auth/registrations/edit.html.haml @@ -4,13 +4,16 @@ = simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: 'auth_edit' }) do |f| = render 'shared/error_messages', object: resource - = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } - = f.input :password, placeholder: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off' } - = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' } - = f.input :current_password, placeholder: t('simple_form.labels.defaults.current_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'off' } + - if !use_pam? || current_user.encrypted_password.present? + = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } + = f.input :password, placeholder: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off' } + = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' } + = f.input :current_password, placeholder: t('simple_form.labels.defaults.current_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'off' } - .actions - = f.button :button, t('generic.save_changes'), type: :submit + .actions + = f.button :button, t('generic.save_changes'), type: :submit + - else + = t('simple_form.labels.defaults.pam_account') %hr/ diff --git a/app/views/auth/sessions/new.html.haml b/app/views/auth/sessions/new.html.haml index a52b0053b..3edb0d2d4 100644 --- a/app/views/auth/sessions/new.html.haml +++ b/app/views/auth/sessions/new.html.haml @@ -5,7 +5,10 @@ = render partial: 'shared/og' = simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| - = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } + - if use_pam? + = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.username_or_email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') } + - else + = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' } .actions diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index d1459d93c..a2a17d0d6 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -4,6 +4,9 @@ = simple_form_for current_user, url: settings_preferences_path, html: { method: :put } do |f| = render 'shared/error_messages', object: current_user + .actions + = f.button :button, t('generic.save_changes'), type: :submit + %h4= t 'preferences.languages' .fields-group |