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 ` 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://: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