@fleetbase/fleetops-engine 0.6.68 → 0.6.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/RELEASE.md +11 -9
  2. package/addon/components/contact/form.hbs +13 -2
  3. package/addon/components/customer/details.hbs +17 -17
  4. package/addon/components/customer/form.hbs +13 -2
  5. package/addon/components/customer/form.js +0 -56
  6. package/addon/components/driver/details.hbs +13 -19
  7. package/addon/components/driver/form.hbs +20 -35
  8. package/addon/components/driver/form.js +0 -58
  9. package/addon/components/modals/driver-form.hbs +2 -2
  10. package/addon/components/modals/reset-profile-credentials.hbs +23 -0
  11. package/addon/components/modals/reset-profile-credentials.js +70 -0
  12. package/addon/components/profile-identity-hint.hbs +13 -0
  13. package/addon/components/profile-identity-hint.js +49 -0
  14. package/addon/controllers/management/drivers/index.js +4 -0
  15. package/addon/extension.js +4 -2
  16. package/addon/services/contact-actions.js +50 -89
  17. package/addon/services/driver-actions.js +98 -0
  18. package/addon/utils/profile-login-actions.js +172 -0
  19. package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
  20. package/app/components/profile-identity-hint.js +1 -0
  21. package/composer.json +1 -1
  22. package/extension.json +1 -1
  23. package/package.json +3 -3
  24. package/scripts/pest-bootstrap.php +12 -0
  25. package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
  26. package/server/resources/views/mail/driver-credentials.blade.php +21 -0
  27. package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
  28. package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
  29. package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
  30. package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
  31. package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
  32. package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
  33. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
  34. package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
  35. package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
  36. package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
  37. package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
  38. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
  39. package/server/src/Http/Resources/v1/Contact.php +3 -0
  40. package/server/src/Http/Resources/v1/Driver.php +3 -0
  41. package/server/src/Mail/DriverCredentialsMail.php +55 -0
  42. package/server/src/Models/Contact.php +55 -90
  43. package/server/src/Models/Sensor.php +3 -5
  44. package/server/src/Observers/ContactObserver.php +2 -22
  45. package/server/src/Observers/DriverObserver.php +5 -6
  46. package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
  47. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
  48. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
  49. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
  50. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
  51. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
  52. package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
  53. package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
  54. package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
  55. package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
  56. package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
  57. package/server/src/Support/ProfileAccountManager.php +405 -0
  58. package/server/src/routes.php +5 -0
  59. package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
  60. package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
  61. package/server/tests/ApiDriverControllerContractsTest.php +102 -20
  62. package/server/tests/CommandContractsTest.php +4 -4
  63. package/server/tests/ControllerHelperContractsTest.php +3 -11
  64. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
  65. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
  66. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
  67. package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
  68. package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
  69. package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
  70. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
  71. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
  72. package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
  73. package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
  74. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
  75. package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
  76. package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
  77. package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
  78. package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
  79. package/server/tests/ModelAccessorContractsTest.php +82 -20
  80. package/server/tests/ObserverContractsTest.php +27 -2
  81. package/server/tests/RequestContractsTest.php +19 -11
  82. package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
  83. package/server/tests/TelematicControllerContractsTest.php +22 -4
  84. package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
  85. package/server/tests/Unit/Models/ContactTest.php +48 -5
  86. package/server/tests/Unit/Models/SensorTest.php +2 -2
  87. package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
  88. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
  89. package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
  90. package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
  91. package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
  92. package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
  93. package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
  94. package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
  95. package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
  96. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
  97. package/translations/en-us.yaml +37 -2
  98. package/translations/uk-ua.yaml +3475 -0
  99. package/addon/components/modals/reset-customer-credentials.hbs +0 -18
  100. package/addon/components/modals/reset-customer-credentials.js +0 -55
@@ -0,0 +1,132 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\Migrations\Migration;
4
+ use Illuminate\Support\Facades\DB;
5
+ use Illuminate\Support\Facades\Schema;
6
+
7
+ /*
8
+ * Driver and customer login accounts are now managed by their FleetOps
9
+ * profile (user type `driver` or `customer`) instead of in IAM.
10
+ *
11
+ * Before this change the driver form created plain `user` accounts holding only
12
+ * the Driver role, so these accounts show up in IAM and can sign in to the
13
+ * console. This converts them to managed accounts. An account is converted only
14
+ * when it is clearly a profile login and nothing else: it has a driver or
15
+ * customer profile, every organization role it holds is the profile's role, it
16
+ * has no direct permissions or policies, and it owns no organization. Anything
17
+ * else is left as a team member.
18
+ *
19
+ * The previous type is kept in `meta.previous_type` so down() can revert it.
20
+ */
21
+ return new class extends Migration {
22
+ private const PROFILE_ROLES = [
23
+ 'driver' => 'Driver',
24
+ 'customer' => 'Fleet-Ops Customer',
25
+ ];
26
+
27
+ public function up(): void
28
+ {
29
+ if (!$this->hasRequiredTables()) {
30
+ return;
31
+ }
32
+
33
+ DB::table('users')
34
+ ->select(['uuid', 'meta'])
35
+ ->where('type', 'user')
36
+ ->whereNull('deleted_at')
37
+ ->where(function ($query) {
38
+ $query->whereExists(function ($query) {
39
+ $query->selectRaw(1)->from('drivers')->whereColumn('drivers.user_uuid', 'users.uuid')->whereNull('drivers.deleted_at');
40
+ })->orWhereExists(function ($query) {
41
+ $query->selectRaw(1)->from('contacts')->whereColumn('contacts.user_uuid', 'users.uuid')->where('contacts.type', 'customer')->whereNull('contacts.deleted_at');
42
+ });
43
+ })
44
+ ->whereNotExists(function ($query) {
45
+ $query->selectRaw(1)->from('companies')->whereColumn('companies.owner_uuid', 'users.uuid');
46
+ })
47
+ ->orderBy('uuid')
48
+ ->chunk(500, function ($users) {
49
+ foreach ($users as $user) {
50
+ $type = $this->managedTypeFor($user->uuid);
51
+ if (!$type) {
52
+ continue;
53
+ }
54
+
55
+ $meta = json_decode($user->meta ?? '', true);
56
+ $meta = is_array($meta) ? $meta : [];
57
+ $meta['previous_type'] = 'user';
58
+
59
+ DB::table('users')->where('uuid', $user->uuid)->update(['type' => $type, 'meta' => json_encode($meta)]);
60
+ }
61
+ });
62
+ }
63
+
64
+ public function down(): void
65
+ {
66
+ if (!Schema::hasTable('users')) {
67
+ return;
68
+ }
69
+
70
+ DB::table('users')
71
+ ->select(['uuid', 'meta'])
72
+ ->whereIn('type', array_keys(self::PROFILE_ROLES))
73
+ ->where('meta', 'like', '%"previous_type"%')
74
+ ->orderBy('uuid')
75
+ ->chunk(500, function ($users) {
76
+ foreach ($users as $user) {
77
+ $meta = json_decode($user->meta ?? '', true);
78
+ if (!is_array($meta) || ($meta['previous_type'] ?? null) !== 'user') {
79
+ continue;
80
+ }
81
+
82
+ unset($meta['previous_type']);
83
+ DB::table('users')->where('uuid', $user->uuid)->update(['type' => 'user', 'meta' => json_encode($meta)]);
84
+ }
85
+ });
86
+ }
87
+
88
+ /**
89
+ * The managed type for the account, or null when it should stay a team member.
90
+ */
91
+ private function managedTypeFor(string $userUuid): ?string
92
+ {
93
+ $hasDriver = DB::table('drivers')->where('user_uuid', $userUuid)->whereNull('deleted_at')->exists();
94
+ $hasCustomer = DB::table('contacts')->where('user_uuid', $userUuid)->where('type', 'customer')->whereNull('deleted_at')->exists();
95
+
96
+ // An account holding both kinds of profile is a person the organization
97
+ // relates to in two ways; leave it for an administrator to decide.
98
+ if ($hasDriver === $hasCustomer) {
99
+ return null;
100
+ }
101
+
102
+ $type = $hasDriver ? 'driver' : 'customer';
103
+ $companyUserUuids = DB::table('company_users')->where('user_uuid', $userUuid)->whereNull('deleted_at')->pluck('uuid');
104
+ $modelUuids = $companyUserUuids->push($userUuid)->all();
105
+
106
+ $roles = DB::table('model_has_roles')
107
+ ->join('roles', 'roles.id', '=', 'model_has_roles.role_id')
108
+ ->whereIn('model_has_roles.model_uuid', $modelUuids)
109
+ ->pluck('roles.name')
110
+ ->unique();
111
+
112
+ if ($roles->isEmpty() || $roles->contains(fn ($role) => $role !== self::PROFILE_ROLES[$type])) {
113
+ return null;
114
+ }
115
+
116
+ $hasPermissions = DB::table('model_has_permissions')->whereIn('model_uuid', $modelUuids)->exists();
117
+ $hasPolicies = DB::table('model_has_policies')->whereIn('model_uuid', $modelUuids)->exists();
118
+
119
+ return $hasPermissions || $hasPolicies ? null : $type;
120
+ }
121
+
122
+ private function hasRequiredTables(): bool
123
+ {
124
+ foreach (['users', 'drivers', 'contacts', 'companies', 'company_users', 'roles', 'model_has_roles', 'model_has_permissions', 'model_has_policies'] as $table) {
125
+ if (!Schema::hasTable($table)) {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ return true;
131
+ }
132
+ };
@@ -0,0 +1,21 @@
1
+ <x-mail-layout>
2
+ <h2 style="font-size: 18px; font-weight: 600;">
3
+ Your driver sign-in details
4
+ </h2>
5
+
6
+ Hi {{ $user->name }},
7
+ <br />
8
+ <br />
9
+ {{ $companyName }} has set up your driver account. Use these details to sign in to the driver app.
10
+ <br />
11
+ <br />
12
+ <strong>Your sign-in details</strong>
13
+ <br />
14
+ Login: {{ $identity }}
15
+ <br />
16
+ Temporary password: {{ $plaintextPassword }}
17
+ <br />
18
+ <br />
19
+ You can change your password after signing in.
20
+
21
+ </x-mail-layout>
@@ -68,7 +68,7 @@ class FixCustomerCompanies extends Command
68
68
  $this->line('Found user ' . $user->name . ' (' . $user->email . ') which doesnt have correct company assignment.');
69
69
  $company = $this->companyByUuid($customer->company_uuid);
70
70
  if ($company) {
71
- $user->assignCompany($company);
71
+ $user->assignCompany($company, 'Fleet-Ops Customer');
72
72
  $this->line('User ' . $user->email . ' was assigned to company: ' . $company->name);
73
73
  }
74
74
  }
@@ -49,7 +49,7 @@ class FixDriverCompanies extends Command
49
49
  $this->line('Found driver ' . $user->name . ' (' . $user->email . ') which doesnt have correct company assignment.');
50
50
  $company = $this->companyByUuid($driver->company_uuid);
51
51
  if ($company) {
52
- $user->assignCompany($company);
52
+ $user->assignCompany($company, 'Driver');
53
53
  $this->line('Driver ' . $user->email . ' was assigned to company: ' . $company->name);
54
54
  }
55
55
  }
