diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c26f25..1f555a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- The password-reset page names the app in text (`display_name`) instead of showing `public/icon.svg`, the template's logo, which apps built from the template kept. The minimal and error page layouts' `` use `display_name` too. The README lists the icons in `public/` as the template's to replace (#163) +- Mails name the app by the new `display_name` setting (`Native App Template`) instead of the identifier-like `NativeAppTemplate`: the sender name (`email.default_from_with_name`, also `support_with_name`), the invitation text, the name of a deleted inviter and the footer. The invitation mail leaves out the App Store / Google Play links while `mobile_app.app_store_url` / `play_store_url` are blank, which is now the default instead of placeholder URLs; the link labels are translated. `mobile_app.name`, `name_ios` and `name_android` are removed. The README's "App name and store links" covers both (#163) - API change: add an admin-set full feature access override for the paid clients. `shopkeepers.feature_access_state` (`normal_feature_access` by default, or `all_feature_access`, indexed) is editable only in `/madmin`; sign-up and `PATCH /shopkeeper_auth` ignore it. Sign-in returns `feature_access_state` and `account_owner_feature_access_state`, and account responses return `owner_feature_access_state`, so the apps can unlock every paid feature in an account whose owner has the override (testing on production, staff/partner/comp accounts, App Review demo accounts). The README's "Full feature access override" covers it and RevenueCat promotional entitlements as the no-code alternative. Ported from myturntagapi - Fix saving a shopkeeper in madmin failing with 500 (`PG::UniqueViolation` on `index_shopkeepers_on_reset_password_token` or `index_shopkeepers_on_confirmation_token`). The edit form sends empty token fields as `""`, so a second shopkeeper with no reset or confirmation token collided with the first. madmin's shopkeeper form no longer shows `reset_password_token` and `confirmation_token` (read only on the show page), and a migration clears the `""` already stored. An earlier version of this fix normalized blank tokens to NULL in `Shopkeeper`; that also rewrote finder values, so `where(reset_password_token: "")` matched every shopkeeper without a token, and it was removed. Ported from myturntagapi, where it hit production (#158) - API change: `Shop#time_zone` and `Shopkeeper#time_zone` must be a Rails time zone name (`ActiveSupport::TimeZone.all.map(&:name)`, e.g. `Tokyo`, `London`); anything else, IANA ids such as `Asia/Tokyo` included, gets 422 "Time zone is not included in the list" from `PATCH /shops/:id`, `POST /shops`, sign-up and `PATCH /shopkeeper_auth` instead of being stored. `test/config/time_zones_test.rb` pins the list against `test/fixtures/files/rails_time_zones.tsv`, which the iOS and Android apps embed for their time zone pickers (#155) diff --git a/README.md b/README.md index ef80c7f..92a13ef 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,14 @@ bin/setup Push notifications (used by the paid iOS/Android clients) need APNs and FCM credentials. Add them per environment with `bin/rails credentials:edit --environment <env>` under `action_push_native:apns` and `action_push_native:fcm`; see `config/push.yml` for the expected keys. +## App name and store links + +`config/settings.yml` has two kinds of app name. `display_name` (`Native App Template`) is the name people read: mails (sender name, invitation text, footer) and the HTML pages use it. `site.name` and `business.name` (`NativeAppTemplate`) are the one-word identifier-like names; keep them for code that needs one. + +The invitation mail links the App Store and Google Play only when `mobile_app.app_store_url` / `mobile_app.play_store_url` are set. Both are blank by default, so no placeholder link is sent; fill them in once the apps are published. + +The icons in `public/` (`favicon.ico`, `icon.svg`, `icon.png`, `icon-192.png`, `icon-512.png`, `apple-touch-icon.png`) are the template's and only appear as browser favicons; replace them with your app's icons. The HTML pages (password reset, email confirmation, error pages) name the app in text with `display_name` instead of showing a logo. + ## Running NativeAppTemplate API on your Wi-Fi Copy `.env.sample` to `.env` and set `HOST` to your current Wi-Fi IP. On macOS: `ipconfig getifaddr en0`. `bin/dev` binds Rails to that address so the dev server is reachable from both the host browser and from any phone on the same network at `http://<wifi-ip>:3000`. When your Wi-Fi IP changes, update `HOST` here and the matching `NATIVEAPPTEMPLATE_API_DOMAIN` in the mobile apps (Xcode scheme for iOS, `~/.gradle/gradle.properties` for Android) — Rails fails loudly if `HOST` is unset, which keeps the three sides honest. Never use `127.0.0.1`, `localhost`, or `0.0.0.0`. diff --git a/app/mailers/shopkeeper/notification_mailer.rb b/app/mailers/shopkeeper/notification_mailer.rb index 2995e8c..1f6afb5 100644 --- a/app/mailers/shopkeeper/notification_mailer.rb +++ b/app/mailers/shopkeeper/notification_mailer.rb @@ -4,7 +4,8 @@ def invited @account = @accounts_invitation.account # invited_by is nullified when the inviter is deleted; the invitation can # still be resent, so name the app instead - @inviter_name = @accounts_invitation.invited_by&.name || ConfigSettings.mobile_app.name + @inviter_name = @accounts_invitation.invited_by&.name || ConfigSettings.display_name + @store_links = store_links name = @accounts_invitation.name email = @accounts_invitation.email @@ -45,4 +46,16 @@ def reset_password_instructions subject: I18n.t("shopkeeper.notification_mailer.reset_password_instructions.subject") ) end + + private + + # Blank store URLs (the default in config/settings.yml) are left out, so + # the mail never links a placeholder before the apps are published + def store_links + app = ConfigSettings.display_name + { + I18n.t("shopkeeper.notification_mailer.invited.ios_app", mobile_app: app) => ConfigSettings.mobile_app.app_store_url, + I18n.t("shopkeeper.notification_mailer.invited.android_app", mobile_app: app) => ConfigSettings.mobile_app.play_store_url + }.select { |_label, url| url.present? } + end end diff --git a/app/views/layouts/errors.html.erb b/app/views/layouts/errors.html.erb index 668ce95..5385eab 100644 --- a/app/views/layouts/errors.html.erb +++ b/app/views/layouts/errors.html.erb @@ -5,7 +5,7 @@ <% if content_for?(:title) %> <%= yield :title %> | <% end %> - <%= ConfigSettings.site.name %> + <%= ConfigSettings.display_name %> <%= csrf_meta_tags %> diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 848b68b..57b0d68 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -115,7 +115,7 @@
- <%= t(".copyright_html", year: Date.current.year, business_name: ConfigSettings.business.name) %> + <%= t(".copyright_html", year: Date.current.year, name: ConfigSettings.display_name) %>
@@ -126,7 +126,7 @@
  - <%= link_to ConfigSettings.site.name, ConfigSettings.site.url, target: :_blank %> + <%= link_to ConfigSettings.display_name, ConfigSettings.site.url, target: :_blank %>  
diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb index 63375de..e90117b 100644 --- a/app/views/layouts/mailer.text.erb +++ b/app/views/layouts/mailer.text.erb @@ -1,3 +1,3 @@ <%= yield %> -<%= ConfigSettings.site.name %>: <%= ConfigSettings.site.url %> +<%= ConfigSettings.display_name %>: <%= ConfigSettings.site.url %> diff --git a/app/views/layouts/minimal.html.erb b/app/views/layouts/minimal.html.erb index 03d4d1b..915d565 100644 --- a/app/views/layouts/minimal.html.erb +++ b/app/views/layouts/minimal.html.erb @@ -5,7 +5,7 @@ <% if content_for?(:title) %> <%= yield :title %> | <% end %> - <%= ConfigSettings.site.name %> + <%= ConfigSettings.display_name %> <%= csrf_meta_tags %> diff --git a/app/views/shopkeeper/notification_mailer/invited.html.erb b/app/views/shopkeeper/notification_mailer/invited.html.erb index 3b2fa2d..de03143 100644 --- a/app/views/shopkeeper/notification_mailer/invited.html.erb +++ b/app/views/shopkeeper/notification_mailer/invited.html.erb @@ -5,11 +5,11 @@

<%= t(".you_can_accept_or_decline") %>
- <%= t(".enter_your_invitation_code_in_the_mobile_app_html", mobile_app: ConfigSettings.mobile_app.name) %> + <%= t(".enter_your_invitation_code_in_the_mobile_app_html", mobile_app: ConfigSettings.display_name) %>

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

-

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

+

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

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

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

4. <%= t(".tap_join_button") %>

@@ -20,7 +20,8 @@ <%= @accounts_invitation.token %> -

<%= link_to ConfigSettings.mobile_app.name_ios, ConfigSettings.mobile_app.app_store_url, target: :_blank %>

-

<%= link_to ConfigSettings.mobile_app.name_android, ConfigSettings.mobile_app.play_store_url, target: :_blank %>

+<% @store_links.each do |label, url| %> +

<%= link_to label, url, target: :_blank %>

+<% end %>

<%= t(".expires_in", expires_in: ConfigSettings.accounts_invitation.expires_in_hours) %>

diff --git a/app/views/shopkeeper/notification_mailer/invited.text.erb b/app/views/shopkeeper/notification_mailer/invited.text.erb index ec5297d..6d992ac 100644 --- a/app/views/shopkeeper/notification_mailer/invited.text.erb +++ b/app/views/shopkeeper/notification_mailer/invited.text.erb @@ -2,10 +2,10 @@ <%= t(".you_can_accept_or_decline") %> -<%= strip_tags t(".enter_your_invitation_code_in_the_mobile_app_html", mobile_app: ConfigSettings.mobile_app.name) %> +<%= strip_tags t(".enter_your_invitation_code_in_the_mobile_app_html", mobile_app: ConfigSettings.display_name) %> <%= t(".instructions_heading") %> -1. <%= strip_tags t(".open_mobile_app_html", mobile_app: ConfigSettings.mobile_app.name) %> +1. <%= strip_tags t(".open_mobile_app_html", mobile_app: ConfigSettings.display_name) %> 2. <%= strip_tags t(".create_account_or_sign_in_with_html", email: @accounts_invitation.email) %> 3. <%= t(".select_organization_tab") %> 4. <%= t(".tap_join_button") %> @@ -14,7 +14,10 @@ <%= @accounts_invitation.token %> -<%= ConfigSettings.mobile_app.name_ios %>: <%= ConfigSettings.mobile_app.app_store_url %> -<%= ConfigSettings.mobile_app.name_android %>: <%= ConfigSettings.mobile_app.play_store_url %> +<% if @store_links.any? %> +<% @store_links.each do |label, url| %> +<%= label %>: <%= url %> +<% end %> +<% end %> <%= t(".expires_in", expires_in: ConfigSettings.accounts_invitation.expires_in_hours) %> diff --git a/app/views/shopkeeper_auth/reset_passwords/edit.html.erb b/app/views/shopkeeper_auth/reset_passwords/edit.html.erb index 7a8dece..d0de856 100644 --- a/app/views/shopkeeper_auth/reset_passwords/edit.html.erb +++ b/app/views/shopkeeper_auth/reset_passwords/edit.html.erb @@ -1,6 +1,6 @@
- <%= image_tag "/icon.svg?color=sky&shade=600", class: "mx-auto h-10 w-auto", alt: ConfigSettings.business.name %> +

<%= ConfigSettings.display_name %>

<%= t("change_your_password") %>

diff --git a/config/locales/en.yml b/config/locales/en.yml index 021e749..a5c1277 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -92,6 +92,8 @@ en: tap_join_button: "Tap Join button." enter_your_invitation_code: "Enter your invitation code below." if_you_accepted_invitation_switch_to_organization: "If you accepted the invitation, switch to the organization(Organizations > [Organization] > Switch)." + ios_app: "%{mobile_app} for iOS" + android_app: "%{mobile_app} for Android" expires_in: "This invitation will expire in %{expires_in} hours." reset_password_instructions: subject: "Reset password instructions" @@ -123,7 +125,7 @@ en: layouts: mailer: - copyright_html: "© %{year} %{business_name} All rights reserved." + copyright_html: "© %{year} %{name} All rights reserved." activerecord: attributes: diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 0faf899..fbe9e08 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -68,6 +68,8 @@ ja: tap_join_button: "「参加」ボタンをタップします。" enter_your_invitation_code: "下記の招待コードを入力します。" if_you_accepted_invitation_switch_to_organization: "招待を承認したら、その組織に切り替えます(組織 > [組織名] > 切り替え)。" + ios_app: "%{mobile_app} iOS版" + android_app: "%{mobile_app} Android版" expires_in: "この招待の有効期限は%{expires_in}時間です。" reset_password_instructions: subject: "パスワード再設定のご案内" @@ -114,7 +116,7 @@ ja: layouts: mailer: - copyright_html: "© %{year} %{business_name} All rights reserved." + copyright_html: "© %{year} %{name} All rights reserved." activerecord: models: diff --git a/config/settings.yml b/config/settings.yml index 4d7a1db..f5bb6c8 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -1,3 +1,8 @@ +# The app's name as people read it, in mails and on the HTML pages. The +# identifier-like names below (site.name, business.name) stay as they are +# for code that needs a one-word name. +display_name: Native App Template + site: name: NativeAppTemplate domain: nativeapptemplate.com @@ -8,19 +13,20 @@ business: email: support: support@nativeapptemplate.com - support_with_name: NativeAppTemplate + support_with_name: Native App Template default_from: noreply@nativeapptemplate.com - default_from_with_name: NativeAppTemplate + default_from_with_name: Native App Template app: domain: api.nativeapptemplate.com +# Store links in the invitation mail. Each is left out of the mail while +# blank; set them once the apps are published, e.g. +# app_store_url: https://apps.apple.com/app//id +# play_store_url: https://play.google.com/store/apps/details?id= mobile_app: - name: NativeAppTemplate - name_ios: NativeAppTemplate for iOS - name_android: NativeAppTemplate for Android - app_store_url: https://apps.apple.com/app/nativeapptemplate/idxxxxxxxxxx - play_store_url: https://play.google.com/store/apps/details?id=com.yourdomain.nativeapptemplate + app_store_url: "" + play_store_url: "" minimum_password_length: 8 diff --git a/test/controllers/errors_controller_test.rb b/test/controllers/errors_controller_test.rb index e21275e..f62ecf6 100644 --- a/test/controllers/errors_controller_test.rb +++ b/test/controllers/errors_controller_test.rb @@ -20,6 +20,12 @@ class ErrorsControllerTest < ActionController::TestCase assert_match(/text\/html/, response.media_type) end + test "HTML error page title names the app by its display name" do + get :not_found + + assert_select "title", text: /#{Regexp.escape(ConfigSettings.display_name)}/ + end + test "internal_server_error responds with 500 JSON when content-type is JSON" do @request.headers["Content-Type"] = "application/json" get :internal_server_error diff --git a/test/controllers/shopkeeper_auth/reset_passwords_controller_test.rb b/test/controllers/shopkeeper_auth/reset_passwords_controller_test.rb index a996580..10fed9f 100644 --- a/test/controllers/shopkeeper_auth/reset_passwords_controller_test.rb +++ b/test/controllers/shopkeeper_auth/reset_passwords_controller_test.rb @@ -22,4 +22,15 @@ class ShopkeeperAuth::ResetPasswordsControllerTest < ActionDispatch::Integration assert_response :success assert_match "tok-xyz", response.body end + + # The page showed public/icon.svg, the template's logo, which an app built + # from the template kept. It names the app in text instead. + test "edit names the app in text instead of showing the template logo" do + get edit_shopkeeper_auth_reset_password_url(reset_password_token: "abc123") + + assert_response :success + assert_select "p", text: ConfigSettings.display_name + assert_select "img", count: 0 + assert_select "title", text: /#{Regexp.escape(ConfigSettings.display_name)}/ + end end diff --git a/test/mailers/shopkeeper/notification_mailer_test.rb b/test/mailers/shopkeeper/notification_mailer_test.rb index ea40599..7e384d4 100644 --- a/test/mailers/shopkeeper/notification_mailer_test.rb +++ b/test/mailers/shopkeeper/notification_mailer_test.rb @@ -31,7 +31,7 @@ class Shopkeeper::NotificationMailerTest < ActionMailer::TestCase # Bug: deleting the inviter nullifies invited_by, and resending such an # invitation raised NoMethodError on nil.name inside the mail job. The mail - # now names the app instead (config/settings.yml mobile_app.name). + # now names the app instead (config/settings.yml display_name). test "invited names the app when the inviter has been deleted" do @invitation.update!(invited_by: nil) @@ -39,13 +39,13 @@ class Shopkeeper::NotificationMailerTest < ActionMailer::TestCase expected_subject = I18n.t( "shopkeeper.notification_mailer.invited.subject", - inviter: ConfigSettings.mobile_app.name, + inviter: ConfigSettings.display_name, account: @account.name ) assert_equal expected_subject, mail.subject assert_equal [@invitation.email], mail.to assert_match @invitation.token, mail.body.encoded - assert_match "#{ConfigSettings.mobile_app.name} has invited you", mail.text_part.body.decoded + assert_match "#{ConfigSettings.display_name} has invited you", mail.text_part.body.decoded end test "confirmation_instructions renders the expected subject, recipient, and body" do @@ -83,4 +83,110 @@ class Shopkeeper::NotificationMailerTest < ActionMailer::TestCase assert_equal [@shopkeeper.email], mail.to assert_match "reset-token-xyz", mail.body.encoded end + + # Mails name the app by its display name ("Native App Template"), not the + # identifier-like name ("NativeAppTemplate"), in English and Japanese + %i[en ja].each do |locale| + test "invited names the app by its display name in #{locale}" do + I18n.with_locale(locale) do + mail = Shopkeeper::NotificationMailer.with(accounts_invitation: @invitation).invited + app = ConfigSettings.display_name + + [mail.html_part.body.decoded, mail.text_part.body.decoded].each do |body| + assert_includes strip_tags(body), strip_tags(I18n.t("shopkeeper.notification_mailer.invited.enter_your_invitation_code_in_the_mobile_app_html", mobile_app: app)) + assert_includes strip_tags(body), strip_tags(I18n.t("shopkeeper.notification_mailer.invited.open_mobile_app_html", mobile_app: app)) + refute_identifier_name body + end + end + end + + test "invited leaves out the store links while their URLs are blank in #{locale}" do + I18n.with_locale(locale) do + mail = Shopkeeper::NotificationMailer.with(accounts_invitation: @invitation).invited + + [mail.html_part.body.decoded, mail.text_part.body.decoded].each do |body| + refute_includes body, I18n.t("shopkeeper.notification_mailer.invited.ios_app", mobile_app: ConfigSettings.display_name) + refute_includes body, I18n.t("shopkeeper.notification_mailer.invited.android_app", mobile_app: ConfigSettings.display_name) + refute_includes body, "apps.apple.com" + refute_includes body, "play.google.com" + end + end + end + + test "invited links each store whose URL is set in #{locale}" do + with_store_urls(app_store: "https://apps.apple.com/app/example/id123", play_store: "") do + I18n.with_locale(locale) do + mail = Shopkeeper::NotificationMailer.with(accounts_invitation: @invitation).invited + ios = I18n.t("shopkeeper.notification_mailer.invited.ios_app", mobile_app: ConfigSettings.display_name) + android = I18n.t("shopkeeper.notification_mailer.invited.android_app", mobile_app: ConfigSettings.display_name) + + assert_includes mail.html_part.body.decoded, %(#{ios}) + assert_includes mail.text_part.body.decoded, "#{ios}: https://apps.apple.com/app/example/id123" + [mail.html_part.body.decoded, mail.text_part.body.decoded].each do |body| + refute_includes body, android + end + end + end + + with_store_urls(app_store: "https://apps.apple.com/app/example/id123", play_store: "https://play.google.com/store/apps/details?id=com.example") do + I18n.with_locale(locale) do + mail = Shopkeeper::NotificationMailer.with(accounts_invitation: @invitation).invited + android = I18n.t("shopkeeper.notification_mailer.invited.android_app", mobile_app: ConfigSettings.display_name) + + assert_includes mail.text_part.body.decoded, "#{android}: https://play.google.com/store/apps/details?id=com.example" + assert_includes mail.html_part.body.decoded, "#{android}" + end + end + end + + test "mail footer names the app by its display name in #{locale}" do + I18n.with_locale(locale) do + mail = Shopkeeper::NotificationMailer.with( + resource: @shopkeeper, + token: "reset-token-xyz", + opts: {redirect_url: "http://example.com/reset", client_config: "default"} + ).reset_password_instructions + + assert_includes mail.text_part.body.decoded, "#{ConfigSettings.display_name}: #{ConfigSettings.site.url}" + assert_includes mail.html_part.body.decoded, "#{Date.current.year} #{ConfigSettings.display_name} All rights reserved." + assert_includes mail.html_part.body.decoded, %(>#{ConfigSettings.display_name}) + [mail.html_part.body.decoded, mail.text_part.body.decoded].each do |body| + refute_identifier_name body + end + end + end + end + + test "mails come from the display name" do + mail = Shopkeeper::NotificationMailer.with( + resource: @shopkeeper, + token: "reset-token-xyz", + opts: {redirect_url: "http://example.com/reset", client_config: "default"} + ).reset_password_instructions + + assert_equal [ConfigSettings.display_name], mail[:from].display_names + end + + private + + # An app whose display name is one word ("Barber") has the same identifier + # name, so there is nothing to tell apart + def refute_identifier_name(body) + [ConfigSettings.site.name, ConfigSettings.business.name].each do |name| + refute_includes body, name unless ConfigSettings.display_name.include?(name) + end + end + + def strip_tags(html) + ActionController::Base.helpers.strip_tags(html) + end + + def with_store_urls(app_store:, play_store:) + original = [ConfigSettings.mobile_app.app_store_url, ConfigSettings.mobile_app.play_store_url] + ConfigSettings.mobile_app.app_store_url = app_store + ConfigSettings.mobile_app.play_store_url = play_store + yield + ensure + ConfigSettings.mobile_app.app_store_url, ConfigSettings.mobile_app.play_store_url = original + end end