From ebd982e3a2f8702e015ff7ac68b82f10c81a85ae Mon Sep 17 00:00:00 2001 From: dadachi Date: Sat, 10 Oct 2026 21:43:39 +0900 Subject: [PATCH 1/4] Add Japanese locale, chosen from Accept-Language Responses, validation errors, HTML pages and emails are now in English or Japanese. SetLocale picks the locale per request from Accept-Language (quality values, regional tags such as ja-JP), falling back to English. It wraps process_action rather than using around_action, because rescue_from handlers and rate_limit responses run outside the callback chain. Mails queued during a request keep its locale. Japanese lives in config/locales/ja.yml (app messages and attribute names), rails.ja.yml (Active Model/Record validation messages) and devise.ja.yml, written by hand instead of adding rails-i18n or devise-i18n. The error pages, ErrorsController messages, the sample item tag and the mail greetings moved from hard-coded English to locale keys; English output is unchanged. test/i18n/locale_files_test.rb fails on any English key without a Japanese translation (app keys plus the Rails, Devise and devise_token_auth scopes) or a Japanese-only %{variable}. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WyUzNEPWyhjjWrBcSaXSW4 --- AGENTS.md | 9 +- app/controllers/application_controller.rb | 2 + app/controllers/concerns/set_locale.rb | 41 ++++ app/controllers/errors_controller.rb | 4 +- .../non_api_application_controller.rb | 2 + app/models/shop.rb | 4 +- .../errors/internal_server_error.html.erb | 4 +- app/views/errors/not_found.html.erb | 4 +- .../confirmation_instructions.html.erb | 2 +- .../confirmation_instructions.text.erb | 2 +- .../reset_password_instructions.html.erb | 2 +- .../reset_password_instructions.text.erb | 2 +- config/application.rb | 2 +- config/locales/devise.ja.yml | 67 +++++++ config/locales/en.yml | 17 +- config/locales/ja.yml | 183 ++++++++++++++++++ config/locales/rails.ja.yml | 51 +++++ docs/openapi.yaml | 5 +- test/i18n/locale_files_test.rb | 67 +++++++ test/integration/locale_test.rb | 163 ++++++++++++++++ 20 files changed, 616 insertions(+), 17 deletions(-) create mode 100644 app/controllers/concerns/set_locale.rb create mode 100644 config/locales/devise.ja.yml create mode 100644 config/locales/ja.yml create mode 100644 config/locales/rails.ja.yml create mode 100644 test/i18n/locale_files_test.rb create mode 100644 test/integration/locale_test.rb diff --git a/AGENTS.md b/AGENTS.md index e5718cc..532993c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,7 +103,14 @@ Cross-platform push via the `noticed` (v3) and `action_push_native` gems. APNs f - `json_response` for parsing JSON API responses - `create_new_auth_token` for generating auth headers (Devise Token Auth) - Fixtures in test/fixtures/ and seed data in db/fixtures/test/ -- Run tests: `bin/rails test` (540 tests, 1342 assertions) +- Run tests: `bin/rails test` (561 tests, 1398 assertions) + +### Internationalization +- Responses are in English (default) or Japanese, picked per request from `Accept-Language` by the `SetLocale` concern (included in `ApplicationController` and `NonApiApplicationController`). It wraps `process_action`, not `around_action`, so `rescue_from` handlers and `rate_limit` responses are localized too. Mails queued during a request keep its locale (Active Job stores it). +- iOS sends `Accept-Language` from the device's preferred languages automatically; an Android client must add it (e.g. an OkHttp interceptor), or it gets English. +- Locale files: `config/locales/en.yml` / `ja.yml` (app messages and attribute names), `rails.ja.yml` (Active Model/Record validation messages), `devise.ja.yml`. devise_token_auth ships its own `ja.yml`. No `rails-i18n` or `devise-i18n` gem. +- When you add or change a key in `en.yml`, add it to `ja.yml`: `test/i18n/locale_files_test.rb` fails on any missing Japanese key or Japanese-only `%{variable}`. +- User-facing text belongs in the locale files, not in Ruby or views (model defaults such as the sample item tag included). The admin area (`/madmin`, `/admin_auth`) stays English. ### Development Server Configuration - `HOST` (Wi-Fi IP) and `PORT` are required in `.env`; `Procfile.dev` uses `${HOST:?...}` so Rails fails loudly if unset, and `development.rb` uses `ENV.fetch("HOST")` for `action_mailer.default_url_options`. Must match `NATIVEAPPTEMPLATE_API_DOMAIN` in the iOS scheme and Android `gradle.properties`. Never `127.0.0.1`, `localhost`, or `0.0.0.0`. diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 61fac7d..160b8c3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,6 @@ class ApplicationController < ActionController::API + include SetLocale + # Per-IP cap on every API and auth request, shared with the HTML pages # through the scope. Sign-in, sign-up and invitation lookups add tighter # limits of their own. diff --git a/app/controllers/concerns/set_locale.rb b/app/controllers/concerns/set_locale.rb new file mode 100644 index 0000000..afffcf7 --- /dev/null +++ b/app/controllers/concerns/set_locale.rb @@ -0,0 +1,41 @@ +# Answers in the language the client prefers in Accept-Language, among +# I18n.available_locales; anything else gets the default (English). iOS sends +# the header from the device's preferred languages, e.g. "ja-JP, en-JP;q=0.9". +# +# Mails queued during the request keep this locale: Active Job stores +# I18n.locale with each job. +# +# This wraps process_action rather than using around_action: rescue_from +# handlers and rate_limit responses run outside the callback chain, and they +# must answer in the client's language too. +module SetLocale + private + + def process_action(...) + I18n.with_locale(locale_from_accept_language) { super } + end + + def locale_from_accept_language + preferred_languages.each do |language| + locale = language.split("-").first.to_sym + return locale if I18n.available_locales.include?(locale) + end + + I18n.default_locale + end + + # Language tags by descending quality value, header order breaking ties. + # Tags the client refuses (q=0) or that fail to parse are dropped. + def preferred_languages + request.headers["Accept-Language"].to_s.split(",").filter_map.with_index do |entry, index| + tag, *params = entry.split(";").map(&:strip) + next if tag.blank? + + q_param = params.find { |param| param.start_with?("q=") } + quality = q_param ? Float(q_param.delete_prefix("q="), exception: false) : 1.0 + next if quality.nil? || quality <= 0 + + [tag.downcase, quality, index] + end.sort_by { |_, quality, index| [-quality, index] }.map(&:first) + end +end diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb index 6643258..40da45d 100644 --- a/app/controllers/errors_controller.rb +++ b/app/controllers/errors_controller.rb @@ -3,11 +3,11 @@ class ErrorsController < NonApiApplicationController API_PATH = %r{\A/api/} def not_found - render_error(404, "Not found.") + render_error(404, t("not_found")) end def internal_server_error - render_error(500, "Internal server error.") + render_error(500, t("internal_server_error")) end private diff --git a/app/controllers/non_api_application_controller.rb b/app/controllers/non_api_application_controller.rb index c8412fe..bc643b1 100644 --- a/app/controllers/non_api_application_controller.rb +++ b/app/controllers/non_api_application_controller.rb @@ -1,4 +1,6 @@ class NonApiApplicationController < ActionController::Base + include SetLocale + allow_browser versions: :modern protect_from_forgery with: :exception diff --git a/app/models/shop.rb b/app/models/shop.rb index 2f6359d..25a63d5 100644 --- a/app/models/shop.rb +++ b/app/models/shop.rb @@ -17,8 +17,8 @@ class Shop < ApplicationRecord def create_sample_item_tag item_tags.create!( account: account, - name: "Sample", - description: "This is a sample. You can update or delete it.", + name: I18n.t("sample_item_tag.name"), + description: I18n.t("sample_item_tag.description"), position: 1, created_by: created_by ) diff --git a/app/views/errors/internal_server_error.html.erb b/app/views/errors/internal_server_error.html.erb index e3778c1..78f0f4c 100644 --- a/app/views/errors/internal_server_error.html.erb +++ b/app/views/errors/internal_server_error.html.erb @@ -1,7 +1,7 @@

500

-

Internal server error

-

Sorry, we had a problem loading this page.

+

<%= t("error_pages.internal_server_error.title") %>

+

<%= t("error_pages.internal_server_error.message") %>

diff --git a/app/views/errors/not_found.html.erb b/app/views/errors/not_found.html.erb index a813210..d216f11 100644 --- a/app/views/errors/not_found.html.erb +++ b/app/views/errors/not_found.html.erb @@ -1,7 +1,7 @@

404

-

Page not found

-

Sorry, we couldn’t find the page you’re looking for.

+

<%= t("error_pages.not_found.title") %>

+

<%= t("error_pages.not_found.message") %>

diff --git a/app/views/shopkeeper/notification_mailer/confirmation_instructions.html.erb b/app/views/shopkeeper/notification_mailer/confirmation_instructions.html.erb index 0a88cf8..d5a9656 100644 --- a/app/views/shopkeeper/notification_mailer/confirmation_instructions.html.erb +++ b/app/views/shopkeeper/notification_mailer/confirmation_instructions.html.erb @@ -1,4 +1,4 @@ -

<%= "#{t(:welcome).capitalize} #{@email}!" %>

+

<%= t(:welcome_email, email: @email) %>

<%= t ".confirm_link_msg" %>

diff --git a/app/views/shopkeeper/notification_mailer/confirmation_instructions.text.erb b/app/views/shopkeeper/notification_mailer/confirmation_instructions.text.erb index b74f50a..74a564e 100644 --- a/app/views/shopkeeper/notification_mailer/confirmation_instructions.text.erb +++ b/app/views/shopkeeper/notification_mailer/confirmation_instructions.text.erb @@ -1,4 +1,4 @@ -<%= "#{t(:welcome).capitalize} #{@email}!" %> +<%= t(:welcome_email, email: @email) %> <%= t ".confirm_link_msg" %> diff --git a/app/views/shopkeeper/notification_mailer/reset_password_instructions.html.erb b/app/views/shopkeeper/notification_mailer/reset_password_instructions.html.erb index f4eb0af..653e52a 100644 --- a/app/views/shopkeeper/notification_mailer/reset_password_instructions.html.erb +++ b/app/views/shopkeeper/notification_mailer/reset_password_instructions.html.erb @@ -1,4 +1,4 @@ -

<%= t(:hello).capitalize %> <%= @resource.email %>!

+

<%= t(:hello_email, email: @resource.email) %>

<%= t ".request_reset_link_msg" %>