@@ -0,0 +1,34 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Exceptions;
4
+
5
+ /**
6
+ * Thrown when a driver, contact or customer profile is given an email or phone
7
+ * that belongs to an account it cannot be linked to.
8
+ */
9
+ class ProfileIdentityConflictException extends \Exception
10
+ {
11
+ /**
12
+ * The profile field the conflict was found on (`email` or `phone`).
13
+ */
14
+ protected string $field;
15
+
16
+ public function __construct(string $message, string $field = 'email')
17
+ {
18
+ parent::__construct($message);
19
+ $this->field = $field;
20
+ }
21
+
22
+ public function getField(): string
23
+ {
24
+ return $this->field;
25
+ }
26
+
27
+ /**
28
+ * The conflict keyed by field, in the shape of a validation error bag.
29
+ */
30
+ public function getErrors(): array
31
+ {
32
+ return [$this->field => [$this->getMessage()]];
33
+ }
34
+ }
@@ -44,6 +44,8 @@ class CustomerController extends Controller
44
44
  {
45
45
  use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass;
46
46
 
47
+ public const DEACTIVATED_LOGIN_MESSAGE = 'This customer login has been deactivated.';
48
+
47
49
  /* ============================================================
48
50
  | Public auth flows (API credential only, no Customer-Token)
49
51
  * ============================================================ */
@@ -345,6 +347,10 @@ class CustomerController extends Controller
345
347
  return response()->apiError('Authentication failed using credentials provided.', 401);
346
348
  }
347
349
 
350
+ if ($user->status === 'inactive') {
351
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
352
+ }
353
+
348
354
  $sessionCompany = $this->sessionCompany();
349
355
  if (!$sessionCompany) {
350
356
  return response()->apiError('No company resolved from API credential.', 500);
@@ -381,6 +387,10 @@ class CustomerController extends Controller
381
387
  return response()->apiError('No customer with this phone number found.');
382
388
  }
383
389
 
