Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,16 @@ 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` (583 tests, 1454 assertions)

### Internationalization
- 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.

### 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`.
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]

- 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)
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions app/controllers/api/v1/shopkeeper/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
57 changes: 57 additions & 0 deletions app/controllers/concerns/set_locale.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 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. 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
# must answer in the client's language too.
module SetLocale
private

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 = 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
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
4 changes: 2 additions & 2 deletions app/controllers/errors_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/non_api_application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class NonApiApplicationController < ActionController::Base
include SetLocale

allow_browser versions: :modern
protect_from_forgery with: :exception

Expand Down
13 changes: 11 additions & 2 deletions app/controllers/shopkeeper_auth/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion app/models/accounts_invitation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/models/shop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
10 changes: 8 additions & 2 deletions app/models/shopkeeper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/views/errors/internal_server_error.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div class="grid min-h-full place-items-center bg-white px-6 py-24 sm:py-32 lg:px-8">
<div class="text-center">
<p class="text-base font-semibold text-sky-600">500</p>
<h1 class="mt-4 text-balance text-5xl font-semibold tracking-tight text-gray-900 sm:text-7xl">Internal server error</h1>
<p class="mt-6 text-pretty text-lg font-medium text-gray-500 sm:text-xl/8">Sorry, we had a problem loading this page.</p>
<h1 class="mt-4 text-balance text-5xl font-semibold tracking-tight text-gray-900 sm:text-7xl"><%= t("error_pages.internal_server_error.title") %></h1>
<p class="mt-6 text-pretty text-lg font-medium text-gray-500 sm:text-xl/8"><%= t("error_pages.internal_server_error.message") %></p>
</div>
</div>
4 changes: 2 additions & 2 deletions app/views/errors/not_found.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div class="grid min-h-full place-items-center bg-white px-6 py-24 sm:py-32 lg:px-8">
<div class="text-center">
<p class="text-base font-semibold text-sky-600">404</p>
<h1 class="mt-4 text-balance text-5xl font-semibold tracking-tight text-gray-900 sm:text-7xl">Page not found</h1>
<p class="mt-6 text-pretty text-lg font-medium text-gray-500 sm:text-xl/8">Sorry, we couldn’t find the page you’re looking for.</p>
<h1 class="mt-4 text-balance text-5xl font-semibold tracking-tight text-gray-900 sm:text-7xl"><%= t("error_pages.not_found.title") %></h1>
<p class="mt-6 text-pretty text-lg font-medium text-gray-500 sm:text-xl/8"><%= t("error_pages.not_found.message") %></p>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<p><%= "#{t(:welcome).capitalize} #{@email}!" %></p>
<p><%= t(:welcome_email, email: @email) %></p>

<p><%= t ".confirm_link_msg" %> </p>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<%= "#{t(:welcome).capitalize} #{@email}!" %>
<%= t(:welcome_email, email: @email) %>

<%= t ".confirm_link_msg" %>

Expand Down
2 changes: 1 addition & 1 deletion app/views/shopkeeper/notification_mailer/invited.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<%= t(".enter_your_invitation_code_in_the_mobile_app_html", mobile_app: ConfigSettings.mobile_app.name) %>
</p>

<p><%= t(".instructions").capitalize %>:</p>
<p><%= t(".instructions_heading") %></p>
<p>1. <%= t(".open_mobile_app_html", mobile_app: ConfigSettings.mobile_app.name) %></p>
<p>2. <%= t(".create_account_or_sign_in_with_html", email: @accounts_invitation.email) %></p>
<p>3. <%= t(".select_organization_tab") %></p>
Expand Down
2 changes: 1 addition & 1 deletion app/views/shopkeeper/notification_mailer/invited.text.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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") %>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<p><%= t(:hello).capitalize %> <%= @resource.email %>!</p>
<p><%= t(:hello_email, email: @resource.email) %></p>

<p><%= t ".request_reset_link_msg" %></p>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<%= "#{t(:hello).capitalize} #{@resource.email}!" %>
<%= t(:hello_email, email: @resource.email) %>

<%= t ".request_reset_link_msg" %>

Expand Down
6 changes: 3 additions & 3 deletions app/views/shopkeeper_auth/reset_passwords/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
<%= f.label :password, t("new_password"), class: "block text-sm font-medium leading-6 text-gray-900" %>
</div>
<div class="mt-2">
<%= 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") %>
</div>
<div class="text-sm">
<p class="mt-3 text-sm text-gray-600">(<%= @minimum_password_length %> characters minimum)</p>
<p class="mt-3 text-sm text-gray-600"><%= t("password_minimum_length", count: @minimum_password_length) %></p>
</div>
</div>

Expand All @@ -24,7 +24,7 @@
<%= f.label :password_confirmation, t("confirm_new_password"), class: "block text-sm font-medium leading-6 text-gray-900" %>
</div>
<div class="mt-2">
<%= 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") %>
</div>
</div>

Expand Down
2 changes: 1 addition & 1 deletion config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading