Use browser Accept-Language for login and onboarding locale (#768)

* Use Accept-Language for unauthenticated locale

* Add per-user locale overrides

* Fix test

* Use more than the top `accept-language` entry

* Localization of string
This commit is contained in:
Juan José Mata
2026-01-24 22:00:41 +01:00
committed by GitHub
parent 959029fda6
commit c7ab25b866
21 changed files with 163 additions and 17 deletions

View File

@@ -8,12 +8,104 @@ module Localize
private
def switch_locale(&action)
locale = locale_from_param || Current.family.try(:locale) || I18n.default_locale
locale = locale_from_param || locale_from_user || locale_from_accept_language || locale_from_family || I18n.default_locale
I18n.with_locale(locale, &action)
end
def locale_from_user
locale = Current.user&.locale
return if locale.blank?
locale_sym = locale.to_sym
locale_sym if I18n.available_locales.include?(locale_sym)
end
def locale_from_family
locale = Current.family&.locale
return if locale.blank?
locale_sym = locale.to_sym
locale_sym if I18n.available_locales.include?(locale_sym)
end
def locale_from_accept_language
locale = accept_language_top_locale
return if locale.blank?
locale_sym = locale.to_sym
return unless I18n.available_locales.include?(locale_sym)
# Auto-save detected locale to user profile (once per user, not per session)
if Current.user.present? && Current.user.locale.blank?
Current.user.update_column(:locale, locale_sym.to_s)
end
locale_sym
end
def accept_language_top_locale
header = request.get_header("HTTP_ACCEPT_LANGUAGE")
return if header.blank?
# Parse language;q pairs and sort by q-value (descending), preserving header order for ties
parsed_languages = parse_accept_language(header)
return if parsed_languages.empty?
# Find first supported locale by q-value priority
parsed_languages.each do |lang, _q|
normalized = normalize_locale(lang)
canonical = supported_locales[normalized.downcase]
return canonical if canonical.present?
primary_language = normalized.split("-").first
primary_match = supported_locales[primary_language.downcase]
return primary_match if primary_match.present?
end
nil
end
def parse_accept_language(header)
entries = []
header.split(",").each_with_index do |entry, index|
parts = entry.split(";")
language = parts.first.to_s.strip
next if language.blank?
# Extract q-value, default to 1.0
q_value = 1.0
parts[1..].each do |param|
param = param.strip
if param.start_with?("q=")
q_str = param[2..]
q_value = Float(q_str) rescue 1.0
q_value = q_value.clamp(0.0, 1.0)
break
end
end
entries << [ language, q_value, index ]
end
# Sort by q-value descending, then by original header order ascending
entries.sort_by { |_lang, q, idx| [ -q, idx ] }.map { |lang, q, _idx| [ lang, q ] }
end
def supported_locales
@supported_locales ||= LanguagesHelper::SUPPORTED_LOCALES.each_with_object({}) do |locale, locales|
normalized = normalize_locale(locale)
locales[normalized.downcase] = normalized
end
end
def normalize_locale(locale)
locale.to_s.strip.gsub("_", "-")
end
def locale_from_param
return unless params[:locale].is_a?(String) && params[:locale].present?
locale = params[:locale].to_sym
locale if I18n.available_locales.include?(locale)
end

View File

@@ -105,8 +105,8 @@ class UsersController < ApplicationController
def user_params
params.require(:user).permit(
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
:show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at,
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ],
:show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at, :locale,
family_attributes: [ :name, :currency, :country, :date_format, :timezone, :locale, :id ],
goals: []
)
end

View File

@@ -39,6 +39,7 @@ class User < ApplicationRecord
validate :ensure_valid_profile_image
validates :default_period, inclusion: { in: Period::PERIODS.keys }
validates :default_account_order, inclusion: { in: AccountOrder::ORDERS.keys }
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }, allow_nil: true
# Password is required on create unless the user is being created via SSO JIT.
# SSO JIT users have password_digest = nil and authenticate via OIDC only.

View File

@@ -76,7 +76,7 @@
<%= family_form.select :locale,
language_options,
{ label: t(".locale"), required: true, selected: params[:locale] || @user.family.locale },
{ label: t(".locale"), required: true, selected: params[:locale] || @user.locale || I18n.locale },
{ data: { action: "onboarding#setLocale" } } %>
<%= family_form.select :currency,

View File