390
+ if ($user->status === 'inactive') {
391
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
392
+ }
393
+
384
394
  try {
385
395
  $this->generateSmsVerification($user, 'fleetops_customer_login', [
386
396
  'messageCallback' => fn ($verification) => 'Your ' . config('app.name') . ' verification code is ' . $verification->code,
@@ -423,6 +433,10 @@ class CustomerController extends Controller
423
433
  return response()->apiError('Unable to verify code.');
424
434
  }
425
435
 
436
+ if ($user->status === 'inactive') {
437
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
438
+ }
439
+
426
440
  $verificationCode = $this->verificationCodeExists([
427
441
  'subject_uuid' => $user->uuid,
428
442
  'code' => $code,
@@ -6,6 +6,7 @@ use Fleetbase\FleetOps\Events\DriverLocationChanged;
6
6
  use Fleetbase\FleetOps\Events\GeofenceEntered;
7
7
  use Fleetbase\FleetOps\Events\GeofenceExited;
8
8
  use Fleetbase\FleetOps\Events\VehicleLocationChanged;
9
+ use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException;
9
10
  use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException;
10
11
  use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources;
11
12
  use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesPublicExpansions;
@@ -22,6 +23,7 @@ use Fleetbase\FleetOps\Models\Vehicle;
22
23
  use Fleetbase\FleetOps\Models\Vendor;
23
24
  use Fleetbase\FleetOps\Support\GeofenceIntersectionService;
24
25
  use Fleetbase\FleetOps\Support\OSRM;
26
+ use Fleetbase\FleetOps\Support\ProfileAccountManager;
25
27
  use Fleetbase\FleetOps\Support\Utils;
26
28
  use Fleetbase\Http\Controllers\Controller;
27
29
  use Fleetbase\Http\Requests\SwitchOrganizationRequest;
@@ -47,6 +49,11 @@ class DriverController extends Controller
47
49
  use ResolvesFleetOpsApiResources;
48
50
  use ResolvesPublicExpansions;
49
51
 
52
+ /**
53
+ * Returned when a driver whose login was deactivated from the console tries to sign in.
54
+ */
55
+ public const DEACTIVATED_LOGIN_MESSAGE = 'This driver login has been deactivated.';
56
+
50
57
  /**
51
58
  * Public expansion name => Eloquent relation name.
52
59
  *
@@ -97,20 +104,14 @@ class DriverController extends Controller
97
104
  // Apply user infos
98
105
  $userDetails = $this->applyUserInfoFromRequest($request, $userDetails);
99
106
 
100
- // Set company_uuid before creating user
101
- $userDetails['company_uuid'] = $company->uuid;
102
-
103
- // create user account for driver
104
- $user = $this->createUser($userDetails);
105
-
106
- // Assign company — the early return above guarantees $company is set
107
- $user->assignCompany($company);
108
-
109
- // Set user type
110
- $user->setUserType('driver');
111
-
112
- // assign driver role
113
- $user->assignSingleRole('Driver');
107
+ // Resolve the driver's login account: a team member of the company with
108
+ // the email/phone is linked, otherwise a driver account is created and
109
+ // added to the company without an organization invite.
110
+ try {
111
+ $user = $this->resolveDriverAccount($company->uuid, $userDetails);
112
+ } catch (ProfileIdentityConflictException $exception) {
113
+ return $this->jsonResponse(['error' => $exception->getMessage()], 422);
114
+ }
114
115
 
115
116
  // set user id
116
117
  $input['user_uuid'] = $user->uuid;
@@ -193,10 +194,11 @@ class DriverController extends Controller
193
194
  // it. The driver's own record has no timezone column; the user's does.
194
195
  $userDetails = $request->only(['name', 'email', 'phone', 'timezone']);
195
196
 
196
- // update driver user details
197
- $driverUser = $driver->getUser();
198
- if ($driverUser) {
199
- $driverUser->update($userDetails);
197
+ // update the driver's login account through its proxy fields
198
+ try {
199
+ ProfileAccountManager::syncProxyFields($driver->getUser(), $userDetails);
200
+ } catch (ProfileIdentityConflictException $exception) {
201
+ return $this->jsonResponse(['error' => $exception->getMessage()], 422);
200
202
  }
201
203
 
202
204
  // latitude / longitude
@@ -519,10 +521,14 @@ class DriverController extends Controller
519
521
  )->whereHas('driver')->first();
520
522
 
521
523
  // Check password to authenticate driver
522
- if (!Hash::check($password, $user->password)) {
524
+ if (!$user || !is_string($user->password) || !Hash::check((string) $password, $user->password)) {
523
525
  return response()->apiError('Authentication failed using password provided.', 401);
524
526
  }
525
527
 
528
+ if (static::isLoginDeactivated($user)) {
529
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
530
+ }
531
+
526
532
  // Get the user's company for this driver profile
527
533
  $company = static::getDriverCompanyFromUser($user);
528
534
 
@@ -561,6 +567,10 @@ class DriverController extends Controller
561
567
  return response()->apiError('No driver with this phone # found.');
562
568
  }
563
569
 
570
+ if (static::isLoginDeactivated($user)) {
571
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
572
+ }
573
+
564
574
  // Get the user's company for this driver profile
565
575
  $company = static::getDriverCompanyFromUser($user);
566
576
 
@@ -627,6 +637,10 @@ class DriverController extends Controller
627
637
  return response()->apiError('Unable to verify code.');
628
638
  }
629
639
 
640
+ if (static::isLoginDeactivated($user)) {
641
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
642
+ }
643
+
630
644
  // find and verify code
631
645
  $verificationCode = VerificationCode::where(['subject_uuid' => $user->uuid, 'code' => $code, 'for' => $for])->exists();
632
646
  if (!$verificationCode && !static::verificationBypassMatches($identity, $code)) {
@@ -975,26 +989,29 @@ class DriverController extends Controller
975
989
  return User::applyUserInfoFromRequest($request, $userDetails);
976
990
  }
977
991
 
978
- protected function createUser(array $userDetails): User
992
+ /**
993
+ * Resolve the login account for a driver created through the API.
994
+ *
995
+ * @throws ProfileIdentityConflictException
996
+ */
997
+ protected function resolveDriverAccount(string $companyUuid, array $userDetails): User
979
998
  {
980
- /*
981
- * `password` is guarded on User, so mass assignment drops it without a
982
- * word. The create endpoint has always accepted and validated one, so a
983
- * driver created through the API could never sign in with the password
984
- * their operator chose for them. Set it after the fact, where the
985
- * model's mutator hashes it.
986
- */
987
- $password = $userDetails['password'] ?? null;
988
- unset($userDetails['password']);
989
-
990
- $user = User::create($userDetails);
991
-
992
- if (is_string($password) && strlen($password)) {
993
- $user->password = $password;
994
- $user->save();
995
- }
999
+ return ProfileAccountManager::resolveForProfile(
1000
+ $companyUuid,
1001
+ 'driver',
1002
+ $userDetails['name'] ?? null,
1003
+ $userDetails['email'] ?? null,
1004
+ $userDetails['phone'] ?? null,
1005
+ array_merge(Arr::except($userDetails, ['name', 'email', 'phone']), ['status' => 'active'])
1006
+ );
1007
+ }
996
1008
 
997
- return $user;
1009
+ /**
1010
+ * Whether the driver's login was deactivated from the console.
1011
+ */
1012
+ public static function isLoginDeactivated(User $user): bool
1013
+ {
1014
+ return $user->status === 'inactive';
998
1015
  }
999
1016
 
1000
1017
  protected function getUuid(array|string $table, array $where, array $options = []): mixed
@@ -1313,7 +1330,7 @@ class DriverController extends Controller
1313
1330
  }
1314
1331
 
1315
1332
  $user = static::findDriverUserByIdentity($identity);
1316
- if (!$user) {
1333
+ if (!$user || static::isLoginDeactivated($user)) {
1317
1334
  return response()->json(['status' => 'ok']);
1318
1335
  }
1319
1336
 
@@ -1344,7 +1361,7 @@ class DriverController extends Controller
1344
1361
  }
1345
1362
 
1346
1363
  $user = static::findDriverUserByIdentity($identity);
1347
- if (!$user) {
1364
+ if (!$user || static::isLoginDeactivated($user)) {
1348
1365
  return response()->apiError('Invalid or expired reset code.', 422);
1349
1366
  }
1350
1367
 
@@ -265,25 +265,13 @@ class ContactController extends FleetOpsController
265
265
  ->get();
266
266
  }
267
267
 
268
+ /**
269
+ * The login account is managed by the contact profile, so a user can't be
270
+ * picked or swapped from the console.
271
+ */
268
272
  private function resolveUserInput(Request $request, array &$input): void
269
273
  {
270
- $user = data_get($input, 'user_uuid') ?? data_get($input, 'user') ?? $request->input('contact.user_uuid') ?? $request->input('contact.user');
271
-
272
- if (is_array($user)) {
273
- $user = data_get($user, 'uuid') ?? data_get($user, 'id');
274
- }
275
-
276
- if (!$user) {
277
- return;
278
- }
279
-
280
- $input['user_uuid'] = $this->resolveUserUuid($user);
281
- unset($input['user']);
282
- }
283
-
284
- protected function resolveUserUuid(string $user): string
285
- {
286
- return User::where('uuid', $user)->orWhere('public_id', $user)->value('uuid') ?? $user;
274
+ unset($input['user_uuid'], $input['user']);
287
275
  }
288
276
 
289
277
  protected function assertCustomerPortalCanSendWelcomeEmail(array $input): void
@@ -4,6 +4,7 @@ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
4
4
 
5
5
  use Fleetbase\FleetOps\Mail\CustomerCredentialsMail;
6
6
  use Fleetbase\FleetOps\Models\Contact;
7
+ use Fleetbase\FleetOps\Support\ProfileAccountManager;
7
8
  use Fleetbase\Http\Controllers\Controller;
8
9
  use Fleetbase\Models\User;
9
10
  use Illuminate\Http\Request;
@@ -21,6 +22,10 @@ class CustomerController extends Controller
21
22
  return response()->error('Unable to create customer portal login.');
22
23
  }
23
24
 
25
+ if ($error = $this->staffLinkedError($user)) {
26
+ return $error;
27
+ }
28
+
24
29
  if ($user->status !== 'active') {
25
30
  $user->activate();
26
31
  }
@@ -39,6 +44,10 @@ class CustomerController extends Controller
39
44
  return response()->error('Unable to send customer portal credentials.');
40
45
  }
41
46
 
47
+ if ($error = $this->staffLinkedError($user)) {
48
+ return $error;
49
+ }
50
+
42
51
  $password = $this->randomPassword();
43
52
  $user->changePassword($password);
44
53
  $this->sendCustomerCredentials($user, $password, $customer);
@@ -57,6 +66,10 @@ class CustomerController extends Controller
57
66
  return response()->error('Customer portal login not found.');
58
67
  }
