@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.
- package/RELEASE.md +11 -9
- package/addon/components/contact/form.hbs +13 -2
- package/addon/components/customer/details.hbs +17 -17
- package/addon/components/customer/form.hbs +13 -2
- package/addon/components/customer/form.js +0 -56
- package/addon/components/driver/details.hbs +13 -19
- package/addon/components/driver/form.hbs +20 -35
- package/addon/components/driver/form.js +0 -58
- package/addon/components/modals/driver-form.hbs +2 -2
- package/addon/components/modals/reset-profile-credentials.hbs +23 -0
- package/addon/components/modals/reset-profile-credentials.js +70 -0
- package/addon/components/profile-identity-hint.hbs +13 -0
- package/addon/components/profile-identity-hint.js +49 -0
- package/addon/controllers/management/drivers/index.js +4 -0
- package/addon/extension.js +4 -2
- package/addon/services/contact-actions.js +50 -89
- package/addon/services/driver-actions.js +98 -0
- package/addon/utils/profile-login-actions.js +172 -0
- package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
- package/app/components/profile-identity-hint.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +3 -3
- package/scripts/pest-bootstrap.php +12 -0
- package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
- package/server/resources/views/mail/driver-credentials.blade.php +21 -0
- package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
- package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
- package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
- package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
- package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
- package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
- package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
- package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
- package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
- package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
- package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
- package/server/src/Http/Resources/v1/Contact.php +3 -0
- package/server/src/Http/Resources/v1/Driver.php +3 -0
- package/server/src/Mail/DriverCredentialsMail.php +55 -0
- package/server/src/Models/Contact.php +55 -90
- package/server/src/Models/Sensor.php +3 -5
- package/server/src/Observers/ContactObserver.php +2 -22
- package/server/src/Observers/DriverObserver.php +5 -6
- package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
- package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
- package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
- package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
- package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
- package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
- package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
- package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
- package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
- package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
- package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
- package/server/src/Support/ProfileAccountManager.php +405 -0
- package/server/src/routes.php +5 -0
- package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
- package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
- package/server/tests/ApiDriverControllerContractsTest.php +102 -20
- package/server/tests/CommandContractsTest.php +4 -4
- package/server/tests/ControllerHelperContractsTest.php +3 -11
- package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
- package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
- package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
- package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
- package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
- package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
- package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
- package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
- package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
- package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
- package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
- package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
- package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
- package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
- package/server/tests/ModelAccessorContractsTest.php +82 -20
- package/server/tests/ObserverContractsTest.php +27 -2
- package/server/tests/RequestContractsTest.php +19 -11
- package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
- package/server/tests/TelematicControllerContractsTest.php +22 -4
- package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
- package/server/tests/Unit/Models/ContactTest.php +48 -5
- package/server/tests/Unit/Models/SensorTest.php +2 -2
- package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
- package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
- package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
- package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
- package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
- package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
- package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
- package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
- package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
- package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
- package/translations/en-us.yaml +37 -2
- package/translations/uk-ua.yaml +3475 -0
- package/addon/components/modals/reset-customer-credentials.hbs +0 -18
- package/addon/components/modals/reset-customer-credentials.js +0 -55
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Support;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException;
|
|
6
|
+
use Fleetbase\FleetOps\Mail\CustomerCredentialsMail;
|
|
7
|
+
use Fleetbase\FleetOps\Mail\DriverCredentialsMail;
|
|
8
|
+
use Fleetbase\FleetOps\Models\Contact;
|
|
9
|
+
use Fleetbase\FleetOps\Models\Driver;
|
|
10
|
+
use Fleetbase\Models\Company;
|
|
11
|
+
use Fleetbase\Models\CompanyUser;
|
|
12
|
+
use Fleetbase\Models\User;
|
|
13
|
+
use Fleetbase\Services\SmsService;
|
|
14
|
+
use Illuminate\Support\Facades\Mail;
|
|
15
|
+
use Illuminate\Support\Str;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Manages the login accounts behind driver, contact and customer profiles.
|
|
19
|
+
*
|
|
20
|
+
* A profile either owns a managed account (user type `driver`, `contact` or
|
|
21
|
+
* `customer`), which it creates, updates and deletes, or it is linked to a
|
|
22
|
+
* staff account (type `user` or `admin`) of the same organization. A staff
|
|
23
|
+
* account is managed in IAM, so a profile never changes its email or phone,
|
|
24
|
+
* resets its password, deactivates it or deletes it.
|
|
25
|
+
*/
|
|
26
|
+
class ProfileAccountManager
|
|
27
|
+
{
|
|
28
|
+
/**
|
|
29
|
+
* User types owned by a profile.
|
|
30
|
+
*/
|
|
31
|
+
public const MANAGED_TYPES = ['driver', 'contact', 'customer'];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The organization role given to each managed account type.
|
|
35
|
+
*/
|
|
36
|
+
public const ROLES = [
|
|
37
|
+
'driver' => 'Driver',
|
|
38
|
+
'contact' => 'Fleet-Ops Contact',
|
|
39
|
+
'customer' => 'Fleet-Ops Customer',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether the account is owned by a driver, contact or customer profile.
|
|
44
|
+
*/
|
|
45
|
+
public static function isManagedAccount(?object $user): bool
|
|
46
|
+
{
|
|
47
|
+
return $user !== null && in_array(data_get($user, 'type'), static::MANAGED_TYPES, true);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the account is a team member's account managed in IAM.
|
|
52
|
+
*/
|
|
53
|
+
public static function isStaffAccount(?object $user): bool
|
|
54
|
+
{
|
|
55
|
+
return $user !== null && !static::isManagedAccount($user);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Find the active account holding the given email or phone.
|
|
60
|
+
*/
|
|
61
|
+
public static function findAccountByIdentity(?string $email, ?string $phone, ?string $ignoreUserUuid = null): ?User
|
|
62
|
+
{
|
|
63
|
+
$email = static::normalizeEmail($email);
|
|
64
|
+
$phone = static::normalizePhone($phone);
|
|
65
|
+
|
|
66
|
+
if (!$email && !$phone) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return User::where(function ($query) use ($email, $phone) {
|
|
71
|
+
if ($email) {
|
|
72
|
+
$query->orWhere('email', $email);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if ($phone) {
|
|
76
|
+
$query->orWhere('phone', $phone);
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
->when($ignoreUserUuid, fn ($query) => $query->where('uuid', '!=', $ignoreUserUuid))
|
|
80
|
+
->whereNull('deleted_at')
|
|
81
|
+
->first();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Describe what would happen to an email/phone entered on a new or existing
|
|
86
|
+
* profile: `staff` is the team member the profile would link to, `conflict`
|
|
87
|
+
* is why the email/phone cannot be used.
|
|
88
|
+
*
|
|
89
|
+
* @return array{account: ?User, staff: ?User, conflict: ?string}
|
|
90
|
+
*/
|
|
91
|
+
public static function lookup(string $companyUuid, string $type, ?string $email, ?string $phone, ?User $currentUser = null): array
|
|
92
|
+
{
|
|
93
|
+
$result = ['account' => null, 'staff' => null, 'conflict' => null];
|
|
94
|
+
$account = static::findAccountByIdentity($email, $phone, $currentUser?->uuid);
|
|
95
|
+
|
|
96
|
+
if (!$account) {
|
|
97
|
+
return $result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
$result['account'] = $account;
|
|
101
|
+
$field = static::matchedField($account, $email);
|
|
102
|
+
|
|
103
|
+
// An existing profile can't swap its account for another one
|
|
104
|
+
if ($currentUser) {
|
|
105
|
+
$result['conflict'] = static::takenMessage($field);
|
|
106
|
+
|
|
107
|
+
return $result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
static::assertAccountCanHoldProfile($account, $companyUuid, $type, $field);
|
|
112
|
+
} catch (ProfileIdentityConflictException $e) {
|
|
113
|
+
$result['conflict'] = $e->getMessage();
|
|
114
|
+
|
|
115
|
+
return $result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (static::isStaffAccount($account)) {
|
|
119
|
+
$result['staff'] = $account;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return $result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve the account for a new profile. A staff member of the organization
|
|
127
|
+
* or a managed account of the same type with the email/phone is linked;
|
|
128
|
+
* otherwise a managed account is created.
|
|
129
|
+
*
|
|
130
|
+
* @param array $attributes extra user attributes: password, status, timezone, avatar_uuid, country, ip_address, meta
|
|
131
|
+
*
|
|
132
|
+
* @throws ProfileIdentityConflictException
|
|
133
|
+
*/
|
|
134
|
+
public static function resolveForProfile(string $companyUuid, string $type, ?string $name, ?string $email, ?string $phone, array $attributes = []): User
|
|
135
|
+
{
|
|
136
|
+
$account = static::findAccountByIdentity($email, $phone);
|
|
137
|
+
|
|
138
|
+
if ($account) {
|
|
139
|
+
static::assertAccountCanHoldProfile($account, $companyUuid, $type, static::matchedField($account, $email));
|
|
140
|
+
|
|
141
|
+
if (static::isManagedAccount($account)) {
|
|
142
|
+
static::attachToCompany($account, $companyUuid, $type);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return $account;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return static::createManagedAccount($companyUuid, $type, $name, $email, $phone, $attributes);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Create a managed account and add it to the organization without an invite.
|
|
153
|
+
*/
|
|
154
|
+
public static function createManagedAccount(string $companyUuid, string $type, ?string $name, ?string $email, ?string $phone, array $attributes = []): User
|
|
155
|
+
{
|
|
156
|
+
$company = Company::where('uuid', $companyUuid)->first();
|
|
157
|
+
$password = $attributes['password'] ?? null;
|
|
158
|
+
$userData = array_filter([
|
|
159
|
+
'company_uuid' => $companyUuid,
|
|
160
|
+
'name' => $name,
|
|
161
|
+
'email' => static::normalizeEmail($email),
|
|
162
|
+
'phone' => static::normalizePhone($phone),
|
|
163
|
+
'username' => User::generateUsername($name ?: Str::random(6)),
|
|
164
|
+
'timezone' => $attributes['timezone'] ?? $company?->timezone ?? date_default_timezone_get(),
|
|
165
|
+
'status' => $attributes['status'] ?? 'pending',
|
|
166
|
+
'avatar_uuid' => $attributes['avatar_uuid'] ?? null,
|
|
167
|
+
'country' => $attributes['country'] ?? null,
|
|
168
|
+
'ip_address' => $attributes['ip_address'] ?? null,
|
|
169
|
+
'meta' => $attributes['meta'] ?? null,
|
|
170
|
+
], fn ($value) => $value !== null);
|
|
171
|
+
|
|
172
|
+
$user = new User($userData);
|
|
173
|
+
$user->password = $password ?: Str::random(16);
|
|
174
|
+
$user->save();
|
|
175
|
+
$user->setType($type);
|
|
176
|
+
|
|
177
|
+
static::attachToCompany($user, $companyUuid, $type);
|
|
178
|
+
|
|
179
|
+
return $user;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Push the profile's proxy fields to its account. A staff account only
|
|
184
|
+
* takes the name: its email and phone are its console identity.
|
|
185
|
+
*
|
|
186
|
+
* @throws ProfileIdentityConflictException
|
|
187
|
+
*/
|
|
188
|
+
public static function syncProxyFields(?User $user, array $attributes): bool
|
|
189
|
+
{
|
|
190
|
+
if (!$user) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
$fields = static::isStaffAccount($user) ? ['name'] : ['name', 'email', 'phone', 'timezone', 'avatar_uuid', 'password'];
|
|
195
|
+
$input = array_intersect_key($attributes, array_flip($fields));
|
|
196
|
+
|
|
197
|
+
if (array_key_exists('email', $input)) {
|
|
198
|
+
$input['email'] = static::normalizeEmail($input['email']);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (array_key_exists('phone', $input)) {
|
|
202
|
+
$input['phone'] = static::normalizePhone($input['phone']);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
$input = array_filter($input, fn ($value, $key) => $value !== null || in_array($key, ['email', 'phone'], true), ARRAY_FILTER_USE_BOTH);
|
|
206
|
+
$input = array_filter($input, fn ($value, $key) => $key === 'password' || $user->{$key} !== $value, ARRAY_FILTER_USE_BOTH);
|
|
207
|
+
|
|
208
|
+
if (empty($input)) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
$changedEmail = array_key_exists('email', $input) ? $input['email'] : null;
|
|
213
|
+
$changedPhone = array_key_exists('phone', $input) ? $input['phone'] : null;
|
|
214
|
+
$taken = static::findAccountByIdentity($changedEmail, $changedPhone, $user->uuid);
|
|
215
|
+
if ($taken) {
|
|
216
|
+
throw new ProfileIdentityConflictException(static::takenMessage(static::matchedField($taken, $changedEmail)), static::matchedField($taken, $changedEmail));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (isset($input['password'])) {
|
|
220
|
+
$user->password = $input['password'];
|
|
221
|
+
unset($input['password']);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
$user->fill($input);
|
|
225
|
+
|
|
226
|
+
return $user->save();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Release a profile's account after the profile is deleted. A managed
|
|
231
|
+
* account with no other profile is deleted, which frees its email and
|
|
232
|
+
* phone; one still used by another organization's profile only leaves this
|
|
233
|
+
* organization. Staff accounts are never touched.
|
|
234
|
+
*/
|
|
235
|
+
public static function releaseForProfile(?User $user, ?string $companyUuid = null): void
|
|
236
|
+
{
|
|
237
|
+
if (!static::isManagedAccount($user) || $user->trashed()) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
$remainingProfiles = static::profileCompanies($user);
|
|
242
|
+
if ($remainingProfiles->isEmpty()) {
|
|
243
|
+
$user->delete();
|
|
244
|
+
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if ($companyUuid && !$remainingProfiles->contains($companyUuid)) {
|
|
249
|
+
CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->delete();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Generate a new password for the account and send it to the profile.
|
|
255
|
+
*
|
|
256
|
+
* @return string how the credentials were sent: `email` or `sms`
|
|
257
|
+
*/
|
|
258
|
+
public static function sendCredentials(Driver|Contact $profile, User $user, ?string $password = null): string
|
|
259
|
+
{
|
|
260
|
+
$password ??= Str::random(12);
|
|
261
|
+
$user->changePassword($password);
|
|
262
|
+
|
|
263
|
+
if ($user->status !== 'active') {
|
|
264
|
+
$user->activate();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return static::deliverCredentials($profile, $user, $password);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Send already set credentials to the profile by email, or by SMS when the
|
|
272
|
+
* account has no email.
|
|
273
|
+
*/
|
|
274
|
+
public static function deliverCredentials(Driver|Contact $profile, User $user, string $password): string
|
|
275
|
+
{
|
|
276
|
+
if ($user->email) {
|
|
277
|
+
Mail::to($user)->send($profile instanceof Driver ? new DriverCredentialsMail($password, $profile, $user) : new CustomerCredentialsMail($password, $profile));
|
|
278
|
+
|
|
279
|
+
return 'email';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if ($user->phone) {
|
|
283
|
+
$profile->loadMissing('company');
|
|
284
|
+
$companyName = $profile->company?->name ?? config('app.name');
|
|
285
|
+
$identity = $user->phone;
|
|
286
|
+
$result = app(SmsService::class)->send($user->phone, "Your {$companyName} sign-in details. Login: {$identity} Password: {$password}");
|
|
287
|
+
|
|
288
|
+
if (is_array($result) && array_key_exists('success', $result) && !$result['success']) {
|
|
289
|
+
throw new \Exception('The credentials could not be texted: ' . ($result['error'] ?? $result['message'] ?? 'the SMS provider refused it.'));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return 'sms';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
throw new \Exception('This profile has no email or phone to send credentials to.');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Throw when the account can't hold the kind of profile being created.
|
|
300
|
+
*
|
|
301
|
+
* @throws ProfileIdentityConflictException
|
|
302
|
+
*/
|
|
303
|
+
public static function assertAccountCanHoldProfile(User $account, string $companyUuid, string $type, string $field = 'email'): void
|
|
304
|
+
{
|
|
305
|
+
if (static::isStaffAccount($account)) {
|
|
306
|
+
if (!static::isCompanyMember($account, $companyUuid)) {
|
|
307
|
+
throw new ProfileIdentityConflictException(static::takenMessage($field), $field);
|
|
308
|
+
}
|
|
309
|
+
} elseif ($account->type !== $type) {
|
|
310
|
+
throw new ProfileIdentityConflictException('This ' . static::fieldLabel($field) . ' is already used by a ' . $account->type . '.', $field);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (static::hasProfileInCompany($account, $companyUuid, $type)) {
|
|
314
|
+
throw new ProfileIdentityConflictException('A ' . $type . ' with this ' . static::fieldLabel($field) . ' already exists.', $field);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
public static function hasProfileInCompany(User $user, string $companyUuid, string $type): bool
|
|
319
|
+
{
|
|
320
|
+
if ($type === 'driver') {
|
|
321
|
+
return Driver::withoutGlobalScopes()->where(['user_uuid' => $user->uuid, 'company_uuid' => $companyUuid])->whereNull('deleted_at')->exists();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return Contact::withoutGlobalScopes()->where(['user_uuid' => $user->uuid, 'company_uuid' => $companyUuid])->whereNull('deleted_at')->exists();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The organizations the account still has a driver or contact profile in.
|
|
329
|
+
*/
|
|
330
|
+
public static function profileCompanies(User $user): \Illuminate\Support\Collection
|
|
331
|
+
{
|
|
332
|
+
$drivers = Driver::withoutGlobalScopes()->where('user_uuid', $user->uuid)->whereNull('deleted_at')->pluck('company_uuid');
|
|
333
|
+
$contacts = Contact::withoutGlobalScopes()->where('user_uuid', $user->uuid)->whereNull('deleted_at')->pluck('company_uuid');
|
|
334
|
+
|
|
335
|
+
return $drivers->merge($contacts)->filter()->unique()->values();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
public static function isCompanyMember(User $user, ?string $companyUuid): bool
|
|
339
|
+
{
|
|
340
|
+
if (!$companyUuid) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return $user->company_uuid === $companyUuid || CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->exists();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Add a managed account to the organization with its profile role, without
|
|
349
|
+
* sending an organization invite.
|
|
350
|
+
*/
|
|
351
|
+
public static function attachToCompany(User $user, string $companyUuid, string $type): ?CompanyUser
|
|
352
|
+
{
|
|
353
|
+
$company = Company::where('uuid', $companyUuid)->first();
|
|
354
|
+
if (!$company) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
$role = static::ROLES[$type] ?? static::ROLES['contact'];
|
|
359
|
+
$companyUser = CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->first();
|
|
360
|
+
if (!$companyUser) {
|
|
361
|
+
$companyUser = $company->addUser($user, $role);
|
|
362
|
+
} elseif (static::isManagedAccount($user)) {
|
|
363
|
+
$companyUser->assignSingleRole($role);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!$user->company_uuid) {
|
|
367
|
+
$user->forceFill(['company_uuid' => $companyUuid])->saveQuietly();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
$user->setRelation('companyUser', $companyUser);
|
|
371
|
+
|
|
372
|
+
return $companyUser;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
public static function normalizeEmail(?string $email): ?string
|
|
376
|
+
{
|
|
377
|
+
$email = is_string($email) ? strtolower(trim($email)) : null;
|
|
378
|
+
|
|
379
|
+
return $email ?: null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
public static function normalizePhone(?string $phone): ?string
|
|
383
|
+
{
|
|
384
|
+
$phone = is_string($phone) ? trim($phone) : null;
|
|
385
|
+
|
|
386
|
+
return $phone ? Utils::formatPhoneNumber($phone) : null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private static function matchedField(User $account, ?string $email): string
|
|
390
|
+
{
|
|
391
|
+
$email = static::normalizeEmail($email);
|
|
392
|
+
|
|
393
|
+
return $email && strtolower((string) $account->email) === $email ? 'email' : 'phone';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private static function fieldLabel(string $field): string
|
|
397
|
+
{
|
|
398
|
+
return $field === 'phone' ? 'phone number' : 'email';
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private static function takenMessage(string $field): string
|
|
402
|
+
{
|
|
403
|
+
return 'This ' . static::fieldLabel($field) . ' is already in use by another account.';
|
|
404
|
+
}
|
|
405
|
+
}
|
package/server/src/routes.php
CHANGED
|
@@ -444,6 +444,10 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
|
|
|
444
444
|
$router->post('{id}/unassign-order', $controller('unassignOrder'));
|
|
445
445
|
$router->post('{id}/assign-vehicle', $controller('assignVehicle'));
|
|
446
446
|
$router->post('{id}/unassign-vehicle', $controller('unassignVehicle'));
|
|
447
|
+
$router->post('{id}/send-credentials', $controller('sendCredentials'));
|
|
448
|
+
$router->post('{id}/reset-credentials', $controller('resetCredentials'));
|
|
449
|
+
$router->post('{id}/deactivate-login', $controller('deactivateLogin'));
|
|
450
|
+
$router->post('{id}/reactivate-login', $controller('reactivateLogin'));
|
|
447
451
|
$router->match(['get', 'post'], 'export', $controller('export'));
|
|
448
452
|
$router->post('import', $controller('import'));
|
|
449
453
|
// Driver scheduling endpoints
|
|
@@ -764,6 +768,7 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
|
|
|
764
768
|
function ($router) {
|
|
765
769
|
$router->get('customers', 'FleetOpsLookupController@polymorphs');
|
|
766
770
|
$router->get('facilitators', 'FleetOpsLookupController@polymorphs');
|
|
771
|
+
$router->get('profile-identity', 'FleetOpsLookupController@profileIdentity');
|
|
767
772
|
}
|
|
768
773
|
);
|
|
769
774
|
$router->group(
|
|
@@ -406,6 +406,7 @@ test('asset status capability exposes metadata and denied branches', function ()
|
|
|
406
406
|
->and($capability->label())->toBe('Fleet-Ops asset status')
|
|
407
407
|
->and($capability->description())->toContain('vehicle, device, sensor')
|
|
408
408
|
->and($capability->permissions())->toBe([
|
|
409
|
+
'fleet-ops see driver',
|
|
409
410
|
'fleet-ops see vehicle',
|
|
410
411
|
'fleet-ops see device',
|
|
411
412
|
'fleet-ops see sensor',
|
|
@@ -800,6 +800,27 @@ test('api customer controller authenticates and verifies customer codes', functi
|
|
|
800
800
|
]))))->toBe(['error' => 'Invalid verification code.']);
|
|
801
801
|
});
|
|
802
802
|
|
|
803
|
+
test('api customer controller refuses deactivated customer logins', function () {
|
|
804
|
+
$controller = fleetopsApiCustomerController();
|
|
805
|
+
foreach (['activeUser', 'loginUser', 'verificationUser'] as $property) {
|
|
806
|
+
$controller->{$property}->setAttribute('status', 'inactive');
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
$responses = [
|
|
810
|
+
$controller->login(Request::create('/v1/customers/login', 'POST', ['identity' => 'jane@example.test', 'password' => 'password-secret'])),
|
|
811
|
+
$controller->loginWithPhone(Request::create('/v1/customers/login/phone', 'POST', ['phone' => '15551234567'])),
|
|
812
|
+
$controller->verifyCode(Request::create('/v1/customers/verify', 'POST', ['identity' => 'jane@example.test', 'code' => '123456'])),
|
|
813
|
+
];
|
|
814
|
+
|
|
815
|
+
foreach ($responses as $response) {
|
|
816
|
+
expect($response->getStatusCode())->toBe(403)
|
|
817
|
+
->and(fleetopsApiCustomerJson($response))->toBe(['error' => 'This customer login has been deactivated.']);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// No verification code is sent
|
|
821
|
+
expect($controller->smsVerifications)->toBe([]);
|
|
822
|
+
});
|
|
823
|
+
|
|
803
824
|
test('api customer controller resets forgotten passwords', function () {
|
|
804
825
|
$controller = fleetopsApiCustomerController();
|
|
805
826
|
$forgot = $controller->forgotPassword(Request::create('/v1/customers/forgot-password', 'POST', [
|
|
@@ -30,6 +30,8 @@ class FleetOpsApiDriverControllerProbe extends DriverController
|
|
|
30
30
|
public array $deviceCreates = [];
|
|
31
31
|
public array $companyCalls = [];
|
|
32
32
|
public array $createdUsers = [];
|
|
33
|
+
public array $resolvedAccounts = [];
|
|
34
|
+
public ?Throwable $accountConflict = null;
|
|
33
35
|
public array $uuidLookups = [];
|
|
34
36
|
public array $relationLookups = [];
|
|
35
37
|
public array $relationCompanyScopes = [];
|
|
@@ -65,11 +67,17 @@ class FleetOpsApiDriverControllerProbe extends DriverController
|
|
|
65
67
|
return $userDetails;
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
protected function
|
|
70
|
+
protected function resolveDriverAccount(string $companyUuid, array $userDetails): User
|
|
69
71
|
{
|
|
70
|
-
$this->
|
|
72
|
+
$this->resolvedAccounts[] = $companyUuid;
|
|
73
|
+
$this->createdUsers[] = $userDetails;
|
|
74
|
+
|
|
75
|
+
if ($this->accountConflict) {
|
|
76
|
+
throw $this->accountConflict;
|
|
77
|
+
}
|
|
78
|
+
|
|
71
79
|
$this->user ??= new FleetOpsApiDriverUserFake();
|
|
72
|
-
$this->user->setRawAttributes(array_merge(['uuid' => 'user-uuid'], $userDetails), true);
|
|
80
|
+
$this->user->setRawAttributes(array_merge(['uuid' => 'user-uuid', 'type' => 'driver'], $userDetails), true);
|
|
73
81
|
|
|
74
82
|
return $this->user;
|
|
75
83
|
}
|
|
@@ -282,7 +290,17 @@ class FleetOpsApiDriverUserFake extends User
|
|
|
282
290
|
return true;
|
|
283
291
|
}
|
|
284
292
|
|
|
285
|
-
public function
|
|
293
|
+
public function save(array $options = []): bool
|
|
294
|
+
{
|
|
295
|
+
$this->updates[] = $this->getDirty();
|
|
296
|
+
$this->syncOriginal();
|
|
297
|
+
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Matches core-api 1.6.63+, where no role is granted unless one is given (a nullable
|
|
302
|
+
// role is also compatible with older core-api, whose parameter was a plain string).
|
|
303
|
+
public function assignCompany(Company $company, ?string $role = null): User
|
|
286
304
|
{
|
|
287
305
|
$this->assignedCompanies[] = $company->uuid;
|
|
288
306
|
|
|
@@ -317,6 +335,27 @@ class FleetOpsApiDriverUserFake extends User
|
|
|
317
335
|
}
|
|
318
336
|
}
|
|
319
337
|
|
|
338
|
+
/**
|
|
339
|
+
* ProfileAccountManager checks a changed email/phone against the users table.
|
|
340
|
+
*/
|
|
341
|
+
function fleetopsApiDriverContractsUsersTable(array $rows = []): void
|
|
342
|
+
{
|
|
343
|
+
$connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
|
|
344
|
+
$connection->getSchemaBuilder()->create('users', function ($table) {
|
|
345
|
+
$table->string('uuid')->nullable();
|
|
346
|
+
$table->string('type')->nullable();
|
|
347
|
+
$table->string('email')->nullable();
|
|
348
|
+
$table->string('phone')->nullable();
|
|
349
|
+
$table->timestamp('deleted_at')->nullable();
|
|
350
|
+
});
|
|
351
|
+
if ($rows) {
|
|
352
|
+
$connection->table('users')->insert($rows);
|
|
353
|
+
}
|
|
354
|
+
$resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
|
|
355
|
+
$resolver->setDefaultConnection('mysql');
|
|
356
|
+
Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
|
|
357
|
+
}
|
|
358
|
+
|
|
320
359
|
class FleetOpsApiDriverVehicleFake extends Vehicle
|
|
321
360
|
{
|
|
322
361
|
public array $quietUpdates = [];
|
|
@@ -363,16 +402,15 @@ test('api driver controller creates drivers with user company assignment and rel
|
|
|
363
402
|
|
|
364
403
|
expect($response)->toBe(['resource' => 'driver', 'driver' => $controller->driver])
|
|
365
404
|
->and($controller->companyCalls)->toBe([['request', 'company_public']])
|
|
405
|
+
// The login account is resolved by ProfileAccountManager in the driver's company
|
|
406
|
+
->and($controller->resolvedAccounts)->toBe(['company-uuid'])
|
|
366
407
|
->and($controller->createdUsers[0])->toMatchArray([
|
|
367
|
-
'name'
|
|
368
|
-
'email'
|
|
369
|
-
'phone'
|
|
370
|
-
'
|
|
371
|
-
'applied'
|
|
408
|
+
'name' => 'Driver One',
|
|
409
|
+
'email' => 'driver@example.test',
|
|
410
|
+
'phone' => '+15551234567',
|
|
411
|
+
'password' => 'secret-password',
|
|
412
|
+
'applied' => true,
|
|
372
413
|
])
|
|
373
|
-
->and($controller->user->assignedCompanies)->toBe(['company-uuid'])
|
|
374
|
-
->and($controller->user->assignedTypes)->toBe(['driver'])
|
|
375
|
-
->and($controller->user->assignedRoles)->toBe(['Driver'])
|
|
376
414
|
->and($controller->createdDrivers[0])->toMatchArray([
|
|
377
415
|
'status' => 'available',
|
|
378
416
|
'vehicle_uuid' => 'vehicle-uuid',
|
|
@@ -407,8 +445,10 @@ test('api driver controller reports missing company before creating drivers', fu
|
|
|
407
445
|
});
|
|
408
446
|
|
|
409
447
|
test('api driver controller updates drivers user details assignments location and photo', function () {
|
|
448
|
+
fleetopsApiDriverContractsUsersTable();
|
|
449
|
+
|
|
410
450
|
$user = new FleetOpsApiDriverUserFake();
|
|
411
|
-
$user->setRawAttributes(['uuid' => 'user-uuid'], true);
|
|
451
|
+
$user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
|
|
412
452
|
|
|
413
453
|
$driver = new FleetOpsApiDriverFake();
|
|
414
454
|
$driver->setRawAttributes([
|
|
@@ -645,7 +685,7 @@ test('api driver controller refuses to set a password through a general update',
|
|
|
645
685
|
* and now has its own endpoint that demands the current password.
|
|
646
686
|
*/
|
|
647
687
|
$user = new FleetOpsApiDriverUserFake();
|
|
648
|
-
$user->setRawAttributes(['uuid' => 'user-uuid'], true);
|
|
688
|
+
$user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
|
|
649
689
|
$driver = new FleetOpsApiDriverFake();
|
|
650
690
|
$driver->setRawAttributes([
|
|
651
691
|
'uuid' => 'driver-uuid',
|
|
@@ -759,11 +799,9 @@ test('api driver controller creates an operational driver with no email or phone
|
|
|
759
799
|
'company_uuid' => 'company-uuid',
|
|
760
800
|
'status' => 'available',
|
|
761
801
|
])
|
|
762
|
-
// The Driver-to-User relationship
|
|
763
|
-
//
|
|
764
|
-
->and($controller->
|
|
765
|
-
->and($controller->user->assignedTypes)->toBe(['driver'])
|
|
766
|
-
->and($controller->user->assignedRoles)->toBe(['Driver']);
|
|
802
|
+
// The Driver-to-User relationship and organization membership are
|
|
803
|
+
// preserved for a credential-less driver.
|
|
804
|
+
->and($controller->resolvedAccounts)->toBe(['company-uuid']);
|
|
767
805
|
});
|
|
768
806
|
|
|
769
807
|
test('api driver controller creates a driver with only one contact method', function () {
|
|
@@ -873,7 +911,7 @@ test('api driver controller copies timezone through to the linked user account',
|
|
|
873
911
|
]));
|
|
874
912
|
|
|
875
913
|
$user = new FleetOpsApiDriverUserFake();
|
|
876
|
-
$user->setRawAttributes(['uuid' => 'user-uuid'], true);
|
|
914
|
+
$user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
|
|
877
915
|
|
|
878
916
|
$driver = new FleetOpsApiDriverFake();
|
|
879
917
|
$driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'user-uuid'], true);
|
|
@@ -905,3 +943,47 @@ test('api driver controller only expands relationships the public contract allow
|
|
|
905
943
|
// name is dropped rather than reaching Eloquent, where it would be a 500.
|
|
906
944
|
expect($request->input('with'))->toBe(['vehicle', 'currentJob']);
|
|
907
945
|
});
|
|
946
|
+
|
|
947
|
+
test('api driver controller answers 422 when the email or phone belongs to another account', function () {
|
|
948
|
+
$create = new FleetOpsApiDriverControllerProbe();
|
|
949
|
+
$create->accountConflict = new Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException('This email is already in use by another account.');
|
|
950
|
+
|
|
951
|
+
expect($create->create(new CreateDriverRequest(['name' => 'Driver One', 'email' => 'taken@example.test'])))->toBe([
|
|
952
|
+
'json' => ['error' => 'This email is already in use by another account.'],
|
|
953
|
+
'status' => 422,
|
|
954
|
+
])
|
|
955
|
+
->and($create->createdDrivers)->toBe([]);
|
|
956
|
+
|
|
957
|
+
fleetopsApiDriverContractsUsersTable([['uuid' => 'other-user', 'type' => 'user', 'phone' => '+15550009999']]);
|
|
958
|
+
|
|
959
|
+
$user = new FleetOpsApiDriverUserFake();
|
|
960
|
+
$user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver', 'phone' => '+15550001111'], true);
|
|
961
|
+
$driver = new FleetOpsApiDriverFake();
|
|
962
|
+
$driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'user-uuid'], true);
|
|
963
|
+
$driver->userForTest = $user;
|
|
964
|
+
|
|
965
|
+
$update = new FleetOpsApiDriverControllerProbe();
|
|
966
|
+
$update->driver = $driver;
|
|
967
|
+
|
|
968
|
+
expect($update->update('driver_public', new UpdateDriverRequest(['phone' => '+1 555 000 9999'])))->toBe([
|
|
969
|
+
'json' => ['error' => 'This phone number is already in use by another account.'],
|
|
970
|
+
'status' => 422,
|
|
971
|
+
])
|
|
972
|
+
->and($user->updates)->toBe([])
|
|
973
|
+
->and($driver->updates)->toBe([]);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
test('api driver controller only renames a team member account linked to a driver', function () {
|
|
977
|
+
$staff = new FleetOpsApiDriverUserFake();
|
|
978
|
+
$staff->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'user', 'name' => 'Staff', 'email' => 'staff@example.test'], true);
|
|
979
|
+
$driver = new FleetOpsApiDriverFake();
|
|
980
|
+
$driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'staff-uuid'], true);
|
|
981
|
+
$driver->userForTest = $staff;
|
|
982
|
+
|
|
983
|
+
$update = new FleetOpsApiDriverControllerProbe();
|
|
984
|
+
$update->driver = $driver;
|
|
985
|
+
$update->update('driver_public', new UpdateDriverRequest(['name' => 'Staff Driver', 'email' => 'changed@example.test']));
|
|
986
|
+
|
|
987
|
+
expect($staff->updates)->toBe([['name' => 'Staff Driver']])
|
|
988
|
+
->and($staff->email)->toBe('staff@example.test');
|
|
989
|
+
});
|