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