diff --git a/app/views/shopkeeper/notification_mailer/reset_password_instructions.text.erb b/app/views/shopkeeper/notification_mailer/reset_password_instructions.text.erb index b93eea2..2907146 100644 --- a/app/views/shopkeeper/notification_mailer/reset_password_instructions.text.erb +++ b/app/views/shopkeeper/notification_mailer/reset_password_instructions.text.erb @@ -1,4 +1,4 @@ -<%= "#{t(:hello).capitalize} #{@resource.email}!" %> +<%= t(:hello_email, email: @resource.email) %> <%= t ".request_reset_link_msg" %> diff --git a/config/application.rb b/config/application.rb index 0d0f96c..fd9a8ed 100644 --- a/config/application.rb +++ b/config/application.rb @@ -32,7 +32,7 @@ class Application < Rails::Application config.i18n.load_path += Dir[Rails.root.join("config/locales/**/*.{rb,yml}")] # Permitted locales available for the application - config.i18n.available_locales = [:en] + config.i18n.available_locales = [:en, :ja] # Set default locale config.i18n.default_locale = :en diff --git a/config/locales/devise.ja.yml b/config/locales/devise.ja.yml new file mode 100644 index 0000000..cb8ef4c --- /dev/null +++ b/config/locales/devise.ja.yml @@ -0,0 +1,67 @@ +# Japanese for Devise's own messages (Devise ships only en.yml). Most API +# responses use devise_token_auth's keys instead; these still reach clients, +# e.g. devise.failure.unauthenticated on a request without a valid token. + +ja: + devise: + confirmations: + confirmed: "メールアドレスを確認しました。" + send_instructions: "メールアドレスの確認方法を記載したメールを、数分以内にお送りします。" + send_paranoid_instructions: "メールアドレスが登録されている場合、メールアドレスの確認方法を記載したメールを数分以内にお送りします。" + failure: + already_authenticated: "すでにサインインしています。" + inactive: "アカウントがまだ有効になっていません。" + invalid: "%{authentication_keys}またはパスワードが正しくありません。" + locked: "アカウントはロックされています。" + last_attempt: "あと1回失敗すると、アカウントがロックされます。" + not_found_in_database: "%{authentication_keys}またはパスワードが正しくありません。" + timeout: "セッションの有効期限が切れました。もう一度サインインしてください。" + unauthenticated: "サインインまたは新規登録してから続けてください。" + unconfirmed: "メールアドレスを確認してから続けてください。" + mailer: + confirmation_instructions: + subject: "メールアドレス確認のご案内" + reset_password_instructions: + subject: "パスワード再設定のご案内" + unlock_instructions: + subject: "アカウントのロック解除のご案内" + email_changed: + subject: "メールアドレス変更のお知らせ" + password_change: + subject: "パスワード変更のお知らせ" + omniauth_callbacks: + failure: "%{kind} での認証に失敗しました。理由: \"%{reason}\"" + success: "%{kind} アカウントで認証しました。" + passwords: + no_token: "このページには、パスワード再設定メールのリンクからのみアクセスできます。メールのリンクから来た場合は、URL 全体を使っているか確認してください。" + send_instructions: "パスワードの再設定方法を記載したメールを、数分以内にお送りします。" + send_paranoid_instructions: "メールアドレスが登録されている場合、パスワード再設定用のリンクを数分以内にお送りします。" + updated: "パスワードを変更しました。サインインしました。" + updated_not_active: "パスワードを変更しました。" + registrations: + destroyed: "アカウントを削除しました。ご利用ありがとうございました。" + signed_up: "新規登録が完了しました。" + signed_up_but_inactive: "新規登録が完了しました。ただし、アカウントが有効になっていないため、サインインできませんでした。" + signed_up_but_locked: "新規登録が完了しました。ただし、アカウントがロックされているため、サインインできませんでした。" + signed_up_but_unconfirmed: "確認用のリンクを記載したメールを送信しました。リンクを開いてアカウントを有効にしてください。" + update_needs_confirmation: "アカウントを更新しました。新しいメールアドレスの確認が必要です。メールに記載された確認用のリンクを開いてください。" + updated: "アカウントを更新しました。" + updated_but_not_signed_in: "アカウントを更新しました。パスワードが変わったため、もう一度サインインしてください。" + sessions: + signed_in: "サインインしました。" + signed_out: "サインアウトしました。" + already_signed_out: "サインアウトしました。" + unlocks: + send_instructions: "アカウントのロック解除方法を記載したメールを、数分以内にお送りします。" + send_paranoid_instructions: "アカウントが存在する場合、ロック解除方法を記載したメールを数分以内にお送りします。" + unlocked: "アカウントのロックを解除しました。続けるにはサインインしてください。" + errors: + messages: + already_confirmed: "はすでに確認済みです。サインインしてください" + confirmation_period_expired: "の確認期限(%{period})が過ぎました。もう一度リクエストしてください" + expired: "の有効期限が切れました。もう一度リクエストしてください" + not_found: "が見つかりません" + not_locked: "はロックされていません" + not_saved: + one: "エラーが%{count}件あるため、%{resource}を保存できませんでした:" + other: "エラーが%{count}件あるため、%{resource}を保存できませんでした:" diff --git a/config/locales/en.yml b/config/locales/en.yml index cb0a91d..4c4c410 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -30,8 +30,8 @@ # available at https://guides.rubyonrails.org/i18n.html. en: - hello: "hello" - welcome: "welcome" + hello_email: "Hello %{email}!" + welcome_email: "Welcome %{email}!" change_my_password: "Change my password" change_your_password: "Change your password" confirm_new_password: "Confirm new password" @@ -40,9 +40,22 @@ en: not_destroyed: "Could not be deleted." not_found: "Not found." something_went_wrong: "Something went wrong" + internal_server_error: "Internal server error." unauthorized: "You are not authorized to perform this action." default_shop_description: "This is a sample shop.\nYou can update or remove this shop." + sample_item_tag: + name: "Sample" + description: "This is a sample. You can update or delete it." + + error_pages: + not_found: + title: "Page not found" + message: "Sorry, we couldn’t find the page you’re looking for." + internal_server_error: + title: "Internal server error" + message: "Sorry, we had a problem loading this page." + notifiers: item_tag: title: "%{shop}" diff --git a/config/locales/ja.yml b/config/locales/ja.yml new file mode 100644 index 0000000..63695b7 --- /dev/null +++ b/config/locales/ja.yml @@ -0,0 +1,183 @@ +# Japanese for the app's own messages. Keep the keys in step with en.yml: +# test/i18n/locale_files_test.rb fails when a key is missing here. +# +# Rails' validation messages are in rails.ja.yml and Devise's in devise.ja.yml. +# devise_token_auth ships its own ja.yml; the keys below override it where +# en.yml overrides the English. + +ja: + hello_email: "%{email} 様" + welcome_email: "%{email} 様、ようこそ。" + change_my_password: "パスワードを変更する" + change_your_password: "パスワードの変更" + confirm_new_password: "新しいパスワード(確認)" + new_password: "新しいパスワード" + bad_request: "リクエストが正しくありません。" + not_destroyed: "削除できませんでした。" + not_found: "見つかりませんでした。" + something_went_wrong: "問題が発生しました" + internal_server_error: "サーバーでエラーが発生しました。" + unauthorized: "この操作を行う権限がありません。" + default_shop_description: "これはサンプルのショップです。\n編集や削除ができます。" + + sample_item_tag: + name: "サンプル" + description: "これはサンプルです。編集や削除ができます。" + + error_pages: + not_found: + title: "ページが見つかりません" + message: "お探しのページは見つかりませんでした。" + internal_server_error: + title: "サーバーエラー" + message: "ページの読み込み中に問題が発生しました。" + + notifiers: + item_tag: + title: "%{shop}" + body: "%{name}" + + api: + shopkeeper: + accounts: + personal: + cannot_delete: "個人の組織は削除できません。" + accounts_invitations: + not_found: "招待が見つかりませんでした。組織の管理者に新しい招待を依頼してください。" + expired: "この招待は有効期限が切れています。組織の管理者に招待の再送を依頼してください。" + accounts_shopkeepers: + require_non_personal_account: "個人の組織では利用できません。" + item_tags: + not_found: "アイテムタグが見つかりませんでした。" + + shopkeeper: + notification_mailer: + invited: + subject: "%{inviter}さんから%{account}への招待が届いています" + header_html: "%{inviter}さんが%{account}にあなたを招待しました。" + you_can_accept_or_decline: "この招待は承認することも、辞退することもできます。" + enter_your_invitation_code_in_the_mobile_app_html: "モバイルアプリ%{mobile_app}で、下記の招待コードを入力してください。" + instructions: "手順" + open_mobile_app_html: "%{mobile_app}を開きます。" + create_account_or_sign_in_with_html: "%{email}でアカウントを作成するか、サインインします。" + select_organization_tab: "「組織」タブを選びます。" + tap_join_button: "「参加」ボタンをタップします。" + enter_your_invitation_code: "下記の招待コードを入力します。" + if_you_accepted_invitation_switch_to_organization: "招待を承認したら、その組織に切り替えます(組織 > [組織名] > 切り替え)。" + expires_in: "この招待の有効期限は%{expires_in}時間です。" + reset_password_instructions: + subject: "パスワード再設定のご案内" + request_reset_link_msg: "パスワード変更のリクエストを受け付けました。下記のリンクからパスワードを変更できます。" + password_change_link: "パスワードを変更する" + ignore_mail_msg: "このリクエストに心当たりがない場合は、このメールを無視してください。" + no_changes_msg: "上記のリンクから新しいパスワードを設定するまで、パスワードは変わりません。" + confirmation_instructions: + subject: "メールアドレス確認のご案内" + confirm_link_msg: "下記のリンクから、アカウントのメールアドレスを確認できます。" + confirm_account_link: "メールアドレスを確認する" + + devise_token_auth: + sessions: + not_confirmed: "'%{email}' に確認メールを送信しました。アカウントを有効にするには、メールの案内に従ってください。" + bad_credentials: "メールアドレスまたはパスワードが正しくありません。もう一度お試しください。" + user_not_found: "ユーザーが見つからないか、サインインしていません。" + passwords: + missing_email: "メールアドレスを入力してください。" + missing_redirect_url: "リダイレクト URL が指定されていません。" + not_allowed_redirect_url: "'%{redirect_url}' へのリダイレクトは許可されていません。" + sended: "'%{email}' にパスワード再設定の案内を送信しました。" + sended_paranoid: "メールアドレスが登録されている場合、数分以内にパスワード再設定用のリンクをお送りします。" + user_not_found: "メールアドレス '%{email}' のユーザーが見つかりません。" + missing_passwords: "「パスワード」と「パスワード(確認)」を入力してください。" + successfully_updated: "パスワードを更新しました。" + confirmations: + successfully_confirmed: "メールアドレスを確認しました。" + + layouts: + mailer: + copyright_html: "© %{year} %{business_name} All rights reserved." + + activerecord: + models: + account: "組織" + accounts_invitation: "招待" + accounts_shopkeeper: "メンバー" + application_push_device: "デバイス" + item_tag: "アイテムタグ" + shop: "ショップ" + shopkeeper: "ユーザー" + + attributes: + account: + name: "組織名" + owner: "オーナー" + accounts_invitation: + account: "組織" + email: "メールアドレス" + invited_by: "招待者" + name: "名前" + roles: "権限" + accounts_shopkeeper: + account: "組織" + admin: "管理者" + member: "メンバー" + roles: "権限" + shopkeeper: "ユーザー" + shopkeeper_id: "ユーザー" + application_push_device: + bundle_id: "バンドル ID" + owner: "所有者" + platform: "プラットフォーム" + token: "トークン" + item_tag: + name: "名前" + description: "説明" + position: "表示順" + shop: "ショップ" + shop: + created_by: "作成者" + description: "説明" + name: "店舗名" + time_zone: "タイムゾーン" + shopkeeper: + current_password: "現在のパスワード" + current_platform: "プラットフォーム" + email: "メールアドレス" + name: "名前" + password: "パスワード" + password_confirmation: "パスワード(確認)" + time_zone: "タイムゾーン" + unconfirmed_email: "確認待ちのメールアドレス" + + errors: + models: + account: + attributes: + organizations: + limit_count: "は%{limit_count}件まで作成できます。" + accounts_invitation: + attributes: + email: + invited: "はすでに招待されています" + accounts_shopkeeper: + attributes: + admin: + cannot_be_removed: "の権限は組織のオーナーから外せません" + shopkeeper: + attributes: + # spam protection + current_platform: + format: "%{message}" + blank: "入力データが正しくありません。" + + errors: + messages: + validate_sign_up_params: "リクエストボディに正しい新規登録データを指定してください。" + validate_account_update_params: "リクエストボディに正しいアカウント更新データを指定してください。" + limit_count_account: "組織は%{limit_count}件まで作成できます。" + limit_count_shop: "ショップはすべての組織で合わせて%{limit_count}件まで作成できます。" + limit_count_accounts_shopkeeper: "組織のメンバーは%{limit_count}人まで追加できます。組織の管理者またはオーナーにお問い合わせください。" + limit_count_item_tag: "アイテムタグは%{limit_count}件まで作成できます。" + too_many_signups: "新規登録の試行回数が多すぎます。しばらくしてからもう一度お試しください。" + too_many_logins: "サインインの試行回数が多すぎます。しばらくしてからもう一度お試しください。" + too_many_requests: "リクエストが多すぎます。しばらくしてからもう一度お試しください。" diff --git a/config/locales/rails.ja.yml b/config/locales/rails.ja.yml new file mode 100644 index 0000000..ca92b12 --- /dev/null +++ b/config/locales/rails.ja.yml @@ -0,0 +1,51 @@ +# Japanese for the Active Model and Active Record messages (their en.yml ships +# with Rails). Attribute names live in ja.yml. A full message joins the two with +# no space: "店舗名" + "を入力してください". +# +# Pluralized messages keep both "one" and "other": the default I18n +# pluralization looks up "one" when count is 1. + +ja: + errors: + format: "%{attribute}%{message}" + messages: + model_invalid: "バリデーションに失敗しました: %{errors}" + inclusion: "は一覧にありません" + exclusion: "は予約されています" + invalid: "は不正な値です" + confirmation: "と%{attribute}の入力が一致しません" + accepted: "を受諾してください" + empty: "を入力してください" + blank: "を入力してください" + present: "は入力しないでください" + too_long: + one: "は%{count}文字以内で入力してください" + other: "は%{count}文字以内で入力してください" + password_too_long: "が長すぎます" + too_short: + one: "は%{count}文字以上で入力してください" + other: "は%{count}文字以上で入力してください" + wrong_length: + one: "は%{count}文字で入力してください" + other: "は%{count}文字で入力してください" + not_a_number: "は数値で入力してください" + not_an_integer: "は整数で入力してください" + greater_than: "は%{count}より大きい値にしてください" + greater_than_or_equal_to: "は%{count}以上の値にしてください" + equal_to: "は%{count}にしてください" + less_than: "は%{count}より小さい値にしてください" + less_than_or_equal_to: "は%{count}以下の値にしてください" + other_than: "は%{count}以外の値にしてください" + in: "は%{count}の範囲で指定してください" + odd: "は奇数にしてください" + even: "は偶数にしてください" + required: "を入力してください" + taken: "はすでに存在します" + + activerecord: + errors: + messages: + record_invalid: "バリデーションに失敗しました: %{errors}" + restrict_dependent_destroy: + has_one: "%{record}が存在するため削除できません" + has_many: "%{record}が存在するため削除できません" diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1c86236..ff5a4d4 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,10 @@ openapi: 3.1.0 info: title: NativeAppTemplate API - description: REST API for multi-tenant SaaS backend serving NativeAppTemplate iOS/Android mobile applications. Shopkeepers authenticate via token-based auth to manage shops and item tags. + description: | + REST API for multi-tenant SaaS backend serving NativeAppTemplate iOS/Android mobile applications. Shopkeepers authenticate via token-based auth to manage shops and item tags. + + Messages (`error_message`, validation errors, emails) are in English or Japanese, chosen from the `Accept-Language` request header (e.g. `ja-JP, en-JP;q=0.9`). Unsupported or missing languages get English. Emails sent because of a request use that request's language. version: 1.0.0 servers: diff --git a/test/i18n/locale_files_test.rb b/test/i18n/locale_files_test.rb new file mode 100644 index 0000000..49efcab --- /dev/null +++ b/test/i18n/locale_files_test.rb @@ -0,0 +1,67 @@ +require "test_helper" + +# Every message an API client can see must exist in Japanese. With fallbacks on, +# a missing Japanese key silently answers in English, so this compares the +# loaded translations directly instead of going through I18n.t. +class LocaleFilesTest < ActiveSupport::TestCase + # Scopes the gems contribute (Active Model/Record, Devise, devise_token_auth) + GEM_SCOPES = %i[errors activerecord devise devise_token_auth].freeze + + def translations(locale) + I18n.backend.send(:init_translations) unless I18n.backend.initialized? + I18n.backend.send(:translations).fetch(locale) + end + + def flatten(hash, prefix = nil) + hash.each_with_object({}) do |(key, value), flat| + path = [prefix, key].compact.join(".") + value.is_a?(Hash) ? flat.merge!(flatten(value, path)) : flat[path] = value + end + end + + def app_en_keys + flatten(YAML.load_file(Rails.root.join("config/locales/en.yml")).fetch("en")).keys + end + + def en + @en ||= flatten(translations(:en)) + end + + def ja + @ja ||= flatten(translations(:ja)) + end + + def checked_keys + gem_keys = en.keys.select { |key| GEM_SCOPES.include?(key.split(".").first.to_sym) } + (app_en_keys + gem_keys).uniq + end + + def variables(string) + string.to_s.scan(/%\{(\w+)\}/).flatten.to_set + end + + test "ja is an available locale" do + assert_includes I18n.available_locales, :ja + end + + test "every English message has a Japanese translation" do + missing = checked_keys.reject { |key| ja.key?(key) } + + assert_empty missing, "Missing Japanese translations:\n#{missing.join("\n")}" + end + + # A variable only the Japanese text uses would never be passed in. Plural + # forms may add %{count}: English "one" spells out "1 character". + test "Japanese translations only use variables the English text provides" do + unexpected = checked_keys.filter_map do |key| + next unless ja.key?(key) + + allowed = variables(en[key]) + allowed << "count" if key.end_with?(".one", ".other") + extra = variables(ja[key]) - allowed + "#{key}: #{extra.to_a.join(", ")}" if extra.any? + end + + assert_empty unexpected, "Japanese-only variables:\n#{unexpected.join("\n")}" + end +end diff --git a/test/integration/locale_test.rb b/test/integration/locale_test.rb new file mode 100644 index 0000000..1d48db5 --- /dev/null +++ b/test/integration/locale_test.rb @@ -0,0 +1,163 @@ +require "test_helper" + +# Responses use the language the client asks for in Accept-Language (English +# or Japanese), falling back to English. iOS sends the header from the +# device's preferred languages; other clients must add it themselves. +class LocaleTest < ActionDispatch::IntegrationTest + include ActionMailer::TestHelper + + setup do + @shopkeeper = shopkeepers(:one) + @shopkeeper.create_default_account + @account = @shopkeeper.accounts.first + end + + # Sign in with a wrong password and return the error message + def bad_credentials_message(accept_language = nil) + headers = accept_language ? {"Accept-Language" => accept_language} : {} + post shopkeeper_session_url, params: {email: @shopkeeper.email, password: "wrong-password"}, headers: headers + assert_response :unauthorized + response.parsed_body["error_message"] + end + + def bad_credentials(locale) + I18n.t("devise_token_auth.sessions.bad_credentials", locale: locale) + end + + # Like production: no debug page, so routing errors reach ErrorsController + def without_detailed_exceptions + env_config = Rails.application.env_config + original = env_config["action_dispatch.show_detailed_exceptions"] + env_config["action_dispatch.show_detailed_exceptions"] = false + yield + ensure + env_config["action_dispatch.show_detailed_exceptions"] = original + end + + def auth_headers(accept_language) + @shopkeeper.create_new_auth_token.merge("Accept-Language" => accept_language) + end + + test "the two locales really differ" do + assert_not_equal bad_credentials(:en), bad_credentials(:ja) + end + + test "answers in English without Accept-Language" do + assert_equal bad_credentials(:en), bad_credentials_message + end + + test "answers in Japanese for ja" do + assert_equal bad_credentials(:ja), bad_credentials_message("ja") + end + + test "matches a regional tag such as ja-JP" do + assert_equal bad_credentials(:ja), bad_credentials_message("ja-JP") + end + + test "picks the language with the highest quality value" do + assert_equal bad_credentials(:ja), bad_credentials_message("en;q=0.5, ja") + assert_equal bad_credentials(:en), bad_credentials_message("ja;q=0.4, en;q=0.8") + end + + # iOS sends e.g. "ja-JP, en-JP;q=0.9" for a Japanese device + test "keeps header order between equal quality values" do + assert_equal bad_credentials(:ja), bad_credentials_message("ja-JP, en-JP;q=0.9") + assert_equal bad_credentials(:en), bad_credentials_message("en-US, ja-JP") + end + + test "skips a language the client refuses with q=0" do + assert_equal bad_credentials(:en), bad_credentials_message("ja;q=0, fr") + end + + test "falls back to English for unsupported languages" do + assert_equal bad_credentials(:en), bad_credentials_message("fr-FR, de;q=0.8") + assert_equal bad_credentials(:en), bad_credentials_message("*") + end + + test "ignores a malformed header" do + assert_equal bad_credentials(:en), bad_credentials_message(",;q=abc, ;, ja;q=x") + end + + test "does not leak the locale into the next request" do + bad_credentials_message("ja") + + assert_equal bad_credentials(:en), bad_credentials_message + end + + # errors.format "%{attribute}%{message}" + shop name "店舗名" + errors.messages.blank "を入力してください" + test "validation errors use Japanese attribute names and messages" do + post api_v1_shopkeeper_shops_url, + params: {shop: {name: "", time_zone: "Tokyo"}}, + headers: auth_headers("ja") + + assert_response :unprocessable_entity + assert_equal "店舗名を入力してください", response.parsed_body["error_message"] + end + + test "an unauthenticated API request answers in Japanese" do + get api_v1_shopkeeper_shops_url, headers: {"Accept-Language" => "ja"} + + assert_response :unauthorized + assert_equal [I18n.t("devise.failure.unauthenticated", locale: :ja)], response.parsed_body["errors"] + assert_not_equal I18n.t("devise.failure.unauthenticated", locale: :en), I18n.t("devise.failure.unauthenticated", locale: :ja) + end + + test "a missing API record answers in Japanese" do + get api_v1_shopkeeper_shop_url("00000000-0000-0000-0000-000000000000"), headers: auth_headers("ja") + + assert_response :not_found + assert_equal I18n.t("not_found", locale: :ja), response.parsed_body["error_message"] + end + + test "an unknown API route answers in Japanese" do + without_detailed_exceptions do + get "/api/v1/shopkeeper/no_such_route", headers: {"Accept-Language" => "ja"} + end + + assert_response :not_found + assert_equal I18n.t("not_found", locale: :ja), response.parsed_body["error_message"] + end + + test "the HTML not-found page is Japanese" do + without_detailed_exceptions do + get "/no_such_page", headers: {"Accept-Language" => "ja"} + end + + assert_response :not_found + assert_includes response.body, I18n.t("error_pages.not_found.title", locale: :ja) + assert_includes response.body, 'lang="ja"' + end + + test "a throttled request answers in Japanese" do + 6.times { post shopkeeper_session_url, params: {email: "nobody@example.com", password: "x"}, headers: {"Accept-Language" => "ja"} } + + assert_response :too_many_requests + assert_equal I18n.t("errors.messages.too_many_logins", locale: :ja), response.parsed_body["error_message"] + end + + test "an invitation sent during a Japanese request is written in Japanese" do + perform_enqueued_jobs do + post api_v1_shopkeeper_account_accounts_invitations_url(@account), + params: {accounts_invitation: {name: "New User", email: "newuser@example.com", member: true}}, + headers: auth_headers("ja") + end + + assert_response :created + mail = ActionMailer::Base.deliveries.last + expected = I18n.t("shopkeeper.notification_mailer.invited.subject", locale: :ja, inviter: @shopkeeper.name, account: @account.name) + assert_equal expected, mail.subject + assert_includes mail.text_part.body.decoded, I18n.t("shopkeeper.notification_mailer.invited.you_can_accept_or_decline", locale: :ja) + end + + test "a shop created during a Japanese request gets a Japanese sample item tag" do + post api_v1_shopkeeper_shops_url, + params: {shop: {name: "新しい店", time_zone: "Tokyo"}}, + headers: auth_headers("ja") + + assert_response :created + shop = Shop.find(response.parsed_body["data"]["id"]) + sample = ActsAsTenant.without_tenant { shop.item_tags.first } + assert_equal I18n.t("sample_item_tag.name", locale: :ja), sample.name + assert_equal I18n.t("sample_item_tag.description", locale: :ja), sample.description + end +end From be62609b0d0492fbc1c18b2ef1615c74a146920a Mon Sep 17 00:00:00 2001 From: dadachi Date: Sat, 10 Oct 2026 21:47:00 +0900 Subject: [PATCH 2/4] Add CHANGELOG entry for #150 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WyUzNEPWyhjjWrBcSaXSW4 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7ce51d..d0f90bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- API change: add a Japanese locale. Responses, validation errors, the HTML pages and emails follow the `Accept-Language` header (English or Japanese, English by default) via the new `SetLocale` concern; mails queued during a request keep its locale. Translations are in `config/locales/ja.yml`, `rails.ja.yml` and `devise.ja.yml`, with no extra gems. Hard-coded English in the error pages, `ErrorsController`, the sample item tag and the mail greetings moved to locale keys. `test/i18n/locale_files_test.rb` fails on any untranslated key (#150) - Fix a failed sign-in saving the `source` header as `current_platform`: anyone knowing an email could set it to any string, after which that shopkeeper's profile updates returned 422. Only successful sign-ins write it, and only `ios` or `android`. Fix resending an invitation whose inviter was deleted (the mail job raised on `nil.name`); the mail names the app instead. Rename `.erb-lint.yml` to `.erb_lint.yml` and drop a removed RuboCop cop from `.rubocop.yml` (#149) - Deploy with Kamal 2 to GitHub Container Registry instead of Render: add `Dockerfile`, `.dockerignore`, `config/deploy.yml` (kamal-proxy serving a Cloudflare Origin Certificate with `forward_headers: true` and `ssl_redirect: false`, Postgres 18 as the `db` accessory on the private Docker network, Solid Queue in Puma, Active Storage volume, amd64 builder), `.kamal/secrets`, `bin/kamal` and `bin/thrust`. Add the missing `GET /up` health check route and `assume_ssl = true` so kamal-proxy's health check passes. Add Cloudflare's ranges to `trusted_proxies` (`config/cloudflare_ips.yml`) so `rate_limit` keys on the caller rather than the edge. Remove `render.yaml`, `bin/render-*.sh` and the staging environment, which only served Render PR previews. New tests: `test/config/deploy_config_test.rb`, `test/integration/health_check_test.rb`, `test/integration/trusted_proxies_test.rb` (#144) - Point the `SOLID_QUEUE_IN_PUMA` comment in `.env.sample` at `config/deploy.yml`; it still referred to the removed `render.yaml` (#147) @@ -24,7 +25,7 @@ - API change: members and invitations must have at least one role (422 "Roles can't be blank"). A role-less member made `GET /permissions` raise (#121) - API change: password reset and confirmation `redirect_url` must be on the API's own host. Other hosts get 422 for resets and fall back to the default page for confirmations (#120) - Harden admin sign-in: rate limit by IP and email, `reset_session` on sign-in and sign-out, and Madmin now rejects a deleted admin's session (#119) -- AGENTS.md: correct the CORS and test-worker notes, and keep the test count current (#118, #127, #132, #147, #149) +- AGENTS.md: correct the CORS and test-worker notes, and keep the test count current (#118, #127, #132, #147, #149, #150) - Clear finished Solid Queue jobs every hour via `config/recurring.yml` (#117) - API change: `item_tags#update` no longer accepts `state`. Completion goes through the `complete`/`idle` events, and an unknown state no longer returns 500 (#116) - API change: API errors are always JSON `{code, error_message}`, including missing records (404), missing parameters (400), unknown API routes and 500s; before, they were HTML unless the request sent a JSON Content-Type (#115) From 08f73db9ee8ff02be0143b3b6ad0f7b4e2753cb6 Mon Sep 17 00:00:00 2001 From: dadachi Date: Sat, 10 Oct 2026 22:06:37 +0900 Subject: [PATCH 3/4] Fix the Japanese messages a translation review flagged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broken output: - Several errors were joined as "A, B, and C": add support.array (、). - An unknown current_platform printed only "は一覧にありません", since the attribute prints just its message; it now gets the spam-protection text (also in English, which printed only "is not included in the list"). - Accepting an invitation as an existing member said the user "already exists"; it now says they are already a member (also in English). - An invalid reset link read "Reset password tokenは不正な値です". - The reset password form still had "(8 characters minimum)" and an English placeholder. Wording: "は正しくありません" for invalid, "はすでに使われています" for taken, "を選択してください" for a missing role, 「」 instead of '...' in devise_token_auth messages, サインイン instead of the gem's ログイン, a clearer email format error, 個人用の組織, a full-width colon after the invitation's instructions heading, and a thank-you greeting in the confirmation mail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WyUzNEPWyhjjWrBcSaXSW4 --- AGENTS.md | 2 +- CHANGELOG.md | 2 +- .../notification_mailer/invited.html.erb | 2 +- .../notification_mailer/invited.text.erb | 2 +- .../reset_passwords/edit.html.erb | 6 +- config/locales/devise.ja.yml | 4 +- config/locales/en.yml | 9 ++- config/locales/ja.yml | 46 ++++++++++++--- config/locales/rails.ja.yml | 13 ++++- test/i18n/japanese_messages_test.rb | 57 +++++++++++++++++++ test/i18n/locale_files_test.rb | 5 +- test/integration/locale_test.rb | 20 +++++++ test/models/accounts_shopkeeper_test.rb | 2 +- test/models/shopkeeper_test.rb | 3 +- 14 files changed, 146 insertions(+), 27 deletions(-) create mode 100644 test/i18n/japanese_messages_test.rb diff --git a/AGENTS.md b/AGENTS.md index 532993c..67d6b1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,7 +103,7 @@ Cross-platform push via the `noticed` (v3) and `action_push_native` gems. APNs f - `json_response` for parsing JSON API responses - `create_new_auth_token` for generating auth headers (Devise Token Auth) - Fixtures in test/fixtures/ and seed data in db/fixtures/test/ -- Run tests: `bin/rails test` (561 tests, 1398 assertions) +- Run tests: `bin/rails test` (568 tests, 1416 assertions) ### Internationalization - Responses are in English (default) or Japanese, picked per request from `Accept-Language` by the `SetLocale` concern (included in `ApplicationController` and `NonApiApplicationController`). It wraps `process_action`, not `around_action`, so `rescue_from` handlers and `rate_limit` responses are localized too. Mails queued during a request keep its locale (Active Job stores it). diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f90bc..4f37494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -- API change: add a Japanese locale. Responses, validation errors, the HTML pages and emails follow the `Accept-Language` header (English or Japanese, English by default) via the new `SetLocale` concern; mails queued during a request keep its locale. Translations are in `config/locales/ja.yml`, `rails.ja.yml` and `devise.ja.yml`, with no extra gems. Hard-coded English in the error pages, `ErrorsController`, the sample item tag and the mail greetings moved to locale keys. `test/i18n/locale_files_test.rb` fails on any untranslated key (#150) +- API change: add a Japanese locale. Responses, validation errors, the HTML pages and emails follow the `Accept-Language` header (English or Japanese, English by default) via the new `SetLocale` concern; mails queued during a request keep its locale. Translations are in `config/locales/ja.yml`, `rails.ja.yml` and `devise.ja.yml`, with no extra gems. Hard-coded English in the error pages, `ErrorsController`, the sample item tag and the mail greetings moved to locale keys. `test/i18n/locale_files_test.rb` fails on any untranslated key. Two English messages changed: an unknown `current_platform` now gets the same "Your input data is wrong." as a blank one, and a duplicate member gets "User is already a member of this organization" instead of "has already been taken" (#150) - Fix a failed sign-in saving the `source` header as `current_platform`: anyone knowing an email could set it to any string, after which that shopkeeper's profile updates returned 422. Only successful sign-ins write it, and only `ios` or `android`. Fix resending an invitation whose inviter was deleted (the mail job raised on `nil.name`); the mail names the app instead. Rename `.erb-lint.yml` to `.erb_lint.yml` and drop a removed RuboCop cop from `.rubocop.yml` (#149) - Deploy with Kamal 2 to GitHub Container Registry instead of Render: add `Dockerfile`, `.dockerignore`, `config/deploy.yml` (kamal-proxy serving a Cloudflare Origin Certificate with `forward_headers: true` and `ssl_redirect: false`, Postgres 18 as the `db` accessory on the private Docker network, Solid Queue in Puma, Active Storage volume, amd64 builder), `.kamal/secrets`, `bin/kamal` and `bin/thrust`. Add the missing `GET /up` health check route and `assume_ssl = true` so kamal-proxy's health check passes. Add Cloudflare's ranges to `trusted_proxies` (`config/cloudflare_ips.yml`) so `rate_limit` keys on the caller rather than the edge. Remove `render.yaml`, `bin/render-*.sh` and the staging environment, which only served Render PR previews. New tests: `test/config/deploy_config_test.rb`, `test/integration/health_check_test.rb`, `test/integration/trusted_proxies_test.rb` (#144) - Point the `SOLID_QUEUE_IN_PUMA` comment in `.env.sample` at `config/deploy.yml`; it still referred to the removed `render.yaml` (#147) diff --git a/app/views/shopkeeper/notification_mailer/invited.html.erb b/app/views/shopkeeper/notification_mailer/invited.html.erb index ba86449..3b2fa2d 100644 --- a/app/views/shopkeeper/notification_mailer/invited.html.erb +++ b/app/views/shopkeeper/notification_mailer/invited.html.erb @@ -8,7 +8,7 @@ <%= t(".enter_your_invitation_code_in_the_mobile_app_html", mobile_app: ConfigSettings.mobile_app.name) %>

