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
5 changes: 3 additions & 2 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
> v0.6.75 ~ "Safer integrations and tenant-isolated profile accounts"
> v0.6.75 ~ "Safer integrations, tenant-isolated profile accounts and App Review access for Navigator"

---
## Highlights
Expand All @@ -10,10 +10,11 @@
- **Fix: `scheduled_at` keeps its UTC offset.** `2026-10-21T11:00:00-07:00` is now stored as 18:00 UTC instead of 11:00, and `PUT /v1/orders/{id}/schedule` stores the UTC instant of a date and time in the company timezone. Rows written before this keep their old values. (#412)
- **Fix: service quotes.** The `currency` filter works again, stored quotes expire after 48 hours by default (`fleetops.service_quotes.ttl_minutes`), and `preview=1` prices a request without storing quote rows. (#412)
- **Fix: tracking numbers with a space can be looked up again.** Since v0.6.72, a number such as `AL 7796486490AE`, generated for a company whose name has a space in its first three letters, got "not found". Single inner spaces are accepted now. New numbers only use letters and digits: the company prefix skips spaces and punctuation ("Al Noor" gives `ALN`), and a region such as "New York" becomes `NEWYORK`. (#413, #416)
- **Feature: App Review access for Navigator.** Apple and Google reviewers can't receive the texted sign-in code. An organization can turn on **Settings → Navigator App → App Review**: reviewers sign in to its Navigator with the test number +1 202 555 0144 (a range kept for fiction, never texted) and a six-digit code the owner creates and enters in App Store Connect and Google Play Console. The code is stored hashed, shown once, and only works for that organization. The first sign-in adds an **App Reviewer** driver, which ad-hoc offers, nearest-driver assignment and the orchestrator always leave out. Built on core-api's App Review access; the `NAVIGATOR_*` review env settings still work on self-hosted installs. See `docs/NAVIGATOR_APP_REVIEW.md`. (#418)

---
## Upgrading
- No migrations or commands.
- **Requires fleetbase/core-api v1.6.74** (`composer.json` now requires `^1.6.74`), which adds App Review access and its `review_accesses` table: run `php artisan migrate`.

---
## Need help?
Expand Down
75 changes: 75 additions & 0 deletions addon/components/navigator-app-review-settings.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{{! App Review access for the Navigator app: the test number and code Apple and Google reviewers sign in with. }}
<ContentPanel @title={{t "navigator-app-review-settings.title"}} @open={{true}} @wrapperClass="bordered-classic">
<div data-test-navigator-app-review>
<div class="flex items-start justify-between gap-4 mb-3">
<p class="text-sm text-gray-500 dark:text-gray-400">{{t "navigator-app-review-settings.lede"}}</p>
{{#if this.status}}
<Badge
@status={{if this.enabled "success" "default"}}
@text={{if this.enabled (t "navigator-app-review-settings.on") (t "navigator-app-review-settings.off")}}
@disableHumanize={{true}}
data-test-navigator-app-review-status
/>
{{/if}}
</div>

{{#if this.load.isRunning}}
<div class="py-4"><Spinner /></div>
{{else if this.status}}
<InputGroup @name={{t "navigator-app-review-settings.phone-label"}} @helpText={{t "navigator-app-review-settings.phone-help"}}>
<ClickToCopy @value={{this.status.phone}} class="form-input disabled cursor-pointer" data-test-navigator-app-review-phone>{{this.status.phone_display}}</ClickToCopy>
</InputGroup>
<InputGroup @name={{t "navigator-app-review-settings.code-label"}}>
{{#if this.code}}
<ClickToCopy @value={{this.code}} class="form-input disabled cursor-pointer font-mono tracking-widest" data-test-navigator-app-review-code>{{this.code}}</ClickToCopy>
<p class="mt-1 text-xs font-semibold text-yellow-700 dark:text-yellow-400">{{t "navigator-app-review-settings.code-new"}}</p>
{{else if this.status.has_code}}
<div class="form-input disabled font-mono tracking-widest">••••••</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{t "navigator-app-review-settings.code-hidden" date=this.codeCreatedAt}}</p>
{{else}}
<p class="text-xs text-gray-500 dark:text-gray-400">{{t "navigator-app-review-settings.code-none"}}</p>
{{/if}}
</InputGroup>

<ul class="list-disc pl-5 text-xs text-gray-500 dark:text-gray-400 space-y-1">
<li>{{t "navigator-app-review-settings.where"}}</li>
<li>{{t "navigator-app-review-settings.driver"}}</li>
</ul>

<div class="flex flex-wrap justify-end gap-2 mt-4">
{{#if this.enabled}}
<Button
@size="sm"
@icon="rotate"
@text={{t "navigator-app-review-settings.regenerate"}}
@onClick={{this.regenerate}}
@isLoading={{this.save.isRunning}}
@permission={{this.permission}}
data-test-navigator-app-review-regenerate
/>
<Button
@type="danger"
@size="sm"
@icon="power-off"
@text={{t "navigator-app-review-settings.turn-off"}}
@onClick={{this.turnOff}}
@isLoading={{this.save.isRunning}}
@permission={{this.permission}}
data-test-navigator-app-review-turn-off
/>
{{else}}
<Button
@type="primary"
@size="sm"
@icon="user-check"
@text={{t "navigator-app-review-settings.turn-on"}}
@onClick={{this.turnOn}}
@isLoading={{this.save.isRunning}}
@permission={{this.permission}}
data-test-navigator-app-review-turn-on
/>
{{/if}}
</div>
{{/if}}
</div>
</ContentPanel>
99 changes: 99 additions & 0 deletions addon/components/navigator-app-review-settings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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 the organization's Navigator app: lets Apple and Google reviewers sign
* in with a fixed test number (never texted) and a code the owner gives the app store. Core
* (`int/v1/review-access/navigator`) keeps the code hashed, so it is shown only in the
* response that created it.
*/
export default class NavigatorAppReviewSettingsComponent extends Component {
@service fetch;
@service intl;
@service currentUser;
@service notifications;
@service modalsManager;

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

permission = 'fleet-ops update navigator-settings';

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

get endpoint() {
return `review-access/navigator/${this.currentUser.companyId}`;
}

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

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

@task *load() {
if (!this.currentUser.companyId) 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('navigator-app-review-settings.turned-on'));
}

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

@action regenerate() {
this.modalsManager.confirm({
title: this.intl.t('navigator-app-review-settings.regenerate-title'),
body: this.intl.t('navigator-app-review-settings.regenerate-body'),
acceptButtonText: this.intl.t('navigator-app-review-settings.regenerate'),
acceptButtonType: 'danger',
acceptButtonIcon: 'rotate',
confirm: async (modal) => {
modal.startLoading();
await this.save.perform({ regenerate_code: true }, this.intl.t('navigator-app-review-settings.regenerated'));
modal.done();
},
});
}
}
1 change: 1 addition & 0 deletions addon/templates/settings/navigator-app.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<div class="max-w-3xl my-10 mx-auto space-y-6">
<EntityFieldEditingSettings />
<DriverOnboardSettings />
<NavigatorAppReviewSettings />
</div>
</div>
<Spacer @height="600px" />
Expand Down
1 change: 1 addition & 0 deletions app/components/navigator-app-review-settings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '@fleetbase/fleetops-engine/components/navigator-app-review-settings';
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"barryvdh/laravel-dompdf": "^3.1",
"brick/geo": "0.7.2",
"cknow/laravel-money": "^7.1",
"fleetbase/core-api": "^1.6.69",
"fleetbase/core-api": "^1.6.74",
"geocoder-php/google-maps-places-provider": "^1.4",
"giggsey/libphonenumber-for-php": "^8.13",
"league/geotools": "^1.1.0",
Expand Down
67 changes: 67 additions & 0 deletions docs/NAVIGATOR_APP_REVIEW.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Navigator App Review access

Apple and Google test every app before it goes on sale. Navigator signs drivers in with a
code sent by text message, and app store reviewers can't receive one. Apple often rejects an
app its reviewers can't sign in to.

App Review access gives reviewers a test phone number and a sign-in code that you enter in
App Store Connect and Google Play Console. Nobody is sent a text message.

## Turn it on

1. Open **Fleet-Ops → Settings → Navigator App** and find **App Review**.
2. Choose **Turn on**. A six-digit sign-in code is created.
3. Copy the code straight away. It is only shown once.
4. Enter the phone number and the code:
- **App Store Connect:** your app's version page, under **App Review Information →
Sign-in required**. Enter the phone number as the user name and the code as the
password.
- **Google Play Console:** **App content → App access**, as instructions with the phone
number and the code.

The phone number is **+1 202 555 0144**. It belongs to a range kept for films and books, so it
can't reach a real person, and it is never sent a message. Every organization uses the same
number; the code decides which organization it signs in to, and one organization's code
doesn't work for another.

## What reviewers see

The reviewer signs in like a driver: they enter the phone number, Navigator asks for a code,
and they enter yours. The first time, a driver called **App Reviewer** is added to your
drivers list. It is never offered or assigned orders automatically (ad-hoc offers, nearest
driver and the orchestrator all leave it out). Assign an order to it by hand if you want the
reviewer to see one.

## Create a new code or turn it off

- **Create new code** replaces the code. The old one stops working at once.
- **Turn off** stops reviewers signing in. Turning it back on restores the same code, unless
you created a new one.

Changing it needs the **update navigator-settings** permission; administrators always can.

## Self-hosted installs

The instance-wide review accounts still work alongside each organization's setting:

```
NAVIGATOR_BYPASS_VERIFICATION_CODE=<a secret code>
NAVIGATOR_REVIEW_ACCOUNTS=reviewer@example.com,+12025550100
```

## For developers

The mechanism is core-api's App Review access (`Fleetbase\Support\AppReview`, core-api
1.6.74 or later). FleetOps registers the organization (`Company`) as the `navigator` type in
`FleetOpsServiceProvider` (`Fleetbase\FleetOps\Support\NavigatorReview`). The Console uses
core's endpoints, scoped to the signed-in organization:

```
GET /int/v1/review-access/navigator/{company uuid or public id}
PUT /int/v1/review-access/navigator/{company uuid or public id} {enabled?, regenerate_code?}
```

The response is `{"review_access": {type, enabled, phone, phone_display, has_code,
code_generated_at, enabled_at}}`, plus `code` only in the response that created it.
`POST /v1/drivers/login-with-sms` and `POST /v1/drivers/verify-code` (the API credential's
organization) accept the review number and the organization's code.
4 changes: 4 additions & 0 deletions server/src/Console/Commands/DispatchAdhocOrders.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Fleetbase\FleetOps\Models\Driver;
use Fleetbase\FleetOps\Models\Order;
use Fleetbase\FleetOps\Notifications\OrderPing;
use Fleetbase\FleetOps\Support\NavigatorReview;
use Fleetbase\FleetOps\Support\Utils;
use Fleetbase\LaravelMysqlSpatial\Types\Point;
use Illuminate\Console\Command;
Expand Down Expand Up @@ -134,6 +135,9 @@ public function getNearbyDriversForOrder(Order $order, Point $pickup, int $dista
->whereNull('deleted_at')
->withoutGlobalScopes();

// An app store reviewer's driver is never offered a real order.
NavigatorReview::excludeFrom($driverQuery);

if (!$testing) {
$driverQuery->whereNotNull('location')->whereRaw('
ST_Y(location) BETWEEN -90 AND 90
Expand Down
52 changes: 52 additions & 0 deletions server/src/Http/Controllers/Api/v1/DriverController.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use Fleetbase\FleetOps\Support\ApiCacheThrottle;
use Fleetbase\FleetOps\Support\Geocoding;
use Fleetbase\FleetOps\Support\GeofenceIntersectionService;
use Fleetbase\FleetOps\Support\NavigatorReview;
use Fleetbase\FleetOps\Support\OSRM;
use Fleetbase\FleetOps\Support\ProfileAccountManager;
use Fleetbase\FleetOps\Support\TrackingPublisher;
Expand Down Expand Up @@ -581,6 +582,16 @@ public function loginWithPhone()
{
$phone = static::phone();

// An app store reviewer is sent nothing: they sign in with the code the organization
// set up for its Navigator (Settings → Navigator App), and verify-code creates the
// organization's review driver the first time. The number is refused, still unsent,
// where that access is off.
if (NavigatorReview::isReviewPhone($phone) && !static::verificationBypassListed($phone)) {
return NavigatorReview::isEnabledFor($this->navigatorReviewCompany())
? response()->json(['status' => 'OK', 'method' => 'sms'])
: response()->apiError(NavigatorReview::OFF_MESSAGE, 422);
}

// check if user exists
$user = User::where('phone', $phone)->whereHas('driver')->whereNull('deleted_at')->first();
if (!$user) {
Expand Down Expand Up @@ -655,6 +666,14 @@ public function verifyCode(Request $request)
return $this->create($request);
}

// The organization's App Review code signs the review number in as its review driver.
if (NavigatorReview::isReviewPhone($identity)) {
$reviewCompany = $this->navigatorReviewCompany();
if (NavigatorReview::acceptsCode($reviewCompany, $identity, $code)) {
return $this->issueDriverToken(NavigatorReview::driver($reviewCompany));
}
}

// check if user exists
$user = User::whereHas('driver')->where(function ($query) use ($identity) {
$query->where('phone', $identity);
Expand Down Expand Up @@ -686,6 +705,20 @@ public function verifyCode(Request $request)
]
)->first();

return $this->issueDriverToken($driver, $user);
}

/**
* Sign a driver in: a new access token for its account, stored on the driver and returned
* with the driver resource.
*/
protected function issueDriverToken(?Driver $driver, ?User $user = null)
{
$user ??= $driver ? User::where('uuid', $driver->user_uuid)->first() : null;
if (!$driver || !$user) {
return response()->apiError('Unable to authenticate driver.');
}

// generate auth token
try {
$token = $user->createToken($driver->uuid);
Expand Down Expand Up @@ -1007,6 +1040,16 @@ protected function sessionCompany(): ?string
return session('company');
}

/**
* The organization whose Navigator the request is for: the API credential's.
*/
protected function navigatorReviewCompany(): ?Company
{
$uuid = $this->sessionCompany();

return $uuid ? Company::where('uuid', $uuid)->first() : null;
}

protected function applyUserInfoFromRequest(Request $request, array $userDetails): array
{
return User::applyUserInfoFromRequest($request, $userDetails);
Expand Down Expand Up @@ -1178,6 +1221,15 @@ private static function getDriverCompanyFromUser(User $user): ?Company
* Shared with Internal\v1\DriverController so both verify-code paths cannot
* drift apart.
*/
/**
* Whether an identity is a self-hosted review account listed in env config.
*/
public static function verificationBypassListed(?string $identity): bool
{
return NavigatorReview::isAvailable()
&& \Fleetbase\Support\AppReview::isConfiguredAccount('fleetops.navigator.bypass_verification_code', 'fleetops.navigator.review_accounts', $identity);
}

public static function verificationBypassMatches(?string $identity, ?string $code): bool
{
return static::reviewAccountBypassMatches(
Expand Down
Loading
Loading