mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: Açık
|
||||
|
||||
@@ -38,6 +38,7 @@ zh-CN:
|
||||
general_subtitle: 配置个人偏好设置
|
||||
general_title: 通用设置
|
||||
language: 语言
|
||||
language_auto: 浏览器语言
|
||||
page_title: 偏好设置
|
||||
theme_dark: 深色模式
|
||||
theme_light: 浅色模式
|
||||
|
||||
@@ -35,6 +35,7 @@ zh-TW:
|
||||
default_period: 預設時段
|
||||
default_account_order: 預設帳戶排序
|
||||
language: 語言
|
||||
language_auto: 瀏覽器語言
|
||||
page_title: 偏好設定
|
||||
theme_dark: 深色
|
||||
theme_light: 淺色
|
||||
|
||||
6
db/migrate/20260124180211_add_locale_to_users.rb
Normal file
6
db/migrate/20260124180211_add_locale_to_users.rb
Normal 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
4
db/schema.rb
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user