diff --git a/RELEASE.md b/RELEASE.md index 123c1667e..d13258e83 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 @@ -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? diff --git a/addon/components/navigator-app-review-settings.hbs b/addon/components/navigator-app-review-settings.hbs new file mode 100644 index 000000000..4802c6a7f --- /dev/null +++ b/addon/components/navigator-app-review-settings.hbs @@ -0,0 +1,75 @@ +{{! App Review access for the Navigator app: the test number and code Apple and Google reviewers sign in with. }} + +
+
+

{{t "navigator-app-review-settings.lede"}}

+ {{#if this.status}} + + {{/if}} +
+ + {{#if this.load.isRunning}} +
+ {{else if this.status}} + + {{this.status.phone_display}} + + + {{#if this.code}} + {{this.code}} +

{{t "navigator-app-review-settings.code-new"}}

+ {{else if this.status.has_code}} +
••••••
+

{{t "navigator-app-review-settings.code-hidden" date=this.codeCreatedAt}}

+ {{else}} +

{{t "navigator-app-review-settings.code-none"}}

+ {{/if}} +
+ + + +
+ {{#if this.enabled}} +
+ {{/if}} +
+
\ No newline at end of file diff --git a/addon/components/navigator-app-review-settings.js b/addon/components/navigator-app-review-settings.js new file mode 100644 index 000000000..77baddf90 --- /dev/null +++ b/addon/components/navigator-app-review-settings.js @@ -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(); + }, + }); + } +} diff --git a/addon/templates/settings/navigator-app.hbs b/addon/templates/settings/navigator-app.hbs index c1f486519..037b9f569 100644 --- a/addon/templates/settings/navigator-app.hbs +++ b/addon/templates/settings/navigator-app.hbs @@ -5,6 +5,7 @@
+
diff --git a/app/components/navigator-app-review-settings.js b/app/components/navigator-app-review-settings.js new file mode 100644 index 000000000..ae7f4e063 --- /dev/null +++ b/app/components/navigator-app-review-settings.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/navigator-app-review-settings'; diff --git a/composer.json b/composer.json index e59cb943f..924dc7d61 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/docs/NAVIGATOR_APP_REVIEW.md b/docs/NAVIGATOR_APP_REVIEW.md new file mode 100644 index 000000000..b7c3107e4 --- /dev/null +++ b/docs/NAVIGATOR_APP_REVIEW.md @@ -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= +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. diff --git a/server/src/Console/Commands/DispatchAdhocOrders.php b/server/src/Console/Commands/DispatchAdhocOrders.php index fbdd79ac8..31e91f342 100644 --- a/server/src/Console/Commands/DispatchAdhocOrders.php +++ b/server/src/Console/Commands/DispatchAdhocOrders.php @@ -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; @@ -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 diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php index cdb187245..fd006cb2c 100644 --- a/server/src/Http/Controllers/Api/v1/DriverController.php +++ b/server/src/Http/Controllers/Api/v1/DriverController.php @@ -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; @@ -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) { @@ -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); @@ -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); @@ -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); @@ -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( diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 7afd523b8..74e8162e4 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -12,6 +12,7 @@ use Fleetbase\FleetOps\Flow\Activity; use Fleetbase\FleetOps\Notifications\OrderUnassigned; use Fleetbase\FleetOps\Support\LabelPdf; +use Fleetbase\FleetOps\Support\NavigatorReview; use Fleetbase\FleetOps\Support\OrderEditPolicy; use Fleetbase\FleetOps\Support\OrderTracker; use Fleetbase\FleetOps\Support\Utils; @@ -2314,7 +2315,8 @@ public function findClosestDrivers(int $distance = 6000): Collection return collect(); } - $drivers = Driver::where(['status' => 'available', 'online' => 1]) + // An app store reviewer's driver is never assigned a real order. + $drivers = NavigatorReview::excludeFrom(Driver::where(['status' => 'available', 'online' => 1])) ->whereHas('company', function ($q) { $q->whereHas('users', function ($q) { $q->whereHas('driver', function ($q) { diff --git a/server/src/Orchestration/Engines/DriverAssignmentEngine.php b/server/src/Orchestration/Engines/DriverAssignmentEngine.php index 4bc26cc02..5ea1c9f19 100644 --- a/server/src/Orchestration/Engines/DriverAssignmentEngine.php +++ b/server/src/Orchestration/Engines/DriverAssignmentEngine.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Support\NavigatorReview; use Illuminate\Support\Collection; /** @@ -242,7 +243,8 @@ protected function findBestDriver( protected function availableDriversForCompany(?string $companyUuid): Collection { - return Driver::where('company_uuid', $companyUuid) + // An app store reviewer's driver is never assigned a real order. + return NavigatorReview::excludeFrom(Driver::where('company_uuid', $companyUuid)) ->with(['scheduleItems']) ->get(); } diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index bb24c8932..1f55a713d 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -4,6 +4,7 @@ use Brick\Geo\Engine\GeometryEngineRegistry; use Brick\Geo\Engine\GEOSEngine; +use Fleetbase\FleetOps\Support\NavigatorReview; use Fleetbase\Providers\CoreServiceProvider; use Fleetbase\Support\NotificationRegistry; use Fleetbase\Support\Utils; @@ -160,6 +161,7 @@ public function boot() $this->registerNotifications(); $this->registerAiCapabilities(); $this->registerSocketChannels(); + $this->registerAppReviewTypes(); $this->registerExpansionsFrom(__DIR__ . '/../Expansions'); // Register built-in orchestration engines. @@ -317,4 +319,15 @@ protected function registerAiCapabilities(): void } }); } + + /** + * Registers the organization's Navigator app with core-api's App Review access, so its + * owner can let app store reviewers sign in to Navigator without a texted code. + */ + public function registerAppReviewTypes(): void + { + if (NavigatorReview::isAvailable()) { + NavigatorReview::register(); + } + } } diff --git a/server/src/Support/NavigatorReview.php b/server/src/Support/NavigatorReview.php new file mode 100644 index 000000000..4dca85d44 --- /dev/null +++ b/server/src/Support/NavigatorReview.php @@ -0,0 +1,128 @@ + Company::class, + 'phone' => static::PHONE, + 'name' => 'Navigator', + 'permission' => 'fleet-ops update navigator-settings', + 'view_permission' => 'fleet-ops view navigator-settings', + ]); + } + + public static function isAvailable(): bool + { + return class_exists(AppReview::class); + } + + /** + * Whether an identity is Navigator's review number, however it is formatted. + */ + public static function isReviewPhone(?string $identity): bool + { + if (blank($identity)) { + return false; + } + + return preg_replace('/\D+/', '', $identity) === preg_replace('/\D+/', '', static::PHONE); + } + + public static function isEnabledFor(?Company $company): bool + { + return static::isAvailable() && AppReview::isEnabledFor($company); + } + + /** + * Whether a code signs the review number in to this organization's Navigator. + */ + public static function acceptsCode(?Company $company, ?string $identity, mixed $code): bool + { + return static::isAvailable() && static::isReviewPhone($identity) && AppReview::acceptsCode($company, static::PHONE, $code); + } + + /** + * The organization's review driver, created the first time a reviewer signs in. Each + * organization has its own: an account of one organization is never linked to another. + */ + public static function driver(Company $company): Driver + { + $driver = Driver::withoutGlobalScopes() + ->where('company_uuid', $company->uuid) + ->whereNull('deleted_at') + ->whereHas('user', fn ($query) => $query->where('phone', static::PHONE)->whereNull('deleted_at')) + ->first(); + + if ($driver) { + return $driver; + } + + $user = ProfileAccountManager::createManagedAccount($company->uuid, 'driver', static::DRIVER_NAME, null, static::PHONE, ['status' => 'active']); + $user->forceFill(['phone_verified_at' => now()])->save(); + + return Driver::create([ + 'company_uuid' => $company->uuid, + 'user_uuid' => $user->uuid, + 'status' => 'available', + 'online' => 0, + ]); + } + + /** + * Leave the review drivers out of a driver query (an Eloquent builder, or anything that + * takes a nested where), so a reviewer is never offered or assigned a real order + * automatically. + */ + public static function excludeFrom(mixed $query): mixed + { + return $query->where(fn (Builder $drivers) => $drivers->whereDoesntHave('user', fn ($user) => $user->where('phone', static::PHONE))); + } + + public static function isReviewDriver(?Driver $driver): bool + { + if (!$driver || !$driver->user_uuid) { + return false; + } + + $phone = User::where('uuid', $driver->user_uuid)->withoutGlobalScopes()->value('phone'); + + return static::isReviewPhone($phone); + } +} diff --git a/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php b/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php index f3b6bc9b3..fab99ccae 100644 --- a/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +++ b/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php @@ -587,3 +587,156 @@ public function __call($method, $arguments) expect($failure)->toBeInstanceOf(Illuminate\Database\Eloquent\ModelNotFoundException::class); }); + +/** + * The driver auth harness with core's App Review access: the review_accesses table, a hasher + * that really checks, Navigator registered, and an existing review driver in two + * organizations (one is created on first sign-in in production; see ProfileAccountManagerTest). + */ +function fleetopsNavigatorReviewBoot(): SQLiteConnection +{ + $connection = fleetopsDriverAuthBoot(); + $connection->getSchemaBuilder()->create('review_accesses', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('subject_type'); + $table->string('subject_uuid'); + $table->boolean('enabled')->default(false); + $table->string('code')->nullable(); + $table->timestamp('code_generated_at')->nullable(); + $table->timestamp('enabled_at')->nullable(); + $table->string('updated_by_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } + + public function make($value, array $options = []): string + { + return password_hash((string) $value, PASSWORD_BCRYPT); + } + + public function check($value, $hashedValue, array $options = []): bool + { + return is_string($hashedValue) && password_verify((string) $value, $hashedValue); + } + + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } + + public function verifyConfiguration($value): bool + { + return true; + } + }); + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); + app()->instance('cache', new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore())); + Illuminate\Support\Facades\Cache::clearResolvedInstance('cache'); + app()->instance('responsecache', new class { + public function clear(): void + { + } + }); + + Fleetbase\FleetOps\Support\NavigatorReview::register(); + + $connection->table('companies')->insert(['uuid' => 'company-2', 'public_id' => 'company_two', 'name' => 'Globex']); + foreach (['company-1', 'company-2'] as $company) { + $connection->table('users')->insert(['uuid' => 'reviewer-' . $company, 'company_uuid' => $company, 'name' => 'App Reviewer', 'phone' => Fleetbase\FleetOps\Support\NavigatorReview::PHONE, 'type' => 'driver', 'status' => 'active']); + $connection->table('drivers')->insert(['uuid' => 'review-driver-' . $company, 'company_uuid' => $company, 'user_uuid' => 'reviewer-' . $company, 'status' => 'available']); + } + + return $connection; +} + +function fleetopsNavigatorReviewOn(string $company): string +{ + [, $code] = Fleetbase\Support\AppReview::update(Fleetbase\Models\Company::where('uuid', $company)->first(), true); + + return $code; +} + +test('with Navigator review access on, the review number signs in as the organization review driver and is never texted', function () { + $connection = fleetopsNavigatorReviewBoot(); + $twilio = new FleetOpsDriverAuthTwilioFake(); + app()->instance('twilio', $twilio); + Fleetbase\Twilio\Support\Laravel\Facade::clearResolvedInstances(); + $code = fleetopsNavigatorReviewOn('company-1'); + + app()->instance('request', Request::create('/x', 'POST', ['phone' => '+1 202 555 0144'])); + $sent = (new DriverController())->loginWithPhone(); + $resource = (new DriverController())->verifyCode(Request::create('/x', 'POST', ['identity' => '12025550144', 'code' => $code])); + + expect($sent->getData(true))->toBe(['status' => 'OK', 'method' => 'sms']) + ->and($twilio->messages)->toBe([]) + ->and($connection->table('verification_codes')->count())->toBe(0) + ->and($resource)->toBeInstanceOf(DriverResource::class) + ->and($resource->resource->uuid)->toBe('review-driver-company-1') + ->and($resource->token)->not->toBeEmpty() + ->and($connection->table('personal_access_tokens')->value('name'))->toBe('review-driver-company-1'); +}); + +test('with Navigator review access off, the review number is refused and not texted', function () { + $connection = fleetopsNavigatorReviewBoot(); + $twilio = new FleetOpsDriverAuthTwilioFake(); + app()->instance('twilio', $twilio); + Fleetbase\Twilio\Support\Laravel\Facade::clearResolvedInstances(); + $code = fleetopsNavigatorReviewOn('company-1'); + Fleetbase\Support\AppReview::update(Fleetbase\Models\Company::where('uuid', 'company-1')->first(), false); + + app()->instance('request', Request::create('/x', 'POST', ['phone' => '12025550144'])); + $sent = (new DriverController())->loginWithPhone(); + $refused = (new DriverController())->verifyCode(Request::create('/x', 'POST', ['identity' => '12025550144', 'code' => $code])); + + expect($sent->getStatusCode())->toBe(422) + ->and($sent->getData(true)['error'])->toBe(Fleetbase\FleetOps\Support\NavigatorReview::OFF_MESSAGE) + ->and($twilio->messages)->toBe([]) + ->and($refused->getData(true)['error'])->toBe('Invalid verification code!') + ->and($connection->table('personal_access_tokens')->count())->toBe(0); +}); + +test('a wrong code or another organization code does not sign the Navigator review number in', function () { + $connection = fleetopsNavigatorReviewBoot(); + $codeOne = fleetopsNavigatorReviewOn('company-1'); + do { + [, $codeTwo] = Fleetbase\Support\AppReview::update(Fleetbase\Models\Company::where('uuid', 'company-2')->first(), true, true); + } while ($codeTwo === $codeOne); + $wrong = $codeOne === '000000' ? '000001' : '000000'; + + $crossed = (new DriverController())->verifyCode(Request::create('/x', 'POST', ['identity' => '12025550144', 'code' => $codeTwo])); + $guessed = (new DriverController())->verifyCode(Request::create('/x', 'POST', ['identity' => '12025550144', 'code' => $wrong])); + + expect($crossed->getData(true)['error'])->toBe('Invalid verification code!') + ->and($guessed->getData(true)['error'])->toBe('Invalid verification code!') + ->and($connection->table('personal_access_tokens')->count())->toBe(0); + + // At its own organization the second code signs in as that organization's review driver. + session(['company' => 'company-2']); + $resource = (new DriverController())->verifyCode(Request::create('/x', 'POST', ['identity' => '12025550144', 'code' => $codeTwo])); + expect($resource->resource->uuid)->toBe('review-driver-company-2'); +}); + +test('the self-hosted Navigator review accounts still sign in where the organization has not turned access on', function () { + $connection = fleetopsNavigatorReviewBoot(); + config([ + 'fleetops.navigator.bypass_verification_code' => '135790', + 'fleetops.navigator.review_accounts' => [Fleetbase\FleetOps\Support\NavigatorReview::PHONE], + ]); + + try { + $resource = (new DriverController())->verifyCode(Request::create('/x', 'POST', ['identity' => '12025550144', 'code' => '135790'])); + + expect($resource)->toBeInstanceOf(DriverResource::class) + ->and(DriverController::verificationBypassListed('+12025550144'))->toBeTrue() + ->and(DriverController::verificationBypassListed('+6591234567'))->toBeFalse(); + } finally { + config(['fleetops.navigator.bypass_verification_code' => null, 'fleetops.navigator.review_accounts' => []]); + } +}); diff --git a/server/tests/Unit/Support/ProfileAccountManagerTest.php b/server/tests/Unit/Support/ProfileAccountManagerTest.php index fa1eda3c9..f8e33c906 100644 --- a/server/tests/Unit/Support/ProfileAccountManagerTest.php +++ b/server/tests/Unit/Support/ProfileAccountManagerTest.php @@ -908,3 +908,41 @@ function fleetopsProfileAccountLeaveSandbox(): void expect($live->table('users')->where('email', 'contact@example.com')->count())->toBe(0); }); + +test('each organization gets one Navigator review driver, never linked to another, and dispatch leaves it out', function () { + $connection = fleetopsProfileAccountBoot(); + $schema = $connection->getSchemaBuilder(); + $schema->table('users', fn ($table) => $table->string('phone_verified_at')->nullable()); + $schema->table('drivers', function ($table) use ($schema) { + foreach (['status', 'online', 'internal_id', '_key', 'slug', 'created_at', 'updated_at', 'deleted_at'] as $column) { + if (!$schema->hasColumn('drivers', $column)) { + $table->string($column)->nullable(); + } + } + }); + fleetopsProfileAccountRows($connection, 'users', [['uuid' => 'user-real', 'company_uuid' => 'company-1', 'phone' => '+6590003333', 'type' => 'driver']]); + fleetopsProfileAccountRows($connection, 'drivers', [['uuid' => 'driver-real', 'company_uuid' => 'company-1', 'user_uuid' => 'user-real', 'status' => 'available']]); + + $acme = Fleetbase\Models\Company::where('uuid', 'company-1')->first(); + $globex = Fleetbase\Models\Company::where('uuid', 'company-2')->first(); + + $first = Fleetbase\FleetOps\Support\NavigatorReview::driver($acme); + $again = Fleetbase\FleetOps\Support\NavigatorReview::driver($acme); + $other = Fleetbase\FleetOps\Support\NavigatorReview::driver($globex); + $user = $connection->table('users')->where('uuid', $first->user_uuid)->first(); + + expect($again->uuid)->toBe($first->uuid) + ->and($first->company_uuid)->toBe('company-1') + ->and($other->company_uuid)->toBe('company-2') + ->and($other->user_uuid)->not->toBe($first->user_uuid) + ->and($user->phone)->toBe(Fleetbase\FleetOps\Support\NavigatorReview::PHONE) + ->and($user->type)->toBe('driver') + ->and($user->status)->toBe('active') + ->and($user->phone_verified_at)->not->toBeNull() + ->and(fleetopsProfileAccountRoles($connection, $first->user_uuid, 'company-1'))->toBe(['Driver']) + ->and(Fleetbase\FleetOps\Support\NavigatorReview::isReviewDriver($first))->toBeTrue() + ->and(Fleetbase\FleetOps\Support\NavigatorReview::isReviewDriver(Driver::withoutGlobalScopes()->where('uuid', 'driver-real')->first()))->toBeFalse(); + + $offered = Fleetbase\FleetOps\Support\NavigatorReview::excludeFrom(Driver::withoutGlobalScopes())->pluck('uuid')->all(); + expect($offered)->toBe(['driver-real']); +}); diff --git a/tests/integration/components/navigator-app-review-settings-test.js b/tests/integration/components/navigator-app-review-settings-test.js new file mode 100644 index 000000000..61ae3e630 --- /dev/null +++ b/tests/integration/components/navigator-app-review-settings-test.js @@ -0,0 +1,90 @@ +import Service from '@ember/service'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +const status = (overrides = {}) => ({ + type: 'navigator', + enabled: false, + phone: '+12025550144', + phone_display: '+1 202 555 0144', + has_code: false, + code_generated_at: null, + enabled_at: null, + ...overrides, +}); + +class FetchStubService extends Service { + requests = []; + current = status(); + get(url, query, options) { + this.requests.push(['GET', url, options.namespace]); + return Promise.resolve({ review_access: this.current }); + } + put(url, body) { + this.requests.push(['PUT', url, body]); + if (body.enabled === true) { + this.current = status({ enabled: true, has_code: true, code_generated_at: '2026-10-24T09:00:00+00:00' }); + return Promise.resolve({ review_access: { ...this.current, code: '482913' } }); + } + this.current = { ...this.current, enabled: false }; + return Promise.resolve({ review_access: this.current }); + } +} + +class CurrentUserStubService extends Service { + companyId = 'company-uuid-1'; +} + +class ModalsManagerStubService extends Service { + confirm(options) { + return options.confirm({ startLoading() {}, stopLoading() {}, done() {} }); + } +} + +class AbilitiesStubService extends Service { + can() { + return true; + } + cannot() { + return false; + } +} + +class NotificationsStubService extends Service { + success() {} + serverError() {} +} + +module('Integration | Component | navigator-app-review-settings', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:fetch', FetchStubService); + this.owner.register('service:current-user', CurrentUserStubService); + this.owner.register('service:modals-manager', ModalsManagerStubService); + this.owner.register('service:abilities', AbilitiesStubService); + this.owner.register('service:notifications', NotificationsStubService); + }); + + test('it turns App Review access on for the organization, shows the code once and turns it off', async function (assert) { + const fetch = this.owner.lookup('service:fetch'); + + await render(hbs``); + + assert.deepEqual(fetch.requests[0], ['GET', 'review-access/navigator/company-uuid-1', 'int/v1']); + assert.dom('[data-test-navigator-app-review-status]').hasText('Off'); + assert.dom('[data-test-navigator-app-review-phone]').includesText('+1 202 555 0144'); + + await click('[data-test-navigator-app-review-turn-on]'); + assert.deepEqual(fetch.requests[1], ['PUT', 'review-access/navigator/company-uuid-1', { enabled: true }]); + assert.dom('[data-test-navigator-app-review-status]').hasText('On'); + assert.dom('[data-test-navigator-app-review-code]').includesText('482913'); + + await click('[data-test-navigator-app-review-turn-off]'); + assert.deepEqual(fetch.requests[2], ['PUT', 'review-access/navigator/company-uuid-1', { enabled: false }]); + assert.dom('[data-test-navigator-app-review-status]').hasText('Off'); + assert.dom('[data-test-navigator-app-review-code]').doesNotExist(); + }); +}); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 8a51aa3cb..e24844b38 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -1725,6 +1725,29 @@ entity-field-editing-settings: enable-driver-to-edit-entity-fields: Allow driver to edit entity details enable-driver-to-edit-entity-fields-help-text: Enabling this toggle grants the driver permission to modify specific entity fields chosen below. +navigator-app-review-settings: + title: App Review + lede: Apple and Google test Navigator before it goes live, and their reviewers can't receive a text message. Turn this on to let them sign in as a test driver with a phone number and a code you give the app store. + 'on': 'On' + 'off': 'Off' + phone-label: Reviewer phone number + phone-help: A test number that is never texted. The code decides which organization it signs in to. + code-label: Sign-in code + code-new: Copy this code now. It is only shown once. + code-hidden: Code created {date}. It is only shown once, so create a new one if you've lost it. + code-none: No code yet. Turning this on creates one. + where: In App Store Connect, enter both under App Review Information, Sign-in required. In Google Play Console, enter them under App content, App access. + driver: The reviewer signs in as a test driver, App Reviewer, in your drivers list. It is never offered or assigned orders automatically; assign one by hand if you want them to see an order. + turn-on: Turn on + turn-off: Turn off + turn-off-title: Turn off App Review access? + turn-off-body: Reviewers can no longer sign in to Navigator. Turn it off once your app is approved, and turn it back on before you submit an update. + regenerate: Create new code + regenerate-title: Create a new code? + regenerate-body: The current code stops working at once. Enter the new one in App Store Connect and Google Play Console. + turned-on: App Review access is on. + turned-off: App Review access is off. + regenerated: New code created. driver-onboard-settings: driver-onboard-settings: Driver Onboard Settings enable-driver-onboard-from-app: Enable driver onboard from Navigator App