59
68
 
69
+ if ($error = $this->staffLinkedError($user)) {
70
+ return $error;
71
+ }
72
+
60
73
  $user->deactivate();
61
74
 
62
75
  return response()->json([
@@ -73,6 +86,10 @@ class CustomerController extends Controller
73
86
  return response()->error('Customer portal login not found.');
74
87
  }
75
88
 
89
+ if ($error = $this->staffLinkedError($user)) {
90
+ return $error;
91
+ }
92
+
76
93
  $user->activate();
77
94
 
78
95
  return response()->json([
@@ -149,6 +166,10 @@ class CustomerController extends Controller
149
166
  return response()->error('Unable to reset customer credentials');
150
167
  }
151
168
 
169
+ if ($error = $this->staffLinkedError($user)) {
170
+ return $error;
171
+ }
172
+
152
173
  // Change password
153
174
  $user->changePassword($password);
154
175
 
@@ -163,6 +184,19 @@ class CustomerController extends Controller
163
184
  ]);
164
185
  }
165
186
 
187
+ /**
188
+ * A customer linked to a team member's account signs in with that account,
189
+ * which is managed in IAM.
190
+ */
191
+ protected function staffLinkedError(User $user): ?\Illuminate\Http\JsonResponse
192
+ {
193
+ if (ProfileAccountManager::isStaffAccount($user)) {
194
+ return response()->error('This customer signs in with a team member account. Manage this login in IAM.', 422);
195
+ }
196
+
197
+ return null;
198
+ }
199
+
166
200
  protected function resolveCustomer(Request $request): Contact
167
201
  {
168
202
  $customerId = $request->input('customer');
@@ -213,10 +247,12 @@ class CustomerController extends Controller
213
247
  $customer->loadMissing('user');
214
248
 
215
249
  return [
216
- 'id' => $customer->public_id,
217
- 'uuid' => $customer->uuid,
218
- 'user_uuid' => $customer->user_uuid,
219
- 'user' => $customer->user ? [
250
+ 'id' => $customer->public_id,
251
+ 'uuid' => $customer->uuid,
252
+ 'user_uuid' => $customer->user_uuid,
253
+ 'is_staff_linked' => ProfileAccountManager::isStaffAccount($customer->user),
254
+ 'login_status' => $customer->user?->status,
255
+ 'user' => $customer->user ? [
220
256
  'id' => $customer->user->public_id,
221
257
  'uuid' => $customer->user->uuid,
222
258
  'name' => $customer->user->name,