@@ -5,16 +5,16 @@
<%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
<%= form.hidden_field :redirect_to, value: "preferences" %>
<%= form.select :locale,
language_options,
{ label: t(".language"), include_blank: t(".language_auto") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= form.fields_for :family do |family_form| %>
<%= family_form.select :currency,
Money::Currency.as_options.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] },
{ label: t(".currency") }, disabled: true %>
<%= family_form.select :locale,
language_options,
{ label: t(".language") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :timezone,
timezone_options,
{ label: t(".timezone") },

View File

@@ -41,6 +41,7 @@ ca:
general_subtitle: Configura les teves preferències
general_title: General
language: Idioma
language_auto: Idioma del navegador
page_title: Preferències
theme_dark: Fosc
theme_light: Clar

View File

@@ -34,6 +34,7 @@ de:
default_period: Standardzeitraum
default_account_order: Standardreihenfolge der Konten
language: Sprache
language_auto: Browsersprache
page_title: Einstellungen
theme_dark: Dunkel
theme_light: Hell

View File

@@ -36,6 +36,7 @@ en:
default_period: Default Period
default_account_order: Default Account Order
language: Language
language_auto: Browser language
page_title: Preferences
theme_dark: Dark
theme_light: Light

View File

@@ -35,6 +35,7 @@ es:
default_period: Periodo Predeterminado
default_account_order: Orden Predeterminado de Cuentas
language: Idioma
language_auto: Idioma del navegador
page_title: Preferencias
theme_dark: Oscuro
theme_light: Claro

View File

@@ -35,6 +35,7 @@ fr:
default_period: Période par défaut
default_account_order: Ordre d'affichage des comptes par défaut
language: Langue
language_auto: Langue du navigateur
page_title: Préférences
theme_dark: Sombre
theme_light: Clair

View File

@@ -19,6 +19,7 @@ nb:
general_title: Generelt
default_period: Standardperiode
language: Språk
language_auto: Nettleserens språk
page_title: Preferanser
theme_dark: Mørk
theme_light: Lys

View File

@@ -35,6 +35,7 @@ nl:
default_period: Standaard Periode
default_account_order: Standaard Account Volgorde
language: Taal
language_auto: Browsertaal
page_title: Voorkeuren
theme_dark: Donker
theme_light: Licht

View File

@@ -34,6 +34,7 @@ pt-BR:
general_title: Geral
default_period: Período Padrão
language: Idioma
language_auto: Idioma do navegador
page_title: Preferências
theme_dark: Escuro
theme_light: Claro

View File

@@ -35,6 +35,7 @@ ro:
default_period: Perioadă implicită
default_account_order: Ordine implicită conturi
language: Limbă
language_auto: Limba browserului
page_title: Preferințe
theme_dark: Întunecat
theme_light: Luminos

View File

@@ -19,6 +19,7 @@ tr:
general_title: Genel
default_period: Varsayılan Dönem
language: Dil
language_auto: Tarayıcı dili
page_title: Tercihler
theme_dark: Koyu
theme_light: ık

View File

@@ -38,6 +38,7 @@ zh-CN:
general_subtitle: 配置个人偏好设置
general_title: 通用设置
language: 语言
language_auto: 浏览器语言
page_title: 偏好设置
theme_dark: 深色模式
theme_light: 浅色模式

View File

@@ -35,6 +35,7 @@ zh-TW:
default_period: 預設時段
default_account_order: 預設帳戶排序
language: 語言
language_auto: 瀏覽器語言
page_title: 偏好設定
theme_dark: 深色
theme_light: 淺色

View File

@@ -0,0 +1,6 @@
class AddLocaleToUsers < ActiveRecord::Migration[7.2]
def change
add_column :users, :locale, :string, null: true, default: nil
add_index :users, :locale
end
end

4
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_01_23_214127) do
ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -1402,9 +1402,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_23_214127) do
t.datetime "set_onboarding_goals_at"
t.string "default_account_order", default: "name_asc"
t.jsonb "preferences", default: {}, null: false
t.string "locale"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["family_id"], name: "index_users_on_family_id"
t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id"
t.index ["locale"], name: "index_users_on_locale"
t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)"
t.index ["preferences"], name: "index_users_on_preferences", using: :gin
end

View File

@@ -1,23 +1,55 @@
require "test_helper"
class LocalizeTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
test "uses Accept-Language top locale on login when supported" do
get new_session_url, headers: { "Accept-Language" => "fr-CA,fr;q=0.9" }
assert_response :success
assert_select "button", text: /Se connecter/i
end
test "uses family locale by default" do
get preferences_onboarding_url
test "falls back to English when Accept-Language is unsupported" do
get new_session_url, headers: { "Accept-Language" => "ru-RU,ru;q=0.9" }
assert_response :success
assert_select "button", text: /Log in/i
end
test "uses Accept-Language for onboarding when user locale is not set" do
sign_in users(:family_admin)
get preferences_onboarding_url, headers: { "Accept-Language" => "es-ES,es;q=0.9" }
assert_response :success
assert_select "h1", text: /Configura tus preferencias/i
end
test "falls back to family locale when Accept-Language is unsupported" do
sign_in users(:family_admin)
get preferences_onboarding_url, headers: { "Accept-Language" => "ru-RU,ru;q=0.9" }
assert_response :success
assert_select "h1", text: /Configure your preferences/i
end
test "respects user locale override even when Accept-Language differs" do
user = users(:family_admin)
user.update!(locale: "fr")
sign_in user
get preferences_onboarding_url, headers: { "Accept-Language" => "es-ES,es;q=0.9" }
assert_response :success
assert_select "h1", text: /Configurez vos préférences/i
end
test "switches locale when locale param is provided" do
sign_in users(:family_admin)
get preferences_onboarding_url(locale: "fr")
assert_response :success
assert_select "h1", text: /Configurez vos préférences/i
end
test "ignores invalid locale param and uses family locale" do
sign_in users(:family_admin)
get preferences_onboarding_url(locale: "invalid_locale")
assert_response :success
assert_select "h1", text: /Configure your preferences/i

View File

@@ -21,14 +21,15 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
name: "New Family Name",
country: "US",
date_format: "%m/%d/%Y",
currency: "USD",
locale: "en"
}
currency: "USD"
},
locale: "es"
}
}
assert_redirected_to settings_profile_url
assert_equal "Your profile has been updated.", flash[:notice]
assert_equal "es", @user.reload.locale
end
test "admin can reset family data" do