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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,8 @@ Important environment variables include:
| Variable | Purpose |
| --- | --- |
| `STOREFRONT_DB_CONNECTION` | Database connection name for Storefront models. |
| `STOREFRONT_BYPASS_VERIFICATION_CODE` | Development bypass code for Storefront app verification flows. |
| `STOREFRONT_BYPASS_VERIFICATION_CODE` | Instance-wide sign-in code for the review accounts in `STOREFRONT_REVIEW_ACCOUNTS` (self-hosted). Stores can instead turn on [App Review access](docs/app-review.md) in their settings. |
| `STOREFRONT_REVIEW_ACCOUNTS` | Comma-separated phone numbers and emails that sign in with `STOREFRONT_BYPASS_VERIFICATION_CODE` and are never sent a code. |
| `STOREFRONT_THROTTLE_REQUESTS_PER_MINUTE` | Public Storefront API requests allowed per device (store key + client IP) per window. Default `120`. |
| `STOREFRONT_THROTTLE_DECAY_MINUTES` | Public Storefront API throttle window in minutes. Default `1`. |

Expand All @@ -248,6 +249,7 @@ For customer app configuration, see the [Storefront App configuration docs](http
- [Storefront documentation](https://www.fleetbase.io/docs/storefront)
- [Products and catalog](https://docs.fleetbase.io/guides/storefront/products/)
- [Orders and checkout](https://www.fleetbase.io/docs/storefront/orders/overview)
- [App Review access](docs/app-review.md): letting Apple and Google reviewers sign in to a store's app
- [Checkout flow](https://www.fleetbase.io/docs/storefront/orders/checkout)
- [Store locations](https://www.fleetbase.io/docs/storefront/stores/store-locations)
- [Storefront App configuration](https://www.fleetbase.io/docs/storefront/app/configuration)
Expand Down
13 changes: 9 additions & 4 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
> v0.4.29 ~ "A phone sign-up keeps its number"
> v0.4.30 ~ "App store reviewers can sign in, Continue with Fleetbase, and answers in the customer's language"

---
## Highlights

- **Phone sign-up saves the number.** Signing up with a phone number and texted code created the account without that number: the app sends it as the `identity` the code was filed under, and account creation only read a separate `phone` field. Customers were then asked to verify the same number again, couldn't sign back in by phone, and the same number could sign up a second time. The verified identity is now the account's phone, on the user and the customer contact, and is marked verified (`phone_verified_at`), as is an existing user's matching number. A `phone` sent alongside still takes precedence. Email sign-up is unchanged.
- **App Review access.** Apple and Google reviewers can't receive the texted sign-in code, and Apple rejects apps its reviewers can't sign in to. A store or network can now turn on **Settings → App Review**: reviewers sign in with the test number +1 202 555 0143 (a range kept for fiction, never texted) and a six-digit code the owner creates in the Console and enters in App Store Connect and Google Play Console. The code is stored hashed and shown once; it works only in the store that created it. The mechanism is core-api's App Review access (core-api 1.6.74), which Navigator and future apps share. Reviewers can sign up, sign in, add the number at checkout and delete their account without a text message, on Fleetbase Cloud as well as self-hosted. See [docs/app-review.md](docs/app-review.md).
- **Reviewers' orders are marked.** Orders placed by the review account carry `meta.app_review = true`, are labelled **App Review order** in the order panel and staff alerts, and are never auto-dispatched to a driver.
- **Self-hosted review accounts keep working** (`STOREFRONT_BYPASS_VERIFICATION_CODE` with `STOREFRONT_REVIEW_ACCOUNTS`), now for sign-up as well, and the accounts they list are never sent a code.
- **Endpoints and permission.** The Console uses core's `GET|PUT /int/v1/review-access/storefront-store/{id}` (and `storefront-network`). A new permission, `storefront manage-app-review store` / `network`, is needed to change it. (#162)
- **Continue with Fleetbase.** Apps signed in with core-api's OAuth provider (core-api 1.6.74) can, with an organization administrator's consent, read the organization's stores and networks with their public keys (`storefront:read`, `GET /storefront/oauth/v1/storefronts`), get a dedicated API key for their Storefront app (`storefront:app-key`, `POST /storefront/oauth/v1/app-key`, listed under Developers → API Keys) and turn App Review access on or off (`storefront:review-access`, through core's `/oauth/v1/review-access/storefront-store/{id}`). This is how AppHelm connects a Cloud customer's storefront without pasting keys. (#163)
- **Answers in the customer's language.** The customer app already sends `Accept-Language`, but the server ignored it. Storefront API errors, validation messages, SMS verification and account-closure texts, push notifications and inbox items now come back in the closest shipped language (English, Mongolian or Ukrainian; anything else gets English). A signed-in customer's language is remembered in their contact's `meta.locale`, so queued order notifications use it too. Merchant-authored text, error codes and core-api / Fleet-Ops messages are unchanged, and English text is identical to before. (#164)

---
## Upgrading
- No migrations.
- Accounts created by phone before this release have no phone saved. They are asked to add it once in the app (Storefront App v0.1.1 also asks at checkout).
- **Requires fleetbase/core-api v1.6.74** (`composer.json` now requires `^1.6.74`), which adds App Review access (fleetbase/core-api#323) and the OAuth provider (fleetbase/core-api#324). Run migrations: core adds `review_accesses` and the OAuth tables; Storefront adds none.
- Grant `manage-app-review` on stores or networks to staff who aren't administrators and should manage App Review access (the Storefront Administrator role's full-access policy already covers it).

---
## Need help?
Expand Down
79 changes: 79 additions & 0 deletions addon/components/storefront/app-review-access.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{{! App Review access: the test number and code Apple and Google reviewers sign in with. Buttons only, no inputs, so it can sit inside a settings form without marking it dirty. }}
<div class="storefront-app-review" data-test-app-review-access {{did-update this.reload @owner.id}}>
<div class="storefront-app-review__head">
<p class="storefront-settings-section__lede mb-0">{{t "storefront.settings.app-review.lede"}}</p>
{{#if this.status}}
<Badge
@status={{if this.enabled "success" "default"}}
@text={{if this.enabled (t "storefront.settings.app-review.on") (t "storefront.settings.app-review.off")}}
@disableHumanize={{true}}
data-test-app-review-status
/>
{{/if}}
</div>

{{#if this.load.isRunning}}
<div class="storefront-app-review__loading"><Spinner /></div>
{{else if this.status}}
<div class="storefront-app-review__fields">
<InputGroup @name={{t "storefront.settings.app-review.phone-label"}} @helpText={{t "storefront.settings.app-review.phone-help"}} @wrapperClass="mb-0">
<ClickToCopy @value={{this.status.phone}} class="form-input disabled storefront-app-review__value" data-test-app-review-phone>{{this.status.phone_display}}</ClickToCopy>
</InputGroup>
<InputGroup @name={{t "storefront.settings.app-review.code-label"}} @wrapperClass="mb-0">
{{#if this.code}}
<ClickToCopy
@value={{this.code}}
class="form-input disabled storefront-app-review__value storefront-app-review__code"
data-test-app-review-code
>{{this.code}}</ClickToCopy>
<p class="storefront-app-review__hint storefront-app-review__hint--warn">{{t "storefront.settings.app-review.code-new"}}</p>
{{else if this.status.has_code}}
<div class="form-input disabled storefront-app-review__value storefront-app-review__code">••••••</div>
<p class="storefront-app-review__hint">{{t "storefront.settings.app-review.code-hidden" date=this.codeCreatedAt}}</p>
{{else}}
<p class="storefront-app-review__hint">{{t "storefront.settings.app-review.code-none"}}</p>
{{/if}}
</InputGroup>
</div>

<ul class="storefront-app-review__notes">
<li>{{t "storefront.settings.app-review.where"}}</li>
<li>{{t "storefront.settings.app-review.orders"}}</li>
</ul>

<div class="storefront-app-review__actions">
{{#if this.enabled}}
<Button
@size="sm"
@icon="rotate"
@text={{t "storefront.settings.app-review.regenerate"}}
@onClick={{this.regenerate}}
@isLoading={{this.save.isRunning}}
@permission={{this.permission}}
data-test-app-review-regenerate
/>
<Button
@type="danger"
@size="sm"
@icon="power-off"
@text={{t "storefront.settings.app-review.turn-off"}}
@onClick={{this.turnOff}}
@isLoading={{this.save.isRunning}}
@permission={{this.permission}}
data-test-app-review-turn-off
/>
{{else}}
<Button
@type="primary"
@size="sm"
@icon="user-check"
@text={{t "storefront.settings.app-review.turn-on"}}
@onClick={{this.turnOn}}
@isLoading={{this.save.isRunning}}
@permission={{this.permission}}
data-test-app-review-turn-on
/>
{{/if}}
</div>
{{/if}}
</div>
113 changes: 113 additions & 0 deletions addon/components/storefront/app-review-access.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';

/**
* App Review access for a store or network: lets Apple and Google reviewers sign in to its app
* with a fixed test number (never texted) and a code the owner gives the app store. Core
* (`int/v1/review-access`) keeps the code hashed, so it is shown only in the response that
* created it.
*
* Args: @owner (a store or network record).
*/
export default class StorefrontAppReviewAccessComponent extends Component {
@service fetch;
@service intl;
@service notifications;
@service modalsManager;

@tracked status = null;
@tracked code = null;

constructor() {
super(...arguments);
this.load.perform();
}

get type() {
return this.args.owner?.constructor?.modelName === 'network' ? 'network' : 'store';
}

get endpoint() {
return `review-access/storefront-${this.type}/${this.args.owner?.id}`;
}

get permission() {
return `storefront manage-app-review ${this.type}`;
}

get enabled() {
return this.status?.enabled === true;
}

get codeCreatedAt() {
const at = this.status?.code_generated_at;
return at ? new Date(at).toLocaleString() : null;
}

/** Another store or network was opened in the same place: forget the last one's code. */
@action reload() {
this.status = null;
this.code = null;
this.load.perform();
}

@task *load() {
if (!this.args.owner?.id) return;

try {
const { review_access } = yield this.fetch.get(this.endpoint, {}, { namespace: 'int/v1' });
this.status = review_access;
} catch (error) {
this.notifications.serverError(error);
}
}

@task *save(payload, message) {
try {
const { review_access } = yield this.fetch.put(this.endpoint, payload, { namespace: 'int/v1' });
const { code, ...status } = review_access;
this.status = status;
if (code) this.code = code;
if (!status.enabled) this.code = null;
this.notifications.success(message);
} catch (error) {
this.notifications.serverError(error);
}
}

@action turnOn() {
this.save.perform({ enabled: true }, this.intl.t('storefront.settings.app-review.turned-on'));
}

@action turnOff() {
this.modalsManager.confirm({
title: this.intl.t('storefront.settings.app-review.turn-off-title'),
body: this.intl.t('storefront.settings.app-review.turn-off-body', { name: this.args.owner?.name }),
acceptButtonText: this.intl.t('storefront.settings.app-review.turn-off'),
acceptButtonType: 'danger',
confirm: async (modal) => {
modal.startLoading();
await this.save.perform({ enabled: false }, this.intl.t('storefront.settings.app-review.turned-off'));
modal.done();
},
});
}

@action regenerate() {
this.modalsManager.confirm({
title: this.intl.t('storefront.settings.app-review.regenerate-title'),
body: this.intl.t('storefront.settings.app-review.regenerate-body'),
acceptButtonText: this.intl.t('storefront.settings.app-review.regenerate'),
acceptButtonType: 'danger',
acceptButtonIcon: 'rotate',
confirm: async (modal) => {
modal.startLoading();
await this.save.perform({ regenerate_code: true }, this.intl.t('storefront.settings.app-review.regenerated'));
modal.done();
},
});
}
}
3 changes: 2 additions & 1 deletion addon/components/storefront/order/overview.hbs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{{! The 11a order panel body (the first tab): the customer's note, people, delivery, proof of delivery when there is any, items, staff notes, then the folded paperwork; messages open in a drawer beside the panel. The next step, steps and notices are in the header. }}
{{! The 11a order panel body (the first tab): the App Review mark on a reviewer's test order, the customer's note, people, delivery, proof of delivery when there is any, items, staff notes, then the folded paperwork; messages open in a drawer beside the panel. The next step, steps and notices are in the header. }}
<div class="sfop" data-test-order-overview {{did-update this.reload this.order.status this.order.updated_at}}>
<Storefront::Order::PanelAppReview @order={{this.order}} />
<Storefront::Order::PanelCustomerNote @order={{this.order}} />
<Storefront::Order::PanelPeople @order={{this.order}} @overview={{this.overview}} @refresh={{this.refresh}} />
<Storefront::Order::PanelDelivery @order={{this.order}} @overview={{this.overview}} @canEdit={{this.canEdit}} />
Expand Down
12 changes: 12 additions & 0 deletions addon/components/storefront/order/panel-app-review.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{{! An app store reviewer placed this order to test the app. Shown first so the store does not fulfil it. }}
{{#if this.isAppReview}}
<div class="sfop-app-review" data-test-order-app-review>
<div class="sfop-alert sfop-alert--bad" role="note">
<FaIcon @icon="user-check" class="sfop-alert__icon" />
<div>
<div class="sfop-lbl">{{t "storefront.order.panel.app-review"}}</div>
<p class="sfop-app-review__body">{{t "storefront.order.panel.app-review-body"}}</p>
</div>
</div>
</div>
{{/if}}
11 changes: 11 additions & 0 deletions addon/components/storefront/order/panel-app-review.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Component from '@glimmer/component';

/**
* Marks an order an app store reviewer placed (`meta.app_review`, set at checkout when the
* customer is the App Review account) so the store does not fulfil it.
*/
export default class StorefrontOrderPanelAppReviewComponent extends Component {
get isAppReview() {
return this.args.order?.meta?.app_review === true;
}
}
2 changes: 1 addition & 1 deletion addon/components/storefront/order/panel-header.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
<div class="sfop-head__badges" data-test-order-panel-badges>
<Badge @status={{this.order.status}} @size="xs" />
<Badge @status="violet" @text={{this.fulfillmentLabel}} @hideStatusDot={{true}} @disableHumanize={{true}} @size="xs" />
<Badge @status="gray" @text={{this.sourceLabel}} @hideStatusDot={{true}} @disableHumanize={{true}} @size="xs" />
<Badge @status={{if this.isAppReview "warning" "gray"}} @text={{this.sourceLabel}} @hideStatusDot={{true}} @disableHumanize={{true}} @size="xs" />
{{#if this.storeName}}<Badge @status="blue" @text={{this.storeName}} @hideStatusDot={{true}} @disableHumanize={{true}} @size="xs" />{{/if}}
{{#if this.order.meta.food_truck_id}}
<Badge
Expand Down
8 changes: 8 additions & 0 deletions addon/components/storefront/order/panel-header.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,15 @@ export default class StorefrontOrderPanelHeaderComponent extends Component {
return meta.is_pickup ? this.intl.t('storefront.common.pickup') : this.intl.t('storefront.networks.orders.delivery');
}

get isAppReview() {
return this.order?.meta?.app_review === true;
}

get sourceLabel() {
if (this.isAppReview) {
return this.intl.t('storefront.order.panel.app-review-order');
}

return this.panel.overview?.source === 'console' ? this.intl.t('storefront.order.panel.console-order') : this.intl.t('storefront.order.panel.app-order');
}

Expand Down
1 change: 1 addition & 0 deletions addon/controllers/networks/index/network/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export default class NetworksIndexNetworkSettingsController extends Controller {
item('gateways', this.intl.t('storefront.settings.sections.gateways'), 'cash-register', this.gateways?.length || null),
item('notifications', this.intl.t('storefront.settings.sections.notifications'), 'bell-concierge', this.channels?.length || null),
item('api', this.intl.t('storefront.settings.sections.api-keys'), 'code'),
item('app-review', this.intl.t('storefront.settings.sections.app-review'), 'user-check'),
];
}

Expand Down
1 change: 1 addition & 0 deletions addon/controllers/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export default class SettingsController extends Controller {
sectionItem('alerts', this.intl.t('storefront.common.alerts'), 'bell'),
sectionItem('messages', this.intl.t('storefront.settings.sections.messages'), 'comments'),
routeItem('api', this.intl.t('storefront.settings.sections.api-keys'), 'code', 'settings.api'),
routeItem('app-review', this.intl.t('storefront.settings.sections.app-review'), 'user-check', 'settings.app-review'),
sectionItem('danger', this.intl.t('storefront.settings.sections.danger'), 'triangle-exclamation'),
];
}
Expand Down
1 change: 1 addition & 0 deletions addon/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export default buildRoutes(function () {
this.route('settings', function () {
this.route('index', { path: '/' });
this.route('api');
this.route('app-review');
this.route('locations');
this.route('gateways');
this.route('notifications');
Expand Down
10 changes: 10 additions & 0 deletions addon/routes/settings/app-review.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';

export default class SettingsAppReviewRoute extends Route {
@service storefront;

model() {
return this.storefront?.activeStore;
}
}
Loading
Loading