-

<%= t(".instructions").capitalize %>:

+

<%= t(".instructions_heading") %>

1. <%= t(".open_mobile_app_html", mobile_app: ConfigSettings.mobile_app.name) %>

2. <%= t(".create_account_or_sign_in_with_html", email: @accounts_invitation.email) %>

3. <%= t(".select_organization_tab") %>

diff --git a/app/views/shopkeeper/notification_mailer/invited.text.erb b/app/views/shopkeeper/notification_mailer/invited.text.erb index 7b6a0b0..ec5297d 100644 --- a/app/views/shopkeeper/notification_mailer/invited.text.erb +++ b/app/views/shopkeeper/notification_mailer/invited.text.erb @@ -4,7 +4,7 @@ <%= strip_tags t(".enter_your_invitation_code_in_the_mobile_app_html", mobile_app: ConfigSettings.mobile_app.name) %> -<%= t(".instructions").capitalize %>: +<%= t(".instructions_heading") %> 1. <%= strip_tags t(".open_mobile_app_html", mobile_app: ConfigSettings.mobile_app.name) %> 2. <%= strip_tags t(".create_account_or_sign_in_with_html", email: @accounts_invitation.email) %> 3. <%= t(".select_organization_tab") %> diff --git a/app/views/shopkeeper_auth/reset_passwords/edit.html.erb b/app/views/shopkeeper_auth/reset_passwords/edit.html.erb index 7a36d30..7a8dece 100644 --- a/app/views/shopkeeper_auth/reset_passwords/edit.html.erb +++ b/app/views/shopkeeper_auth/reset_passwords/edit.html.erb @@ -12,10 +12,10 @@ <%= f.label :password, t("new_password"), class: "block text-sm font-medium leading-6 text-gray-900" %>
- <%= f.password_field :password, autofocus: true, autocomplete: "new-password", class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-sky-600 sm:text-sm sm:leading-6", placeholder: "password" %> + <%= f.password_field :password, autofocus: true, autocomplete: "new-password", class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-sky-600 sm:text-sm sm:leading-6", placeholder: t("password_placeholder") %>
-

(<%= @minimum_password_length %> characters minimum)

+

<%= t("password_minimum_length", count: @minimum_password_length) %>

@@ -24,7 +24,7 @@ <%= f.label :password_confirmation, t("confirm_new_password"), class: "block text-sm font-medium leading-6 text-gray-900" %>
- <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-sky-600 sm:text-sm sm:leading-6", placeholder: "password" %> + <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-sky-600 sm:text-sm sm:leading-6", placeholder: t("password_placeholder") %>
diff --git a/config/locales/devise.ja.yml b/config/locales/devise.ja.yml index cb8ef4c..8c57cc2 100644 --- a/config/locales/devise.ja.yml +++ b/config/locales/devise.ja.yml @@ -63,5 +63,5 @@ ja: not_found: "が見つかりません" not_locked: "はロックされていません" not_saved: - one: "エラーが%{count}件あるため、%{resource}を保存できませんでした:" - other: "エラーが%{count}件あるため、%{resource}を保存できませんでした:" + one: "エラーが%{count}件あるため、%{resource}を保存できませんでした:" + other: "エラーが%{count}件あるため、%{resource}を保存できませんでした:" diff --git a/config/locales/en.yml b/config/locales/en.yml index 4c4c410..1edf45a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -36,6 +36,8 @@ en: change_your_password: "Change your password" confirm_new_password: "Confirm new password" new_password: "New password" + password_minimum_length: "(%{count} characters minimum)" + password_placeholder: "password" bad_request: "Bad request." not_destroyed: "Could not be deleted." not_found: "Not found." @@ -81,7 +83,7 @@ en: header_html: "%{inviter} has invited you to collaborate on %{account}." you_can_accept_or_decline: "You can accept or decline this invitation." enter_your_invitation_code_in_the_mobile_app_html: "Enter your invitation code in the mobile app %{mobile_app} below." - instructions: "instructions" + instructions_heading: "Instructions:" open_mobile_app_html: "Open %{mobile_app}." create_account_or_sign_in_with_html: "Create account or sign in with %{email}." select_organization_tab: "Select Organizations tab." @@ -145,12 +147,15 @@ en: attributes: admin: cannot_be_removed: "role cannot be removed for the account owner" + shopkeeper_id: + taken: "is already a member of this organization" shopkeeper: attributes: # spam protection current_platform: format: "%{message}" - blank: "Your input data is wrong." + blank: "Your input data is wrong." + inclusion: "Your input data is wrong." errors: messages: validate_sign_up_params: "Please submit proper sign up data in request body." diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 63695b7..c89bc16 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -7,15 +7,17 @@ ja: hello_email: "%{email} 様" - welcome_email: "%{email} 様、ようこそ。" + welcome_email: "%{email} 様、ご登録ありがとうございます。" change_my_password: "パスワードを変更する" change_your_password: "パスワードの変更" confirm_new_password: "新しいパスワード(確認)" new_password: "新しいパスワード" + password_minimum_length: "(%{count}文字以上)" + password_placeholder: "パスワード" bad_request: "リクエストが正しくありません。" not_destroyed: "削除できませんでした。" not_found: "見つかりませんでした。" - something_went_wrong: "問題が発生しました" + something_went_wrong: "問題が発生しました。" internal_server_error: "サーバーでエラーが発生しました。" unauthorized: "この操作を行う権限がありません。" default_shop_description: "これはサンプルのショップです。\n編集や削除ができます。" @@ -41,12 +43,12 @@ ja: shopkeeper: accounts: personal: - cannot_delete: "個人の組織は削除できません。" + cannot_delete: "個人用の組織は削除できません。" accounts_invitations: not_found: "招待が見つかりませんでした。組織の管理者に新しい招待を依頼してください。" expired: "この招待は有効期限が切れています。組織の管理者に招待の再送を依頼してください。" accounts_shopkeepers: - require_non_personal_account: "個人の組織では利用できません。" + require_non_personal_account: "個人用の組織では利用できません。" item_tags: not_found: "アイテムタグが見つかりませんでした。" @@ -57,7 +59,7 @@ ja: header_html: "%{inviter}さんが%{account}にあなたを招待しました。" you_can_accept_or_decline: "この招待は承認することも、辞退することもできます。" enter_your_invitation_code_in_the_mobile_app_html: "モバイルアプリ%{mobile_app}で、下記の招待コードを入力してください。" - instructions: "手順" + instructions_heading: "手順:" open_mobile_app_html: "%{mobile_app}を開きます。" create_account_or_sign_in_with_html: "%{email}でアカウントを作成するか、サインインします。" select_organization_tab: "「組織」タブを選びます。" @@ -78,20 +80,35 @@ ja: devise_token_auth: sessions: - not_confirmed: "'%{email}' に確認メールを送信しました。アカウントを有効にするには、メールの案内に従ってください。" + not_confirmed: "「%{email}」に確認メールを送信しました。アカウントを有効にするには、メールの案内に従ってください。" bad_credentials: "メールアドレスまたはパスワードが正しくありません。もう一度お試しください。" + not_supported: "/sign_in への GET は使えません。POST を使ってください。" user_not_found: "ユーザーが見つからないか、サインインしていません。" + token_validations: + invalid: "認証情報が正しくありません。もう一度サインインしてください。" + registrations: + missing_confirm_success_url: "confirm_success_url パラメーターが指定されていません。" + redirect_url_not_allowed: "「%{redirect_url}」へのリダイレクトは許可されていません。" + email_already_exists: "「%{email}」のアカウントはすでに存在します。" + account_with_uid_destroyed: "「%{uid}」のアカウントを削除しました。" + account_to_destroy_not_found: "削除するアカウントが見つかりません。" + user_not_found: "ユーザーが見つかりません。" passwords: missing_email: "メールアドレスを入力してください。" missing_redirect_url: "リダイレクト URL が指定されていません。" - not_allowed_redirect_url: "'%{redirect_url}' へのリダイレクトは許可されていません。" - sended: "'%{email}' にパスワード再設定の案内を送信しました。" + not_allowed_redirect_url: "「%{redirect_url}」へのリダイレクトは許可されていません。" + sended: "「%{email}」にパスワード再設定の案内を送信しました。" sended_paranoid: "メールアドレスが登録されている場合、数分以内にパスワード再設定用のリンクをお送りします。" - user_not_found: "メールアドレス '%{email}' のユーザーが見つかりません。" + user_not_found: "メールアドレス「%{email}」のユーザーが見つかりません。" + password_not_required: "このアカウントにはパスワードがありません。%{provider} でサインインしてください。" missing_passwords: "「パスワード」と「パスワード(確認)」を入力してください。" successfully_updated: "パスワードを更新しました。" confirmations: successfully_confirmed: "メールアドレスを確認しました。" + sended: "「%{email}」にメールアドレスの確認方法を送信しました。" + sended_paranoid: "メールアドレスが登録されている場合、数分以内にメールアドレスの確認方法をお送りします。" + user_not_found: "メールアドレス「%{email}」のユーザーが見つかりません。" + missing_email: "メールアドレスを入力してください。" layouts: mailer: @@ -147,6 +164,7 @@ ja: password: "パスワード" password_confirmation: "パスワード(確認)" time_zone: "タイムゾーン" + reset_password_token: "パスワード再設定用のリンク" unconfirmed_email: "確認待ちのメールアドレス" errors: @@ -159,19 +177,29 @@ ja: attributes: email: invited: "はすでに招待されています" + roles: + blank: "を選択してください" accounts_shopkeeper: attributes: admin: cannot_be_removed: "の権限は組織のオーナーから外せません" + roles: + blank: "を選択してください" + shopkeeper_id: + taken: "はすでにこの組織のメンバーです" shopkeeper: attributes: # spam protection current_platform: format: "%{message}" blank: "入力データが正しくありません。" + inclusion: "入力データが正しくありません。" + reset_password_token: + invalid: "が無効か、有効期限が切れています。もう一度パスワードの再設定をリクエストしてください" errors: messages: + not_email: "の形式が正しくありません" validate_sign_up_params: "リクエストボディに正しい新規登録データを指定してください。" validate_account_update_params: "リクエストボディに正しいアカウント更新データを指定してください。" limit_count_account: "組織は%{limit_count}件まで作成できます。" diff --git a/config/locales/rails.ja.yml b/config/locales/rails.ja.yml index ca92b12..1914b71 100644 --- a/config/locales/rails.ja.yml +++ b/config/locales/rails.ja.yml @@ -6,13 +6,20 @@ # pluralization looks up "one" when count is 1. ja: + # Array#to_sentence, used to join several error messages into one + support: + array: + words_connector: "、" + two_words_connector: "、" + last_word_connector: "、" + errors: format: "%{attribute}%{message}" messages: model_invalid: "バリデーションに失敗しました: %{errors}" - inclusion: "は一覧にありません" + inclusion: "は一覧から選択してください" exclusion: "は予約されています" - invalid: "は不正な値です" + invalid: "は正しくありません" confirmation: "と%{attribute}の入力が一致しません" accepted: "を受諾してください" empty: "を入力してください" @@ -40,7 +47,7 @@ ja: odd: "は奇数にしてください" even: "は偶数にしてください" required: "を入力してください" - taken: "はすでに存在します" + taken: "はすでに使われています" activerecord: errors: diff --git a/test/i18n/japanese_messages_test.rb b/test/i18n/japanese_messages_test.rb new file mode 100644 index 0000000..5ed7049 --- /dev/null +++ b/test/i18n/japanese_messages_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +# Composed Japanese messages that a translation review found broken. Expected +# strings are errors.format "%{attribute}%{message}" applied to the attribute +# name and message in config/locales/ja.yml / rails.ja.yml. +class JapaneseMessagesTest < ActiveSupport::TestCase + setup do + @shopkeeper = shopkeepers(:one) + @shopkeeper.create_default_account + @account = @shopkeeper.accounts.first + end + + def ja(&) = I18n.with_locale(:ja, &) + + # Bug: support.array was missing, so joined errors read "A, B, and C" + test "joins several errors with Japanese commas" do + assert_equal "A、B、C", ja { %w[A B C].to_sentence } + assert_equal "A、B", ja { %w[A B].to_sentence } + end + + # Bug: current_platform prints only its message (format "%{message}"), and + # only blank was overridden, so an unknown platform read "は一覧にありません" + test "an unknown platform gets the spam-protection message" do + shopkeeper = Shopkeeper.new(current_platform: "web") + ja { shopkeeper.valid? } + assert_equal ["入力データが正しくありません。"], ja { shopkeeper.errors.full_messages_for(:current_platform) } + + shopkeeper.valid? + assert_equal ["Your input data is wrong."], shopkeeper.errors.full_messages_for(:current_platform) + end + + # Bug: accepting an invitation as an existing member showed "ユーザーはすでに存在します" + test "an existing member is told they already belong to the organization" do + member = AccountsShopkeeper.new(account: @account, shopkeeper: @shopkeeper, member: true) + + ja { member.valid? } + assert_equal ["ユーザーはすでにこの組織のメンバーです"], ja { member.errors.full_messages } + member.valid? + assert_equal ["User is already a member of this organization"], member.errors.full_messages + end + + # Bug: an invalid reset link read "Reset password tokenは不正な値です" + test "an invalid password reset link reads as Japanese" do + shopkeeper = Shopkeeper.reset_password_by_token(reset_password_token: "no-such-token", password: "password1", password_confirmation: "password1") + + assert_equal ["パスワード再設定用のリンクが無効か、有効期限が切れています。もう一度パスワードの再設定をリクエストしてください"], + ja { shopkeeper.errors.full_messages } + end + + # Roles are chosen, not typed + test "a missing role asks to choose one" do + invitation = AccountsInvitation.new(account: @account, name: "x", email: "x@example.com") + + ja { invitation.valid? } + assert_equal ["権限を選択してください"], ja { invitation.errors.full_messages_for(:roles) } + end +end diff --git a/test/i18n/locale_files_test.rb b/test/i18n/locale_files_test.rb index 49efcab..5d79eca 100644 --- a/test/i18n/locale_files_test.rb +++ b/test/i18n/locale_files_test.rb @@ -4,8 +4,9 @@ # a missing Japanese key silently answers in English, so this compares the # loaded translations directly instead of going through I18n.t. class LocaleFilesTest < ActiveSupport::TestCase - # Scopes the gems contribute (Active Model/Record, Devise, devise_token_auth) - GEM_SCOPES = %i[errors activerecord devise devise_token_auth].freeze + # Scopes the gems contribute (Active Model/Record, Devise, devise_token_auth, + # and Active Support's to_sentence connectors) + GEM_SCOPES = %i[errors activerecord devise devise_token_auth support].freeze def translations(locale) I18n.backend.send(:init_translations) unless I18n.backend.initialized? diff --git a/test/integration/locale_test.rb b/test/integration/locale_test.rb index 1d48db5..8487cde 100644 --- a/test/integration/locale_test.rb +++ b/test/integration/locale_test.rb @@ -94,6 +94,26 @@ def auth_headers(accept_language) assert_equal "店舗名を入力してください", response.parsed_body["error_message"] end + test "several sign-up errors are joined with Japanese commas" do + post shopkeeper_registration_url, + params: {email: "not-an-email", password: "short", name: "", time_zone: "Tokyo", current_platform: "ios"}, + headers: {"Accept-Language" => "ja"} + + assert_response :unprocessable_entity + message = response.parsed_body["error_message"] + assert_includes message, "、" + assert_no_match(/, and |, /, message) + end + + # ConfigSettings.minimum_password_length is 8 + test "the password reset form is fully Japanese" do + get edit_shopkeeper_auth_reset_password_url(reset_password_token: "x"), headers: {"Accept-Language" => "ja"} + + assert_response :success + assert_includes response.body, "(8文字以上)" + assert_not_includes response.body, "characters minimum" + end + test "an unauthenticated API request answers in Japanese" do get api_v1_shopkeeper_shops_url, headers: {"Accept-Language" => "ja"} diff --git a/test/models/accounts_shopkeeper_test.rb b/test/models/accounts_shopkeeper_test.rb index 0415581..5131f2d 100644 --- a/test/models/accounts_shopkeeper_test.rb +++ b/test/models/accounts_shopkeeper_test.rb @@ -38,7 +38,7 @@ def setup ) assert_not duplicate.valid? - assert_includes duplicate.errors[:shopkeeper_id], "has already been taken" + assert_includes duplicate.errors[:shopkeeper_id], "is already a member of this organization" end test "database rejects duplicate shopkeeper within account when validation is skipped" do diff --git a/test/models/shopkeeper_test.rb b/test/models/shopkeeper_test.rb index 8ffe91f..abbeaad 100644 --- a/test/models/shopkeeper_test.rb +++ b/test/models/shopkeeper_test.rb @@ -51,7 +51,8 @@ class ShopkeeperTest < ActiveSupport::TestCase current_platform: "windows" ) assert_not shopkeeper.valid? - assert_includes shopkeeper.errors[:current_platform], "is not included in the list" + # Same message as blank (en.yml): current_platform errors are spam protection + assert_includes shopkeeper.errors[:current_platform], "Your input data is wrong." end test "should accept ios as current_platform" do From 12d2e91f76f93b253bec7e59f33d4eb7e6806f2c Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 11 Oct 2026 06:20:08 +0900 Subject: [PATCH 4/4] Pick a signed-in shopkeeper's language from shopkeepers.locale Sign-up stores the locale the app sends (regional tags reduced to the language; unsupported or missing falls back to Accept-Language), and PATCH /shopkeeper_auth can change it (en or ja, else 422). Signed-in requests answer in that language; requests before authentication keep using Accept-Language. Confirmation, reset and invitation mails use the recipient's locale, an invitation to a new email the inviter's. Also fix PATCH /shopkeeper_auth returning 422 unless email was sent: devise_token_auth validated the params before name, time_zone and locale were permitted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WyUzNEPWyhjjWrBcSaXSW4 --- AGENTS.md | 8 +- CHANGELOG.md | 2 +- .../api/v1/shopkeeper/base_controller.rb | 1 + app/controllers/concerns/set_locale.rb | 24 ++- .../registrations_controller.rb | 13 +- app/models/accounts_invitation.rb | 7 +- app/models/shopkeeper.rb | 10 +- config/locales/en.yml | 2 + config/locales/ja.yml | 1 + docs/openapi.yaml | 10 +- .../registrations_controller_test.rb | 10 ++ test/integration/locale_test.rb | 142 ++++++++++++++++-- test/models/shopkeeper_test.rb | 18 +++ 13 files changed, 224 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 67d6b1a..8b1027c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,11 +103,13 @@ Cross-platform push via the `noticed` (v3) and `action_push_native` gems. APNs f - `json_response` for parsing JSON API responses - `create_new_auth_token` for generating auth headers (Devise Token Auth) - Fixtures in test/fixtures/ and seed data in db/fixtures/test/ -- Run tests: `bin/rails test` (568 tests, 1416 assertions) +- Run tests: `bin/rails test` (583 tests, 1454 assertions) ### Internationalization -- Responses are in English (default) or Japanese, picked per request from `Accept-Language` by the `SetLocale` concern (included in `ApplicationController` and `NonApiApplicationController`). It wraps `process_action`, not `around_action`, so `rescue_from` handlers and `rate_limit` responses are localized too. Mails queued during a request keep its locale (Active Job stores it). -- iOS sends `Accept-Language` from the device's preferred languages automatically; an Android client must add it (e.g. an OkHttp interceptor), or it gets English. +- Responses are in English (default) or Japanese, picked by the `SetLocale` concern (included in `ApplicationController` and `NonApiApplicationController`). It wraps `process_action`, not `around_action`, so `rescue_from` handlers and `rate_limit` responses are localized too. +- A signed-in shopkeeper gets `shopkeepers.locale` (`use_locale_of`, called after authentication in `Api::V1::Shopkeeper::BaseController` and `ShopkeeperAuth::RegistrationsController`). The app sends `locale` at sign-up (unsupported or missing falls back to the request's language) and changes it with `PATCH /shopkeeper_auth`. Requests before authentication (sign-in, sign-up, rate limits, 401s, the HTML pages) use `Accept-Language`. +- Mails to a shopkeeper use their `locale` (`Shopkeeper#send_*_instructions`); an invitation uses the invitee's if they have an account, otherwise the inviter's. Other mails queued during a request keep its locale (Active Job stores it). +- iOS sends `Accept-Language` from the device's preferred languages automatically; an Android client must add it (e.g. an OkHttp interceptor), or its pre-sign-in messages are English. - Locale files: `config/locales/en.yml` / `ja.yml` (app messages and attribute names), `rails.ja.yml` (Active Model/Record validation messages), `devise.ja.yml`. devise_token_auth ships its own `ja.yml`. No `rails-i18n` or `devise-i18n` gem. - When you add or change a key in `en.yml`, add it to `ja.yml`: `test/i18n/locale_files_test.rb` fails on any missing Japanese key or Japanese-only `%{variable}`. - User-facing text belongs in the locale files, not in Ruby or views (model defaults such as the sample item tag included). The admin area (`/madmin`, `/admin_auth`) stays English. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f37494..782d24f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -- API change: add a Japanese locale. Responses, validation errors, the HTML pages and emails follow the `Accept-Language` header (English or Japanese, English by default) via the new `SetLocale` concern; mails queued during a request keep its locale. Translations are in `config/locales/ja.yml`, `rails.ja.yml` and `devise.ja.yml`, with no extra gems. Hard-coded English in the error pages, `ErrorsController`, the sample item tag and the mail greetings moved to locale keys. `test/i18n/locale_files_test.rb` fails on any untranslated key. Two English messages changed: an unknown `current_platform` now gets the same "Your input data is wrong." as a blank one, and a duplicate member gets "User is already a member of this organization" instead of "has already been taken" (#150) +- API change: add a Japanese locale. A signed-in shopkeeper gets the language in `shopkeepers.locale`, which sign-up now sets from a `locale` param (falling back to `Accept-Language`) and `PATCH /shopkeeper_auth` can change (`en` or `ja`, else 422). Other requests follow the `Accept-Language` header (English by default) via the new `SetLocale` concern. Mails to a shopkeeper use their `locale`; an invitation to an email with no account uses the inviter's. Translations are in `config/locales/ja.yml`, `rails.ja.yml` and `devise.ja.yml`, with no extra gems. Hard-coded English in the error pages, `ErrorsController`, the sample item tag and the mail greetings moved to locale keys. `test/i18n/locale_files_test.rb` fails on any untranslated key. Two English messages changed: an unknown `current_platform` now gets the same "Your input data is wrong." as a blank one, and a duplicate member gets "User is already a member of this organization" instead of "has already been taken". Fix `PATCH /shopkeeper_auth` returning 422 unless `email` was sent: devise_token_auth checked the params before `name`, `time_zone` and `locale` were permitted (#150) - Fix a failed sign-in saving the `source` header as `current_platform`: anyone knowing an email could set it to any string, after which that shopkeeper's profile updates returned 422. Only successful sign-ins write it, and only `ios` or `android`. Fix resending an invitation whose inviter was deleted (the mail job raised on `nil.name`); the mail names the app instead. Rename `.erb-lint.yml` to `.erb_lint.yml` and drop a removed RuboCop cop from `.rubocop.yml` (#149) - Deploy with Kamal 2 to GitHub Container Registry instead of Render: add `Dockerfile`, `.dockerignore`, `config/deploy.yml` (kamal-proxy serving a Cloudflare Origin Certificate with `forward_headers: true` and `ssl_redirect: false`, Postgres 18 as the `db` accessory on the private Docker network, Solid Queue in Puma, Active Storage volume, amd64 builder), `.kamal/secrets`, `bin/kamal` and `bin/thrust`. Add the missing `GET /up` health check route and `assume_ssl = true` so kamal-proxy's health check passes. Add Cloudflare's ranges to `trusted_proxies` (`config/cloudflare_ips.yml`) so `rate_limit` keys on the caller rather than the edge. Remove `render.yaml`, `bin/render-*.sh` and the staging environment, which only served Render PR previews. New tests: `test/config/deploy_config_test.rb`, `test/integration/health_check_test.rb`, `test/integration/trusted_proxies_test.rb` (#144) - Point the `SOLID_QUEUE_IN_PUMA` comment in `.env.sample` at `config/deploy.yml`; it still referred to the removed `render.yaml` (#147) diff --git a/app/controllers/api/v1/shopkeeper/base_controller.rb b/app/controllers/api/v1/shopkeeper/base_controller.rb index 192dd3f..ea35640 100644 --- a/app/controllers/api/v1/shopkeeper/base_controller.rb +++ b/app/controllers/api/v1/shopkeeper/base_controller.rb @@ -6,6 +6,7 @@ class Api::V1::Shopkeeper::BaseController < ApplicationController include Pagination before_action :authenticate_shopkeeper! + before_action -> { use_locale_of(current_shopkeeper) } after_action :verify_authorized rescue_from ActiveRecord::RecordNotFound, with: :record_not_found diff --git a/app/controllers/concerns/set_locale.rb b/app/controllers/concerns/set_locale.rb index afffcf7..87fa99a 100644 --- a/app/controllers/concerns/set_locale.rb +++ b/app/controllers/concerns/set_locale.rb @@ -1,9 +1,12 @@ -# Answers in the language the client prefers in Accept-Language, among +# A signed-in shopkeeper gets the language stored in shopkeepers.locale (set +# at sign-up and from the profile screen). Requests without a signed-in +# shopkeeper use the language the client prefers in Accept-Language, among # I18n.available_locales; anything else gets the default (English). iOS sends # the header from the device's preferred languages, e.g. "ja-JP, en-JP;q=0.9". # # Mails queued during the request keep this locale: Active Job stores -# I18n.locale with each job. +# I18n.locale with each job. Mails to a shopkeeper switch to their own +# locale (Shopkeeper#send_*_instructions, AccountsInvitation#send_invite). # # This wraps process_action rather than using around_action: rescue_from # handlers and rate_limit responses run outside the callback chain, and they @@ -15,15 +18,28 @@ def process_action(...) I18n.with_locale(locale_from_accept_language) { super } end + # Call once the shopkeeper is authenticated. I18n.with_locale above + # restores the request's locale afterwards. + def use_locale_of(shopkeeper) + locale = shopkeeper&.locale&.to_sym + I18n.locale = locale if I18n.available_locales.include?(locale) + end + def locale_from_accept_language preferred_languages.each do |language| - locale = language.split("-").first.to_sym - return locale if I18n.available_locales.include?(locale) + locale = supported_locale(language) + return locale if locale end I18n.default_locale end + # "ja-JP" -> :ja; nil when the language is not offered + def supported_locale(language_tag) + locale = language_tag.to_s.split("-").first.to_s.downcase.to_sym + locale if I18n.available_locales.include?(locale) + end + # Language tags by descending quality value, header order breaking ties. # Tags the client refuses (q=0) or that fail to parse are dropped. def preferred_languages diff --git a/app/controllers/shopkeeper_auth/registrations_controller.rb b/app/controllers/shopkeeper_auth/registrations_controller.rb index fd5d44e..4da225b 100644 --- a/app/controllers/shopkeeper_auth/registrations_controller.rb +++ b/app/controllers/shopkeeper_auth/registrations_controller.rb @@ -6,13 +6,22 @@ class ShopkeeperAuth::RegistrationsController < DeviseTokenAuth::RegistrationsCo } before_action :set_confirm_success_url, only: %i[create] - before_action :configure_permitted_parameters + prepend_before_action :configure_permitted_parameters + # devise_token_auth's set_user_by_token runs first and sets @resource + before_action -> { use_locale_of(@resource) }, only: %i[update destroy] protected def configure_permitted_parameters devise_parameter_sanitizer.permit(:sign_up, keys: %i[name email password time_zone current_platform]) - devise_parameter_sanitizer.permit(:account_update, keys: %i[name email time_zone]) + devise_parameter_sanitizer.permit(:account_update, keys: %i[name email time_zone locale]) + end + + # The app sends the device's language. Without one the app supports, the + # shopkeeper gets the request's language rather than a failed sign-up. + def build_resource + super + @resource.locale = supported_locale(params[:locale]) || I18n.locale end def render_create_success diff --git a/app/models/accounts_invitation.rb b/app/models/accounts_invitation.rb index b93d1cd..0cad9d1 100644 --- a/app/models/accounts_invitation.rb +++ b/app/models/accounts_invitation.rb @@ -20,8 +20,13 @@ def save_and_send_invite save && send_invite end + # Written in the invitee's language when they already have an account, + # otherwise in the inviter's (the request's locale) def send_invite - Shopkeeper::NotificationMailer.with(accounts_invitation: self).invited.deliver_later + locale = Shopkeeper.find_by(email: email.to_s.strip.downcase)&.locale || I18n.locale + I18n.with_locale(locale) do + Shopkeeper::NotificationMailer.with(accounts_invitation: self).invited.deliver_later + end end def resend_invite diff --git a/app/models/shopkeeper.rb b/app/models/shopkeeper.rb index c108459..49490bf 100644 --- a/app/models/shopkeeper.rb +++ b/app/models/shopkeeper.rb @@ -29,6 +29,8 @@ class Shopkeeper < ApplicationRecord presence: true, inclusion: {in: CURRENT_PLATFORMS} + validates :locale, inclusion: {in: ->(_) { I18n.available_locales.map(&:to_s) }} + # override devise method to include additional info as opts hash def send_confirmation_instructions(opts = {}) generate_confirmation_token! unless @raw_confirmation_token @@ -38,7 +40,9 @@ def send_confirmation_instructions(opts = {}) opts[:to] = unconfirmed_email if pending_reconfirmation? opts[:redirect_url] ||= DeviseTokenAuth.default_confirm_success_url - Shopkeeper::NotificationMailer.with(resource: self, token: @raw_confirmation_token, opts: opts).confirmation_instructions.deliver_later + I18n.with_locale(locale) do + Shopkeeper::NotificationMailer.with(resource: self, token: @raw_confirmation_token, opts: opts).confirmation_instructions.deliver_later + end end # override devise method to include additional info as opts hash @@ -48,7 +52,9 @@ def send_reset_password_instructions(opts = {}) # fall back to "default" config name opts[:client_config] ||= "default" - Shopkeeper::NotificationMailer.with(resource: self, token: token, opts: opts).reset_password_instructions.deliver_later + I18n.with_locale(locale) do + Shopkeeper::NotificationMailer.with(resource: self, token: token, opts: opts).reset_password_instructions.deliver_later + end token end diff --git a/config/locales/en.yml b/config/locales/en.yml index 1edf45a..214f77b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -132,6 +132,8 @@ en: accounts_shopkeeper: shopkeeper: "User" shopkeeper_id: "User" + shopkeeper: + locale: "Language" errors: models: diff --git a/config/locales/ja.yml b/config/locales/ja.yml index c89bc16..59128d5 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -160,6 +160,7 @@ ja: current_password: "現在のパスワード" current_platform: "プラットフォーム" email: "メールアドレス" + locale: "言語" name: "名前" password: "パスワード" password_confirmation: "パスワード(確認)" diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ff5a4d4..648165f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -4,7 +4,7 @@ info: description: | REST API for multi-tenant SaaS backend serving NativeAppTemplate iOS/Android mobile applications. Shopkeepers authenticate via token-based auth to manage shops and item tags. - Messages (`error_message`, validation errors, emails) are in English or Japanese, chosen from the `Accept-Language` request header (e.g. `ja-JP, en-JP;q=0.9`). Unsupported or missing languages get English. Emails sent because of a request use that request's language. + Messages (`error_message`, validation errors, emails) are in English or Japanese. A signed-in shopkeeper gets their stored `locale` (sent at sign-up, changed with `PATCH /shopkeeper_auth`). Other requests use the `Accept-Language` request header (e.g. `ja-JP, en-JP;q=0.9`); unsupported or missing languages get English. Emails to a shopkeeper use their `locale`; an invitation to an email with no account uses the inviter's. version: 1.0.0 servers: @@ -607,6 +607,10 @@ components: current_platform: type: string enum: [ios, android] + locale: + type: string + description: The device's language (`ja`, `ja-JP`, `en`...). A language the API does not offer, or none, falls back to the `Accept-Language` header, then English. + example: ja SignInRequest: type: object @@ -628,6 +632,10 @@ components: format: email time_zone: type: string + locale: + type: string + enum: [en, ja] + description: Any other value gets 422. ConfirmationRequest: type: object diff --git a/test/controllers/shopkeeper_auth/registrations_controller_test.rb b/test/controllers/shopkeeper_auth/registrations_controller_test.rb index 2c6c6a2..293b505 100644 --- a/test/controllers/shopkeeper_auth/registrations_controller_test.rb +++ b/test/controllers/shopkeeper_auth/registrations_controller_test.rb @@ -23,6 +23,16 @@ class ShopkeeperAuth::RegistrationsControllerTest < ActionDispatch::IntegrationT assert response.parsed_body["data"] end + # devise_token_auth checks account_update_params in a before_action declared + # before ours, so the extra keys must be permitted ahead of it + test "updates a profile field without resending the email" do + shopkeeper.create_default_account + patch shopkeeper_registration_url, params: {time_zone: "Osaka"}, headers: shopkeeper.create_new_auth_token + + assert_response :success + assert_equal "Osaka", shopkeeper.reload.time_zone + end + test "delete current shopkeeper" do assert_difference "Shopkeeper.count", -1 do delete shopkeeper_registration_url, headers: shopkeeper.create_new_auth_token diff --git a/test/integration/locale_test.rb b/test/integration/locale_test.rb index 8487cde..1e92798 100644 --- a/test/integration/locale_test.rb +++ b/test/integration/locale_test.rb @@ -1,6 +1,7 @@ require "test_helper" -# Responses use the language the client asks for in Accept-Language (English +# A signed-in shopkeeper gets the language stored in shopkeepers.locale. Other +# requests use the language the client asks for in Accept-Language (English # or Japanese), falling back to English. iOS sends the header from the # device's preferred languages; other clients must add it themselves. class LocaleTest < ActionDispatch::IntegrationTest @@ -34,8 +35,15 @@ def without_detailed_exceptions env_config["action_dispatch.show_detailed_exceptions"] = original end - def auth_headers(accept_language) - @shopkeeper.create_new_auth_token.merge("Accept-Language" => accept_language) + def auth_headers(accept_language = nil) + headers = @shopkeeper.create_new_auth_token + accept_language ? headers.merge("Accept-Language" => accept_language) : headers + end + + def sign_up(params = {}, headers = {}) + post shopkeeper_registration_url, + params: {email: "new@example.com", name: "New", password: "password", time_zone: "Tokyo", current_platform: "ios"}.merge(params), + headers: headers end test "the two locales really differ" do @@ -86,9 +94,10 @@ def auth_headers(accept_language) # errors.format "%{attribute}%{message}" + shop name "店舗名" + errors.messages.blank "を入力してください" test "validation errors use Japanese attribute names and messages" do + @shopkeeper.update!(locale: "ja") post api_v1_shopkeeper_shops_url, params: {shop: {name: "", time_zone: "Tokyo"}}, - headers: auth_headers("ja") + headers: auth_headers assert_response :unprocessable_entity assert_equal "店舗名を入力してください", response.parsed_body["error_message"] @@ -122,8 +131,9 @@ def auth_headers(accept_language) assert_not_equal I18n.t("devise.failure.unauthenticated", locale: :en), I18n.t("devise.failure.unauthenticated", locale: :ja) end - test "a missing API record answers in Japanese" do - get api_v1_shopkeeper_shop_url("00000000-0000-0000-0000-000000000000"), headers: auth_headers("ja") + test "a missing API record answers in the stored language" do + @shopkeeper.update!(locale: "ja") + get api_v1_shopkeeper_shop_url("00000000-0000-0000-0000-000000000000"), headers: auth_headers assert_response :not_found assert_equal I18n.t("not_found", locale: :ja), response.parsed_body["error_message"] @@ -155,11 +165,12 @@ def auth_headers(accept_language) assert_equal I18n.t("errors.messages.too_many_logins", locale: :ja), response.parsed_body["error_message"] end - test "an invitation sent during a Japanese request is written in Japanese" do + test "an invitation to a new email is written in the inviter's language" do + @shopkeeper.update!(locale: "ja") perform_enqueued_jobs do post api_v1_shopkeeper_account_accounts_invitations_url(@account), params: {accounts_invitation: {name: "New User", email: "newuser@example.com", member: true}}, - headers: auth_headers("ja") + headers: auth_headers("en") end assert_response :created @@ -169,10 +180,11 @@ def auth_headers(accept_language) assert_includes mail.text_part.body.decoded, I18n.t("shopkeeper.notification_mailer.invited.you_can_accept_or_decline", locale: :ja) end - test "a shop created during a Japanese request gets a Japanese sample item tag" do + test "a shop created by a Japanese shopkeeper gets a Japanese sample item tag" do + @shopkeeper.update!(locale: "ja") post api_v1_shopkeeper_shops_url, params: {shop: {name: "新しい店", time_zone: "Tokyo"}}, - headers: auth_headers("ja") + headers: auth_headers assert_response :created shop = Shop.find(response.parsed_body["data"]["id"]) @@ -180,4 +192,114 @@ def auth_headers(accept_language) assert_equal I18n.t("sample_item_tag.name", locale: :ja), sample.name assert_equal I18n.t("sample_item_tag.description", locale: :ja), sample.description end + + # --- shopkeepers.locale --- + + test "a signed-in shopkeeper's stored language wins over Accept-Language" do + missing_shop = api_v1_shopkeeper_shop_url("00000000-0000-0000-0000-000000000000") + + @shopkeeper.update!(locale: "ja") + get missing_shop, headers: auth_headers("en") + assert_equal I18n.t("not_found", locale: :ja), response.parsed_body["error_message"] + + @shopkeeper.update!(locale: "en") + get missing_shop, headers: auth_headers("ja") + assert_equal I18n.t("not_found", locale: :en), response.parsed_body["error_message"] + end + + test "a failed sign-in still answers in the Accept-Language" do + @shopkeeper.update!(locale: "ja") + + assert_equal bad_credentials(:en), bad_credentials_message("en") + end + + test "sign-up stores the locale the app sends" do + sign_up({locale: "ja"}, {"Accept-Language" => "en"}) + + assert_response :success + assert_equal "ja", Shopkeeper.find_by!(email: "new@example.com").locale + assert_equal "ja", response.parsed_body["data"]["attributes"]["locale"] + end + + test "sign-up reduces a regional locale such as ja-JP to its language" do + sign_up(locale: "ja-JP") + + assert_equal "ja", Shopkeeper.find_by!(email: "new@example.com").locale + end + + test "sign-up without a locale takes the Accept-Language" do + sign_up({}, {"Accept-Language" => "ja-JP, en;q=0.9"}) + + assert_equal "ja", Shopkeeper.find_by!(email: "new@example.com").locale + end + + test "sign-up without a locale or Accept-Language stores English" do + sign_up + + assert_equal "en", Shopkeeper.find_by!(email: "new@example.com").locale + end + + # A device set to a language the API lacks must still be able to sign up + test "sign-up with an unsupported locale falls back to the Accept-Language" do + sign_up({locale: "fr"}, {"Accept-Language" => "ja"}) + + assert_response :success + assert_equal "ja", Shopkeeper.find_by!(email: "new@example.com").locale + end + + test "the confirmation mail is written in the stored language" do + perform_enqueued_jobs do + sign_up({locale: "ja"}, {"Accept-Language" => "en"}) + end + + assert_equal I18n.t("shopkeeper.notification_mailer.confirmation_instructions.subject", locale: :ja), + ActionMailer::Base.deliveries.last.subject + end + + test "the profile update stores a new locale" do + patch shopkeeper_registration_url, params: {locale: "ja"}, headers: auth_headers + + assert_response :success + assert_equal "ja", @shopkeeper.reload.locale + assert_equal "ja", response.parsed_body["data"]["attributes"]["locale"] + end + + # errors.format "%{attribute}%{message}" + locale "言語" + errors.messages.inclusion "は一覧から選択してください" + test "the profile update rejects an unsupported locale" do + @shopkeeper.update!(locale: "ja") + patch shopkeeper_registration_url, params: {locale: "fr"}, headers: auth_headers("en") + + assert_response :unprocessable_entity + assert_equal "言語は一覧から選択してください", response.parsed_body["error_message"] + assert_equal "ja", @shopkeeper.reload.locale + end + + test "the reset password mail is written in the stored language" do + @shopkeeper.update!(locale: "ja") + + perform_enqueued_jobs do + post shopkeeper_password_url, + params: {email: @shopkeeper.email, redirect_url: "http://www.example.com/reset"}, + headers: {"Accept-Language" => "en"}, + as: :json + end + + assert_equal I18n.t("shopkeeper.notification_mailer.reset_password_instructions.subject", locale: :ja), + ActionMailer::Base.deliveries.last.subject + end + + test "an invitation to an existing shopkeeper is written in their language" do + invitee = shopkeepers(:two) + invitee.update!(locale: "ja") + + perform_enqueued_jobs do + post api_v1_shopkeeper_account_accounts_invitations_url(@account), + params: {accounts_invitation: {name: invitee.name, email: invitee.email.upcase, member: true}}, + headers: auth_headers + end + + assert_response :created + expected = I18n.t("shopkeeper.notification_mailer.invited.subject", locale: :ja, inviter: @shopkeeper.name, account: @account.name) + assert_equal expected, ActionMailer::Base.deliveries.last.subject + end end diff --git a/test/models/shopkeeper_test.rb b/test/models/shopkeeper_test.rb index abbeaad..62a7444 100644 --- a/test/models/shopkeeper_test.rb +++ b/test/models/shopkeeper_test.rb @@ -67,6 +67,24 @@ class ShopkeeperTest < ActiveSupport::TestCase assert shopkeeper.valid? end + # config.i18n.available_locales is [:en, :ja] + test "should accept every available locale" do + shopkeeper = shopkeepers(:one) + %w[en ja].each do |locale| + shopkeeper.locale = locale + assert shopkeeper.valid?, locale + end + end + + test "should reject a locale the app does not offer" do + shopkeeper = shopkeepers(:one) + ["fr", "ja-JP", ""].each do |locale| + shopkeeper.locale = locale + assert_not shopkeeper.valid?, locale + assert_includes shopkeeper.errors[:locale], "is not included in the list" + end + end + test "should have many shops through accounts" do shopkeeper = shopkeepers(:one) shopkeeper.create_default_account