From 1765536fff932f3090150fe6cdf813b672fcb0f0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 00:51:09 +0800 Subject: [PATCH 1/4] feat: manage driver, customer and contact login accounts from the profile Driver, customer and contact login accounts are now fully managed on the server through their profile. The console never selects or creates a user for them. Server: - ProfileAccountManager resolves a profile's account: - a team member of the organization with the same email or phone is linked (staff-linked profile); - a managed account of the same type is reused; - otherwise a managed driver, contact or customer account is created, with its role and no organization invite; - it pushes the profile's name/email/phone to the account, and a staff account only takes the name; - on profile delete it deletes a managed account with no other profile, which frees its email and phone; - it sends credentials by email, or by SMS when there is no email. - Driver create/update (internal and public API) and Contact go through the manager. The internal API ignores user_uuid, and email/phone conflicts return 422. - New internal endpoints for drivers: send-credentials, reset-credentials, deactivate-login (also revokes app tokens) and reactivate-login. - New profile-identity lookup for the form hint. - Deactivated driver and customer logins are refused by the app login, SMS login, code verification and password reset endpoints. - Customer credential actions refuse staff-linked customers. Driver and contact resources expose is_staff_linked and login_status. - A migration converts existing `user` accounts that only hold the Driver or Fleet-Ops Customer role for their profile into managed accounts, and can be reverted. Console: - The driver form drops the user picker. It gets name, email and a PhoneInput, a hint when the email/phone belongs to a team member, and locked email/phone for staff-linked profiles. - Drivers get Reset Password, Send Credentials and Deactivate/Reactivate Login, like customers. - The shared login-action util and reset-profile-credentials modal are reused for drivers and customers. --- addon/components/contact/form.hbs | 15 +- addon/components/customer/form.hbs | 15 +- addon/components/customer/form.js | 56 -- addon/components/driver/form.hbs | 55 +- addon/components/driver/form.js | 58 -- addon/components/modals/driver-form.hbs | 4 +- .../modals/reset-customer-credentials.hbs | 18 - .../modals/reset-customer-credentials.js | 55 -- .../modals/reset-profile-credentials.hbs | 23 + .../modals/reset-profile-credentials.js | 70 ++ addon/components/profile-identity-hint.hbs | 13 + addon/components/profile-identity-hint.js | 49 ++ addon/controllers/management/drivers/index.js | 4 + addon/services/contact-actions.js | 139 ++-- addon/services/driver-actions.js | 98 +++ addon/utils/profile-login-actions.js | 172 +++++ ...ntials.js => reset-profile-credentials.js} | 2 +- app/components/profile-identity-hint.js | 1 + ...profile_only_users_to_managed_accounts.php | 138 ++++ .../views/mail/driver-credentials.blade.php | 21 + .../ProfileIdentityConflictException.php | 34 + .../Controllers/Api/v1/CustomerController.php | 14 + .../Controllers/Api/v1/DriverController.php | 95 ++- .../Internal/v1/ContactController.php | 22 +- .../Internal/v1/CustomerController.php | 44 +- .../Internal/v1/DriverController.php | 344 +++++---- .../Internal/v1/FleetOpsLookupController.php | 47 ++ .../src/Http/Requests/CreateDriverRequest.php | 8 +- .../Requests/Internal/CreateDriverRequest.php | 27 +- server/src/Http/Resources/v1/Contact.php | 3 + server/src/Http/Resources/v1/Driver.php | 3 + server/src/Mail/DriverCredentialsMail.php | 55 ++ server/src/Models/Contact.php | 145 ++-- server/src/Observers/ContactObserver.php | 24 +- server/src/Observers/DriverObserver.php | 11 +- server/src/Support/ProfileAccountManager.php | 405 ++++++++++ server/src/routes.php | 5 + .../ApiCustomerControllerContractsTest.php | 21 + .../ApiDriverControllerContractsTest.php | 118 ++- .../tests/ControllerHelperContractsTest.php | 14 +- .../Api/DriverControllerAuthFlowsTest.php | 36 + .../DriverControllerSessionHelpersTest.php | 25 +- ...ProfileOnlyUserConversionMigrationTest.php | 220 ++++++ .../ContactControllerEndpointsTest.php | 10 +- .../CustomerControllerCredentialsTest.php | 8 +- .../DriverControllerContractsTest.php | 23 + .../DriverControllerCreateRecordTest.php | 59 +- ...iverControllerExistingUserAdoptionTest.php | 494 ++++-------- .../DriverControllerLoginManagementTest.php | 716 ++++++++++++++++++ ...nternalCustomerControllerContractsTest.php | 33 +- server/tests/ModelAccessorContractsTest.php | 102 ++- server/tests/ObserverContractsTest.php | 29 +- server/tests/RequestContractsTest.php | 30 +- .../TelematicControllerContractsTest.php | 26 +- .../Unit/Models/ContactCustomerUserTest.php | 104 ++- server/tests/Unit/Models/ContactTest.php | 50 +- .../Unit/Models/SmallModelSurfacesTest.php | 63 +- .../Observers/ObserverListenerSeamsTest.php | 9 +- .../Support/ProfileAccountManagerTest.php | 643 ++++++++++++++++ .../components/customer/form-test.js | 18 +- .../modals/reset-customer-credentials-test.js | 26 - .../modals/reset-profile-credentials-test.js | 61 ++ tests/unit/services/driver-actions-test.js | 53 ++ translations/en-us.yaml | 36 +- 64 files changed, 4128 insertions(+), 1191 deletions(-) delete mode 100644 addon/components/modals/reset-customer-credentials.hbs delete mode 100644 addon/components/modals/reset-customer-credentials.js create mode 100644 addon/components/modals/reset-profile-credentials.hbs create mode 100644 addon/components/modals/reset-profile-credentials.js create mode 100644 addon/components/profile-identity-hint.hbs create mode 100644 addon/components/profile-identity-hint.js create mode 100644 addon/utils/profile-login-actions.js rename app/components/modals/{reset-customer-credentials.js => reset-profile-credentials.js} (64%) create mode 100644 app/components/profile-identity-hint.js create mode 100644 server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php create mode 100644 server/resources/views/mail/driver-credentials.blade.php create mode 100644 server/src/Exceptions/ProfileIdentityConflictException.php create mode 100644 server/src/Mail/DriverCredentialsMail.php create mode 100644 server/src/Support/ProfileAccountManager.php create mode 100644 server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php create mode 100644 server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php create mode 100644 server/tests/Unit/Support/ProfileAccountManagerTest.php delete mode 100644 tests/integration/components/modals/reset-customer-credentials-test.js create mode 100644 tests/integration/components/modals/reset-profile-credentials-test.js diff --git a/addon/components/contact/form.hbs b/addon/components/contact/form.hbs index 3c9bf157b..40bfbc08d 100644 --- a/addon/components/contact/form.hbs +++ b/addon/components/contact/form.hbs @@ -30,11 +30,22 @@ - + - + + {{#if @resource.is_staff_linked}} +
{{t "profile-account.staff-linked-helper"}}
+ {{else if @resource.isNew}} + + {{/if}} diff --git a/addon/components/customer/form.hbs b/addon/components/customer/form.hbs index 94a7ef342..1909b192a 100644 --- a/addon/components/customer/form.hbs +++ b/addon/components/customer/form.hbs @@ -37,11 +37,22 @@ - + - + + {{#if @resource.is_staff_linked}} +
{{t "profile-account.staff-linked-helper"}}
+ {{else if @resource.isNew}} + + {{/if}} diff --git a/addon/components/customer/form.js b/addon/components/customer/form.js index 2ebe34e75..9046258ce 100644 --- a/addon/components/customer/form.js +++ b/addon/components/customer/form.js @@ -1,71 +1,15 @@ 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'; export default class CustomerFormComponent extends Component { @service customerActions; - @service store; @service fetch; @service currentUser; @service notifications; - @service modalsManager; @service('universe/extension-manager') extensionManager; - @tracked userAccountActionButtons = [ - { - icon: 'user-plus', - size: 'xs', - permission: 'iam create user', - onClick: async () => { - // Load IAM engine for user-form modal component - await this.extensionManager.ensureEngineLoaded('@fleetbase/iam-engine'); - - const user = this.store.createRecord('user', { - status: 'pending', - type: 'user', - }); - - this.modalsManager.show('modals/user-form', { - title: 'Create a new user', - user, - formPermission: 'iam create user', - uploadNewPhoto: (file) => { - this.fetch.uploadFile.perform( - file, - { - path: `uploads/${this.currentUser.companyId}/users/${user.slug}`, - subject_uui: user.id, - subject_type: 'user', - type: 'user_photo', - }, - (uploadedFile) => { - user.setProperties({ - avatar_uuid: uploadedFile.id, - avatar_url: uploadedFile.url, - avatar: uploadedFile, - }); - } - ); - }, - confirm: async (modal) => { - modal.startLoading(); - - try { - await user.save(); - this.notifications.success('New user created successfully!'); - modal.done(); - } catch (error) { - this.notifications.serverError(error); - modal.stopLoading(); - } - }, - }); - }, - }, - ]; - get showWelcomeEmailOption() { return this.args.resource?.isNew && this.extensionManager.isInstalled('@fleetbase/customer-portal-engine'); } diff --git a/addon/components/driver/form.hbs b/addon/components/driver/form.hbs index 870936552..1103fdcef 100644 --- a/addon/components/driver/form.hbs +++ b/addon/components/driver/form.hbs @@ -1,39 +1,4 @@
- -
- - - - - - {{#if @resource.user}} -
- - - - - - - - - -
- {{/if}} -
-
-
@@ -65,6 +30,26 @@
+ + + + + + + + + + {{#if @resource.is_staff_linked}} +
{{t "profile-account.staff-linked-helper"}}
+ {{else if @resource.isNew}} + + {{/if}} diff --git a/addon/components/driver/form.js b/addon/components/driver/form.js index 1bc13c38b..56dde37e9 100644 --- a/addon/components/driver/form.js +++ b/addon/components/driver/form.js @@ -3,67 +3,9 @@ import { inject as service } from '@ember/service'; import { task } from 'ember-concurrency'; export default class DriverFormComponent extends Component { - @service store; @service fetch; @service currentUser; @service notifications; - @service modalsManager; - @service('universe/extension-manager') extensionManager; - - get userAccountActionButtons() { - return [ - { - icon: 'user-plus', - size: 'xs', - permission: 'iam create user', - onClick: async () => { - // Load IAM engine for user-form modal component - await this.extensionManager.ensureEngineLoaded('@fleetbase/iam-engine'); - - const user = this.store.createRecord('user', { - status: 'pending', - type: 'user', - }); - - this.modalsManager.show('modals/user-form', { - title: 'Create a new user', - user, - formPermission: 'iam create user', - uploadNewPhoto: (file) => { - this.fetch.uploadFile.perform( - file, - { - path: `uploads/${this.currentUser.companyId}/users/${user.slug}`, - subject_uui: user.id, - subject_type: 'user', - type: 'user_photo', - }, - (uploadedFile) => { - user.setProperties({ - avatar_uuid: uploadedFile.id, - avatar_url: uploadedFile.url, - avatar: uploadedFile, - }); - } - ); - }, - confirm: async (modal) => { - modal.startLoading(); - - try { - await user.save(); - this.notifications.success('New user created successfully!'); - modal.done(); - } catch (error) { - this.notifications.serverError(error); - modal.stopLoading(); - } - }, - }); - }, - }, - ]; - } @task *handlePhotoUpload(file) { try { diff --git a/addon/components/modals/driver-form.hbs b/addon/components/modals/driver-form.hbs index bd9db1807..a079f248e 100644 --- a/addon/components/modals/driver-form.hbs +++ b/addon/components/modals/driver-form.hbs @@ -41,14 +41,14 @@ - +
- +
diff --git a/addon/components/modals/reset-customer-credentials.hbs b/addon/components/modals/reset-customer-credentials.hbs deleted file mode 100644 index 34614779e..000000000 --- a/addon/components/modals/reset-customer-credentials.hbs +++ /dev/null @@ -1,18 +0,0 @@ - -
-
- You are about to reset the password for - {{this.customer.name}} -
-
Please enter the new password and confirm it below. You have the option to send the new credentials to the customer via email by selecting the checkbox.
- -
- - -
-
- - - -
-
\ No newline at end of file diff --git a/addon/components/modals/reset-customer-credentials.js b/addon/components/modals/reset-customer-credentials.js deleted file mode 100644 index 92d6d70ad..000000000 --- a/addon/components/modals/reset-customer-credentials.js +++ /dev/null @@ -1,55 +0,0 @@ -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { inject as service } from '@ember/service'; - -const INTERNAL_NAMESPACE = 'int/v1'; - -export default class ModalsResetCustomerCredentialsComponent extends Component { - @service fetch; - @service notifications; - @tracked options = {}; - @tracked password; - @tracked confirmPassword; - @tracked sendCredentials = true; - @tracked customer; - - constructor(owner, { options }) { - super(...arguments); - this.customer = options.customer; - this.options = options; - this.setupOptions(); - } - - setupOptions() { - this.options.title = 'Reset Customer Credentials'; - this.options.acceptButtonText = 'Reset Credentials'; - this.options.declineButtonHidden = true; - this.options.confirm = async (modal) => { - modal.startLoading(); - - try { - await this.fetch.post( - 'customers/reset-credentials', - { - customer: this.customer.id, - password: this.password, - password_confirmation: this.confirmPassword, - send_credentials: this.sendCredentials, - }, - { namespace: INTERNAL_NAMESPACE } - ); - - this.notifications.success('Customer password reset.'); - - if (typeof this.options.onPasswordResetComplete === 'function') { - this.options.onPasswordResetComplete(); - } - - modal.done(); - } catch (error) { - this.notifications.serverError(error); - modal.stopLoading(); - } - }; - } -} diff --git a/addon/components/modals/reset-profile-credentials.hbs b/addon/components/modals/reset-profile-credentials.hbs new file mode 100644 index 000000000..e6374e0dc --- /dev/null +++ b/addon/components/modals/reset-profile-credentials.hbs @@ -0,0 +1,23 @@ + +
+
+ {{t "profile-account.prompts.reset-password-heading" name=this.profile.name}} +
+
{{t "profile-account.prompts.reset-password-body"}}
+ +
+ + +
+
+ + + +
+
\ No newline at end of file diff --git a/addon/components/modals/reset-profile-credentials.js b/addon/components/modals/reset-profile-credentials.js new file mode 100644 index 000000000..2c8cbfc90 --- /dev/null +++ b/addon/components/modals/reset-profile-credentials.js @@ -0,0 +1,70 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { INTERNAL_NAMESPACE, updateProfileFromResponse } from '../../utils/profile-login-actions'; + +/** + * Resets the login password of a driver, contact or customer profile. + * + * Options: + * - `profile`: the driver or contact record + * - `endpoint`: the reset credentials endpoint (internal namespace) + * - `payload`: extra body params, or a function returning them + * - `onPasswordResetComplete(response)`: optional callback + */ +export default class ModalsResetProfileCredentialsComponent extends Component { + @service fetch; + @service intl; + @service notifications; + @tracked options = {}; + @tracked password; + @tracked confirmPassword; + @tracked sendCredentials = true; + @tracked profile; + + constructor(owner, { options }) { + super(...arguments); + this.profile = options.profile; + this.options = options; + this.setupOptions(); + } + + get extraPayload() { + const { payload } = this.options; + return (typeof payload === 'function' ? payload(this.profile) : payload) ?? {}; + } + + setupOptions() { + this.options.title = this.options.title ?? this.intl.t('profile-account.prompts.reset-password-title'); + this.options.acceptButtonText = this.options.acceptButtonText ?? this.intl.t('profile-account.prompts.reset-password-accept'); + this.options.declineButtonHidden = true; + this.options.confirm = async (modal) => { + modal.startLoading(); + + try { + const response = await this.fetch.post( + this.options.endpoint, + { + ...this.extraPayload, + password: this.password, + password_confirmation: this.confirmPassword, + send_credentials: this.sendCredentials, + }, + { namespace: INTERNAL_NAMESPACE } + ); + + updateProfileFromResponse(this.profile, response); + this.notifications.success(this.intl.t('profile-account.prompts.reset-password-success')); + + if (typeof this.options.onPasswordResetComplete === 'function') { + this.options.onPasswordResetComplete(response); + } + + modal.done(); + } catch (error) { + this.notifications.serverError(error); + modal.stopLoading(); + } + }; + } +} diff --git a/addon/components/profile-identity-hint.hbs b/addon/components/profile-identity-hint.hbs new file mode 100644 index 000000000..34e46171d --- /dev/null +++ b/addon/components/profile-identity-hint.hbs @@ -0,0 +1,13 @@ +
+ {{#if this.conflict}} +
+ + {{this.conflict}} +
+ {{else if this.staff}} +
+ + {{t "profile-account.will-link-to-staff" name=(or this.staff.name this.staff.email this.staff.phone)}} +
+ {{/if}} +
\ No newline at end of file diff --git a/addon/components/profile-identity-hint.js b/addon/components/profile-identity-hint.js new file mode 100644 index 000000000..b07a39bb9 --- /dev/null +++ b/addon/components/profile-identity-hint.js @@ -0,0 +1,49 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task, timeout } from 'ember-concurrency'; + +/** + * Inline hint shown while creating a driver, contact or customer profile: + * tells the user when the entered email/phone belongs to an existing team + * member (the profile will be linked to that account) or cannot be used. + * + * Args: `@email`, `@phone`, `@type` ('driver' | 'customer' | 'contact'), + * `@ignore` (optional profile id). + */ +export default class ProfileIdentityHintComponent extends Component { + @service fetch; + @tracked staff = null; + @tracked conflict = null; + + @action lookupIdentity() { + this.lookup.perform(); + } + + @task({ restartable: true }) *lookup() { + const { email, phone, type, ignore } = this.args; + + if (!email && !phone) { + this.staff = null; + this.conflict = null; + return; + } + + yield timeout(400); + + const query = { type }; + if (email) query.email = email; + if (phone) query.phone = phone; + if (ignore) query.ignore = ignore; + + try { + const response = yield this.fetch.get('fleet-ops/lookup/profile-identity', query, { namespace: 'int/v1' }); + this.staff = response?.staff ?? null; + this.conflict = response?.conflict ?? null; + } catch { + this.staff = null; + this.conflict = null; + } + } +} diff --git a/addon/controllers/management/drivers/index.js b/addon/controllers/management/drivers/index.js index 70ff9d2db..9762122d6 100644 --- a/addon/controllers/management/drivers/index.js +++ b/addon/controllers/management/drivers/index.js @@ -358,6 +358,10 @@ export default class ManagementDriversIndexController extends Controller { { separator: true, }, + ...this.driverActions.accountRowActionItems(), + { + separator: true, + }, { label: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.driver') }), icon: 'trash', diff --git a/addon/services/contact-actions.js b/addon/services/contact-actions.js index b76d2b3e7..7021bd5cc 100644 --- a/addon/services/contact-actions.js +++ b/addon/services/contact-actions.js @@ -1,9 +1,8 @@ import ResourceActionService from '@fleetbase/ember-core/services/resource-action'; import { inject as service } from '@ember/service'; -import { action, get } from '@ember/object'; +import { action } from '@ember/object'; import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel'; - -const INTERNAL_NAMESPACE = 'int/v1'; +import { confirmLoginAction, hasInactiveLogin, hasLinkedUser, hasManagedLogin, linkedUserStatus, updateProfileFromResponse } from '../utils/profile-login-actions'; export default class ContactActionsService extends ResourceActionService { @service fetch; @@ -124,17 +123,23 @@ export default class ContactActionsService extends ResourceActionService { } hasLinkedUser(contact) { - const user = contact?.user; - return Boolean(contact?.user_uuid || (user && (get(user, 'id') || get(user, 'uuid')))); + return hasLinkedUser(contact); + } + + /** + * True when the contact has a login account managed from Fleet-Ops. A + * contact owned by a staff member is managed from IAM instead. + */ + hasManagedLogin(contact) { + return hasManagedLogin(contact); } linkedUserStatus(contact) { - const user = contact?.user; - return (user ? (get(user, 'status') ?? get(user, 'session_status')) : null) ?? 'active'; + return linkedUserStatus(contact); } hasInactiveLogin(contact) { - return ['inactive', 'disabled', 'suspended'].includes(this.linkedUserStatus(contact)); + return hasInactiveLogin(contact); } isCustomerPortalInstalled() { @@ -156,7 +161,7 @@ export default class ContactActionsService extends ResourceActionService { } accountActionButton(contact, options = {}) { - if (!this.hasLinkedUser(contact)) { + if (!this.hasManagedLogin(contact)) { return null; } @@ -171,23 +176,23 @@ export default class ContactActionsService extends ResourceActionService { accountActionItems(contact, options = {}) { const actions = [ { - text: 'Reset Password', + text: this.intl.t('profile-account.actions.reset-password'), icon: 'key', fn: () => this.openResetPasswordModal(contact), }, { - text: 'Send Credentials', + text: this.intl.t('profile-account.actions.send-credentials'), icon: 'paper-plane', fn: () => this.confirmSendCredentials(contact), }, this.hasInactiveLogin(contact) ? { - text: 'Reactivate Login', + text: this.intl.t('profile-account.actions.reactivate-login'), icon: 'unlock', fn: () => this.confirmReactivatePortalLogin(contact), } : { - text: 'Deactivate Login', + text: this.intl.t('profile-account.actions.deactivate-login'), icon: 'lock', fn: () => this.confirmDeactivatePortalLogin(contact), class: 'text-red-500 hover:text-red-600', @@ -200,7 +205,7 @@ export default class ContactActionsService extends ResourceActionService { separator: true, }, { - text: 'Convert to Vendor', + text: this.intl.t('profile-account.actions.convert-to-vendor'), icon: 'building', fn: () => this.openConvertToVendorModal(contact, options), } @@ -212,42 +217,42 @@ export default class ContactActionsService extends ResourceActionService { accountRowActionItems(options = {}) { const hasCustomerPortal = this.isCustomerPortalInstalled(); - const hasLinkedUser = (contact) => this.hasLinkedUser(contact); + const hasManagedLogin = (contact) => this.hasManagedLogin(contact); const actions = [ { - label: 'Reset Password', + label: this.intl.t('profile-account.actions.reset-password'), icon: 'key', fn: (contact) => this.openResetPasswordModal(contact), - isVisible: hasLinkedUser, + isVisible: hasManagedLogin, }, { - label: 'Send Credentials', + label: this.intl.t('profile-account.actions.send-credentials'), icon: 'paper-plane', fn: (contact) => this.confirmSendCredentials(contact), - isVisible: hasLinkedUser, + isVisible: hasManagedLogin, }, { - label: 'Deactivate Login', + label: this.intl.t('profile-account.actions.deactivate-login'), icon: 'lock', class: 'text-red-500 hover:text-red-600', fn: (contact) => this.confirmDeactivatePortalLogin(contact), - isVisible: (contact) => hasLinkedUser(contact) && !this.hasInactiveLogin(contact), + isVisible: (contact) => hasManagedLogin(contact) && !this.hasInactiveLogin(contact), }, { - label: 'Reactivate Login', + label: this.intl.t('profile-account.actions.reactivate-login'), icon: 'unlock', fn: (contact) => this.confirmReactivatePortalLogin(contact), - isVisible: (contact) => hasLinkedUser(contact) && this.hasInactiveLogin(contact), + isVisible: (contact) => hasManagedLogin(contact) && this.hasInactiveLogin(contact), }, ]; if (hasCustomerPortal) { actions.push({ - label: 'Convert to Vendor', + label: this.intl.t('profile-account.actions.convert-to-vendor'), icon: 'building', fn: (contact) => this.openConvertToVendorModal(contact, options), - isVisible: hasLinkedUser, + isVisible: hasManagedLogin, }); } @@ -255,8 +260,10 @@ export default class ContactActionsService extends ResourceActionService { } openResetPasswordModal(contact) { - this.modalsManager.show('modals/reset-customer-credentials', { - customer: contact, + this.modalsManager.show('modals/reset-profile-credentials', { + profile: contact, + endpoint: 'customers/reset-credentials', + payload: (profile) => ({ customer: profile?.id }), }); } @@ -278,86 +285,40 @@ export default class ContactActionsService extends ResourceActionService { confirmSendCredentials(contact) { this.confirmLoginAction(contact, { - title: 'Send Portal Credentials', - body: 'Generate a new temporary password and email this contact their portal credentials.', - acceptButtonText: 'Send Credentials', + title: this.intl.t('profile-account.prompts.send-portal-credentials-title'), + body: this.intl.t('profile-account.prompts.send-portal-credentials-body'), + acceptButtonText: this.intl.t('profile-account.actions.send-credentials'), endpoint: 'customers/send-credentials', - successMessage: 'Portal credentials sent.', + successMessage: this.intl.t('profile-account.prompts.send-portal-credentials-success'), }); } confirmDeactivatePortalLogin(contact) { this.confirmLoginAction(contact, { - title: 'Deactivate Login', - body: 'Deactivate portal access for this contact. Their profile and history will be preserved.', - acceptButtonText: 'Deactivate Login', + title: this.intl.t('profile-account.actions.deactivate-login'), + body: this.intl.t('profile-account.prompts.deactivate-portal-login-body'), + acceptButtonText: this.intl.t('profile-account.actions.deactivate-login'), acceptButtonScheme: 'danger', endpoint: 'customers/deactivate-portal-login', - successMessage: 'Portal login deactivated.', + successMessage: this.intl.t('profile-account.prompts.deactivate-portal-login-success'), }); } confirmReactivatePortalLogin(contact) { this.confirmLoginAction(contact, { - title: 'Reactivate Login', - body: 'Reactivate portal access for this contact.', - acceptButtonText: 'Reactivate Login', + title: this.intl.t('profile-account.actions.reactivate-login'), + body: this.intl.t('profile-account.prompts.reactivate-portal-login-body'), + acceptButtonText: this.intl.t('profile-account.actions.reactivate-login'), endpoint: 'customers/reactivate-portal-login', - successMessage: 'Portal login reactivated.', + successMessage: this.intl.t('profile-account.prompts.reactivate-portal-login-success'), }); } - confirmLoginAction(contact, { title, body, acceptButtonText, acceptButtonScheme, endpoint, successMessage }) { - this.modalsManager.confirm({ - title, - body, - acceptButtonText, - acceptButtonScheme, - confirm: async (modal) => { - modal.startLoading(); - - try { - const response = await this.fetch.post(endpoint, { customer: contact?.id }, { namespace: INTERNAL_NAMESPACE }); - await this.updateContactFromResponse(contact, response); - this.notifications.success(successMessage); - modal.done(); - } catch (error) { - this.notifications.serverError(error); - modal.stopLoading(); - } - }, - }); + confirmLoginAction(contact, options = {}) { + return confirmLoginAction(this, contact, { payload: { customer: contact?.id }, ...options }); } - async updateContactFromResponse(contact, response) { - const payload = response.customer ?? response.contact; - - if (!payload || typeof contact?.setProperties !== 'function') { - return; - } - - contact.setProperties( - this.withoutIdentityFields({ - user_uuid: payload.user_uuid, - }) - ); - - if (payload.user) { - const user = await contact.user; - - if (typeof user?.setProperties === 'function') { - user.setProperties(this.withoutIdentityFields(payload.user)); - } - } - } - - withoutIdentityFields(payload = {}) { - const attributes = { ...payload }; - - delete attributes.id; - delete attributes.uuid; - delete attributes.public_id; - - return attributes; + updateContactFromResponse(contact, response) { + return updateProfileFromResponse(contact, response); } } diff --git a/addon/services/driver-actions.js b/addon/services/driver-actions.js index 5252f64cf..7a4692579 100644 --- a/addon/services/driver-actions.js +++ b/addon/services/driver-actions.js @@ -5,6 +5,7 @@ import { action } from '@ember/object'; import { isArray } from '@ember/array'; import { dasherize } from '@ember/string'; import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel'; +import { confirmLoginAction, hasInactiveLogin, hasManagedLogin } from '../utils/profile-login-actions'; export default class DriverActionsService extends ResourceActionService { @service('universe/menu-service') menuService; @@ -422,9 +423,106 @@ export default class DriverActionsService extends ResourceActionService { { separator: true }, { text: this.intl.t('driver.actions.locate-driver'), icon: 'location-dot', fn: () => this.locate(driver), permission: 'fleet-ops view driver' }, { text: this.intl.t('driver.actions.create-issue'), icon: 'triangle-exclamation', fn: () => this.createIssue(driver), permission: 'fleet-ops create issue' }, + ...this.accountMenuItems(driver), ]; } + /** + * The login account section of a driver's actions menu. Only offered when + * the driver has a login managed from Fleet-Ops; a staff-linked driver's + * login is managed from IAM. + */ + accountMenuItems(driver) { + if (!hasManagedLogin(driver)) { + return []; + } + + const items = this.accountRowActionItems() + .filter(({ isVisible }) => isVisible(driver)) + // eslint-disable-next-line no-unused-vars + .map(({ label, fn, isVisible, ...item }) => ({ ...item, text: label, fn: () => fn(driver) })); + + return [{ separator: true }, ...items]; + } + + /** + * Login account actions for the drivers table row menu. + */ + accountRowActionItems() { + const permission = 'fleet-ops update driver'; + + return [ + { + label: this.intl.t('profile-account.actions.reset-password'), + icon: 'key', + fn: (driver) => this.resetCredentials(driver), + permission, + isVisible: (driver) => hasManagedLogin(driver), + }, + { + label: this.intl.t('profile-account.actions.send-credentials'), + icon: 'paper-plane', + fn: (driver) => this.sendCredentials(driver), + permission, + isVisible: (driver) => hasManagedLogin(driver), + }, + { + label: this.intl.t('profile-account.actions.deactivate-login'), + icon: 'lock', + class: 'text-red-500 hover:text-red-600', + fn: (driver) => this.deactivateLogin(driver), + permission, + isVisible: (driver) => hasManagedLogin(driver) && !hasInactiveLogin(driver), + }, + { + label: this.intl.t('profile-account.actions.reactivate-login'), + icon: 'unlock', + fn: (driver) => this.reactivateLogin(driver), + permission, + isVisible: (driver) => hasManagedLogin(driver) && hasInactiveLogin(driver), + }, + ]; + } + + @action resetCredentials(driver, options = {}) { + return this.modalsManager.show('modals/reset-profile-credentials', { + profile: driver, + endpoint: `drivers/${driver.id}/reset-credentials`, + ...options, + }); + } + + @action sendCredentials(driver) { + return confirmLoginAction(this, driver, { + title: this.intl.t('profile-account.prompts.send-credentials-title'), + body: this.intl.t('profile-account.prompts.send-credentials-body', { name: driver.name }), + acceptButtonText: this.intl.t('profile-account.actions.send-credentials'), + endpoint: `drivers/${driver.id}/send-credentials`, + successMessage: this.intl.t('profile-account.prompts.send-credentials-success', { name: driver.name }), + }); + } + + @action deactivateLogin(driver) { + return confirmLoginAction(this, driver, { + title: this.intl.t('profile-account.actions.deactivate-login'), + body: this.intl.t('profile-account.prompts.deactivate-login-body', { name: driver.name }), + acceptButtonText: this.intl.t('profile-account.actions.deactivate-login'), + acceptButtonScheme: 'danger', + endpoint: `drivers/${driver.id}/deactivate-login`, + successMessage: this.intl.t('profile-account.prompts.deactivate-login-success', { name: driver.name }), + }); + } + + @action reactivateLogin(driver) { + return confirmLoginAction(this, driver, { + title: this.intl.t('profile-account.actions.reactivate-login'), + body: this.intl.t('profile-account.prompts.reactivate-login-body', { name: driver.name }), + acceptButtonText: this.intl.t('profile-account.actions.reactivate-login'), + endpoint: `drivers/${driver.id}/reactivate-login`, + successMessage: this.intl.t('profile-account.prompts.reactivate-login-success', { name: driver.name }), + }); + } + @action createIssue(driver) { return this.issueActions.modal.create({ driver, diff --git a/addon/utils/profile-login-actions.js b/addon/utils/profile-login-actions.js new file mode 100644 index 000000000..16b04e181 --- /dev/null +++ b/addon/utils/profile-login-actions.js @@ -0,0 +1,172 @@ +import { get } from '@ember/object'; + +/** + * Shared helpers for the login account behind a driver, contact or customer + * profile. The account itself is created, updated and removed by the server + * from the profile's name/email/phone; the console only offers the account + * actions (reset password, send credentials, deactivate/reactivate login). + * + * A profile owned by a staff member (`is_staff_linked`) has its login + * managed from IAM, so no account actions are offered for it here. + */ + +export const INTERNAL_NAMESPACE = 'int/v1'; +export const INACTIVE_LOGIN_STATUSES = ['inactive', 'disabled', 'suspended']; + +function read(subject, key) { + if (!subject) { + return undefined; + } + + try { + return get(subject, key); + } catch { + return subject[key]; + } +} + +/** + * The linked user record when it is already loaded, without triggering a + * fetch for an async relationship. + */ +export function loadedUser(profile) { + if (typeof profile?.belongsTo === 'function') { + try { + return profile.belongsTo('user').value(); + } catch { + return null; + } + } + + const user = read(profile, 'user'); + if (!user) { + return null; + } + + return typeof user.then === 'function' ? (read(user, 'content') ?? null) : user; +} + +export function isStaffLinked(profile) { + return Boolean(read(profile, 'is_staff_linked')); +} + +export function hasLinkedUser(profile) { + if (!profile) { + return false; + } + + if (read(profile, 'user_uuid') || read(profile, 'login_status')) { + return true; + } + + const user = loadedUser(profile); + return Boolean(user && (read(user, 'id') || read(user, 'uuid'))); +} + +/** + * True when the profile has a login account that is managed from Fleet-Ops, + * i.e. a linked user which is not a staff member's account. + */ +export function hasManagedLogin(profile) { + return hasLinkedUser(profile) && !isStaffLinked(profile); +} + +/** + * The login status, preferring the server-provided `login_status` over the + * embedded user's status. + */ +export function linkedUserStatus(profile) { + const loginStatus = read(profile, 'login_status'); + if (loginStatus) { + return loginStatus; + } + + const user = loadedUser(profile); + return (user ? (read(user, 'status') ?? read(user, 'session_status')) : null) ?? 'active'; +} + +export function hasInactiveLogin(profile) { + return INACTIVE_LOGIN_STATUSES.includes(linkedUserStatus(profile)); +} + +export function withoutIdentityFields(payload = {}) { + const attributes = { ...payload }; + + delete attributes.id; + delete attributes.uuid; + delete attributes.public_id; + + return attributes; +} + +/** + * Applies the profile payload returned by an account action endpoint + * (`{ driver: {...} }`, `{ customer: {...} }` or `{ contact: {...} }`). + */ +export function updateProfileFromResponse(profile, response = {}) { + const payload = response?.driver ?? response?.customer ?? response?.contact; + + if (!payload || typeof profile?.setProperties !== 'function') { + return; + } + + const attributes = {}; + if ('user_uuid' in payload) { + attributes.user_uuid = payload.user_uuid; + } + if ('is_staff_linked' in payload) { + attributes.is_staff_linked = Boolean(payload.is_staff_linked); + } + if ('login_status' in payload) { + attributes.login_status = payload.login_status; + } else if (payload.user && 'status' in payload.user) { + attributes.login_status = payload.user.status; + } + + profile.setProperties(attributes); + + if (payload.user) { + const user = loadedUser(profile); + + if (typeof user?.setProperties === 'function') { + user.setProperties(withoutIdentityFields(payload.user)); + } + } +} + +/** + * Opens a confirm modal which POSTs to an account action endpoint and applies + * the response to the profile. + * + * @param {Object} services { modalsManager, fetch, notifications } + * @param {Object} profile the driver or contact record + * @param {Object} options { title, body, acceptButtonText, acceptButtonScheme, endpoint, payload, successMessage, onSuccess } + */ +export function confirmLoginAction({ modalsManager, fetch, notifications }, profile, options = {}) { + const { title, body, acceptButtonText, acceptButtonScheme, endpoint, payload = {}, successMessage, onSuccess } = options; + + return modalsManager.confirm({ + title, + body, + acceptButtonText, + acceptButtonScheme, + confirm: async (modal) => { + modal.startLoading(); + + try { + const response = await fetch.post(endpoint, payload, { namespace: INTERNAL_NAMESPACE }); + updateProfileFromResponse(profile, response); + notifications.success(successMessage); + + if (typeof onSuccess === 'function') { + onSuccess(response); + } + + modal.done(); + } catch (error) { + notifications.serverError(error); + modal.stopLoading(); + } + }, + }); +} diff --git a/app/components/modals/reset-customer-credentials.js b/app/components/modals/reset-profile-credentials.js similarity index 64% rename from app/components/modals/reset-customer-credentials.js rename to app/components/modals/reset-profile-credentials.js index 7a4e86563..d27dc8eb4 100644 --- a/app/components/modals/reset-customer-credentials.js +++ b/app/components/modals/reset-profile-credentials.js @@ -1 +1 @@ -export { default } from '@fleetbase/fleetops-engine/components/modals/reset-customer-credentials'; +export { default } from '@fleetbase/fleetops-engine/components/modals/reset-profile-credentials'; diff --git a/app/components/profile-identity-hint.js b/app/components/profile-identity-hint.js new file mode 100644 index 000000000..0dd7930f7 --- /dev/null +++ b/app/components/profile-identity-hint.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/profile-identity-hint'; diff --git a/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php b/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php new file mode 100644 index 000000000..8d4579d78 --- /dev/null +++ b/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php @@ -0,0 +1,138 @@ + 'Driver', + 'customer' => 'Fleet-Ops Customer', + ]; + + public function up(): void + { + if (!$this->hasRequiredTables()) { + return; + } + + $converted = ['driver' => 0, 'customer' => 0]; + + DB::table('users') + ->select(['uuid', 'meta']) + ->where('type', 'user') + ->whereNull('deleted_at') + ->where(function ($query) { + $query->whereExists(function ($query) { + $query->selectRaw(1)->from('drivers')->whereColumn('drivers.user_uuid', 'users.uuid')->whereNull('drivers.deleted_at'); + })->orWhereExists(function ($query) { + $query->selectRaw(1)->from('contacts')->whereColumn('contacts.user_uuid', 'users.uuid')->where('contacts.type', 'customer')->whereNull('contacts.deleted_at'); + }); + }) + ->whereNotExists(function ($query) { + $query->selectRaw(1)->from('companies')->whereColumn('companies.owner_uuid', 'users.uuid'); + }) + ->orderBy('uuid') + ->chunk(500, function ($users) use (&$converted) { + foreach ($users as $user) { + $type = $this->managedTypeFor($user->uuid); + if (!$type) { + continue; + } + + $meta = json_decode($user->meta ?? '', true); + $meta = is_array($meta) ? $meta : []; + $meta['previous_type'] = 'user'; + + DB::table('users')->where('uuid', $user->uuid)->update(['type' => $type, 'meta' => json_encode($meta)]); + $converted[$type]++; + } + }); + + Log::info('[fleetops] Converted profile-only user accounts to managed accounts.', $converted); + } + + public function down(): void + { + if (!Schema::hasTable('users')) { + return; + } + + DB::table('users') + ->select(['uuid', 'meta']) + ->whereIn('type', array_keys(self::PROFILE_ROLES)) + ->where('meta', 'like', '%"previous_type"%') + ->orderBy('uuid') + ->chunk(500, function ($users) { + foreach ($users as $user) { + $meta = json_decode($user->meta ?? '', true); + if (!is_array($meta) || ($meta['previous_type'] ?? null) !== 'user') { + continue; + } + + unset($meta['previous_type']); + DB::table('users')->where('uuid', $user->uuid)->update(['type' => 'user', 'meta' => json_encode($meta)]); + } + }); + } + + /** + * The managed type for the account, or null when it should stay a team member. + */ + private function managedTypeFor(string $userUuid): ?string + { + $hasDriver = DB::table('drivers')->where('user_uuid', $userUuid)->whereNull('deleted_at')->exists(); + $hasCustomer = DB::table('contacts')->where('user_uuid', $userUuid)->where('type', 'customer')->whereNull('deleted_at')->exists(); + + // An account holding both kinds of profile is a person the organization + // relates to in two ways; leave it for an administrator to decide. + if ($hasDriver === $hasCustomer) { + return null; + } + + $type = $hasDriver ? 'driver' : 'customer'; + $companyUserUuids = DB::table('company_users')->where('user_uuid', $userUuid)->whereNull('deleted_at')->pluck('uuid'); + $modelUuids = $companyUserUuids->push($userUuid)->all(); + + $roles = DB::table('model_has_roles') + ->join('roles', 'roles.id', '=', 'model_has_roles.role_id') + ->whereIn('model_has_roles.model_uuid', $modelUuids) + ->pluck('roles.name') + ->unique(); + + if ($roles->isEmpty() || $roles->contains(fn ($role) => $role !== self::PROFILE_ROLES[$type])) { + return null; + } + + $hasPermissions = DB::table('model_has_permissions')->whereIn('model_uuid', $modelUuids)->exists(); + $hasPolicies = DB::table('model_has_policies')->whereIn('model_uuid', $modelUuids)->exists(); + + return $hasPermissions || $hasPolicies ? null : $type; + } + + private function hasRequiredTables(): bool + { + foreach (['users', 'drivers', 'contacts', 'companies', 'company_users', 'roles', 'model_has_roles', 'model_has_permissions', 'model_has_policies'] as $table) { + if (!Schema::hasTable($table)) { + return false; + } + } + + return true; + } +}; diff --git a/server/resources/views/mail/driver-credentials.blade.php b/server/resources/views/mail/driver-credentials.blade.php new file mode 100644 index 000000000..3236da9aa --- /dev/null +++ b/server/resources/views/mail/driver-credentials.blade.php @@ -0,0 +1,21 @@ + +

+Your driver sign-in details +

+ +Hi {{ $user->name }}, +
+
+{{ $companyName }} has set up your driver account. Use these details to sign in to the driver app. +
+
+Your sign-in details +
+Login: {{ $identity }} +
+Temporary password: {{ $plaintextPassword }} +
+
+You can change your password after signing in. + +
diff --git a/server/src/Exceptions/ProfileIdentityConflictException.php b/server/src/Exceptions/ProfileIdentityConflictException.php new file mode 100644 index 000000000..ebfb51d8b --- /dev/null +++ b/server/src/Exceptions/ProfileIdentityConflictException.php @@ -0,0 +1,34 @@ +field = $field; + } + + public function getField(): string + { + return $this->field; + } + + /** + * The conflict keyed by field, in the shape of a validation error bag. + */ + public function getErrors(): array + { + return [$this->field => [$this->getMessage()]]; + } +} diff --git a/server/src/Http/Controllers/Api/v1/CustomerController.php b/server/src/Http/Controllers/Api/v1/CustomerController.php index 6a37525a6..204dcd85b 100644 --- a/server/src/Http/Controllers/Api/v1/CustomerController.php +++ b/server/src/Http/Controllers/Api/v1/CustomerController.php @@ -44,6 +44,8 @@ class CustomerController extends Controller { use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass; + public const DEACTIVATED_LOGIN_MESSAGE = 'This customer login has been deactivated.'; + /* ============================================================ | Public auth flows (API credential only, no Customer-Token) * ============================================================ */ @@ -345,6 +347,10 @@ public function login(Request $request) return response()->apiError('Authentication failed using credentials provided.', 401); } + if ($user->status === 'inactive') { + return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403); + } + $sessionCompany = $this->sessionCompany(); if (!$sessionCompany) { return response()->apiError('No company resolved from API credential.', 500); @@ -381,6 +387,10 @@ public function loginWithPhone(Request $request) return response()->apiError('No customer with this phone number found.'); } + if ($user->status === 'inactive') { + return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403); + } + try { $this->generateSmsVerification($user, 'fleetops_customer_login', [ 'messageCallback' => fn ($verification) => 'Your ' . config('app.name') . ' verification code is ' . $verification->code, @@ -423,6 +433,10 @@ public function verifyCode(Request $request) return response()->apiError('Unable to verify code.'); } + if ($user->status === 'inactive') { + return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403); + } + $verificationCode = $this->verificationCodeExists([ 'subject_uuid' => $user->uuid, 'code' => $code, diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php index 8711e0e1a..1fbacbe1d 100644 --- a/server/src/Http/Controllers/Api/v1/DriverController.php +++ b/server/src/Http/Controllers/Api/v1/DriverController.php @@ -6,6 +6,7 @@ use Fleetbase\FleetOps\Events\GeofenceEntered; use Fleetbase\FleetOps\Events\GeofenceExited; use Fleetbase\FleetOps\Events\VehicleLocationChanged; +use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException; use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException; use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources; use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesPublicExpansions; @@ -22,6 +23,7 @@ use Fleetbase\FleetOps\Models\Vendor; use Fleetbase\FleetOps\Support\GeofenceIntersectionService; use Fleetbase\FleetOps\Support\OSRM; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Http\Requests\SwitchOrganizationRequest; @@ -47,6 +49,11 @@ class DriverController extends Controller use ResolvesFleetOpsApiResources; use ResolvesPublicExpansions; + /** + * Returned when a driver whose login was deactivated from the console tries to sign in. + */ + public const DEACTIVATED_LOGIN_MESSAGE = 'This driver login has been deactivated.'; + /** * Public expansion name => Eloquent relation name. * @@ -97,20 +104,14 @@ public function create(CreateDriverRequest $request) // Apply user infos $userDetails = $this->applyUserInfoFromRequest($request, $userDetails); - // Set company_uuid before creating user - $userDetails['company_uuid'] = $company->uuid; - - // create user account for driver - $user = $this->createUser($userDetails); - - // Assign company — the early return above guarantees $company is set - $user->assignCompany($company); - - // Set user type - $user->setUserType('driver'); - - // assign driver role - $user->assignSingleRole('Driver'); + // Resolve the driver's login account: a team member of the company with + // the email/phone is linked, otherwise a driver account is created and + // added to the company without an organization invite. + try { + $user = $this->resolveDriverAccount($company->uuid, $userDetails); + } catch (ProfileIdentityConflictException $exception) { + return $this->jsonResponse(['error' => $exception->getMessage()], 422); + } // set user id $input['user_uuid'] = $user->uuid; @@ -193,10 +194,11 @@ public function update($id, UpdateDriverRequest $request) // it. The driver's own record has no timezone column; the user's does. $userDetails = $request->only(['name', 'email', 'phone', 'timezone']); - // update driver user details - $driverUser = $driver->getUser(); - if ($driverUser) { - $driverUser->update($userDetails); + // update the driver's login account through its proxy fields + try { + ProfileAccountManager::syncProxyFields($driver->getUser(), $userDetails); + } catch (ProfileIdentityConflictException $exception) { + return $this->jsonResponse(['error' => $exception->getMessage()], 422); } // latitude / longitude @@ -519,10 +521,14 @@ function ($query) use ($identity) { )->whereHas('driver')->first(); // Check password to authenticate driver - if (!Hash::check($password, $user->password)) { + if (!$user || !is_string($user->password) || !Hash::check((string) $password, $user->password)) { return response()->apiError('Authentication failed using password provided.', 401); } + if (static::isLoginDeactivated($user)) { + return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403); + } + // Get the user's company for this driver profile $company = static::getDriverCompanyFromUser($user); @@ -561,6 +567,10 @@ public function loginWithPhone() return response()->apiError('No driver with this phone # found.'); } + if (static::isLoginDeactivated($user)) { + return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403); + } + // Get the user's company for this driver profile $company = static::getDriverCompanyFromUser($user); @@ -627,6 +637,10 @@ public function verifyCode(Request $request) return response()->apiError('Unable to verify code.'); } + if (static::isLoginDeactivated($user)) { + return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403); + } + // find and verify code $verificationCode = VerificationCode::where(['subject_uuid' => $user->uuid, 'code' => $code, 'for' => $for])->exists(); if (!$verificationCode && !static::verificationBypassMatches($identity, $code)) { @@ -975,26 +989,29 @@ protected function applyUserInfoFromRequest(Request $request, array $userDetails return User::applyUserInfoFromRequest($request, $userDetails); } - protected function createUser(array $userDetails): User + /** + * Resolve the login account for a driver created through the API. + * + * @throws ProfileIdentityConflictException + */ + protected function resolveDriverAccount(string $companyUuid, array $userDetails): User { - /* - * `password` is guarded on User, so mass assignment drops it without a - * word. The create endpoint has always accepted and validated one, so a - * driver created through the API could never sign in with the password - * their operator chose for them. Set it after the fact, where the - * model's mutator hashes it. - */ - $password = $userDetails['password'] ?? null; - unset($userDetails['password']); - - $user = User::create($userDetails); - - if (is_string($password) && strlen($password)) { - $user->password = $password; - $user->save(); - } + return ProfileAccountManager::resolveForProfile( + $companyUuid, + 'driver', + $userDetails['name'] ?? null, + $userDetails['email'] ?? null, + $userDetails['phone'] ?? null, + array_merge(Arr::except($userDetails, ['name', 'email', 'phone']), ['status' => 'active']) + ); + } - return $user; + /** + * Whether the driver's login was deactivated from the console. + */ + public static function isLoginDeactivated(User $user): bool + { + return $user->status === 'inactive'; } protected function getUuid(array|string $table, array $where, array $options = []): mixed @@ -1313,7 +1330,7 @@ public function forgotPassword(Request $request) } $user = static::findDriverUserByIdentity($identity); - if (!$user) { + if (!$user || static::isLoginDeactivated($user)) { return response()->json(['status' => 'ok']); } @@ -1344,7 +1361,7 @@ public function resetPassword(Request $request) } $user = static::findDriverUserByIdentity($identity); - if (!$user) { + if (!$user || static::isLoginDeactivated($user)) { return response()->apiError('Invalid or expired reset code.', 422); } diff --git a/server/src/Http/Controllers/Internal/v1/ContactController.php b/server/src/Http/Controllers/Internal/v1/ContactController.php index d0a8cad03..402fd4eb9 100644 --- a/server/src/Http/Controllers/Internal/v1/ContactController.php +++ b/server/src/Http/Controllers/Internal/v1/ContactController.php @@ -265,25 +265,13 @@ protected function customerPortalIssuesForContact(Contact $contact) ->get(); } + /** + * The login account is managed by the contact profile, so a user can't be + * picked or swapped from the console. + */ private function resolveUserInput(Request $request, array &$input): void { - $user = data_get($input, 'user_uuid') ?? data_get($input, 'user') ?? $request->input('contact.user_uuid') ?? $request->input('contact.user'); - - if (is_array($user)) { - $user = data_get($user, 'uuid') ?? data_get($user, 'id'); - } - - if (!$user) { - return; - } - - $input['user_uuid'] = $this->resolveUserUuid($user); - unset($input['user']); - } - - protected function resolveUserUuid(string $user): string - { - return User::where('uuid', $user)->orWhere('public_id', $user)->value('uuid') ?? $user; + unset($input['user_uuid'], $input['user']); } protected function assertCustomerPortalCanSendWelcomeEmail(array $input): void diff --git a/server/src/Http/Controllers/Internal/v1/CustomerController.php b/server/src/Http/Controllers/Internal/v1/CustomerController.php index 411c261bd..e17f4c4fd 100644 --- a/server/src/Http/Controllers/Internal/v1/CustomerController.php +++ b/server/src/Http/Controllers/Internal/v1/CustomerController.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Mail\CustomerCredentialsMail; use Fleetbase\FleetOps\Models\Contact; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Models\User; use Illuminate\Http\Request; @@ -21,6 +22,10 @@ public function createPortalLogin(Request $request) return response()->error('Unable to create customer portal login.'); } + if ($error = $this->staffLinkedError($user)) { + return $error; + } + if ($user->status !== 'active') { $user->activate(); } @@ -39,6 +44,10 @@ public function sendCredentials(Request $request) return response()->error('Unable to send customer portal credentials.'); } + if ($error = $this->staffLinkedError($user)) { + return $error; + } + $password = $this->randomPassword(); $user->changePassword($password); $this->sendCustomerCredentials($user, $password, $customer); @@ -57,6 +66,10 @@ public function deactivatePortalLogin(Request $request) return response()->error('Customer portal login not found.'); } + if ($error = $this->staffLinkedError($user)) { + return $error; + } + $user->deactivate(); return response()->json([ @@ -73,6 +86,10 @@ public function reactivatePortalLogin(Request $request) return response()->error('Customer portal login not found.'); } + if ($error = $this->staffLinkedError($user)) { + return $error; + } + $user->activate(); return response()->json([ @@ -149,6 +166,10 @@ public function resetCredentials(Request $request) return response()->error('Unable to reset customer credentials'); } + if ($error = $this->staffLinkedError($user)) { + return $error; + } + // Change password $user->changePassword($password); @@ -163,6 +184,19 @@ public function resetCredentials(Request $request) ]); } + /** + * A customer linked to a team member's account signs in with that account, + * which is managed in IAM. + */ + protected function staffLinkedError(User $user): ?\Illuminate\Http\JsonResponse + { + if (ProfileAccountManager::isStaffAccount($user)) { + return response()->error('This customer signs in with a team member account. Manage this login in IAM.', 422); + } + + return null; + } + protected function resolveCustomer(Request $request): Contact { $customerId = $request->input('customer'); @@ -213,10 +247,12 @@ protected function customerPayload(Contact $customer): array $customer->loadMissing('user'); return [ - 'id' => $customer->public_id, - 'uuid' => $customer->uuid, - 'user_uuid' => $customer->user_uuid, - 'user' => $customer->user ? [ + 'id' => $customer->public_id, + 'uuid' => $customer->uuid, + 'user_uuid' => $customer->user_uuid, + 'is_staff_linked' => ProfileAccountManager::isStaffAccount($customer->user), + 'login_status' => $customer->user?->status, + 'user' => $customer->user ? [ 'id' => $customer->user->public_id, 'uuid' => $customer->user->uuid, 'name' => $customer->user->name, diff --git a/server/src/Http/Controllers/Internal/v1/DriverController.php b/server/src/Http/Controllers/Internal/v1/DriverController.php index de272c6c1..26203e962 100644 --- a/server/src/Http/Controllers/Internal/v1/DriverController.php +++ b/server/src/Http/Controllers/Internal/v1/DriverController.php @@ -3,6 +3,7 @@ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1; use Fleetbase\Exceptions\FleetbaseRequestValidationException; +use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException; use Fleetbase\FleetOps\Exports\DriverExport; use Fleetbase\FleetOps\Http\Controllers\Api\v1\DriverController as ApiDriverController; use Fleetbase\FleetOps\Http\Controllers\FleetOpsController; @@ -13,11 +14,11 @@ use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Requests\ExportRequest; use Fleetbase\Http\Requests\ImportRequest; use Fleetbase\LaravelMysqlSpatial\Types\Point; -use Fleetbase\Models\Invite; use Fleetbase\Models\User; use Fleetbase\Models\VerificationCode; use Fleetbase\Support\Auth; @@ -58,72 +59,6 @@ public function createRecord(Request $request) $validator = Validator::make($input, $rules); if ($validator->fails()) { - // here if the user exists already - // within organization: offer to create driver record - // outside organization: invite to join organization AS DRIVER - if ($validator->errors()->hasAny(['phone', 'email'])) { - // get existing user - $existingUser = null; - - // if values provided for user lookup - if (!empty($input['phone']) || !empty($input['email'])) { - $existingUserQuery = User::query(); - - if (!empty($input['phone']) && is_string($input['phone'])) { - $existingUserQuery->orWhere(function ($q) use ($input) { - $q->where('phone', $input['phone'])->whereNotNull('phone'); - }); - } - - if (!empty($input['email']) && is_string($input['email'])) { - $existingUserQuery->orWhere(function ($q) use ($input) { - $q->where('email', $input['email'])->whereNotNull('email'); - }); - } - - $existingUser = $existingUserQuery->first(); - } - - if ($existingUser) { - // if exists in organization create driver profile for user - $isOrganizationMember = $existingUser->companies()->where('companies.uuid', session('company'))->exists(); - - // Check if driver profile also already exists - $existingDriverProfile = Driver::where(['company_uuid' => session('company'), 'user_uuid' => $existingUser->uuid])->first(); - if ($existingDriverProfile) { - return ['driver' => new $this->resource($existingDriverProfile)]; - } - - // create driver profile for user - $input = collect($input) - ->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid', 'status']) - ->filter() - ->toArray(); - - // Get current session company - $company = Auth::getCompany(); - $input['company_uuid'] = session('company', $company->uuid); - $input['user_uuid'] = $existingUser->uuid; - $input['slug'] = $existingUser->slug; - - // If no location provided set - if (empty($input['location'])) { - $input['location'] = new Point(0, 0); - } - - // create the profile - $driverProfile = Driver::create($input); - - // If not already a member of the company assign them to the company and send the user an invite - if (!$isOrganizationMember && $company) { - $existingUser->assignCompany($company); - } - - return ['driver' => new $this->resource($driverProfile)]; - } - } - - // check from validator object if phone or email is not unique return $createDriverRequest->responseWithErrors($validator); } @@ -134,104 +69,36 @@ function (&$request, &$input) { $input = collect($input); // Get current session company - $company = Auth::getCompany(); + $company = Auth::getCompany(); if (!$company) { throw new \Exception('Unable to create driver.'); } - if ($input->has('user_uuid')) { - $user = User::where('uuid', $input->get('user_uuid'))->first(); - - // Check if a driver profile already exists for this user in the current company - if ($user) { - $existingDriver = Driver::where(['user_uuid' => $user->uuid, 'company_uuid' => session('company')])->first(); - if ($existingDriver) { - throw new \Exception('This user account already belongs to a driver.'); - } - } - - // If user doesn't exist with provided UUID, create new user - if (!$user) { - $userInput = $input - ->only(['name', 'password', 'email', 'phone', 'status', 'avatar_uuid']) - ->filter() - ->toArray(); - - // handle `photo_uuid` - if (isset($input['photo_uuid']) && Str::isUuid($input['photo_uuid'])) { - $userInput['avatar_uuid'] = $input['photo_uuid']; - } - - // Make sure password is set - if (empty($userInput['password'])) { - $userInput['password'] = Str::random(14); - } - - // Set user company - $userInput['company_uuid'] = session('company', $company->uuid); - - // Apply user infos - $userInput = User::applyUserInfoFromRequest($request, $userInput); - - // Create user account - $user = User::create($userInput); - - // Set the user type to driver - $user->setType('driver'); - } elseif ($input->has('photo_uuid')) { - // Update existing user's avatar if photo provided - $user->update(['avatar_uuid' => $input->get('photo_uuid')]); - } - } else { - $userInput = $input - ->only(['name', 'password', 'email', 'phone', 'status', 'avatar_uuid']) - ->filter() - ->toArray(); - - // handle `photo_uuid` - if (isset($input['photo_uuid']) && Str::isUuid($input['photo_uuid'])) { - $userInput['avatar_uuid'] = $input['photo_uuid']; - } - - // Make sure password is set - if (empty($userInput['password'])) { - $userInput['password'] = Str::random(14); - } - - // Set user company - $userInput['company_uuid'] = session('company', $company->uuid); - - // Apply user infos - $userInput = User::applyUserInfoFromRequest($request, $userInput); - - // Create user account - $user = User::create($userInput); - - // Set the user type to driver - $user->setType('driver'); - } - - // if exists in organization create driver profile for user - $isOrganizationMember = $user->companies()->where('companies.uuid', session('company'))->exists(); + // The login account is managed by the driver profile: a team member + // with the email/phone is linked, otherwise a driver account is created. + $avatarUuid = Str::isUuid($input->get('photo_uuid')) ? $input->get('photo_uuid') : $input->get('avatar_uuid'); + $user = ProfileAccountManager::resolveForProfile( + $company->uuid, + 'driver', + $input->get('name'), + $input->get('email'), + $input->get('phone'), + User::applyUserInfoFromRequest($request, array_filter([ + 'password' => $input->get('password'), + 'status' => 'active', + 'avatar_uuid' => $avatarUuid, + ])) + ); // Prepare input $input = $input - ->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid', 'status']) + ->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid', 'status', 'user_uuid', 'user']) ->filter() ->toArray(); - // Assign user to company and send invite - if (!$isOrganizationMember && $company) { - $user->assignCompany($company); - } - - // Set user type as driver and set role to driver - if ($user->type === 'driver') { - $user->assignSingleRole('Driver'); - } - - $input['user_uuid'] = $user->uuid; - $input['slug'] = $user->slug; + $input['company_uuid'] = $company->uuid; + $input['user_uuid'] = $user->uuid; + $input['slug'] = $user->slug; // If no location provided set if (empty($input['location'])) { @@ -248,6 +115,8 @@ function ($request, &$driver) { ); return ['driver' => new $this->resource($record)]; + } catch (ProfileIdentityConflictException $e) { + return response()->error($e->getMessage(), 422, ['field' => $e->getField()]); } catch (QueryException $e) { return response()->error(env('DEBUG') ? $e->getMessage() : 'Error occurred while trying to create a ' . $this->resourceSingularlName); } catch (FleetbaseRequestValidationException $e) { @@ -287,18 +156,15 @@ public function updateRecord(Request $request, string $id) function (&$request, &$driver, &$input) { $driver->load(['user'])->guard(['user_uuid']); $input = collect($input); - $userInput = $input->only(['name', 'password', 'email', 'phone', 'avatar_uuid'])->reject(fn ($value) => $value === null)->toArray(); + $userInput = $input->only(['name', 'password', 'email', 'phone', 'avatar_uuid'])->reject(fn ($value, $key) => $value === null && !in_array($key, ['email', 'phone'], true))->toArray(); // handle `photo_uuid` if (isset($input['photo_uuid']) && Str::isUuid($input['photo_uuid'])) { $userInput['avatar_uuid'] = $input['photo_uuid']; } - $input = $input->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid'])->toArray(); + $input = $input->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid', 'user_uuid', 'user'])->toArray(); - // Update driver user details - $driverUser = $driver->getUser(); - if ($driverUser && !empty($userInput)) { - $driverUser->update($userInput); - } + // Update the driver's login account through its proxy fields + ProfileAccountManager::syncProxyFields($driver->getUser(), $userInput); // Flush cache $driver->flushAttributesCache(); @@ -318,6 +184,8 @@ function ($request, &$driver) { ); return ['driver' => new $this->resource($record)]; + } catch (ProfileIdentityConflictException $e) { + return response()->error($e->getMessage(), 422, ['field' => $e->getField()]); } catch (QueryException $e) { return response()->error(env('DEBUG') ? $e->getMessage() : 'Error occurred while trying to update a ' . $this->resourceSingularlName); } catch (FleetbaseRequestValidationException $e) { @@ -327,6 +195,150 @@ function ($request, &$driver) { } } + /** + * Generate a new password for the driver's login and send it to them. + * + * @return JsonResponse + */ + public function sendCredentials(string $id) + { + [$driver, $user, $error] = $this->resolveDriverLogin($id); + if ($error) { + return $error; + } + + try { + $sentVia = ProfileAccountManager::sendCredentials($driver, $user); + } catch (\Exception $e) { + return response()->error($e->getMessage()); + } + + return response()->json(['status' => 'ok', 'sent_via' => $sentVia, 'driver' => $this->driverLoginPayload($driver)]); + } + + /** + * Set a new password for the driver's login, optionally sending it to them. + * + * @return JsonResponse + */ + public function resetCredentials(Request $request, string $id) + { + $password = $request->input('password'); + if (!is_string($password) || strlen($password) < 8) { + return response()->error('Password must be at least 8 characters.'); + } + + if ($password !== $request->input('password_confirmation')) { + return response()->error('Passwords do not match.'); + } + + [$driver, $user, $error] = $this->resolveDriverLogin($id); + if ($error) { + return $error; + } + + $user->changePassword($password); + + try { + if ($request->boolean('send_credentials')) { + ProfileAccountManager::deliverCredentials($driver, $user, $password); + } + } catch (\Exception $e) { + return response()->error($e->getMessage()); + } + + return response()->json(['status' => 'ok', 'driver' => $this->driverLoginPayload($driver)]); + } + + /** + * Stop the driver from signing in to the driver app. + * + * @return JsonResponse + */ + public function deactivateLogin(string $id) + { + [$driver, $user, $error] = $this->resolveDriverLogin($id); + if ($error) { + return $error; + } + + $user->deactivate(); + + // Sign the driver out of the driver app + $user->tokens()->delete(); + + return response()->json(['status' => 'ok', 'driver' => $this->driverLoginPayload($driver)]); + } + + /** + * Allow the driver to sign in to the driver app again. + * + * @return JsonResponse + */ + public function reactivateLogin(string $id) + { + [$driver, $user, $error] = $this->resolveDriverLogin($id); + if ($error) { + return $error; + } + + $user->activate(); + + return response()->json(['status' => 'ok', 'driver' => $this->driverLoginPayload($driver)]); + } + + /** + * Find the driver and its managed login account. A driver linked to a team + * member's account has its login managed in IAM. + * + * @return array{0: ?Driver, 1: ?User, 2: ?JsonResponse} + */ + protected function resolveDriverLogin(string $id): array + { + $driver = Driver::where('company_uuid', session('company')) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->first(); + + if (!$driver) { + return [null, null, response()->error('Driver not found.', 404)]; + } + + $user = $driver->user_uuid ? User::where('uuid', $driver->user_uuid)->first() : null; + if (!$user) { + return [$driver, null, response()->error('This driver has no login account.')]; + } + + if (ProfileAccountManager::isStaffAccount($user)) { + return [$driver, $user, response()->error('This driver signs in with a team member account. Manage this login in IAM.', 422)]; + } + + return [$driver, $user, null]; + } + + protected function driverLoginPayload(Driver $driver): array + { + $user = User::where('uuid', $driver->user_uuid)->first(); + + return [ + 'id' => $driver->public_id, + 'uuid' => $driver->uuid, + 'user_uuid' => $driver->user_uuid, + 'is_staff_linked' => ProfileAccountManager::isStaffAccount($user), + 'login_status' => $user?->status, + 'user' => $user ? [ + 'id' => $user->public_id, + 'uuid' => $user->uuid, + 'name' => $user->name, + 'email' => $user->email, + 'phone' => $user->phone, + 'status' => $user->status, + 'session_status' => $user->session_status, + ] : null, + ]; + } + /** * Get all status options for an driver. * @@ -635,6 +647,10 @@ public function loginWithPhone() return response()->error('No driver with this phone # found.'); } + if (ApiDriverController::isLoginDeactivated($user)) { + return response()->error(ApiDriverController::DEACTIVATED_LOGIN_MESSAGE, 403); + } + // Generate verification token static::generateDriverLoginVerification($user); @@ -663,6 +679,10 @@ public function verifyCode(Request $request) return response()->error('Unable to verify code.'); } + if (ApiDriverController::isLoginDeactivated($user)) { + return response()->error(ApiDriverController::DEACTIVATED_LOGIN_MESSAGE, 403); + } + // Find and verify code $verificationCode = static::verificationCodeExists($user, $code, $for); if (!$verificationCode && !ApiDriverController::verificationBypassMatches($identity, $code)) { diff --git a/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php b/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php index 0f41d503a..ee0143ed3 100644 --- a/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +++ b/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php @@ -3,16 +3,63 @@ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1; use Fleetbase\FleetOps\Models\Contact; +use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\IntegratedVendor; use Fleetbase\FleetOps\Models\Vendor; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; +use Fleetbase\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Str; class FleetOpsLookupController extends Controller { + /** + * Describes what an email/phone entered on a driver, contact or customer + * form resolves to: the team member the profile would be linked to, or why + * the email/phone can't be used. + * + * @return \Illuminate\Http\JsonResponse + */ + public function profileIdentity(Request $request) + { + $type = $request->input('type', 'driver'); + if (!in_array($type, ProfileAccountManager::MANAGED_TYPES, true)) { + return response()->error('Invalid profile type.', 422); + } + + $currentUser = $this->currentProfileUser($type, $request->input('ignore')); + $lookup = ProfileAccountManager::lookup(session('company'), $type, $request->input('email'), $request->input('phone'), $currentUser); + $staff = $lookup['staff']; + + return response()->json([ + 'staff' => $staff ? ['uuid' => $staff->uuid, 'name' => $staff->name, 'email' => $staff->email, 'phone' => $staff->phone] : null, + 'conflict' => $lookup['conflict'], + ]); + } + + /** + * The account of the profile being edited, so its own email/phone isn't + * reported as taken. + */ + private function currentProfileUser(string $type, ?string $id): ?User + { + if (!$id) { + return null; + } + + $model = $type === 'driver' ? Driver::withoutGlobalScopes() : Contact::query(); + $profile = $model->where('company_uuid', session('company')) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->first(); + + return $profile?->user_uuid ? User::where('uuid', $profile->user_uuid)->first() : null; + } + /** * Returns a collection of polymorphic resources as JSON. * diff --git a/server/src/Http/Requests/CreateDriverRequest.php b/server/src/Http/Requests/CreateDriverRequest.php index 3bc95d57f..5f645b953 100644 --- a/server/src/Http/Requests/CreateDriverRequest.php +++ b/server/src/Http/Requests/CreateDriverRequest.php @@ -41,9 +41,13 @@ public function rules(): array * value later. Both are still validated and still unique when they * are supplied; a driver created without them simply cannot sign in * to Navigator until credentials are added. + * + * On create, availability is decided by ProfileAccountManager: a team + * member of the company with the email or phone is linked to the new + * driver instead of being rejected as a duplicate. */ - 'email' => ['nullable', Rule::when($this->filled('email'), ['email']), $this->uniqueAmongUsers()], - 'phone' => ['nullable', 'string', $this->uniqueAmongUsers()], + 'email' => ['nullable', Rule::when($this->filled('email'), ['email']), Rule::when(!$isCreating, [$this->uniqueAmongUsers()])], + 'phone' => ['nullable', 'string', Rule::when(!$isCreating, [$this->uniqueAmongUsers()])], 'password' => 'nullable|string', 'timezone' => 'nullable|string|max:64', diff --git a/server/src/Http/Requests/Internal/CreateDriverRequest.php b/server/src/Http/Requests/Internal/CreateDriverRequest.php index f8fe7e350..825244789 100644 --- a/server/src/Http/Requests/Internal/CreateDriverRequest.php +++ b/server/src/Http/Requests/Internal/CreateDriverRequest.php @@ -23,22 +23,17 @@ public function authorize(): bool */ public function rules(): array { - $isCreating = $this->isMethod('POST'); - $isCreatingWithUser = $this->filled('driver.user_uuid'); - $shouldValidateUserAttributes = $isCreating && !$isCreatingWithUser; + $isCreating = $this->isMethod('POST'); return [ - // Required fields for driver creation - 'name' => [Rule::requiredIf($shouldValidateUserAttributes), 'nullable', 'string', 'max:255'], - 'email' => [ - Rule::requiredIf($shouldValidateUserAttributes), - Rule::when($this->filled('email'), ['email']), - Rule::when($shouldValidateUserAttributes, [Rule::unique('users')->whereNull('deleted_at')]), - ], - 'phone' => [ - Rule::requiredIf($shouldValidateUserAttributes), - Rule::when($shouldValidateUserAttributes, [Rule::unique('users')->whereNull('deleted_at')]), - ], + // The driver profile manages its login account: name, email and phone + // are proxied to it. Email/phone availability is checked against the + // account the profile resolves to (see ProfileAccountManager), because a + // team member of the organization with the same email or phone is linked + // rather than rejected as a duplicate. + 'name' => [Rule::requiredIf($isCreating), 'nullable', 'string', 'max:255'], + 'email' => ['nullable', Rule::when($this->filled('email'), ['email'])], + 'phone' => ['nullable', 'string'], // Optional fields 'password' => 'nullable|string|min:8', @@ -84,11 +79,7 @@ public function messages(): array { return [ 'name.required' => 'Driver name is required.', - 'email.required' => 'Email address is required.', 'email.email' => 'Please provide a valid email address.', - 'email.unique' => 'This email address is already registered.', - 'phone.required' => 'Phone number is required.', - 'phone.unique' => 'This phone number is already registered.', 'password.min' => 'Password must be at least 8 characters.', ]; } diff --git a/server/src/Http/Resources/v1/Contact.php b/server/src/Http/Resources/v1/Contact.php index abd708b16..be4bc47b8 100644 --- a/server/src/Http/Resources/v1/Contact.php +++ b/server/src/Http/Resources/v1/Contact.php @@ -2,6 +2,7 @@ namespace Fleetbase\FleetOps\Http\Resources\v1; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Resources\FleetbaseResource; use Fleetbase\Http\Resources\User; @@ -40,6 +41,8 @@ public function toArray($request) 'places' => $this->whenLoaded('places', fn () => Place::collection($this->places)->without('owner')), 'user' => $this->when(Http::isInternalRequest(), fn () => new User($this->user), fn () => $this->user ? $this->user->public_id : null), 'address' => $this->when(Http::isInternalRequest(), data_get($this, 'place.address')), + 'is_staff_linked' => $this->when(Http::isInternalRequest(), fn () => ProfileAccountManager::isStaffAccount($this->user)), + 'login_status' => $this->when(Http::isInternalRequest(), fn () => data_get($this, 'user.status')), 'address_street' => $this->when(Http::isInternalRequest(), data_get($this, 'place.street1')), 'type' => $this->type ?? null, 'customer_type' => $this->when(isset($this->customer_type), Utils::toEmberResourceType($this->customer_type)), diff --git a/server/src/Http/Resources/v1/Driver.php b/server/src/Http/Resources/v1/Driver.php index 0da9aeea4..0a83248a1 100644 --- a/server/src/Http/Resources/v1/Driver.php +++ b/server/src/Http/Resources/v1/Driver.php @@ -2,6 +2,7 @@ namespace Fleetbase\FleetOps\Http\Resources\v1; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Resources\FleetbaseResource; use Fleetbase\Http\Resources\FleetbaseResourceCollection; @@ -32,6 +33,8 @@ public function toArray($request) 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), 'user' => $this->when(Http::isPublicRequest(), fn () => $this->user ? $this->user->public_id : null, new User($this->user)), 'internal_id' => $this->internal_id, + 'is_staff_linked' => $this->when(Http::isInternalRequest(), fn () => ProfileAccountManager::isStaffAccount($this->user)), + 'login_status' => $this->when(Http::isInternalRequest(), fn () => data_get($this, 'user.status')), 'company' => $this->when(Http::isPublicRequest(), fn () => $this->company ? $this->company->public_id : null), 'company_name' => $this->when(Http::isPublicRequest(), fn () => $this->company ? $this->company->name : null), 'name' => $this->name, diff --git a/server/src/Mail/DriverCredentialsMail.php b/server/src/Mail/DriverCredentialsMail.php new file mode 100644 index 000000000..a917e8f60 --- /dev/null +++ b/server/src/Mail/DriverCredentialsMail.php @@ -0,0 +1,55 @@ +driver->loadMissing('company'); + + return new Envelope( + subject: 'Your ' . ($this->driver->company?->name ?? config('app.name')) . ' driver sign-in details', + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + $this->driver->loadMissing('company'); + + return new Content( + markdown: 'fleetops::mail.driver-credentials', + with: [ + 'driver' => $this->driver, + 'user' => $this->user, + 'companyName' => $this->driver->company?->name ?? config('app.name'), + 'identity' => $this->user->email ?? $this->user->phone, + 'plaintextPassword' => $this->plaintextPassword, + ] + ); + } +} diff --git a/server/src/Models/Contact.php b/server/src/Models/Contact.php index 4a041e99d..b0b25a143 100644 --- a/server/src/Models/Contact.php +++ b/server/src/Models/Contact.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException; use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Models\CompanyUser; use Fleetbase\Models\Model; @@ -157,9 +158,13 @@ public function anyUser(): BelongsTo|Builder return $this->belongsTo(User::class, 'user_uuid'); } + /** + * The contact's login account: a managed `contact`/`customer` account, or a + * team member's account linked by email or phone. + */ public function user(): BelongsTo|Builder { - return $this->belongsTo(User::class, 'user_uuid')->where('type', $this->type); + return $this->belongsTo(User::class, 'user_uuid'); } public function photo(): BelongsTo @@ -316,74 +321,16 @@ public static function createFromImport(array $row, bool $saveInstance = false): */ public static function createUserFromContact(Contact $contact, bool $sendInvite = false, bool $update = false): User { - // Check if user already exist with email or phone number - $existingUser = null; - if ($contact->email || $contact->phone) { - $existingUser = User::where(function ($query) use ($contact) { - $query->where('company_uuid', $contact->company_uuid) - ->orWhereHas('companyUsers', function ($query) use ($contact) { - $query->where('company_uuid', $contact->company_uuid); - }); - }) - ->where(function ($query) use ($contact) { - if ($contact->email) { - $query->where('email', $contact->email); - } - - if ($contact->phone) { - $method = $contact->email ? 'orWhere' : 'where'; - $query->{$method}('phone', Utils::formatPhoneNumber($contact->phone)); - } - }) - ->whereNull('deleted_at') - ->first(); - } - - if ($existingUser) { - if ($contact->isCustomer()) { - $contact->assertCustomerUserCanBeAssigned($existingUser); - } - - // Check if existing user belongs to another contact - $existingUserContact = Contact::where(['user_uuid' => $existingUser->uuid, 'company_uuid' => $contact->company_uuid])->whereHas('user')->first(); - if ($existingUserContact) { - throw new UserAlreadyExistsException('User already exists, try to assigning the user to this contact.', $existingUser); - } - // Assign the user to this contact instead - $contact->setAttribute('user_uuid', $existingUser->uuid); - if ($update) { - $contact->update(['user_uuid' => $existingUser->uuid]); - } - $contact->setRelation('user', $existingUser); - - return $existingUser; - } - - // Load company $contact->loadMissing('company'); - // Create the user record - $user = User::create([ - 'company_uuid' => $contact->company_uuid, - 'name' => $contact->name, - 'email' => $contact->email, - 'phone' => $contact->phone ? Utils::formatPhoneNumber($contact->phone) : null, - 'username' => Str::slug($contact->name . '_' . Str::random(4), '_'), - 'password' => Str::random(), - 'timezone' => $contact->company->timezone ?? date_default_timezone_get(), - 'status' => 'pending', - ]); - - // Set user type - $user->setType($contact->type); - - // Assign to company without triggering core organization invitations. - static::assignUserToContactCompany($contact, $user); - - // Assign customer role - if ($contact->isCustomer()) { - $user->assignSingleRole('Fleet-Ops Customer'); - } + $user = ProfileAccountManager::resolveForProfile( + $contact->company_uuid, + $contact->isCustomer() ? 'customer' : 'contact', + $contact->name, + $contact->email, + $contact->phone, + ['timezone' => $contact->company->timezone ?? null] + ); // Set user to contact $contact->setAttribute('user_uuid', $user->uuid); @@ -412,6 +359,13 @@ public function normalizeCustomerUser(?User $user = null, bool $quiet = false): $this->assertCustomerUserCanBeAssigned($user); + // A team member's account keeps its own organization role + if (ProfileAccountManager::isStaffAccount($user)) { + $this->setRelation('user', $user); + + return $user; + } + $this->loadMissing('company'); if ($this->company) { $companyUser = CompanyUser::firstOrCreate( @@ -511,7 +465,8 @@ public function assignUser(User $user, bool $sendInvite = false): self // Get the company user instance $companyUser = $user->getCompanyUser($this->company); - if ($companyUser) { + // A team member's account keeps its own organization role + if ($companyUser && ProfileAccountManager::isManagedAccount($user)) { $companyUser->assignSingleRole($this->isCustomer() ? 'Fleet-Ops Customer' : 'Fleet-Ops Contact'); } @@ -546,6 +501,11 @@ public function assertCustomerUserCanBeAssigned(User $user): void return; } + // A team member of the organization can hold a customer profile + if (ProfileAccountManager::isStaffAccount($user) && ProfileAccountManager::isCompanyMember($user, $this->company_uuid)) { + return; + } + throw new CustomerUserConflictException(static::customerUserConflictMessage($user), $user); } @@ -553,6 +513,10 @@ public static function customerUserConflictMessage(?User $user = null): string { $field = $user?->email ? 'email' : ($user?->phone ? 'phone number' : 'user account'); + if (ProfileAccountManager::isManagedAccount($user)) { + return 'This ' . $field . ' is already used by a ' . $user->type . ' and cannot be used for a customer account.'; + } + return 'This ' . $field . ' belongs to an existing staff user and cannot be used for a customer account.'; } @@ -591,32 +555,31 @@ private function getAssignedUserWithoutTypeScope(): ?User return User::where('uuid', $this->user_uuid)->first(); } + /** + * Push the contact's changed proxy fields (name, email, phone, timezone) to + * its login account. A team member's account only takes the name. + * + * @throws \Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException + */ public function syncWithUser(): bool { - $updates = []; - - if ($this->isDirty('name')) { - $updates['name'] = $this->name; - } - - if ($this->isDirty('email')) { - $updates['email'] = $this->email; + // A new contact's account was just resolved from these values + if (!$this->exists) { + return false; } - if ($this->isDirty('phone')) { - $updates['phone'] = $this->phone; - } - - if ($this->isDirty('timezone')) { - $updates['timezone'] = $this->timezone; + $updates = []; + foreach (['name', 'email', 'phone', 'timezone'] as $field) { + if ($this->isDirty($field)) { + $updates[$field] = $this->{$field}; + } } - $user = $this->getUser(); - if ($user) { - return $user->update($updates); + if (empty($updates)) { + return false; } - return false; + return ProfileAccountManager::syncProxyFields($this->getUser(), $updates); } /** @@ -638,12 +601,14 @@ public function createUser(bool $sendInvite = false): User */ public function deleteUser(): ?bool { - $this->loadMissing('user'); - if ($this->user && $this->user->type === $this->type) { - return $this->user->delete(); + $user = $this->getUser(); + if (!ProfileAccountManager::isManagedAccount($user)) { + return false; } - return false; + ProfileAccountManager::releaseForProfile($user, $this->company_uuid); + + return $user->trashed(); } public function getUser(): ?User diff --git a/server/src/Observers/ContactObserver.php b/server/src/Observers/ContactObserver.php index ebc2dafd1..18e0f78a8 100644 --- a/server/src/Observers/ContactObserver.php +++ b/server/src/Observers/ContactObserver.php @@ -3,7 +3,6 @@ namespace Fleetbase\FleetOps\Observers; use Fleetbase\FleetOps\Models\Contact; -use Fleetbase\Models\User; class ContactObserver { @@ -42,17 +41,8 @@ public function saving(Contact $contact) $contact->normalizeCustomerUser(); } - // Validate email is available to user - if (!empty($contact->email) && $contact->wasChanged('email') && $this->isEmailUnavailable($contact)) { - throw new \Exception('Email attempting to update for ' . $contact->type . ' is not available.'); - } - - // Validate phone is available to user - if (!empty($contact->phone) && $contact->wasChanged('phone') && $this->isPhoneUnavailable($contact)) { - throw new \Exception('Phone attempting to update for ' . $contact->type . ' is not available.'); - } - - // Sync updates from contact to user + // Sync updates from contact to its login account. This throws when the + // new email or phone is already used by another account. $contact->syncWithUser(); } @@ -66,14 +56,4 @@ public function deleted(Contact $contact) // Delete the assosciated user account $contact->deleteUser(); } - - private function isEmailUnavailable(Contact $contact) - { - return User::where('email', $contact->email)->whereNot('uuid', $contact->user_uuid)->exists() || Contact::where(['email' => $contact->email, 'company_uuid' => $contact->company_uuid])->whereNot('uuid', $contact->uuid)->exists(); - } - - private function isPhoneUnavailable(Contact $contact) - { - return User::where('phone', $contact->phone)->whereNot('uuid', $contact->user_uuid)->exists() || Contact::where(['phone' => $contact->phone, 'company_uuid' => $contact->company_uuid])->whereNot('uuid', $contact->uuid)->exists(); - } } diff --git a/server/src/Observers/DriverObserver.php b/server/src/Observers/DriverObserver.php index 2dbc1ff09..ba806dc83 100644 --- a/server/src/Observers/DriverObserver.php +++ b/server/src/Observers/DriverObserver.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Support\LiveCacheService; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\User; @@ -64,11 +65,9 @@ public function deleted(Driver $driver) // Unassign them from any order they are assigned to $this->unassignOrders($driver); - // If the driver had a user account with the role driver and type user delete it - $user = $this->findDriverUser($driver); - if ($user && $user->hasRole('Driver')) { - $user->delete(); - } + // Delete the driver's managed login account, which frees its email and + // phone. A team member's account linked to the driver is left alone. + ProfileAccountManager::releaseForProfile($this->findDriverUser($driver), $driver->company_uuid); $this->invalidateLiveCache(); } @@ -85,6 +84,6 @@ protected function unassignOrders(Driver $driver): int protected function findDriverUser(Driver $driver): ?User { - return User::where(['uuid' => $driver->user_uuid, 'type' => 'user'])->first(); + return $driver->user_uuid ? User::where('uuid', $driver->user_uuid)->first() : null; } } diff --git a/server/src/Support/ProfileAccountManager.php b/server/src/Support/ProfileAccountManager.php new file mode 100644 index 000000000..521ea4157 --- /dev/null +++ b/server/src/Support/ProfileAccountManager.php @@ -0,0 +1,405 @@ + 'Driver', + 'contact' => 'Fleet-Ops Contact', + 'customer' => 'Fleet-Ops Customer', + ]; + + /** + * Whether the account is owned by a driver, contact or customer profile. + */ + public static function isManagedAccount(?object $user): bool + { + return $user !== null && in_array(data_get($user, 'type'), static::MANAGED_TYPES, true); + } + + /** + * Whether the account is a team member's account managed in IAM. + */ + public static function isStaffAccount(?object $user): bool + { + return $user !== null && !static::isManagedAccount($user); + } + + /** + * Find the active account holding the given email or phone. + */ + public static function findAccountByIdentity(?string $email, ?string $phone, ?string $ignoreUserUuid = null): ?User + { + $email = static::normalizeEmail($email); + $phone = static::normalizePhone($phone); + + if (!$email && !$phone) { + return null; + } + + return User::where(function ($query) use ($email, $phone) { + if ($email) { + $query->orWhere('email', $email); + } + + if ($phone) { + $query->orWhere('phone', $phone); + } + }) + ->when($ignoreUserUuid, fn ($query) => $query->where('uuid', '!=', $ignoreUserUuid)) + ->whereNull('deleted_at') + ->first(); + } + + /** + * Describe what would happen to an email/phone entered on a new or existing + * profile: `staff` is the team member the profile would link to, `conflict` + * is why the email/phone cannot be used. + * + * @return array{account: ?User, staff: ?User, conflict: ?string} + */ + public static function lookup(string $companyUuid, string $type, ?string $email, ?string $phone, ?User $currentUser = null): array + { + $result = ['account' => null, 'staff' => null, 'conflict' => null]; + $account = static::findAccountByIdentity($email, $phone, $currentUser?->uuid); + + if (!$account) { + return $result; + } + + $result['account'] = $account; + $field = static::matchedField($account, $email); + + // An existing profile can't swap its account for another one + if ($currentUser) { + $result['conflict'] = static::takenMessage($field); + + return $result; + } + + try { + static::assertAccountCanHoldProfile($account, $companyUuid, $type, $field); + } catch (ProfileIdentityConflictException $e) { + $result['conflict'] = $e->getMessage(); + + return $result; + } + + if (static::isStaffAccount($account)) { + $result['staff'] = $account; + } + + return $result; + } + + /** + * Resolve the account for a new profile. A staff member of the organization + * or a managed account of the same type with the email/phone is linked; + * otherwise a managed account is created. + * + * @param array $attributes extra user attributes: password, status, timezone, avatar_uuid, country, ip_address, meta + * + * @throws ProfileIdentityConflictException + */ + public static function resolveForProfile(string $companyUuid, string $type, ?string $name, ?string $email, ?string $phone, array $attributes = []): User + { + $account = static::findAccountByIdentity($email, $phone); + + if ($account) { + static::assertAccountCanHoldProfile($account, $companyUuid, $type, static::matchedField($account, $email)); + + if (static::isManagedAccount($account)) { + static::attachToCompany($account, $companyUuid, $type); + } + + return $account; + } + + return static::createManagedAccount($companyUuid, $type, $name, $email, $phone, $attributes); + } + + /** + * Create a managed account and add it to the organization without an invite. + */ + public static function createManagedAccount(string $companyUuid, string $type, ?string $name, ?string $email, ?string $phone, array $attributes = []): User + { + $company = Company::where('uuid', $companyUuid)->first(); + $password = $attributes['password'] ?? null; + $userData = array_filter([ + 'company_uuid' => $companyUuid, + 'name' => $name, + 'email' => static::normalizeEmail($email), + 'phone' => static::normalizePhone($phone), + 'username' => User::generateUsername($name ?: Str::random(6)), + 'timezone' => $attributes['timezone'] ?? $company?->timezone ?? date_default_timezone_get(), + 'status' => $attributes['status'] ?? 'pending', + 'avatar_uuid' => $attributes['avatar_uuid'] ?? null, + 'country' => $attributes['country'] ?? null, + 'ip_address' => $attributes['ip_address'] ?? null, + 'meta' => $attributes['meta'] ?? null, + ], fn ($value) => $value !== null); + + $user = new User($userData); + $user->password = $password ?: Str::random(16); + $user->save(); + $user->setType($type); + + static::attachToCompany($user, $companyUuid, $type); + + return $user; + } + + /** + * Push the profile's proxy fields to its account. A staff account only + * takes the name: its email and phone are its console identity. + * + * @throws ProfileIdentityConflictException + */ + public static function syncProxyFields(?User $user, array $attributes): bool + { + if (!$user) { + return false; + } + + $fields = static::isStaffAccount($user) ? ['name'] : ['name', 'email', 'phone', 'timezone', 'avatar_uuid', 'password']; + $input = array_intersect_key($attributes, array_flip($fields)); + + if (array_key_exists('email', $input)) { + $input['email'] = static::normalizeEmail($input['email']); + } + + if (array_key_exists('phone', $input)) { + $input['phone'] = static::normalizePhone($input['phone']); + } + + $input = array_filter($input, fn ($value, $key) => $value !== null || in_array($key, ['email', 'phone'], true), ARRAY_FILTER_USE_BOTH); + $input = array_filter($input, fn ($value, $key) => $key === 'password' || $user->{$key} !== $value, ARRAY_FILTER_USE_BOTH); + + if (empty($input)) { + return false; + } + + $changedEmail = array_key_exists('email', $input) ? $input['email'] : null; + $changedPhone = array_key_exists('phone', $input) ? $input['phone'] : null; + $taken = static::findAccountByIdentity($changedEmail, $changedPhone, $user->uuid); + if ($taken) { + throw new ProfileIdentityConflictException(static::takenMessage(static::matchedField($taken, $changedEmail)), static::matchedField($taken, $changedEmail)); + } + + if (isset($input['password'])) { + $user->password = $input['password']; + unset($input['password']); + } + + $user->fill($input); + + return $user->save(); + } + + /** + * Release a profile's account after the profile is deleted. A managed + * account with no other profile is deleted, which frees its email and + * phone; one still used by another organization's profile only leaves this + * organization. Staff accounts are never touched. + */ + public static function releaseForProfile(?User $user, ?string $companyUuid = null): void + { + if (!static::isManagedAccount($user) || $user->trashed()) { + return; + } + + $remainingProfiles = static::profileCompanies($user); + if ($remainingProfiles->isEmpty()) { + $user->delete(); + + return; + } + + if ($companyUuid && !$remainingProfiles->contains($companyUuid)) { + CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->delete(); + } + } + + /** + * Generate a new password for the account and send it to the profile. + * + * @return string how the credentials were sent: `email` or `sms` + */ + public static function sendCredentials(Driver|Contact $profile, User $user, ?string $password = null): string + { + $password ??= Str::random(12); + $user->changePassword($password); + + if ($user->status !== 'active') { + $user->activate(); + } + + return static::deliverCredentials($profile, $user, $password); + } + + /** + * Send already set credentials to the profile by email, or by SMS when the + * account has no email. + */ + public static function deliverCredentials(Driver|Contact $profile, User $user, string $password): string + { + if ($user->email) { + Mail::to($user)->send($profile instanceof Driver ? new DriverCredentialsMail($password, $profile, $user) : new CustomerCredentialsMail($password, $profile)); + + return 'email'; + } + + if ($user->phone) { + $profile->loadMissing('company'); + $companyName = $profile->company?->name ?? config('app.name'); + $identity = $user->phone; + $result = app(SmsService::class)->send($user->phone, "Your {$companyName} sign-in details. Login: {$identity} Password: {$password}"); + + if (is_array($result) && array_key_exists('success', $result) && !$result['success']) { + throw new \Exception('The credentials could not be texted: ' . ($result['error'] ?? $result['message'] ?? 'the SMS provider refused it.')); + } + + return 'sms'; + } + + throw new \Exception('This profile has no email or phone to send credentials to.'); + } + + /** + * Throw when the account can't hold the kind of profile being created. + * + * @throws ProfileIdentityConflictException + */ + public static function assertAccountCanHoldProfile(User $account, string $companyUuid, string $type, string $field = 'email'): void + { + if (static::isStaffAccount($account)) { + if (!static::isCompanyMember($account, $companyUuid)) { + throw new ProfileIdentityConflictException(static::takenMessage($field), $field); + } + } elseif ($account->type !== $type) { + throw new ProfileIdentityConflictException('This ' . static::fieldLabel($field) . ' is already used by a ' . $account->type . '.', $field); + } + + if (static::hasProfileInCompany($account, $companyUuid, $type)) { + throw new ProfileIdentityConflictException('A ' . $type . ' with this ' . static::fieldLabel($field) . ' already exists.', $field); + } + } + + public static function hasProfileInCompany(User $user, string $companyUuid, string $type): bool + { + if ($type === 'driver') { + return Driver::withoutGlobalScopes()->where(['user_uuid' => $user->uuid, 'company_uuid' => $companyUuid])->whereNull('deleted_at')->exists(); + } + + return Contact::withoutGlobalScopes()->where(['user_uuid' => $user->uuid, 'company_uuid' => $companyUuid])->whereNull('deleted_at')->exists(); + } + + /** + * The organizations the account still has a driver or contact profile in. + */ + public static function profileCompanies(User $user): \Illuminate\Support\Collection + { + $drivers = Driver::withoutGlobalScopes()->where('user_uuid', $user->uuid)->whereNull('deleted_at')->pluck('company_uuid'); + $contacts = Contact::withoutGlobalScopes()->where('user_uuid', $user->uuid)->whereNull('deleted_at')->pluck('company_uuid'); + + return $drivers->merge($contacts)->filter()->unique()->values(); + } + + public static function isCompanyMember(User $user, ?string $companyUuid): bool + { + if (!$companyUuid) { + return false; + } + + return $user->company_uuid === $companyUuid || CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->exists(); + } + + /** + * Add a managed account to the organization with its profile role, without + * sending an organization invite. + */ + public static function attachToCompany(User $user, string $companyUuid, string $type): ?CompanyUser + { + $company = Company::where('uuid', $companyUuid)->first(); + if (!$company) { + return null; + } + + $role = static::ROLES[$type] ?? static::ROLES['contact']; + $companyUser = CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->first(); + if (!$companyUser) { + $companyUser = $company->addUser($user, $role); + } elseif (static::isManagedAccount($user)) { + $companyUser->assignSingleRole($role); + } + + if (!$user->company_uuid) { + $user->forceFill(['company_uuid' => $companyUuid])->saveQuietly(); + } + + $user->setRelation('companyUser', $companyUser); + + return $companyUser; + } + + public static function normalizeEmail(?string $email): ?string + { + $email = is_string($email) ? strtolower(trim($email)) : null; + + return $email ?: null; + } + + public static function normalizePhone(?string $phone): ?string + { + $phone = is_string($phone) ? trim($phone) : null; + + return $phone ? Utils::formatPhoneNumber($phone) : null; + } + + private static function matchedField(User $account, ?string $email): string + { + $email = static::normalizeEmail($email); + + return $email && strtolower((string) $account->email) === $email ? 'email' : 'phone'; + } + + private static function fieldLabel(string $field): string + { + return $field === 'phone' ? 'phone number' : 'email'; + } + + private static function takenMessage(string $field): string + { + return 'This ' . static::fieldLabel($field) . ' is already in use by another account.'; + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 05a7967a0..631bccc92 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -444,6 +444,10 @@ function ($router, $controller) { $router->post('{id}/unassign-order', $controller('unassignOrder')); $router->post('{id}/assign-vehicle', $controller('assignVehicle')); $router->post('{id}/unassign-vehicle', $controller('unassignVehicle')); + $router->post('{id}/send-credentials', $controller('sendCredentials')); + $router->post('{id}/reset-credentials', $controller('resetCredentials')); + $router->post('{id}/deactivate-login', $controller('deactivateLogin')); + $router->post('{id}/reactivate-login', $controller('reactivateLogin')); $router->match(['get', 'post'], 'export', $controller('export')); $router->post('import', $controller('import')); // Driver scheduling endpoints @@ -764,6 +768,7 @@ function () use ($router) { function ($router) { $router->get('customers', 'FleetOpsLookupController@polymorphs'); $router->get('facilitators', 'FleetOpsLookupController@polymorphs'); + $router->get('profile-identity', 'FleetOpsLookupController@profileIdentity'); } ); $router->group( diff --git a/server/tests/ApiCustomerControllerContractsTest.php b/server/tests/ApiCustomerControllerContractsTest.php index ea1b7202b..49131be51 100644 --- a/server/tests/ApiCustomerControllerContractsTest.php +++ b/server/tests/ApiCustomerControllerContractsTest.php @@ -800,6 +800,27 @@ function fleetopsApiCustomerJson($response): array ]))))->toBe(['error' => 'Invalid verification code.']); }); +test('api customer controller refuses deactivated customer logins', function () { + $controller = fleetopsApiCustomerController(); + foreach (['activeUser', 'loginUser', 'verificationUser'] as $property) { + $controller->{$property}->setAttribute('status', 'inactive'); + } + + $responses = [ + $controller->login(Request::create('/v1/customers/login', 'POST', ['identity' => 'jane@example.test', 'password' => 'password-secret'])), + $controller->loginWithPhone(Request::create('/v1/customers/login/phone', 'POST', ['phone' => '15551234567'])), + $controller->verifyCode(Request::create('/v1/customers/verify', 'POST', ['identity' => 'jane@example.test', 'code' => '123456'])), + ]; + + foreach ($responses as $response) { + expect($response->getStatusCode())->toBe(403) + ->and(fleetopsApiCustomerJson($response))->toBe(['error' => 'This customer login has been deactivated.']); + } + + // No verification code is sent + expect($controller->smsVerifications)->toBe([]); +}); + test('api customer controller resets forgotten passwords', function () { $controller = fleetopsApiCustomerController(); $forgot = $controller->forgotPassword(Request::create('/v1/customers/forgot-password', 'POST', [ diff --git a/server/tests/ApiDriverControllerContractsTest.php b/server/tests/ApiDriverControllerContractsTest.php index fa5e4dfa7..d2743ca72 100644 --- a/server/tests/ApiDriverControllerContractsTest.php +++ b/server/tests/ApiDriverControllerContractsTest.php @@ -30,6 +30,8 @@ class FleetOpsApiDriverControllerProbe extends DriverController public array $deviceCreates = []; public array $companyCalls = []; public array $createdUsers = []; + public array $resolvedAccounts = []; + public ?Throwable $accountConflict = null; public array $uuidLookups = []; public array $relationLookups = []; public array $relationCompanyScopes = []; @@ -65,11 +67,17 @@ protected function applyUserInfoFromRequest(Request $request, array $userDetails return $userDetails; } - protected function createUser(array $userDetails): User + protected function resolveDriverAccount(string $companyUuid, array $userDetails): User { - $this->createdUsers[] = $userDetails; + $this->resolvedAccounts[] = $companyUuid; + $this->createdUsers[] = $userDetails; + + if ($this->accountConflict) { + throw $this->accountConflict; + } + $this->user ??= new FleetOpsApiDriverUserFake(); - $this->user->setRawAttributes(array_merge(['uuid' => 'user-uuid'], $userDetails), true); + $this->user->setRawAttributes(array_merge(['uuid' => 'user-uuid', 'type' => 'driver'], $userDetails), true); return $this->user; } @@ -282,6 +290,14 @@ public function update(array $attributes = [], array $options = []): bool return true; } + public function save(array $options = []): bool + { + $this->updates[] = $this->getDirty(); + $this->syncOriginal(); + + return true; + } + public function assignCompany(Company $company, string $role = 'Administrator'): User { $this->assignedCompanies[] = $company->uuid; @@ -317,6 +333,27 @@ public function setPasswordAttribute($password): void } } +/** + * ProfileAccountManager checks a changed email/phone against the users table. + */ +function fleetopsApiDriverContractsUsersTable(array $rows = []): void +{ + $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:')); + $connection->getSchemaBuilder()->create('users', function ($table) { + $table->string('uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + if ($rows) { + $connection->table('users')->insert($rows); + } + $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver); +} + class FleetOpsApiDriverVehicleFake extends Vehicle { public array $quietUpdates = []; @@ -363,16 +400,15 @@ public function or(array $keys, mixed $default = null): mixed expect($response)->toBe(['resource' => 'driver', 'driver' => $controller->driver]) ->and($controller->companyCalls)->toBe([['request', 'company_public']]) + // The login account is resolved by ProfileAccountManager in the driver's company + ->and($controller->resolvedAccounts)->toBe(['company-uuid']) ->and($controller->createdUsers[0])->toMatchArray([ - 'name' => 'Driver One', - 'email' => 'driver@example.test', - 'phone' => '+15551234567', - 'company_uuid' => 'company-uuid', - 'applied' => true, + 'name' => 'Driver One', + 'email' => 'driver@example.test', + 'phone' => '+15551234567', + 'password' => 'secret-password', + 'applied' => true, ]) - ->and($controller->user->assignedCompanies)->toBe(['company-uuid']) - ->and($controller->user->assignedTypes)->toBe(['driver']) - ->and($controller->user->assignedRoles)->toBe(['Driver']) ->and($controller->createdDrivers[0])->toMatchArray([ 'status' => 'available', 'vehicle_uuid' => 'vehicle-uuid', @@ -407,8 +443,10 @@ public function or(array $keys, mixed $default = null): mixed }); test('api driver controller updates drivers user details assignments location and photo', function () { + fleetopsApiDriverContractsUsersTable(); + $user = new FleetOpsApiDriverUserFake(); - $user->setRawAttributes(['uuid' => 'user-uuid'], true); + $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true); $driver = new FleetOpsApiDriverFake(); $driver->setRawAttributes([ @@ -645,7 +683,7 @@ public function or(array $keys, mixed $default = null): mixed * and now has its own endpoint that demands the current password. */ $user = new FleetOpsApiDriverUserFake(); - $user->setRawAttributes(['uuid' => 'user-uuid'], true); + $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true); $driver = new FleetOpsApiDriverFake(); $driver->setRawAttributes([ 'uuid' => 'driver-uuid', @@ -759,11 +797,9 @@ public function or(array $keys, mixed $default = null): mixed 'company_uuid' => 'company-uuid', 'status' => 'available', ]) - // The Driver-to-User relationship, organization membership, user type - // and role are all preserved for a credential-less driver. - ->and($controller->user->assignedCompanies)->toBe(['company-uuid']) - ->and($controller->user->assignedTypes)->toBe(['driver']) - ->and($controller->user->assignedRoles)->toBe(['Driver']); + // The Driver-to-User relationship and organization membership are + // preserved for a credential-less driver. + ->and($controller->resolvedAccounts)->toBe(['company-uuid']); }); test('api driver controller creates a driver with only one contact method', function () { @@ -873,7 +909,7 @@ public function or(array $keys, mixed $default = null): mixed ])); $user = new FleetOpsApiDriverUserFake(); - $user->setRawAttributes(['uuid' => 'user-uuid'], true); + $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true); $driver = new FleetOpsApiDriverFake(); $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'user-uuid'], true); @@ -905,3 +941,47 @@ public function or(array $keys, mixed $default = null): mixed // name is dropped rather than reaching Eloquent, where it would be a 500. expect($request->input('with'))->toBe(['vehicle', 'currentJob']); }); + +test('api driver controller answers 422 when the email or phone belongs to another account', function () { + $create = new FleetOpsApiDriverControllerProbe(); + $create->accountConflict = new Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException('This email is already in use by another account.'); + + expect($create->create(new CreateDriverRequest(['name' => 'Driver One', 'email' => 'taken@example.test'])))->toBe([ + 'json' => ['error' => 'This email is already in use by another account.'], + 'status' => 422, + ]) + ->and($create->createdDrivers)->toBe([]); + + fleetopsApiDriverContractsUsersTable([['uuid' => 'other-user', 'type' => 'user', 'phone' => '+15550009999']]); + + $user = new FleetOpsApiDriverUserFake(); + $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver', 'phone' => '+15550001111'], true); + $driver = new FleetOpsApiDriverFake(); + $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'user-uuid'], true); + $driver->userForTest = $user; + + $update = new FleetOpsApiDriverControllerProbe(); + $update->driver = $driver; + + expect($update->update('driver_public', new UpdateDriverRequest(['phone' => '+1 555 000 9999'])))->toBe([ + 'json' => ['error' => 'This phone number is already in use by another account.'], + 'status' => 422, + ]) + ->and($user->updates)->toBe([]) + ->and($driver->updates)->toBe([]); +}); + +test('api driver controller only renames a team member account linked to a driver', function () { + $staff = new FleetOpsApiDriverUserFake(); + $staff->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'user', 'name' => 'Staff', 'email' => 'staff@example.test'], true); + $driver = new FleetOpsApiDriverFake(); + $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'staff-uuid'], true); + $driver->userForTest = $staff; + + $update = new FleetOpsApiDriverControllerProbe(); + $update->driver = $driver; + $update->update('driver_public', new UpdateDriverRequest(['name' => 'Staff Driver', 'email' => 'changed@example.test'])); + + expect($staff->updates)->toBe([['name' => 'Staff Driver']]) + ->and($staff->email)->toBe('staff@example.test'); +}); diff --git a/server/tests/ControllerHelperContractsTest.php b/server/tests/ControllerHelperContractsTest.php index aefd4ed75..cc0cbcdaf 100644 --- a/server/tests/ControllerHelperContractsTest.php +++ b/server/tests/ControllerHelperContractsTest.php @@ -254,7 +254,6 @@ public function callHelper(string $method, mixed ...$arguments): mixed class FleetOpsInternalContactControllerProbe extends InternalContactController { public array $installedPackages = []; - public array $resolvedUsers = []; public array $sentCredentials = []; public array $savedMetas = []; public ?User $contactUser = null; @@ -269,13 +268,6 @@ public function callHelper(string $method, mixed ...$arguments): mixed return $reflection->invoke($this, ...$arguments); } - protected function resolveUserUuid(string $user): string - { - $this->resolvedUsers[] = $user; - - return 'resolved-' . $user; - } - protected function installedFleetbaseExtensions(): array { return $this->installedPackages; @@ -1607,7 +1599,7 @@ function fleetopsControllerStaticMethod(string $class, string $method): Reflecti ], ], ]); - $input = ['type' => 'contact', 'user' => ['id' => 'user_public']]; + $input = ['type' => 'contact', 'user' => ['id' => 'user_public'], 'user_uuid' => 'user-uuid']; $controller->onBeforeCreate($request, $input); @@ -1623,8 +1615,8 @@ function fleetopsControllerStaticMethod(string $class, string $method): Reflecti $controller->afterSave($request, $contact); - expect($input)->toBe(['type' => 'contact', 'user_uuid' => 'resolved-user_public']) - ->and($controller->resolvedUsers)->toBe(['user_public']) + // The login account is managed by the profile, so a user can't be picked from the console + expect($input)->toBe(['type' => 'contact']) ->and($contact->normalized)->toBeTrue() ->and($contact->syncedCustomFields)->toBe([ ['key' => 'tier', 'value' => 'gold'], diff --git a/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php b/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php index 6168cf7e9..d2bf127af 100644 --- a/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +++ b/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php @@ -230,6 +230,42 @@ public function __call($method, $arguments) ->and($connection->table('personal_access_tokens')->value('name'))->toBe('driver-1'); }); +test('deactivated driver logins are refused on every sign-in path', function () { + $connection = fleetopsDriverAuthBoot(); + $controller = new DriverController(); + $connection->table('users')->where('uuid', 'user-1')->update(['status' => 'inactive']); + app('hash')->checks = true; + + // An unknown identity fails authentication rather than erroring on a null user + $unknown = $controller->login(Request::create('/x', 'POST', ['identity' => 'ghost@example.test', 'password' => 'secret'])); + expect($unknown->getStatusCode())->toBe(401); + + $password = $controller->login(Request::create('/x', 'POST', ['identity' => 'driver@example.test', 'password' => 'secret'])); + + app()->instance('request', Request::create('/x', 'POST', ['phone' => '6591234567'])); + $phone = $controller->loginWithPhone(); + + $connection->table('verification_codes')->insert(['uuid' => 'vc-1', 'subject_uuid' => 'user-1', 'code' => '424242', 'for' => 'driver_login']); + $code = $controller->verifyCode(Request::create('/x', 'POST', ['identity' => '6591234567', 'code' => '424242'])); + + foreach ([$password, $phone, $code] as $response) { + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['error'])->toBe(DriverController::DEACTIVATED_LOGIN_MESSAGE); + } + + // Password recovery answers as if the identity were unknown + $forgot = $controller->forgotPassword(Request::create('/x', 'POST', ['identity' => 'driver@example.test'])); + $reset = $controller->resetPassword(Request::create('/x', 'POST', ['identity' => 'driver@example.test', 'code' => '424242', 'password' => 'new-secret-123'])); + + expect($forgot->getData(true))->toBe(['status' => 'ok']) + ->and($reset->getStatusCode())->toBe(422) + ->and($reset->getData(true)['error'])->toBe('Invalid or expired reset code.'); + + expect(DriverController::isLoginDeactivated(User::where('uuid', 'user-1')->first()))->toBeTrue() + ->and($connection->table('personal_access_tokens')->count())->toBe(0) + ->and($connection->table('verification_codes')->count())->toBe(1); +}); + test('phone login falls back to email verification and errors without channels', function () { $connection = fleetopsDriverAuthBoot(); $controller = new DriverController(); diff --git a/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php b/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php index d7bfbfbe5..5a7fdcae9 100644 --- a/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +++ b/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php @@ -106,7 +106,7 @@ public function needsRehash($hashedValue, array $options = []): bool $schema = $connection->getSchemaBuilder(); app()->instance('db.schema', $schema); $tables = [ - 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'status', 'type', 'username', 'password'], + 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'status', 'type', 'username', 'password', 'timezone', 'slug', 'meta'], 'drivers' => ['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'status', 'online', 'location', 'meta'], 'companies' => ['uuid', 'public_id', 'name', 'status', 'owner_uuid'], 'user_devices' => ['uuid', 'public_id', 'user_uuid', 'token', 'platform', 'status'], @@ -189,22 +189,35 @@ public function parameters() $userDetails = $probe->callHelper('applyUserInfoFromRequest', Request::create('/x', 'POST', []), ['name' => 'Applicant']); expect($userDetails['name'])->toBe('Applicant'); - $user = $probe->callHelper('createUser', ['name' => 'New User']); + // A company that isn't in the table skips the organization membership seam, + // which needs spatie roles; the managed account itself is still created. + $missingCompany = '33333333-3333-4333-8333-333333333333'; + + $user = $probe->callHelper('resolveDriverAccount', $missingCompany, ['name' => 'New User']); expect($user)->toBeInstanceOf(User::class) - ->and($connection->table('users')->where('name', 'New User')->count())->toBe(1); + ->and($connection->table('users')->where('name', 'New User')->count())->toBe(1) + ->and($connection->table('users')->where('name', 'New User')->value('type'))->toBe('driver') + ->and($connection->table('users')->where('name', 'New User')->value('status'))->toBe('active'); /* * `password` is guarded on User, so mass assignment drops it without a * word. The create endpoint has always documented and validated one, so - * unless the helper sets it explicitly a driver created through the API - * can never sign in with the password chosen for them. + * unless it is set explicitly a driver created through the API can never + * sign in with the password chosen for them. */ - $withPassword = $probe->callHelper('createUser', ['name' => 'Password User', 'password' => 'seeded-password']); + $withPassword = $probe->callHelper('resolveDriverAccount', $missingCompany, ['name' => 'Password User', 'password' => 'seeded-password']); $stored = $connection->table('users')->where('name', 'Password User')->value('password'); expect(Hash::check('seeded-password', $withPassword->password))->toBeTrue() ->and($stored)->not->toBeNull() ->and($stored)->not->toBe('seeded-password'); + // A team member of the company with the email is linked instead of duplicated + $connection->table('users')->insert(['uuid' => 'staff-1', 'company_uuid' => '22222222-2222-4222-8222-222222222222', 'name' => 'Staff', 'email' => 'staff@example.test', 'type' => 'user']); + $linked = $probe->callHelper('resolveDriverAccount', '22222222-2222-4222-8222-222222222222', ['name' => 'Staff Driver', 'email' => 'STAFF@example.test']); + expect($linked->uuid)->toBe('staff-1') + ->and($connection->table('users')->where('email', 'staff@example.test')->count())->toBe(1) + ->and($connection->table('users')->where('uuid', 'staff-1')->value('type'))->toBe('user'); + $driver = $probe->callHelper('createDriver', ['company_uuid' => '22222222-2222-4222-8222-222222222222', 'user_uuid' => 'user-1']); expect($driver)->toBeInstanceOf(Driver::class); diff --git a/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php b/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php new file mode 100644 index 000000000..9a69a6196 --- /dev/null +++ b/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php @@ -0,0 +1,220 @@ + $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function table($table, $as = null) + { + return $this->c->table($table, $as); + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + app()->instance('db.schema', $connection->getSchemaBuilder()); + DB::clearResolvedInstance('db'); + Schema::clearResolvedInstance('db.schema'); + + $GLOBALS['fleetopsProfileConversionLogs'] = []; + app()->instance('log', new class { + public function info($message, array $context = []): void + { + $GLOBALS['fleetopsProfileConversionLogs'][] = [$message, $context]; + } + + public function __call($method, $arguments) + { + return null; + } + }); + Log::clearResolvedInstance('log'); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'users' => ['uuid', 'type', 'meta'], + 'drivers' => ['uuid', 'user_uuid'], + 'contacts' => ['uuid', 'user_uuid', 'type'], + 'companies' => ['uuid', 'owner_uuid'], + 'company_users' => ['uuid', 'user_uuid', 'company_uuid'], + 'roles' => ['name'], + 'model_has_roles' => ['role_id', 'model_uuid'], + 'model_has_permissions' => ['permission_id', 'model_uuid'], + ]; + if ($withPolicies) { + $tables['model_has_policies'] = ['policy_id', 'model_uuid']; + } + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + foreach (['Driver', 'Fleet-Ops Customer', 'Administrator'] as $role) { + $connection->table('roles')->insert(['name' => $role]); + } + + return $connection; +} + +function fleetopsProfileConversionMigration(): object +{ + return require dirname(__DIR__, 4) . '/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php'; +} + +/** + * Seed a user holding the given profile(s), roles on its company membership + * (or on the user itself), and optional direct permission or policy. + */ +function fleetopsProfileConversionUser(SQLiteConnection $connection, string $uuid, array $options = []): void +{ + $connection->table('users')->insert(['uuid' => $uuid, 'type' => $options['type'] ?? 'user', 'meta' => $options['meta'] ?? null, 'deleted_at' => $options['deleted_at'] ?? null]); + $connection->table('company_users')->insert(['uuid' => 'cu-' . $uuid, 'user_uuid' => $uuid, 'company_uuid' => 'company-1']); + + if ($options['driver'] ?? false) { + $connection->table('drivers')->insert(['uuid' => 'driver-' . $uuid, 'user_uuid' => $uuid]); + } + + if ($options['customer'] ?? false) { + $connection->table('contacts')->insert(['uuid' => 'contact-' . $uuid, 'user_uuid' => $uuid, 'type' => 'customer']); + } + + foreach ($options['roles'] ?? [] as $role) { + $roleId = $connection->table('roles')->where('name', $role)->value('id'); + $connection->table('model_has_roles')->insert(['role_id' => $roleId, 'model_uuid' => ($options['role_on_user'] ?? false) ? $uuid : 'cu-' . $uuid]); + } + + if ($options['permission'] ?? false) { + $connection->table('model_has_permissions')->insert(['permission_id' => 1, 'model_uuid' => 'cu-' . $uuid]); + } + + if ($options['policy'] ?? false) { + $connection->table('model_has_policies')->insert(['policy_id' => 1, 'model_uuid' => $uuid]); + } +} + +function fleetopsProfileConversionTypes(SQLiteConnection $connection): array +{ + return $connection->table('users')->orderBy('uuid')->pluck('type', 'uuid')->all(); +} + +test('up converts profile-only accounts and leaves anything that looks like a team member', function () { + $connection = fleetopsProfileConversionBoot(); + + fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver'], 'meta' => json_encode(['source' => 'console'])]); + fleetopsProfileConversionUser($connection, 'b-customer', ['customer' => true, 'roles' => ['Fleet-Ops Customer'], 'role_on_user' => true]); + fleetopsProfileConversionUser($connection, 'c-owner', ['driver' => true, 'roles' => ['Driver']]); + $connection->table('companies')->insert(['uuid' => 'company-1', 'owner_uuid' => 'c-owner']); + fleetopsProfileConversionUser($connection, 'd-extra-role', ['driver' => true, 'roles' => ['Driver', 'Administrator']]); + fleetopsProfileConversionUser($connection, 'e-permission', ['driver' => true, 'roles' => ['Driver'], 'permission' => true]); + fleetopsProfileConversionUser($connection, 'f-policy', ['customer' => true, 'roles' => ['Fleet-Ops Customer'], 'policy' => true]); + fleetopsProfileConversionUser($connection, 'g-both-profiles', ['driver' => true, 'customer' => true, 'roles' => ['Driver']]); + fleetopsProfileConversionUser($connection, 'h-no-roles', ['driver' => true]); + fleetopsProfileConversionUser($connection, 'i-wrong-role', ['customer' => true, 'roles' => ['Driver']]); + fleetopsProfileConversionUser($connection, 'j-no-profile', ['roles' => ['Driver']]); + fleetopsProfileConversionUser($connection, 'k-admin', ['type' => 'admin', 'driver' => true, 'roles' => ['Driver']]); + fleetopsProfileConversionUser($connection, 'l-deleted', ['driver' => true, 'roles' => ['Driver'], 'deleted_at' => '2026-01-01 00:00:00']); + fleetopsProfileConversionUser($connection, 'm-bad-meta', ['driver' => true, 'roles' => ['Driver'], 'meta' => 'not-json']); + + fleetopsProfileConversionMigration()->up(); + + expect(fleetopsProfileConversionTypes($connection))->toBe([ + 'a-driver' => 'driver', + 'b-customer' => 'customer', + 'c-owner' => 'user', + 'd-extra-role' => 'user', + 'e-permission' => 'user', + 'f-policy' => 'user', + 'g-both-profiles' => 'user', + 'h-no-roles' => 'user', + 'i-wrong-role' => 'user', + 'j-no-profile' => 'user', + 'k-admin' => 'admin', + 'l-deleted' => 'user', + 'm-bad-meta' => 'driver', + ]) + // The previous type is recorded next to any existing meta + ->and(json_decode($connection->table('users')->where('uuid', 'a-driver')->value('meta'), true))->toBe(['source' => 'console', 'previous_type' => 'user']) + ->and(json_decode($connection->table('users')->where('uuid', 'm-bad-meta')->value('meta'), true))->toBe(['previous_type' => 'user']) + ->and($GLOBALS['fleetopsProfileConversionLogs'])->toBe([ + ['[fleetops] Converted profile-only user accounts to managed accounts.', ['driver' => 2, 'customer' => 1]], + ]); +}); + +test('down reverts only the accounts up converted', function () { + $connection = fleetopsProfileConversionBoot(); + + fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver'], 'meta' => json_encode(['source' => 'console'])]); + fleetopsProfileConversionUser($connection, 'b-customer', ['customer' => true, 'roles' => ['Fleet-Ops Customer']]); + // Managed accounts created after the change carry no previous type + fleetopsProfileConversionUser($connection, 'c-native-driver', ['type' => 'driver', 'driver' => true, 'meta' => json_encode(['source' => 'app'])]); + // A previous type other than `user` is not this migration's + fleetopsProfileConversionUser($connection, 'd-other-previous', ['type' => 'customer', 'customer' => true, 'meta' => json_encode(['previous_type' => 'contact'])]); + + $migration = fleetopsProfileConversionMigration(); + $migration->up(); + $migration->down(); + + expect(fleetopsProfileConversionTypes($connection))->toBe([ + 'a-driver' => 'user', + 'b-customer' => 'user', + 'c-native-driver' => 'driver', + 'd-other-previous' => 'customer', + ]) + ->and(json_decode($connection->table('users')->where('uuid', 'a-driver')->value('meta'), true))->toBe(['source' => 'console']) + ->and(json_decode($connection->table('users')->where('uuid', 'b-customer')->value('meta'), true))->toBe([]) + ->and(json_decode($connection->table('users')->where('uuid', 'd-other-previous')->value('meta'), true))->toBe(['previous_type' => 'contact']); +}); + +test('up and down do nothing when the tables they read are missing', function () { + $connection = fleetopsProfileConversionBoot(false); + fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver']]); + + $migration = fleetopsProfileConversionMigration(); + $migration->up(); + + expect(fleetopsProfileConversionTypes($connection))->toBe(['a-driver' => 'user']) + ->and($GLOBALS['fleetopsProfileConversionLogs'])->toBe([]); + + $connection->table('users')->where('uuid', 'a-driver')->update(['type' => 'driver', 'meta' => json_encode(['previous_type' => 'user'])]); + $connection->getSchemaBuilder()->drop('users'); + + // Without a users table there is nothing to revert + $migration->down(); + + expect($connection->getSchemaBuilder()->hasTable('users'))->toBeFalse(); +}); diff --git a/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php b/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php index 25a114c67..4b4e81925 100644 --- a/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +++ b/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php @@ -252,10 +252,12 @@ public function __call($method, $arguments) $probe->callProtected('assertContactInputIsValid', [$request, &$input, $contact]); })->toThrow(Exception::class); - // User references resolve through uuid or public id - expect($probe->callProtected('resolveUserUuid', ['user_contactseam']))->toBe('88888888-8888-4888-8888-888888888881') - ->and($probe->callProtected('resolveUserUuid', ['88888888-8888-4888-8888-888888888881']))->toBe('88888888-8888-4888-8888-888888888881') - ->and($probe->callProtected('resolveUserUuid', ['unknown-user']))->toBe('unknown-user'); + // The login account is managed by the profile, so user references are dropped + $userInput = ['name' => 'Seam Contact', 'user_uuid' => '88888888-8888-4888-8888-888888888881', 'user' => 'user_contactseam']; + Closure::bind(function (array &$input) { + $this->resolveUserInput(Request::create('/x', 'PUT', []), $input); + }, $probe, ContactController::class)($userInput); + expect($userInput)->toBe(['name' => 'Seam Contact']); // Portal seams read users, mint passwords, and persist meta quietly expect($probe->callProtected('contactUser', [$contact])?->uuid)->toBe('88888888-8888-4888-8888-888888888881') diff --git a/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php b/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php index a71caed4a..82dc5f916 100644 --- a/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +++ b/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php @@ -125,7 +125,9 @@ public function __call($method, $arguments) 'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'email', 'phone', 'type', 'title', 'meta', 'photo_uuid', 'place_uuid', 'slug', '_key'], 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'password', 'type', 'status', 'timezone', 'slug', 'username', 'avatar_uuid', 'meta', '_key'], 'companies' => ['uuid', 'public_id', 'name', 'country'], - 'files' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'name', 'path', 'disk', 'type', '_key'], + // session_status reads the user's membership of the session company + 'company_users' => ['uuid', 'company_uuid', 'user_uuid', 'status'], + 'files' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'name', 'path', 'disk', 'type', '_key'], ]; foreach ($tables as $table => $columns) { $schema->create($table, function ($blueprint) use ($columns) { @@ -192,5 +194,7 @@ public function __call($method, $arguments) $payload = $probe->callHelper('customerPayload', $customer); expect($payload['uuid'])->toBe('55555555-5555-4555-8555-555555555555') - ->and($payload)->toHaveKey('user'); + ->and($payload['user']['uuid'])->toBe('user-1') + ->and($payload['is_staff_linked'])->toBeFalse() + ->and($payload['login_status'])->toBe('active'); }); diff --git a/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php b/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php index 0d9cc86da..b502938bc 100644 --- a/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +++ b/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php @@ -1131,6 +1131,29 @@ public function getCurrentOrder(): ?Order ]); }); +test('internal driver controller refuses phone login and code verification for deactivated drivers', function () { + FleetOpsInternalDriverAuthControllerProbe::resetProbe(); + app()->instance('request', Request::create('/', 'POST', ['phone' => '15551234567'])); + + $deactivated = fleetopsInternalDriverAuthUser(); + $deactivated->setAttribute('status', 'inactive'); + FleetOpsInternalDriverAuthControllerProbe::$loginUser = $deactivated; + FleetOpsInternalDriverAuthControllerProbe::$verificationUser = $deactivated; + FleetOpsInternalDriverAuthControllerProbe::$verificationExists = true; + + $controller = new FleetOpsInternalDriverAuthControllerProbe(); + $login = $controller->loginWithPhone(); + $verify = $controller->verifyCode(new Request(['identity' => '+15551234567', 'code' => '111111'])); + + expect($login->getStatusCode())->toBe(403) + ->and($login->getData(true))->toBe(['error' => 'This driver login has been deactivated.']) + ->and($verify->getStatusCode())->toBe(403) + ->and($verify->getData(true))->toBe(['error' => 'This driver login has been deactivated.']) + // No code is sent and no token is issued + ->and(FleetOpsInternalDriverAuthControllerProbe::$verifications)->toBe([]) + ->and(FleetOpsInternalDriverAuthControllerProbe::$tokens)->toBe([]); +}); + test('internal driver controller delegates login and create driver verification flows to api controller', function () { $request = new Request(['for' => 'create_driver']); diff --git a/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php b/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php index 141ce1e44..f527fe0fb 100644 --- a/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +++ b/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php @@ -10,11 +10,12 @@ /** * Covers the internal DriverController createRecord and updateRecord flows * against an in-memory SQLite fixture. The creation closure executes end to - * end: company resolution, user creation with driver typing, existing-user - * adoption and conflict detection, organization membership checks, and the - * exception-to-error-response branches. Role assignment requires the full - * spatie permission boot unavailable in the harness, so flows settle in the - * documented error branch after the user record is persisted. + * end: company resolution, managed account creation with driver typing + * through ProfileAccountManager, and the exception-to-error-response + * branches. Role assignment requires the full spatie permission boot, which + * this fixture leaves out, so flows settle in the documented error branch + * after the user record is persisted (see + * DriverControllerExistingUserAdoptionTest for the full flow). */ if (!function_exists('Fleetbase\Support\session')) { eval('namespace Fleetbase\Support; function session($key = null, $default = null) { if ($key === null) { return new class { public function has($k) { return \session($k) !== null; } public function get($k, $d = null) { return \session($k, $d); } }; } return \session($key, $default); }'); @@ -101,6 +102,28 @@ public function validated() } }); Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } + + public function make($value, array $options = []): string + { + return 'hashed:' . $value; + } + + public function check($value, $hashedValue, array $options = []): bool + { + return 'hashed:' . $value === $hashedValue; + } + + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } + }); + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); $schema = $connection->getSchemaBuilder(); $tables = [ @@ -158,31 +181,33 @@ function fleetopsInternalDriverCreateRequest(array $driver): Request ->and($connection->table('company_users')->count())->toBeGreaterThanOrEqual(0); }); -test('create record rejects users that already belong to a driver', function () { +test('create record ignores a picked user account and resolves the login from the email', function () { $connection = fleetopsInternalDriverCreateBoot(); - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Existing', 'type' => 'driver']); - $connection->table('drivers')->insert(['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']); + $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Existing', 'email' => 'existing@example.com', 'type' => 'driver']); - $result = (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([ - 'name' => 'Existing', + (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([ + 'name' => 'Someone Else', + 'email' => 'someone@example.com', 'user_uuid' => '11111111-1111-4111-8111-111111111111', ])); - expect($result)->toBeInstanceOf(JsonResponse::class) - ->and($result->getData(true))->toBe(['error' => 'This user account already belongs to a driver.']); + // The login account is managed by the profile: a new driver account is + // created for the email rather than the picked account being taken over + expect($connection->table('users')->count())->toBe(2) + ->and($connection->table('users')->where('email', 'someone@example.com')->value('type'))->toBe('driver') + ->and($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->value('name'))->toBe('Existing'); }); -test('create record adopts an existing user and applies photo avatars', function () { +test('create record applies photo avatars to the new driver account', function () { $connection = fleetopsInternalDriverCreateBoot(); - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Adoptable', 'type' => 'driver']); $result = (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([ - 'name' => 'Adoptable', - 'user_uuid' => '11111111-1111-4111-8111-111111111111', + 'name' => 'Photo Driver', 'photo_uuid' => '22222222-2222-4222-8222-222222222222', ])); - // Avatar update applies before the harness role-assignment limitation. + // Avatar is set when the account is created, before the harness + // role-assignment limitation. expect($connection->table('users')->value('avatar_uuid'))->toBe('22222222-2222-4222-8222-222222222222') ->and($result)->toBeInstanceOf(JsonResponse::class) ->and($result->getData(true))->toHaveKey('error'); diff --git a/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php b/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php index 09b76933b..b8b8bfdb4 100644 --- a/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +++ b/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php @@ -25,12 +25,13 @@ use Illuminate\Support\Str; /** - * Covers the internal DriverController createRecord unique-conflict branch - * against SQLite with a failing validator: adopting an existing - * organization member by creating a driver profile, returning the existing - * driver profile when one already exists, assigning non-members to the - * company, and falling through to the validation error response when the - * conflict is not a phone or email collision. + * Covers how the internal DriverController createRecord resolves the new + * driver's login account through ProfileAccountManager against SQLite with + * real spatie roles: creating a managed driver account without an invite, + * linking a staff member of the organization without touching their role, + * linking a managed driver account of another organization, rejecting an + * email/phone held by another organization's staff, another profile type or + * an existing driver of the organization, and returning validation failures. */ if (!function_exists('Fleetbase\Observers\event')) { eval('namespace Fleetbase\Observers; function event($event = null, $payload = []) { return []; }'); @@ -212,6 +213,28 @@ public function getMessageBag() } }); Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } + + public function make($value, array $options = []): string + { + return 'hashed:' . $value; + } + + public function check($value, $hashedValue, array $options = []): bool + { + return 'hashed:' . $value === $hashedValue; + } + + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } + }); + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); $schema = $connection->getSchemaBuilder(); $tables = [ @@ -327,66 +350,121 @@ function fleetopsDriverAdoptionRequest(array $driver): Request return Request::create('/int/v1/drivers', 'POST', ['driver' => $driver]); } -test('phone conflicts adopt existing organization members as drivers', function () { - $connection = fleetopsDriverAdoptionBoot(['phone' => ['The phone has already been taken.']]); - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Member', 'phone' => '+6591234567', 'slug' => 'member', 'type' => 'driver']); - $connection->table('company_users')->insert(['uuid' => 'cu-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']); +function fleetopsDriverAdoptionRoles(SQLiteConnection $connection, string $userUuid): array +{ + return $connection->table('model_has_roles') + ->join('roles', 'roles.id', '=', 'model_has_roles.role_id') + ->join('company_users', 'company_users.uuid', '=', 'model_has_roles.model_uuid') + ->where('company_users.user_uuid', $userUuid) + ->pluck('roles.name') + ->unique() + ->values() + ->all(); +} + +test('a new driver gets a managed driver account added to the company without an invite', function () { + $connection = fleetopsDriverAdoptionBoot([]); $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'name' => 'Member', - 'phone' => '+6591234567', + 'name' => 'New Driver', + 'email' => 'New.Driver@Example.com', + 'phone' => '+65 9123-0000', + 'password' => 'chosen-secret', ])); + $user = $connection->table('users')->first(); + expect($result)->toBeArray() - ->and($result['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111111') - ->and($connection->table('drivers')->count())->toBe(1) - ->and($connection->table('drivers')->value('company_uuid'))->toBe('company-1'); + ->and($result['driver']->resource->user_uuid)->toBe($user->uuid) + ->and($user->type)->toBe('driver') + ->and($user->status)->toBe('active') + ->and($user->email)->toBe('new.driver@example.com') + ->and($user->phone)->toBe('+6591230000') + ->and($user->password)->toBe('hashed:chosen-secret') + ->and($connection->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => $user->uuid])->count())->toBe(1) + ->and(fleetopsDriverAdoptionRoles($connection, $user->uuid))->toBe(['Driver']) + ->and($connection->table('invites')->count())->toBe(0); }); -test('phone conflicts assign non members to the session company', function () { - $connection = fleetopsDriverAdoptionBoot(['phone' => ['The phone has already been taken.']]); - - // Same conflict as above, but with no company_users row the user is not yet - // an organization member, so adoption must also attach them to the company - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111112', 'name' => 'Outsider', 'phone' => '+6591234568', 'slug' => 'outsider', 'type' => 'driver']); - - expect($connection->table('company_users')->count())->toBe(0); +test('a staff member of the organization is linked without changing their account or role', function () { + $connection = fleetopsDriverAdoptionBoot([]); + $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Staff Member', 'email' => 'staff@example.com', 'phone' => '+6590001111', 'slug' => 'staff', 'type' => 'user']); $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'name' => 'Outsider', - 'phone' => '+6591234568', + 'name' => 'Different Name', + 'email' => 'staff@example.com', + 'phone' => '+6599998888', ])); + $staff = $connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->first(); + expect($result)->toBeArray() - ->and($result['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111112') - ->and($connection->table('drivers')->count())->toBe(1) - // the assignCompany branch ran: the user is now a member of company-1 - ->and($connection->table('company_users')->where([ - 'company_uuid' => 'company-1', - 'user_uuid' => '11111111-1111-4111-8111-111111111112', - ])->count())->toBe(1); + ->and($result['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111111') + ->and($connection->table('users')->count())->toBe(1) + ->and($staff->type)->toBe('user') + ->and($staff->phone)->toBe('+6590001111') + ->and($staff->name)->toBe('Staff Member') + ->and($connection->table('model_has_roles')->count())->toBe(0) + ->and($connection->table('drivers')->value('company_uuid'))->toBe('company-1'); }); -test('email conflicts return the existing driver profile when present', function () { - $connection = fleetopsDriverAdoptionBoot(['email' => ['The email has already been taken.']]); - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Member', 'email' => 'member@example.com', 'type' => 'driver']); - $connection->table('drivers')->insert(['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']); +test('a managed driver account of another organization is linked and joins the company', function () { + $connection = fleetopsDriverAdoptionBoot([]); + $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111112', 'company_uuid' => 'company-2', 'name' => 'Shared Driver', 'phone' => '+6591234568', 'slug' => 'shared', 'type' => 'driver']); + $connection->table('drivers')->insert(['uuid' => 'driver-elsewhere', 'company_uuid' => 'company-2', 'user_uuid' => '11111111-1111-4111-8111-111111111112']); $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'name' => 'Member', - 'email' => 'member@example.com', + 'name' => 'Shared Driver', + 'phone' => '+6591234568', ])); expect($result)->toBeArray() - ->and($result['driver']->resource->uuid)->toBe('driver-1') - ->and($connection->table('drivers')->count())->toBe(1); + ->and($result['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111112') + ->and($connection->table('drivers')->where('company_uuid', 'company-1')->count())->toBe(1) + ->and($connection->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111112'])->count())->toBe(1) + ->and(fleetopsDriverAdoptionRoles($connection, '11111111-1111-4111-8111-111111111112'))->toBe(['Driver']) + ->and($connection->table('invites')->count())->toBe(0); }); -test('non phone or email conflicts fall through to the error response', function () { +test('an email or phone that cannot be linked is rejected with a 422', function (array $user, ?array $driver, array $input, string $message) { + $connection = fleetopsDriverAdoptionBoot([]); + $connection->table('users')->insert(array_merge(['uuid' => '11111111-1111-4111-8111-111111111111', 'name' => 'Existing'], $user)); + if ($driver) { + $connection->table('drivers')->insert($driver); + } + + $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest(array_merge(['name' => 'New Driver'], $input))); + + expect($result)->toBeInstanceOf(Illuminate\Http\JsonResponse::class) + ->and($result->getStatusCode())->toBe(422) + ->and($result->getData(true)['error'])->toBe($message) + ->and($connection->table('drivers')->where('company_uuid', 'company-1')->where('user_uuid', '!=', '11111111-1111-4111-8111-111111111111')->count())->toBe(0) + ->and($connection->table('users')->count())->toBe(1); +})->with([ + 'staff of another organization' => [ + ['company_uuid' => 'company-2', 'email' => 'outsider@example.com', 'type' => 'admin'], + null, + ['email' => 'outsider@example.com'], + 'This email is already in use by another account.', + ], + 'a customer account' => [ + ['company_uuid' => 'company-1', 'phone' => '+6591112222', 'type' => 'customer'], + null, + ['phone' => '+6591112222'], + 'This phone number is already used by a customer.', + ], + 'an existing driver of the organization' => [ + ['company_uuid' => 'company-1', 'email' => 'member@example.com', 'type' => 'driver'], + ['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111'], + ['email' => 'member@example.com'], + 'A driver with this email already exists.', + ], +]); + +test('validation failures return the error response', function () { fleetopsDriverAdoptionBoot(['name' => ['The name field is required.']]); - // The error response seam raises the validation failure once the - // fall-through branch executes + // The error response seam raises the validation failure $failure = null; try { @@ -398,313 +476,65 @@ function fleetopsDriverAdoptionRequest(array $driver): Request expect($failure?->errors())->toBe(['name' => ['The name field is required.']]); }); -test('valid create requests build the driver user and profile', function () { - $connection = fleetopsDriverAdoptionBoot([]); - - putenv('DEBUG=1'); - $_ENV['DEBUG'] = $_SERVER['DEBUG'] = '1'; - $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'name' => 'Fresh Driver', - 'email' => 'fresh@example.com', - 'phone' => '+6590001111', - ])); - - expect($result)->toBeArray()->toHaveKey('driver') - ->and($connection->table('users')->where('email', 'fresh@example.com')->value('type'))->toBe('driver') - ->and($connection->table('drivers')->whereNotNull('user_uuid')->count())->toBe(1) - ->and($connection->table('model_has_roles')->count())->toBeGreaterThanOrEqual(1); -}); - -test('valid update requests refresh driver and user details', function () { - $connection = fleetopsDriverAdoptionBoot([]); - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Old Name', 'email' => 'old@example.com', 'type' => 'driver']); - $connection->table('drivers')->insert(['uuid' => '22222222-2222-4222-8222-222222222222', 'public_id' => 'driver_updone1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']); - - $request = Request::create('/int/v1/drivers/driver_updone1', 'PUT', ['driver' => [ - 'name' => 'New Name', - 'status' => 'active', - ]]); - $request->setRouteResolver(function () { - return new class { - public function getAction($key = null) - { - return $key === null ? ['controller' => DriverController::class . '@updateRecord'] : DriverController::class . '@updateRecord'; - } - - public function getActionMethod() - { - return 'updateRecord'; - } - - public function getName() - { - return 'internal.drivers.update'; - } - - public function uri() - { - return 'int/v1/drivers/{id}'; - } - - public function parameters() - { - return ['id' => 'driver_updone1']; - } - - public function parameter($name = null, $default = null) - { - return $name === 'id' ? 'driver_updone1' : $default; - } - }; - }); - - $result = (new DriverController())->updateRecord($request, 'driver_updone1'); - - expect($result)->toBeArray()->toHaveKey('driver') - ->and($connection->table('users')->value('name'))->toBe('New Name') - ->and($connection->table('drivers')->value('status'))->toBe('available'); -}); - -test('create with user uuids provisions users guards conflicts and companies', function () { - $connection = fleetopsDriverAdoptionBoot([]); - Illuminate\Support\Facades\Cache::clearResolvedInstance('cache'); - - // Unknown user uuids provision a fresh user with generated credentials - $created = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'user_uuid' => '99999999-9999-4999-8999-999999999901', - 'name' => 'Provisioned Driver', - 'email' => 'provisioned@example.com', - 'phone' => '+6591112222', - 'photo_uuid' => '99999999-9999-4999-8999-999999999902', - ])); - expect($created)->toBeArray()->toHaveKey('driver') - ->and($connection->table('users')->where('email', 'provisioned@example.com')->count())->toBe(1) - ->and($connection->table('users')->where('email', 'provisioned@example.com')->value('avatar_uuid'))->toBe('99999999-9999-4999-8999-999999999902'); - - // Users already holding a driver profile in the company are rejected - $existingUserUuid = $connection->table('users')->where('email', 'provisioned@example.com')->value('uuid'); - $conflict = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'user_uuid' => $existingUserUuid, - 'name' => 'Duplicate Driver', - ])); - expect($conflict->getData(true)['error'] ?? '')->toContain('already belongs'); - - // Without a session company driver creation fails outright - session(['company' => null]); - $noCompany = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'name' => 'Companyless Driver', - ])); - expect($noCompany->getData(true)['error'] ?? '')->toContain('Unable to create driver'); - session(['company' => 'company-1']); -}); - -test('auth can resolves real permissions for requests and ping guards', function () { - $connection = fleetopsDriverAdoptionBoot([]); - $connection->table('users')->insert(['uuid' => '77777777-7777-4777-8777-777777777701', 'public_id' => 'user_authcan1', 'company_uuid' => 'company-1', 'name' => 'Permitted User', 'type' => 'user']); - $connection->table('company_users')->insert(['uuid' => '77777777-7777-4777-8777-777777777702', 'company_uuid' => 'company-1', 'user_uuid' => '77777777-7777-4777-8777-777777777701', 'status' => 'active']); - $connection->table('permissions')->insert([ - ['name' => 'fleet-ops update driver', 'guard_name' => 'sanctum'], - ['name' => 'fleet-ops update order', 'guard_name' => 'sanctum'], - ['name' => 'fleet-ops create driver', 'guard_name' => 'sanctum'], - ]); - $companyUserMorph = (new Fleetbase\Models\CompanyUser())->getMorphClass(); - $permissionIds = $connection->table('permissions')->pluck('id', 'name'); - $connection->table('model_has_permissions')->insert([ - ['permission_id' => $permissionIds['fleet-ops update driver'], 'model_type' => $companyUserMorph, 'model_uuid' => '77777777-7777-4777-8777-777777777702'], - ['permission_id' => $permissionIds['fleet-ops update order'], 'model_type' => $companyUserMorph, 'model_uuid' => '77777777-7777-4777-8777-777777777702'], - ['permission_id' => $permissionIds['fleet-ops create driver'], 'model_type' => $companyUserMorph, 'model_uuid' => '77777777-7777-4777-8777-777777777702'], - ]); - session(['company' => 'company-1', 'user' => '77777777-7777-4777-8777-777777777701']); - - // Granted permissions authorize, missing permissions deny - expect(Fleetbase\Support\Auth::can('fleet-ops update driver'))->toBeTrue() - ->and(Fleetbase\Support\Auth::can('fleet-ops delete driver'))->toBeFalse(); - - // The update driver form request authorizes through the same gate - $updateRequest = new Fleetbase\FleetOps\Http\Requests\Internal\UpdateDriverRequest(); - expect($updateRequest->authorize())->toBeTrue(); - - // Create-driver is granted, create-order-config was never granted, so the - // two internal create requests resolve opposite ways through one gate - expect((new Fleetbase\FleetOps\Http\Requests\Internal\CreateDriverRequest())->authorize())->toBeTrue() - ->and((new Fleetbase\FleetOps\Http\Requests\Internal\CreateOrderConfigRequest())->authorize())->toBeFalse(); - - // Fleet action requests resolve their permission through the same gate - $fleetRequest = Fleetbase\FleetOps\Http\Requests\Internal\FleetActionRequest::create('/int/v1/fleets/assign-vehicle', 'POST'); - $canMethod = new ReflectionMethod(Fleetbase\FleetOps\Http\Requests\Internal\FleetActionRequest::class, 'can'); - $canMethod->setAccessible(true); - expect($canMethod->invoke($fleetRequest, 'fleet-ops update driver'))->toBeTrue() - ->and($canMethod->invoke($fleetRequest, 'fleet-ops assign-vehicle-for fleet'))->toBeFalse(); - - // Driver ping authorization resolves through the order permission - $orderController = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrderController(); - $canPing = new ReflectionMethod($orderController, 'canPingDriver'); - $canPing->setAccessible(true); - expect($canPing->invoke($orderController))->toBeTrue(); - - // Creating an order is gated on its own permission, which was never granted - expect((new Fleetbase\FleetOps\Http\Requests\Internal\CreateOrderRequest())->authorize())->toBeFalse(); - - // Global search consults the same gate per result type. This user is not an - // admin and holds no `see` permissions, so every requested type is skipped - // and nothing is searched rather than leaking unpermitted records - $searchController = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\SearchController(); - $searchResponse = $searchController->search(Request::create('/int/v1/search', 'GET', [ - 'query' => 'anything', - 'types' => 'orders,drivers', - ])); - - expect($searchResponse->getData(true))->toBe(['results' => []]); - - session(['user' => null]); -}); - -function fleetopsDriverAdoptionUpdateRequest(string $publicId, array $driver): Request +function fleetopsDriverAdoptionThrowingHasher(Throwable $exception): void { - $request = Request::create('/int/v1/drivers/' . $publicId, 'PUT', ['driver' => $driver]); - $request->setRouteResolver(function () use ($publicId) { - return new class($publicId) { - public function __construct(private string $publicId) - { - } - - public function getAction($key = null) - { - return $key === null ? ['controller' => DriverController::class . '@updateRecord'] : DriverController::class . '@updateRecord'; - } - - public function getActionMethod() - { - return 'updateRecord'; - } - - public function getName() - { - return 'internal.drivers.update'; - } + $GLOBALS['fleetopsDriverAdoptionHashException'] = $exception; + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } - public function uri() - { - return 'int/v1/drivers/{id}'; - } + public function make($value, array $options = []): string + { + throw $GLOBALS['fleetopsDriverAdoptionHashException']; + } - public function parameters() - { - return ['id' => $this->publicId]; - } + public function check($value, $hashedValue, array $options = []): bool + { + return false; + } - public function parameter($name = null, $default = null) - { - return $name === 'id' ? $this->publicId : $default; - } - }; + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } }); - - return $request; + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); } -test('creation applies photo avatars and syncs custom field values', function () { - $connection = fleetopsDriverAdoptionBoot([]); - $connection->table('custom_fields')->insert(['uuid' => '33333333-3333-4333-8333-333333333301', 'company_uuid' => 'company-1', 'name' => 'badge_number', 'label' => 'Badge Number']); - - $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'name' => 'Photographed Driver', - 'email' => 'photographed@example.com', - 'phone' => '+6590002222', - 'photo_uuid' => '44444444-4444-4444-8444-444444444401', - 'custom_field_values' => [ - ['custom_field_uuid' => '33333333-3333-4333-8333-333333333301', 'value' => 'BADGE-7', 'value_type' => 'text'], - ], - ])); - - expect($result)->toBeArray()->toHaveKey('driver') - ->and($connection->table('users')->where('email', 'photographed@example.com')->value('avatar_uuid'))->toBe('44444444-4444-4444-8444-444444444401') - ->and($connection->table('custom_field_values')->where('value', 'BADGE-7')->count())->toBe(1); -}); - -test('creation maps query and validation failures onto error responses', function () { +test('create record reports a missing organization', function () { fleetopsDriverAdoptionBoot([]); + session(['company' => 'company-missing']); - // Query failures from the persistence pipeline report a generic message - $queryFailure = new DriverController(); - $queryFailure->model = new class extends Fleetbase\FleetOps\Models\Driver { - public function createRecordFromRequest($request, ?callable $onBefore = null, ?callable $onAfter = null, array $options = []) - { - throw new Illuminate\Database\QueryException('mysql', 'insert into drivers', [], new RuntimeException('constraint violation')); - } - }; - $queryResponse = $queryFailure->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Query Failure'])); - expect($queryResponse->getData(true)['error'] ?? '')->not->toBeEmpty(); + $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Nowhere Driver'])); - // Validation exceptions surface their individual error messages - $validationFailure = new DriverController(); - $validationFailure->model = new class extends Fleetbase\FleetOps\Models\Driver { - public function createRecordFromRequest($request, ?callable $onBefore = null, ?callable $onAfter = null, array $options = []) - { - throw new Fleetbase\Exceptions\FleetbaseRequestValidationException(['The name field is required.']); - } - }; - $validationResponse = $validationFailure->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Validation Failure'])); - expect($validationResponse->getData(true)['error'])->toBe(['The name field is required.']); + expect($result->getData(true))->toBe(['error' => 'Unable to create driver.']); }); -test('updates apply photo avatars and sync custom field values', function () { +test('create record syncs custom field values onto the new driver', function () { $connection = fleetopsDriverAdoptionBoot([]); - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111121', 'company_uuid' => 'company-1', 'name' => 'Photo Driver', 'email' => 'photodriver@example.com', 'type' => 'driver']); - $connection->table('drivers')->insert(['uuid' => '22222222-2222-4222-8222-222222222221', 'public_id' => 'driver_photoone', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111121']); - $connection->table('custom_fields')->insert(['uuid' => '33333333-3333-4333-8333-333333333302', 'company_uuid' => 'company-1', 'name' => 'route_code', 'label' => 'Route Code']); - - $result = (new DriverController())->updateRecord(fleetopsDriverAdoptionUpdateRequest('driver_photoone', [ - 'name' => 'Photo Driver Updated', - 'photo_uuid' => '44444444-4444-4444-8444-444444444402', - 'custom_field_values' => [ - ['custom_field_uuid' => '33333333-3333-4333-8333-333333333302', 'value' => 'ROUTE-9', 'value_type' => 'text'], - ], - ]), 'driver_photoone'); - - expect($result)->toBeArray()->toHaveKey('driver') - ->and($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111121')->value('avatar_uuid'))->toBe('44444444-4444-4444-8444-444444444402') - ->and($connection->table('custom_field_values')->where('value', 'ROUTE-9')->count())->toBe(1); -}); + $connection->table('custom_fields')->insert(['uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'company_uuid' => 'company-1', 'name' => 'badge', 'label' => 'Badge']); -test('updates map validation and generic failures onto error responses', function () { - fleetopsDriverAdoptionBoot([]); - - // Validation exceptions from the pipeline surface their error list - $validationFailure = new DriverController(); - $validationFailure->model = new class extends Fleetbase\FleetOps\Models\Driver { - public function updateRecordFromRequest(Request $request, $id, ?callable $onBefore = null, ?callable $onAfter = null, array $options = []) - { - throw new Fleetbase\Exceptions\FleetbaseRequestValidationException(['The status field is invalid.']); - } - }; - $validationResponse = $validationFailure->updateRecord(fleetopsDriverAdoptionUpdateRequest('driver_missing1', ['name' => 'Validation Failure']), 'driver_missing1'); - expect($validationResponse->getData(true)['error'])->toBe(['The status field is invalid.']); + $result = (new DriverController())->createRecord(Request::create('/int/v1/drivers', 'POST', ['driver' => [ + 'name' => 'Badged Driver', + 'custom_field_values' => [['custom_field_uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'value' => 'B-12']], + ]])); - // Any other failure reports its own message - $genericFailure = new DriverController(); - $genericFailure->model = new class extends Fleetbase\FleetOps\Models\Driver { - public function updateRecordFromRequest(Request $request, $id, ?callable $onBefore = null, ?callable $onAfter = null, array $options = []) - { - throw new Exception('Driver profile is locked.'); - } - }; - $genericResponse = $genericFailure->updateRecord(fleetopsDriverAdoptionUpdateRequest('driver_missing1', ['name' => 'Generic Failure']), 'driver_missing1'); - expect($genericResponse->getData(true)['error'] ?? '')->toBe('Driver profile is locked.'); + expect($result)->toBeArray() + ->and($connection->table('custom_field_values')->where('subject_uuid', $result['driver']->resource->uuid)->value('value'))->toBe('B-12'); }); -test('update rejects requests that fail validation before persistence', function () { - fleetopsDriverAdoptionBoot(['status' => ['The selected status is invalid.']]); +test('create record reports database and request validation failures', function () { + $connection = fleetopsDriverAdoptionBoot([]); + $connection->getSchemaBuilder()->drop('drivers'); - // The error-response seam raises the validation failure once the rejection - // branch executes, so the request never reaches persistence - $failure = null; + // The account is created before the driver row fails to insert + $queryFailure = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Tableless Driver'])); + expect($queryFailure->getData(true)['error'])->toContain('drivers'); - try { - (new DriverController())->updateRecord(fleetopsDriverAdoptionUpdateRequest('driver_missing1', ['status' => 'bogus']), 'driver_missing1'); - } catch (Illuminate\Validation\ValidationException $exception) { - $failure = $exception; - } + fleetopsDriverAdoptionThrowingHasher(new Fleetbase\Exceptions\FleetbaseRequestValidationException(['password' => ['The password is too weak.']])); + $validationFailure = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Weak Password', 'password' => 'weak-password'])); - expect($failure?->errors())->toBe(['status' => ['The selected status is invalid.']]); + expect($validationFailure->getData(true))->toBe(['error' => ['password' => ['The password is too weak.']]]); }); diff --git a/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php b/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php new file mode 100644 index 000000000..16bb6cd74 --- /dev/null +++ b/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php @@ -0,0 +1,716 @@ +rule; } }'); +} + +if (!Str::hasMacro('humanize')) { + Str::macro('humanize', fn ($value) => ucfirst(str_replace(['_', '-'], ' ', Str::snake((string) $value)))); +} + +if (!Request::hasMacro('getController')) { + Request::macro('getController', fn () => new DriverController()); +} + +if (!Request::hasMacro('or')) { + Request::macro('or', function (array $params = [], $default = null) { + foreach ($params as $param) { + if ($this->has($param)) { + return $this->input($param); + } + } + + return $default; + }); +} + +function fleetopsDriverLoginContainer(): void +{ + $current = Illuminate\Container\Container::getInstance(); + if (method_exists($current, 'hasDebugModeEnabled')) { + return; + } + + // Core record mutation surfaces exceptions through app()->hasDebugModeEnabled() + // and app()->environment(), which the harness container lacks + $replacement = new class extends Illuminate\Container\Container { + public function environment(...$environments) + { + if (empty($environments)) { + return 'testing'; + } + + $checks = is_array($environments[0]) ? $environments[0] : $environments; + + return in_array('testing', $checks, true); + } + + public function hasDebugModeEnabled() + { + return true; + } + }; + + foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'extenders', 'tags', 'contextual', 'scopedInstances', 'reboundCallbacks', 'globalBeforeResolvingCallbacks', 'globalResolvingCallbacks', 'globalAfterResolvingCallbacks', 'beforeResolvingCallbacks', 'resolvingCallbacks', 'afterResolvingCallbacks'] as $property) { + if (!property_exists(Illuminate\Container\Container::class, $property)) { + continue; + } + $reflection = new ReflectionProperty(Illuminate\Container\Container::class, $property); + $reflection->setAccessible(true); + if ($reflection->isInitialized($current)) { + $reflection->setValue($replacement, $reflection->getValue($current)); + } + } + + Illuminate\Container\Container::setInstance($replacement); + Illuminate\Support\Facades\Facade::setFacadeApplication($replacement); +} + +function fleetopsDriverLoginBoot(array $validatorErrors): SQLiteConnection +{ + fleetopsDriverLoginContainer(); + $pdo = new PDO('sqlite::memory:'); + $pdo->sqliteCreateFunction('ST_PointFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt); + $pdo->sqliteCreateFunction('ST_GeomFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt); + $connection = new SQLiteConnection($pdo); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + if (!EloquentModel::getEventDispatcher()) { + EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher()); + } + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + app()->instance('db.schema', $connection->getSchemaBuilder()); + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + $GLOBALS['fleetopsDriverLoginErrors'] = $validatorErrors; + app()->instance('validator', new class { + public function make($data = [], $rules = [], $messages = [], $attributes = []) + { + return new class implements Illuminate\Contracts\Validation\Validator { + public function fails() + { + return !empty($GLOBALS['fleetopsDriverLoginErrors']); + } + + public function errors() + { + return new MessageBag($GLOBALS['fleetopsDriverLoginErrors']); + } + + public function validated() + { + return []; + } + + public function validate() + { + return []; + } + + public function failed() + { + return array_keys($GLOBALS['fleetopsDriverLoginErrors']); + } + + public function sometimes($attribute, $rules, callable $callback) + { + return $this; + } + + public function after($callback) + { + return $this; + } + + public function getMessageBag() + { + return new MessageBag($GLOBALS['fleetopsDriverLoginErrors']); + } + }; + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } + + public function make($value, array $options = []): string + { + return 'hashed:' . $value; + } + + public function check($value, $hashedValue, array $options = []): bool + { + return 'hashed:' . $value === $hashedValue; + } + + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } + }); + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'drivers' => ['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'current_job_uuid', 'auth_token', 'signup_token_used', 'avatar_url', 'drivers_license_number', 'license_expiry', 'location', 'heading', 'bearing', 'altitude', 'speed', 'currency', 'current_status', 'meta', 'location_updated_at', 'slug', 'status', 'country', 'city', 'online', '_key'], + 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'password', 'status', 'type', 'username', 'avatar_uuid', 'slug', 'timezone', 'country', 'ip_address', 'meta', '_key'], + 'companies' => ['uuid', 'public_id', 'name', 'owner_uuid', 'timezone', 'options', 'status', '_key'], + 'company_users' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'status', '_key'], + 'vehicles' => ['uuid', 'public_id', 'company_uuid', 'driver_uuid'], + 'custom_fields' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'name', 'label'], + 'custom_field_values' => ['uuid', 'public_id', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type', '_key'], + 'settings' => ['key', 'value'], + 'permissions' => ['name', 'guard_name', 'service', 'description'], + 'roles' => ['uuid', 'public_id', 'name', 'guard_name', 'company_uuid', 'service', 'description', '_key'], + 'model_has_roles' => ['role_id', 'model_type', 'model_uuid'], + 'model_has_permissions' => ['permission_id', 'model_type', 'model_uuid'], + 'role_has_permissions' => ['permission_id', 'role_id'], + 'policies' => ['name', 'guard_name', 'company_uuid'], + 'directives' => ['uuid', 'public_id', 'company_uuid', 'permission_uuid', 'subject_type', 'subject_uuid', 'key', 'rules'], + 'files' => ['uuid', 'public_id', 'company_uuid', 'uploader_uuid', 'subject_uuid', 'subject_type', 'name', 'original_filename', 'extension', 'content_type', 'path', 'bucket', 'disk', 'size', 'type', 'meta', '_key'], + 'invites' => ['uuid', 'public_id', 'company_uuid', 'created_by_uuid', 'subject_uuid', 'subject_type', 'code', 'uri', 'protocol', 'recipients', 'reason', 'expires_at', '_key'], + 'notifications' => ['type', 'notifiable_type', 'notifiable_id', 'data', 'read_at'], + 'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'email', 'phone', 'type'], + 'personal_access_tokens' => ['tokenable_type', 'tokenable_id', 'name', 'token', 'abilities', 'last_used_at', 'expires_at'], + ]; + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + app()->instance('cache', new class { + public function tags($tags = null) + { + return $this; + } + + public function flush() + { + return true; + } + + public function remember($key, $ttl, $callback) + { + return $callback(); + } + + public function store($name = null) + { + return $this; + } + + public function __call($method, $arguments) + { + return null; + } + }); + Illuminate\Support\Facades\Cache::clearResolvedInstance('cache'); + app()->instance(Illuminate\Contracts\Notifications\Dispatcher::class, new class { + public array $sent = []; + + public function send($notifiables, $notification) + { + $this->sent[] = $notification; + } + + public function sendNow($notifiables, $notification, ?array $channels = null) + { + $this->sent[] = $notification; + } + + public function __call($method, $arguments) + { + return null; + } + }); + config()->set('cache.default', 'array'); + config()->set('cache.stores.array', ['driver' => 'array']); + config()->set('permission.cache.expiration_time', 60); + config()->set('permission.cache.key', 'spatie.permission.cache'); + config()->set('permission.cache.store', 'default'); + config()->set('permission.models.permission', Fleetbase\Models\Permission::class); + config()->set('permission.models.role', Fleetbase\Models\Role::class); + config()->set('permission.table_names', ['roles' => 'roles', 'permissions' => 'permissions', 'model_has_permissions' => 'model_has_permissions', 'model_has_roles' => 'model_has_roles', 'role_has_permissions' => 'role_has_permissions']); + config()->set('permission.column_names', ['role_pivot_key' => null, 'permission_pivot_key' => null, 'model_morph_key' => 'model_uuid', 'team_foreign_key' => 'team_id']); + config()->set('permission.teams', false); + config()->set('permission.events_enabled', false); + $cacheManager = new Illuminate\Cache\CacheManager(app()); + app()->instance(Illuminate\Cache\CacheManager::class, $cacheManager); + app()->instance(Spatie\Permission\PermissionRegistrar::class, new Spatie\Permission\PermissionRegistrar($cacheManager)); + config()->set('activitylog.enabled', false); + config()->set('activitylog.default_auth_driver', 'web'); + config()->set('auth.defaults.guard', 'web'); + config()->set('permission.models.role', Fleetbase\Models\Role::class); + app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config()); + + session(['company' => 'company-1']); + $connection->table('companies')->insert(['uuid' => 'company-1', 'public_id' => 'company_adopt1', 'name' => 'Acme']); + $connection->table('roles')->insert([ + ['uuid' => 'role-1', 'name' => 'Driver', 'guard_name' => 'web', 'company_uuid' => 'company-1'], + ['uuid' => 'role-2', 'name' => 'Driver', 'guard_name' => 'sanctum', 'company_uuid' => 'company-1'], + ['uuid' => 'role-3', 'name' => 'Administrator', 'guard_name' => 'web', 'company_uuid' => 'company-1'], + ['uuid' => 'role-4', 'name' => 'Administrator', 'guard_name' => 'sanctum', 'company_uuid' => 'company-1'], + ]); + + return $connection; +} + +class FleetOpsDriverLoginSmsFake extends SmsService +{ + public array $sent = []; + + public function __construct() + { + } + + public function send(string $to, string $text, array $options = [], ?string $provider = null): array + { + $this->sent[] = [$to, $text]; + + return ['success' => true]; + } +} + +function fleetopsDriverLoginFixture(): SQLiteConnection +{ + $connection = fleetopsDriverLoginBoot([]); + + Illuminate\Support\Facades\Mail::swap(new class { + public array $sent = []; + + public function to($users) + { + return $this; + } + + public function send($mailable) + { + $this->sent[] = $mailable; + + return null; + } + }); + app()->instance(SmsService::class, new FleetOpsDriverLoginSmsFake()); + + $users = [ + ['uuid' => '11111111-1111-4111-8111-111111111111', 'public_id' => 'user_managed', 'company_uuid' => 'company-1', 'name' => 'Managed Driver', 'email' => 'managed@example.com', 'phone' => '+6590000001', 'password' => 'hashed:old', 'status' => 'pending', 'type' => 'driver'], + ['uuid' => '22222222-2222-4222-8222-222222222222', 'public_id' => 'user_staff', 'company_uuid' => 'company-1', 'name' => 'Staff Driver', 'email' => 'staff@example.com', 'phone' => '+6590000002', 'password' => 'hashed:staff', 'status' => 'active', 'type' => 'user'], + ['uuid' => '33333333-3333-4333-8333-333333333333', 'public_id' => 'user_nocontact', 'company_uuid' => 'company-1', 'name' => 'Unreachable', 'status' => 'active', 'type' => 'driver'], + ]; + foreach ($users as $user) { + $connection->table('users')->insert($user); + } + $connection->table('company_users')->insert(['uuid' => 'cu-managed', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111', 'status' => 'pending']); + $drivers = [ + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', 'public_id' => 'driver_managed', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111'], + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2', 'public_id' => 'driver_staff', 'company_uuid' => 'company-1', 'user_uuid' => '22222222-2222-4222-8222-222222222222'], + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3', 'public_id' => 'driver_nocontact', 'company_uuid' => 'company-1', 'user_uuid' => '33333333-3333-4333-8333-333333333333'], + ]; + foreach ($drivers as $driver) { + $connection->table('drivers')->insert($driver); + } + + return $connection; +} + +function fleetopsDriverLoginUser(SQLiteConnection $connection, string $uuid = '11111111-1111-4111-8111-111111111111'): object +{ + return $connection->table('users')->where('uuid', $uuid)->first(); +} + +test('send credentials sets a new password activates the login and emails it', function () { + $connection = fleetopsDriverLoginFixture(); + + $response = (new DriverController())->sendCredentials('driver_managed'); + $data = $response->getData(true); + $user = fleetopsDriverLoginUser($connection); + + expect($data['status'])->toBe('ok') + ->and($data['sent_via'])->toBe('email') + ->and($data['driver'])->toMatchArray([ + 'id' => 'driver_managed', + 'uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', + 'user_uuid' => '11111111-1111-4111-8111-111111111111', + 'is_staff_linked' => false, + 'login_status' => 'active', + ]) + ->and($data['driver']['user'])->toMatchArray([ + 'id' => 'user_managed', + 'email' => 'managed@example.com', + 'phone' => '+6590000001', + 'status' => 'active', + ]) + ->and($user->status)->toBe('active') + ->and($user->password)->not->toBe('hashed:old') + ->and(Illuminate\Support\Facades\Mail::getFacadeRoot()->sent[0])->toBeInstanceOf(Fleetbase\FleetOps\Mail\DriverCredentialsMail::class); + + // A driver with neither an email nor a phone can't be sent credentials + $unreachable = (new DriverController())->sendCredentials('driver_nocontact'); + expect($unreachable->getData(true))->toBe(['error' => 'This profile has no email or phone to send credentials to.']); +}); + +test('login management endpoints report missing drivers accounts and staff-linked logins', function () { + $connection = fleetopsDriverLoginFixture(); + // The driver scope keeps drivers whose account was deleted (the user + // relation includes trashed accounts), but such a driver has no login + $connection->table('users')->insert(['uuid' => '44444444-4444-4444-8444-444444444444', 'company_uuid' => 'company-1', 'type' => 'driver', 'deleted_at' => '2026-01-01 00:00:00']); + $connection->table('drivers')->insert(['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4', 'public_id' => 'driver_orphan', 'company_uuid' => 'company-1', 'user_uuid' => '44444444-4444-4444-8444-444444444444']); + $controller = new DriverController(); + $reset = fn (string $id) => $controller->resetCredentials(Request::create('/x', 'POST', ['password' => 'long-enough', 'password_confirmation' => 'long-enough']), $id); + + foreach ([ + fn (string $id) => $controller->sendCredentials($id), + $reset, + fn (string $id) => $controller->deactivateLogin($id), + fn (string $id) => $controller->reactivateLogin($id), + ] as $endpoint) { + $missing = $endpoint('driver_unknown'); + $orphan = $endpoint('driver_orphan'); + $staff = $endpoint('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2'); + + expect($missing->getStatusCode())->toBe(404) + ->and($missing->getData(true))->toBe(['error' => 'Driver not found.']) + ->and($orphan->getData(true))->toBe(['error' => 'This driver has no login account.']) + ->and($staff->getStatusCode())->toBe(422) + ->and($staff->getData(true))->toBe(['error' => 'This driver signs in with a team member account. Manage this login in IAM.']); + } + + // The team member's account is never touched + $staffUser = fleetopsDriverLoginUser($connection, '22222222-2222-4222-8222-222222222222'); + expect($staffUser->password)->toBe('hashed:staff') + ->and($staffUser->status)->toBe('active') + ->and(Illuminate\Support\Facades\Mail::getFacadeRoot()->sent)->toBe([]); +}); + +test('reset credentials validates the password and optionally sends it', function () { + $connection = fleetopsDriverLoginFixture(); + $controller = new DriverController(); + $request = fn (array $input) => Request::create('/x', 'POST', $input); + + expect($controller->resetCredentials($request(['password' => 'short', 'password_confirmation' => 'short']), 'driver_managed')->getData(true)) + ->toBe(['error' => 'Password must be at least 8 characters.']) + ->and($controller->resetCredentials($request([]), 'driver_managed')->getData(true)) + ->toBe(['error' => 'Password must be at least 8 characters.']) + ->and($controller->resetCredentials($request(['password' => 'long-enough', 'password_confirmation' => 'different']), 'driver_managed')->getData(true)) + ->toBe(['error' => 'Passwords do not match.']); + + $quiet = $controller->resetCredentials($request(['password' => 'quiet-secret', 'password_confirmation' => 'quiet-secret']), 'driver_managed'); + expect($quiet->getData(true)['status'])->toBe('ok') + ->and(fleetopsDriverLoginUser($connection)->password)->toBe('hashed:quiet-secret') + ->and(Illuminate\Support\Facades\Mail::getFacadeRoot()->sent)->toBe([]); + + $sent = $controller->resetCredentials($request(['password' => 'mailed-secret', 'password_confirmation' => 'mailed-secret', 'send_credentials' => true]), 'driver_managed'); + expect($sent->getData(true)['driver']['user_uuid'])->toBe('11111111-1111-4111-8111-111111111111') + ->and(fleetopsDriverLoginUser($connection)->password)->toBe('hashed:mailed-secret') + ->and(Illuminate\Support\Facades\Mail::getFacadeRoot()->sent)->toHaveCount(1); + + // The password is still changed when there's nowhere to send it + $unsendable = $controller->resetCredentials($request(['password' => 'kept-secret', 'password_confirmation' => 'kept-secret', 'send_credentials' => '1']), 'driver_nocontact'); + expect($unsendable->getData(true))->toBe(['error' => 'This profile has no email or phone to send credentials to.']) + ->and(fleetopsDriverLoginUser($connection, '33333333-3333-4333-8333-333333333333')->password)->toBe('hashed:kept-secret'); +}); + +test('deactivating a login signs the driver out and reactivating restores it', function () { + $connection = fleetopsDriverLoginFixture(); + $connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->update(['status' => 'active']); + $connection->table('personal_access_tokens')->insert([ + 'tokenable_type' => Fleetbase\Models\User::class, + 'tokenable_id' => '11111111-1111-4111-8111-111111111111', + 'name' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', + 'token' => 'token-hash', + ]); + $connection->table('personal_access_tokens')->insert([ + 'tokenable_type' => Fleetbase\Models\User::class, + 'tokenable_id' => '22222222-2222-4222-8222-222222222222', + 'name' => 'staff-session', + 'token' => 'staff-token-hash', + ]); + $controller = new DriverController(); + + $deactivated = $controller->deactivateLogin('driver_managed')->getData(true); + + expect($deactivated['status'])->toBe('ok') + ->and($deactivated['driver']['login_status'])->toBe('inactive') + ->and(fleetopsDriverLoginUser($connection)->status)->toBe('inactive') + ->and($connection->table('company_users')->where('uuid', 'cu-managed')->value('status'))->toBe('inactive') + // Only the driver's own tokens are revoked + ->and($connection->table('personal_access_tokens')->pluck('name')->all())->toBe(['staff-session']); + + $reactivated = $controller->reactivateLogin('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1')->getData(true); + + expect($reactivated['driver']['login_status'])->toBe('active') + ->and($reactivated['driver']['user']['session_status'])->toBe('active') + ->and(fleetopsDriverLoginUser($connection)->status)->toBe('active'); +}); + +function fleetopsDriverLoginUpdateRequest(string $id, array $driver): Request +{ + $request = Request::create('/int/v1/drivers/' . $id, 'PUT', ['driver' => $driver]); + $store = app('session.store'); + $store->put('company', 'company-1'); + $request->setLaravelSession($store); + $request->setRouteResolver(fn () => new class($id) { + public function __construct(private string $id) + { + } + + public function getAction($key = null) + { + return DriverController::class . '@updateRecord'; + } + + public function getActionMethod() + { + return 'updateRecord'; + } + + public function uri() + { + return 'int/v1/drivers/{id}'; + } + + public function getName() + { + return 'int.v1.drivers.update'; + } + + public function parameters() + { + return ['id' => $this->id]; + } + + public function parameter($key, $default = null) + { + return $key === 'id' ? $this->id : $default; + } + }); + app()->instance('request', $request); + + return $request; +} + +test('update record syncs proxy fields to a managed account and only the name to a team member', function () { + $connection = fleetopsDriverLoginFixture(); + $controller = new DriverController(); + + $managed = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', [ + 'name' => 'Renamed Driver', + 'email' => 'Renamed@Example.com', + 'phone' => null, + 'photo_uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + 'user_uuid' => '22222222-2222-4222-8222-222222222222', + ]), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'); + + $user = fleetopsDriverLoginUser($connection); + + expect($managed)->toBeArray() + ->and($managed['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111111') + ->and($user->name)->toBe('Renamed Driver') + ->and($user->email)->toBe('renamed@example.com') + ->and($user->phone)->toBeNull() + ->and($user->avatar_uuid)->toBe('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'); + + $staff = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2', [ + 'name' => 'Staff As Driver', + 'email' => 'changed@example.com', + ]), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2'); + + $staffUser = fleetopsDriverLoginUser($connection, '22222222-2222-4222-8222-222222222222'); + + expect($staff)->toBeArray() + ->and($staffUser->name)->toBe('Staff As Driver') + ->and($staffUser->email)->toBe('staff@example.com'); + + // Taking another account's email is refused + $conflict = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', [ + 'email' => 'staff@example.com', + ]), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'); + + expect($conflict->getStatusCode())->toBe(422) + ->and($conflict->getData(true))->toBe(['error' => 'This email is already in use by another account.']) + ->and(fleetopsDriverLoginUser($connection)->email)->toBe('renamed@example.com'); +}); + +function fleetopsDriverLoginLookup(array $input): array +{ + return (new FleetOpsLookupController())->profileIdentity(Request::create('/int/v1/fleet-ops/lookup/profile-identity', 'GET', $input))->getData(true); +} + +test('profile identity lookup reports the team member to link or the conflict', function () { + $connection = fleetopsDriverLoginFixture(); + $connection->table('users')->insert(['uuid' => '55555555-5555-4555-8555-555555555555', 'company_uuid' => 'company-1', 'name' => 'Office Staff', 'email' => 'office@example.com', 'phone' => '+6590000005', 'type' => 'admin']); + $connection->table('users')->insert(['uuid' => '66666666-6666-4666-8666-666666666666', 'company_uuid' => 'company-1', 'name' => 'Customer', 'email' => 'customer@example.com', 'type' => 'customer']); + $connection->table('contacts')->insert(['uuid' => 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', 'public_id' => 'contact_customer', 'company_uuid' => 'company-1', 'user_uuid' => '66666666-6666-4666-8666-666666666666', 'type' => 'customer']); + $connection->table('contacts')->insert(['uuid' => 'cccccccc-cccc-4ccc-8ccc-ccccccccccc2', 'public_id' => 'contact_nouser', 'company_uuid' => 'company-1', 'type' => 'contact']); + + expect((new FleetOpsLookupController())->profileIdentity(Request::create('/x', 'GET', ['type' => 'user']))->getStatusCode())->toBe(422) + ->and(fleetopsDriverLoginLookup(['type' => 'staff']))->toBe(['error' => 'Invalid profile type.']) + // Nothing entered, nothing found + ->and(fleetopsDriverLoginLookup([]))->toBe(['staff' => null, 'conflict' => null]) + // A new driver with a team member's email links to them + ->and(fleetopsDriverLoginLookup(['email' => 'office@example.com']))->toBe([ + 'staff' => ['uuid' => '55555555-5555-4555-8555-555555555555', 'name' => 'Office Staff', 'email' => 'office@example.com', 'phone' => '+6590000005'], + 'conflict' => null, + ]) + // A new driver can't take a customer's email + ->and(fleetopsDriverLoginLookup(['type' => 'driver', 'email' => 'customer@example.com']))->toBe(['staff' => null, 'conflict' => 'This email is already used by a customer.']) + // An existing driver's own email isn't taken, another account's is + ->and(fleetopsDriverLoginLookup(['ignore' => 'driver_managed', 'email' => 'managed@example.com']))->toBe(['staff' => null, 'conflict' => null]) + ->and(fleetopsDriverLoginLookup(['ignore' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', 'phone' => '+6590000005']))->toBe(['staff' => null, 'conflict' => 'This phone number is already in use by another account.']) + // Customer profiles are looked up among contacts + ->and(fleetopsDriverLoginLookup(['type' => 'customer', 'ignore' => 'contact_customer', 'email' => 'customer@example.com']))->toBe(['staff' => null, 'conflict' => null]) + // A profile without an account, or an unknown profile id, is treated as a new profile + ->and(fleetopsDriverLoginLookup(['type' => 'contact', 'ignore' => 'contact_nouser', 'email' => 'customer@example.com']))->toBe(['staff' => null, 'conflict' => 'This email is already used by a customer.']) + ->and(fleetopsDriverLoginLookup(['type' => 'contact', 'ignore' => 'contact_unknown', 'email' => 'customer@example.com']))->toBe(['staff' => null, 'conflict' => 'This email is already used by a customer.']); +}); + +test('update record syncs custom field values and reports failures', function () { + $connection = fleetopsDriverLoginFixture(); + $connection->table('custom_fields')->insert(['uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'company_uuid' => 'company-1', 'name' => 'badge', 'label' => 'Badge']); + $controller = new DriverController(); + + $updated = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', [ + 'custom_field_values' => [['custom_field_uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'value' => 'B-12']], + ]), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'); + + expect($updated)->toBeArray() + ->and($connection->table('custom_field_values')->where('subject_uuid', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1')->value('value'))->toBe('B-12'); + + // An unknown driver surfaces the model's not-found error + $missing = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa9', ['name' => 'Ghost']), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa9'); + expect($missing->getData(true)['error'])->toContain('not found'); + + // A request validation failure raised while updating the account is returned + $GLOBALS['fleetopsDriverLoginHashException'] = new Fleetbase\Exceptions\FleetbaseRequestValidationException(['password' => ['The password is too weak.']]); + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } + + public function make($value, array $options = []): string + { + throw $GLOBALS['fleetopsDriverLoginHashException']; + } + + public function check($value, $hashedValue, array $options = []): bool + { + return false; + } + + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } + }); + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); + + $weak = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', ['password' => 'weak-password']), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'); + expect($weak->getData(true))->toBe(['error' => ['password' => ['The password is too weak.']]]); +}); + +test('update record returns validation failures', function () { + fleetopsDriverLoginFixture(); + $GLOBALS['fleetopsDriverLoginErrors'] = ['email' => ['The email must be a valid email address.']]; + + $failure = null; + try { + (new DriverController())->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', ['email' => 'not-an-email']), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'); + } catch (Illuminate\Validation\ValidationException $exception) { + $failure = $exception; + } + + expect($failure?->errors())->toBe(['email' => ['The email must be a valid email address.']]); +}); diff --git a/server/tests/InternalCustomerControllerContractsTest.php b/server/tests/InternalCustomerControllerContractsTest.php index c958b07f2..59d370c1f 100644 --- a/server/tests/InternalCustomerControllerContractsTest.php +++ b/server/tests/InternalCustomerControllerContractsTest.php @@ -122,11 +122,12 @@ function fleetopsInternalCustomer(): FleetOpsInternalCustomerContactFake return $customer; } -function fleetopsInternalCustomerUser(string $status = 'active'): FleetOpsInternalCustomerUserFake +function fleetopsInternalCustomerUser(string $status = 'active', string $type = 'customer'): FleetOpsInternalCustomerUserFake { $user = new FleetOpsInternalCustomerUserFake(); $user->setRawAttributes([ 'uuid' => 'user-uuid', + 'type' => $type, 'public_id' => 'user_public', 'name' => 'Ada Customer', 'email' => 'ada@example.test', @@ -236,3 +237,33 @@ function fleetopsInternalCustomerJson(mixed $response): array 'password_confirmation' => 'one', ])))['error'])->toBe('Unable to reset customer credentials'); }); + +test('internal customer controller refuses login management for staff-linked customers', function () { + $staff = fleetopsInternalCustomerUser('active', 'user'); + $controller = fleetopsInternalCustomerController($staff); + $message = 'This customer signs in with a team member account. Manage this login in IAM.'; + $request = new Request(['customer' => 'customer_public']); + + $responses = [ + $controller->createPortalLogin($request), + $controller->sendCredentials($request), + $controller->deactivatePortalLogin($request), + $controller->reactivatePortalLogin($request), + $controller->resetCredentials(new Request([ + 'customer' => 'customer_public', + 'password' => 'chosen-secret', + 'password_confirmation' => 'chosen-secret', + ])), + ]; + + foreach ($responses as $response) { + expect($response->getStatusCode())->toBe(422) + ->and(fleetopsInternalCustomerJson($response)['error'])->toBe($message); + } + + // The team member's account is never touched + expect($staff->passwords)->toBe([]) + ->and($staff->activations)->toBe(0) + ->and($staff->deactivations)->toBe(0) + ->and($controller->sentCredentials)->toBe([]); +}); diff --git a/server/tests/ModelAccessorContractsTest.php b/server/tests/ModelAccessorContractsTest.php index a03d38b21..c40fff6cd 100644 --- a/server/tests/ModelAccessorContractsTest.php +++ b/server/tests/ModelAccessorContractsTest.php @@ -130,14 +130,51 @@ public function update(array $attributes = [], array $options = []): bool return true; } + public function save(array $options = []): bool + { + $this->updates[] = $this->getDirty(); + $this->syncOriginal(); + + return true; + } + public function delete() { $this->deleted = true; + $this->setAttribute('deleted_at', '2026-01-01 00:00:00'); return true; } } +function fleetopsModelAccessorsUseProfileAccountTables(): void +{ + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $schema = $connection->getSchemaBuilder(); + $schema->create('users', function ($table) { + $table->string('uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + foreach (['drivers', 'contacts'] as $profileTable) { + $schema->create($profileTable, function ($table) { + $table->string('uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + } + + $resolver = new ConnectionResolver([ + 'default' => $connection, + 'mysql' => $connection, + ]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); +} + class FleetOpsContactAccessorFake extends Contact { public array $loadedMissing = []; @@ -858,6 +895,8 @@ public function update(array $attributes = [], array $options = []): bool }); test('contact user sync lookup and deletion guards are stable', function () { + fleetopsModelAccessorsUseProfileAccountTables(); + $user = new FleetOpsContactSyncUserFake(); $user->setRawAttributes([ 'uuid' => 'user-uuid', @@ -868,22 +907,25 @@ public function update(array $attributes = [], array $options = []): bool ], true); $contact = new FleetOpsContactAccessorFake(); + $contact->exists = true; $contact->fakeUser = $user; $contact->setRawAttributes([ - 'uuid' => 'contact-uuid', - 'user_uuid' => 'not-a-uuid', - 'type' => 'customer', - 'name' => 'Old Name', - 'email' => 'old@example.com', - 'phone' => '+15550000000', - 'timezone' => 'UTC', + 'uuid' => 'contact-uuid', + 'company_uuid' => 'company-uuid', + 'user_uuid' => 'not-a-uuid', + 'type' => 'customer', + 'name' => 'Old Name', + 'email' => 'old@example.com', + 'phone' => '+15550000000', + 'timezone' => 'UTC', ], true); $contact->name = 'New Name'; - $contact->email = 'new@example.com'; - $contact->phone = '+15551112222'; + $contact->email = 'New@Example.com'; + $contact->phone = '+1 555-111-2222'; $contact->timezone = 'Asia/Singapore'; + // Proxy fields are normalized and pushed to the managed account expect($contact->syncWithUser())->toBeTrue() ->and($user->updates[0])->toBe([ 'name' => 'New Name', @@ -895,26 +937,46 @@ public function update(array $attributes = [], array $options = []): bool ->and($contact->hasUser())->toBeTrue() ->and($contact->doesntHaveUser())->toBeFalse(); - $deleteContact = new FleetOpsContactAccessorFake(); - $deleteContact->type = 'customer'; - $deleteContact->setRelation('user', $user); + // An unsaved contact's account was just resolved from these values + $newContact = new FleetOpsContactAccessorFake(); + $newContact->fakeUser = $user; + $newContact->name = 'Brand New'; + + // A saved contact with no proxy changes has nothing to sync + $cleanContact = new FleetOpsContactAccessorFake(); + $cleanContact->exists = true; + $cleanContact->fakeUser = $user; + $cleanContact->setRawAttributes(['name' => 'New Name', 'type' => 'customer'], true); + + expect($newContact->syncWithUser())->toBeFalse() + ->and($cleanContact->syncWithUser())->toBeFalse() + ->and($user->updates)->toHaveCount(1); + + // Deleting the only profile holding a managed account deletes the account + $deleteContact = new FleetOpsContactAccessorFake(); + $deleteContact->type = 'customer'; + $deleteContact->company_uuid = 'company-uuid'; + $deleteContact->fakeUser = $user; expect($deleteContact->deleteUser())->toBeTrue() - ->and($user->deleted)->toBeTrue() - ->and($deleteContact->loadedMissing)->toBe(['user']); + ->and($user->deleted)->toBeTrue(); - $mismatchedUser = new FleetOpsContactSyncUserFake(); - $mismatchedUser->setRawAttributes(['type' => 'admin'], true); + // A team member's account linked to the contact is never deleted + $staffUser = new FleetOpsContactSyncUserFake(); + $staffUser->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'admin'], true); - $mismatchedContact = new FleetOpsContactAccessorFake(); - $mismatchedContact->type = 'customer'; - $mismatchedContact->setRelation('user', $mismatchedUser); + $staffContact = new FleetOpsContactAccessorFake(); + $staffContact->type = 'customer'; + $staffContact->fakeUser = $staffUser; $emptyContact = new FleetOpsContactAccessorFake(); + $emptyContact->exists = true; $emptyContact->fakeUser = null; $emptyContact->setRawAttributes(['user_uuid' => 'not-a-uuid'], true); + $emptyContact->name = 'Changed'; - expect($mismatchedContact->deleteUser())->toBeFalse() + expect($staffContact->deleteUser())->toBeFalse() + ->and($staffUser->deleted)->toBeFalse() ->and($emptyContact->syncWithUser())->toBeFalse() ->and($emptyContact->getUser())->toBeNull() ->and($emptyContact->hasUser())->toBeFalse() diff --git a/server/tests/ObserverContractsTest.php b/server/tests/ObserverContractsTest.php index aef25a1de..38b097fa3 100644 --- a/server/tests/ObserverContractsTest.php +++ b/server/tests/ObserverContractsTest.php @@ -263,6 +263,7 @@ public function hasRole($roles, ?string $guard = null): bool public function delete() { $this->deleted = true; + $this->setAttribute('deleted_at', '2026-01-01 00:00:00'); return true; } @@ -1149,14 +1150,30 @@ protected function resolveOrder(PurchaseRate $purchaseRate): ?Order }); test('driver observer defaults location unassigns related records and deletes driver users', function () { + // releaseForProfile looks for the account's remaining driver/contact profiles + $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver); + foreach (['drivers', 'contacts'] as $profileTable) { + $connection->getSchemaBuilder()->create($profileTable, function ($table) { + $table->string('uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + } + $driver = new Driver(); $driver->setRawAttributes([ - 'uuid' => 'driver-uuid', - 'user_uuid' => 'user-uuid', + 'uuid' => 'driver-uuid', + 'user_uuid' => 'user-uuid', + 'company_uuid' => 'company-uuid', ], true); $observer = new FleetOpsDriverObserverProbe(); $user = new FleetOpsDriverObserverUserFake(); + $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true); $observer->user = $user; $observer->creating($driver); @@ -1175,6 +1192,14 @@ protected function resolveOrder(PurchaseRate $purchaseRate): ?Order ->and($observer->unassigned)->toBe(['driver-uuid']) ->and($user->deleted)->toBeTrue(); + // A team member's account linked to the driver is never deleted + $staff = new FleetOpsDriverObserverUserFake(); + $staff->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'user'], true); + $observer->user = $staff; + $observer->deleted($driver); + + expect($staff->deleted)->toBeFalse(); + $driverWithLocation = new Driver(); $location = new Fleetbase\LaravelMysqlSpatial\Types\Point(1.3, 103.8); $driverWithLocation->location = $location; diff --git a/server/tests/RequestContractsTest.php b/server/tests/RequestContractsTest.php index 9cda5fd89..86f7280eb 100644 --- a/server/tests/RequestContractsTest.php +++ b/server/tests/RequestContractsTest.php @@ -534,10 +534,13 @@ protected function canUpdateDriver(): bool $request = new InternalCreateDriverRequest(); $updateProbe = new FleetOpsInternalUpdateDriverRequestProbe(); + // Email/phone availability is decided by ProfileAccountManager (a team + // member of the company is linked), so they're optional and not unique here. expect(ruleStrings($createRules['name']))->toContain('required', 'nullable', 'string', 'max:255') - ->and(ruleStrings($createRules['email']))->toContain('required') - ->and(ruleStrings($createRules['phone']))->toContain('required') - ->and(ruleStrings($userRules['name']))->not->toContain('required') + ->and(ruleStrings($createRules['email']))->toBe(['nullable']) + ->and(ruleStrings($createRules['phone']))->toBe(['nullable', 'string']) + ->and(ruleStrings($userRules['name']))->toContain('required') + ->and(ruleStrings(InternalCreateDriverRequest::create('/fleetops-test', 'POST', ['email' => 'driver@example.test'])->rules()['email']))->toBe(['nullable', 'email']) ->and(ruleStrings($patchRules['name']))->not->toContain('required') ->and($updateProbe->authorize())->toBeFalse(); @@ -556,9 +559,10 @@ protected function canUpdateDriver(): bool ]) ->and($request->messages())->toMatchArray([ 'name.required' => 'Driver name is required.', - 'email.required' => 'Email address is required.', + 'email.email' => 'Please provide a valid email address.', 'password.min' => 'Password must be at least 8 characters.', - ]); + ]) + ->and($request->messages())->not->toHaveKeys(['email.required', 'email.unique', 'phone.required', 'phone.unique']); }); test('public create order request validates payload alternatives and pod methods', function () { @@ -929,12 +933,16 @@ public function parameter($key, $default = null) expect($request->authorize())->toBeTrue() ->and(ruleStrings($createRules['name']))->toContain('required') ->and(ruleStrings($patchRules['name']))->not->toContain('required') - // Contact details are optional but still validated and still unique: - // an operational driver record may legitimately have neither. - ->and(ruleStrings($createRules['email']))->toContain('nullable', 'email', 'unique:users') - ->and(ruleStrings($createRules['email']))->not->toContain('required') - ->and(ruleStrings($createRules['phone']))->toContain('nullable', 'unique:users') - ->and(ruleStrings($createRules['phone']))->not->toContain('required') + // Contact details are optional but still validated: an operational + // driver record may legitimately have neither. On create, availability + // is decided by ProfileAccountManager (a team member is linked); on + // update they must stay unique among users. + ->and(ruleStrings($createRules['email']))->toContain('nullable', 'email') + ->and(ruleStrings($createRules['email']))->not->toContain('required', 'unique:users') + ->and(ruleStrings($createRules['phone']))->toContain('nullable') + ->and(ruleStrings($createRules['phone']))->not->toContain('required', 'unique:users') + ->and(ruleStrings($patchRules['email']))->toContain('nullable', 'unique:users') + ->and(ruleStrings($patchRules['phone']))->toContain('nullable', 'string', 'unique:users') ->and($createRules['password'])->toBe('nullable|string') ->and($createRules['country'])->toBe('nullable|size:2') ->and(ruleStrings($createRules['vehicle']))->toContain('nullable', 'string', 'starts_with:vehicle_', 'exists:vehicles,public_id') diff --git a/server/tests/TelematicControllerContractsTest.php b/server/tests/TelematicControllerContractsTest.php index 699054534..aa0ceef45 100644 --- a/server/tests/TelematicControllerContractsTest.php +++ b/server/tests/TelematicControllerContractsTest.php @@ -159,6 +159,7 @@ public function getAttribute($key) $customer->setRelation('user', (object) [ 'uuid' => 'user-uuid', 'public_id' => 'user-public', + 'type' => 'customer', 'name' => 'Ada Customer', 'email' => 'ada@example.test', 'phone' => '+15551234567', @@ -168,10 +169,12 @@ public function getAttribute($key) ]); expect($controller->callHelper('customerPayload', $customer))->toBe([ - 'id' => 'customer-public', - 'uuid' => 'customer-uuid', - 'user_uuid' => 'user-uuid', - 'user' => [ + 'id' => 'customer-public', + 'uuid' => 'customer-uuid', + 'user_uuid' => 'user-uuid', + 'is_staff_linked' => false, + 'login_status' => 'active', + 'user' => [ 'id' => 'user-public', 'uuid' => 'user-uuid', 'name' => 'Ada Customer', @@ -182,4 +185,19 @@ public function getAttribute($key) 'avatar_url' => 'https://example.test/avatar.png', ], ]); + + // A customer linked to a team member's account is reported as staff-linked + $customer->setRelation('user', (object) ['uuid' => 'staff-uuid', 'public_id' => 'staff-public', 'type' => 'user', 'status' => 'inactive']); + $staffPayload = $controller->callHelper('customerPayload', $customer); + + expect($staffPayload['is_staff_linked'])->toBeTrue() + ->and($staffPayload['login_status'])->toBe('inactive'); + + // A customer without a login account is neither + $customer->setRelation('user', null); + $emptyPayload = $controller->callHelper('customerPayload', $customer); + + expect($emptyPayload['is_staff_linked'])->toBeFalse() + ->and($emptyPayload['login_status'])->toBeNull() + ->and($emptyPayload['user'])->toBeNull(); }); diff --git a/server/tests/Unit/Models/ContactCustomerUserTest.php b/server/tests/Unit/Models/ContactCustomerUserTest.php index d075d6847..8f2a2c562 100644 --- a/server/tests/Unit/Models/ContactCustomerUserTest.php +++ b/server/tests/Unit/Models/ContactCustomerUserTest.php @@ -9,7 +9,7 @@ } use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException; -use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException; +use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException; use Fleetbase\FleetOps\Models\Contact; use Fleetbase\Models\User; use Illuminate\Database\ConnectionResolver; @@ -75,9 +75,38 @@ public function __call($method, $arguments) }); Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } + + public function make($value, array $options = []): string + { + return md5((string) $value); + } + + public function check($value, $hashedValue, array $options = []): bool + { + return md5((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'); + $schema = $connection->getSchemaBuilder(); $tables = [ 'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'email', 'phone', 'type', 'title'], + 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid'], 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'username', 'password', 'timezone', 'status', 'type', 'slug', 'avatar_uuid', 'last_login'], 'companies' => ['uuid', 'public_id', 'name', 'timezone', 'owner_uuid'], 'company_users' => ['uuid', 'company_uuid', 'user_uuid', 'status'], @@ -188,24 +217,45 @@ function fleetopsContactCustomerUserContact(array $attributes = []): FleetOpsCon ->and($connection->table('users')->count())->toBe(1); }); -test('create user from contact rejects users already linked to another contact', function () { +test('create user from contact rejects an account that already holds a contact in the company', function () { $connection = fleetopsContactCustomerUserBoot(); - // The contact user() relation constrains users.type against the querying - // contact type, which is null in the static whereHas context — a null-typed - // user keeps the linked-contact subquery matchable. - $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'taken@example.com', 'type' => null]); + $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'taken@example.com', 'type' => 'contact']); $connection->table('contacts')->insert(['uuid' => 'contact-owner', 'company_uuid' => 'company-1', 'user_uuid' => 'user-9', 'name' => 'Owner', 'type' => 'contact']); $contact = fleetopsContactCustomerUserContact(['uuid' => 'contact-2', 'email' => 'taken@example.com']); - expect(fn () => Contact::createUserFromContact($contact))->toThrow(UserAlreadyExistsException::class); + expect(fn () => Contact::createUserFromContact($contact)) + ->toThrow(ProfileIdentityConflictException::class, 'A contact with this email already exists.'); }); -test('create user from contact rejects staff users for customer contacts', function () { +test('create user from contact rejects staff users of another organization for customer contacts', function () { $connection = fleetopsContactCustomerUserBoot(); - $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff']); + $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-other', 'email' => 'staff@example.com', 'type' => 'user']); $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']); - expect(fn () => Contact::createUserFromContact($contact))->toThrow(CustomerUserConflictException::class); + expect(fn () => Contact::createUserFromContact($contact)) + ->toThrow(ProfileIdentityConflictException::class, 'This email is already in use by another account.'); +}); + +test('create user from contact links a staff member of the organization without changing their role', function () { + $connection = fleetopsContactCustomerUserBoot(); + $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'user']); + $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'Staff@Example.com']); + + $user = Contact::createUserFromContact($contact); + + expect($user->uuid)->toBe('user-9') + ->and($user->type)->toBe('user') + ->and($contact->user_uuid)->toBe('user-9') + ->and($connection->table('model_has_roles')->count())->toBe(0); +}); + +test('create user from contact rejects a managed account of another type', function () { + $connection = fleetopsContactCustomerUserBoot(); + $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'phone' => '+6591112222', 'type' => 'driver']); + $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'phone' => '+65 9111-2222']); + + expect(fn () => Contact::createUserFromContact($contact)) + ->toThrow(ProfileIdentityConflictException::class, 'This phone number is already used by a driver.'); }); test('create user from contact assigns the customer role and persists the link', function () { @@ -305,17 +355,27 @@ function fleetopsContactCustomerUserContact(array $attributes = []): FleetOpsCon test('assert customer identity is available detects staff conflicts', function () { $connection = fleetopsContactCustomerUserBoot(); - $connection->table('users')->insert(['uuid' => 'user-1', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff']); + $connection->table('users')->insert([ + ['uuid' => 'user-1', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff'], + ['uuid' => 'user-2', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'type' => 'driver'], + ]); $nonCustomer = fleetopsContactCustomerUserContact(); $nonCustomer->assertCustomerIdentityIsAvailable(); - $conflicted = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']); - expect(fn () => $conflicted->assertCustomerIdentityIsAvailable())->toThrow(CustomerUserConflictException::class); + // A staff member of the organization can hold a customer profile + $staffMember = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']); + $staffMember->assertCustomerIdentityIsAvailable(); + + // A driver's managed account cannot + $conflicted = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'driver@example.com']); + expect(fn () => $conflicted->assertCustomerIdentityIsAvailable()) + ->toThrow(CustomerUserConflictException::class, 'This email is already used by a driver and cannot be used for a customer account.'); // An already-assigned user is checked ahead of the identity lookup, so a // contact whose email matches nothing still trips on its own assignment - $connection->table('users')->insert(['uuid' => '33333333-3333-4333-8333-333333333333', 'company_uuid' => 'company-1', 'email' => 'assigned@example.com', 'type' => 'staff']); + // (a staff account of another organization) + $connection->table('users')->insert(['uuid' => '33333333-3333-4333-8333-333333333333', 'company_uuid' => 'company-other', 'email' => 'assigned@example.com', 'type' => 'staff']); $assigned = fleetopsContactCustomerUserContact([ 'type' => 'customer', 'email' => 'unmatched@example.com', @@ -368,3 +428,19 @@ function fleetopsContactCustomerUserContact(array $attributes = []): FleetOpsCon expect($connection->table('company_users')->where('user_uuid', 'user-n1')->count())->toBe(1) ->and($connection->table('model_has_roles')->count())->toBeGreaterThanOrEqual(1); }); + +test('normalize customer user keeps a staff member of the organization untouched', function () { + $connection = fleetopsContactCustomerUserBoot(); + $connection->table('companies')->insert(['uuid' => 'company-1', 'name' => 'Acme']); + $connection->table('users')->insert(['uuid' => 'user-s1', 'company_uuid' => 'company-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user', 'status' => 'active']); + + $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'user_uuid' => 'user-s1']); + $user = User::where('uuid', 'user-s1')->first(); + + expect($contact->normalizeCustomerUser($user))->toBe($user) + ->and($contact->getRelation('user'))->toBe($user) + // A team member's account keeps its own organization membership and role + ->and($connection->table('company_users')->count())->toBe(0) + ->and($connection->table('model_has_roles')->count())->toBe(0) + ->and($connection->table('users')->where('uuid', 'user-s1')->value('type'))->toBe('user'); +}); diff --git a/server/tests/Unit/Models/ContactTest.php b/server/tests/Unit/Models/ContactTest.php index 40d8738f6..1f8ff69f4 100644 --- a/server/tests/Unit/Models/ContactTest.php +++ b/server/tests/Unit/Models/ContactTest.php @@ -71,9 +71,18 @@ public function update(array $attributes = [], array $options = []): bool return true; } + public function save(array $options = []): bool + { + $this->updates[] = $this->getDirty(); + $this->syncOriginal(); + + return true; + } + public function delete() { $this->deleted = true; + $this->setAttribute('deleted_at', '2026-01-01 00:00:00'); return true; } @@ -180,6 +189,16 @@ public static function where($column, $operator = null, $value = null, $boolean } } +function fleetopsContactUnitUseProfileTables(): SQLiteConnection +{ + $connection = fleetopsContactUnitUseInMemoryConnection(); + $connection->statement('create table drivers (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, deleted_at datetime null)'); + $connection->statement('create table contacts (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, deleted_at datetime null)'); + $connection->statement('create table company_users (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, created_at datetime null, updated_at datetime null, deleted_at datetime null)'); + + return $connection; +} + function fleetopsContactUnitUseInMemoryConnection(): SQLiteConnection { $connection = new SQLiteConnection(new PDO('sqlite::memory:')); @@ -496,6 +515,8 @@ function fleetopsContactUnitUseInMemoryConnection(): SQLiteConnection }); test('contact sync delete and user presence helpers use loaded user relations', function () { + fleetopsContactUnitUseProfileTables(); + $user = new FleetOpsContactUnitUserFake(); $user->setRawAttributes([ 'uuid' => 'user-uuid', @@ -521,6 +542,7 @@ function fleetopsContactUnitUseInMemoryConnection(): SQLiteConnection ]); $contact->setRelation('user', $user); $contact->fakeUser = $user; + $contact->exists = true; expect($contact->syncWithUser())->toBeTrue() ->and($user->updates)->toBe([[ @@ -543,20 +565,40 @@ function fleetopsContactUnitUseInMemoryConnection(): SQLiteConnection ->and($unlinked->doesntHaveUser())->toBeTrue(); }); -test('contact delete user ignores mismatched relation types', function () { +test('contact delete user leaves team member accounts alone', function () { $user = new FleetOpsContactUnitUserFake(); $user->setRawAttributes([ 'uuid' => 'user-uuid', - 'type' => 'customer', + 'type' => 'user', ], true); - $contact = new FleetOpsContactUnitFake(['type' => 'contact']); - $contact->setRelation('user', $user); + $contact = new FleetOpsContactUnitFake(['type' => 'customer']); + $contact->fakeUser = $user; expect($contact->deleteUser())->toBeFalse() ->and($user->deleted)->toBeFalse(); }); +test('contact delete user keeps a managed account still used by another organization', function () { + $connection = fleetopsContactUnitUseProfileTables(); + $connection->table('drivers')->insert(['uuid' => 'driver-elsewhere', 'user_uuid' => 'user-uuid', 'company_uuid' => 'other-company']); + $connection->table('company_users')->insert([ + ['uuid' => 'cu-1', 'user_uuid' => 'user-uuid', 'company_uuid' => 'company-uuid'], + ['uuid' => 'cu-2', 'user_uuid' => 'user-uuid', 'company_uuid' => 'other-company'], + ]); + + $user = new FleetOpsContactUnitUserFake(); + $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true); + + $contact = new FleetOpsContactUnitFake(['type' => 'contact', 'company_uuid' => 'company-uuid']); + $contact->fakeUser = $user; + + // The account only leaves this organization + expect($contact->deleteUser())->toBeFalse() + ->and($user->deleted)->toBeFalse() + ->and($connection->table('company_users')->whereNull('deleted_at')->pluck('company_uuid')->all())->toBe(['other-company']); +}); + test('contact real user helpers return loaded relations without database lookup', function () { $user = new FleetOpsContactUnitUserFake(); $user->setRawAttributes([ diff --git a/server/tests/Unit/Models/SmallModelSurfacesTest.php b/server/tests/Unit/Models/SmallModelSurfacesTest.php index 142f1f77f..6aecd0683 100644 --- a/server/tests/Unit/Models/SmallModelSurfacesTest.php +++ b/server/tests/Unit/Models/SmallModelSurfacesTest.php @@ -176,45 +176,51 @@ public function __call($method, $arguments) expect($customer->orders())->toBeInstanceOf(HasMany::class); }); -test('contact observer availability checks and deletion resolve against users', function () { +test('contact observer deletion releases the managed account and leaves team members alone', function () { $connection = fleetopsSmallModelBoot(); - $connection->table('users')->insert(['uuid' => 'user-2', 'company_uuid' => 'company-1', 'email' => 'taken@example.test', 'phone' => '+6512345678']); + config(['activitylog.enabled' => false]); + app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config()); + $connection->getSchemaBuilder()->create('drivers', function ($blueprint) { + $blueprint->increments('id'); + $blueprint->string('uuid')->nullable(); + $blueprint->string('company_uuid')->nullable(); + $blueprint->string('user_uuid')->nullable(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + $connection->table('users')->insert([ + ['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'type' => 'contact'], + ['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'type' => 'user'], + ]); - $observer = new ContactObserver(); - $reflection = new ReflectionClass($observer); + $observer = new ContactObserver(); $contact = new Fleetbase\FleetOps\Models\Contact(); - $contact->setRawAttributes(['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-1', 'email' => 'taken@example.test', 'phone' => '+6512345678', 'type' => 'contact'], true); + $contact->setRawAttributes(['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111', 'type' => 'contact'], true); $contact->exists = true; - $emailCheck = $reflection->getMethod('isEmailUnavailable'); - $emailCheck->setAccessible(true); - expect($emailCheck->invoke($observer, $contact))->toBeTrue(); - - $phoneCheck = $reflection->getMethod('isPhoneUnavailable'); - $phoneCheck->setAccessible(true); - expect($phoneCheck->invoke($observer, $contact))->toBeTrue(); + $staffContact = new Fleetbase\FleetOps\Models\Contact(); + $staffContact->setRawAttributes(['uuid' => 'contact-2', 'company_uuid' => 'company-1', 'user_uuid' => '22222222-2222-4222-8222-222222222222', 'type' => 'contact'], true); + $staffContact->exists = true; - $contact->email = 'free@example.test'; - $contact->phone = '+6599999999'; - expect($emailCheck->invoke($observer, $contact))->toBeFalse() - ->and($phoneCheck->invoke($observer, $contact))->toBeFalse(); - - // Deletion removes the associated user account $observer->deleted($contact); - expect(true)->toBeTrue(); + $observer->deleted($staffContact); + + // The managed account had no other profile, so it is deleted + expect($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->value('deleted_at'))->not->toBeNull() + ->and($connection->table('users')->where('uuid', '22222222-2222-4222-8222-222222222222')->value('deleted_at'))->toBeNull(); }); test('contact observer rejects saves whose email or phone belongs to another account', function () { $connection = fleetopsSmallModelBoot(); - $connection->table('users')->insert(['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'email' => 'taken@example.test', 'phone' => '+6512345678']); + $connection->table('users')->insert([ + ['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'type' => 'contact', 'email' => 'free@example.test', 'phone' => '+6599999999'], + ['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'type' => 'user', 'email' => 'taken@example.test', 'phone' => '+6512345678'], + ]); $observer = new ContactObserver(); - // The availability checks are private, so the guards can only be reached - // through saving() with a contact that genuinely collides on a real table. // hasUser() keys off a well-formed uuid, so a real one keeps saving() from - // detouring into account provisioning before it reaches the guards. + // detouring into account provisioning before it reaches the sync. $contact = new Fleetbase\FleetOps\Models\Contact(); $contact->setRawAttributes([ 'uuid' => 'contact-collide', @@ -226,23 +232,16 @@ public function __call($method, $arguments) ], true); $contact->exists = true; - // wasChanged() reads the post-save change set, which setRawAttributes does - // not populate — syncChanges promotes the pending edit into it. $contact->email = 'taken@example.test'; - $contact->syncChanges(); expect(fn () => $observer->saving($contact)) - ->toThrow(Exception::class, 'Email attempting to update for contact is not available.'); + ->toThrow(Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException::class, 'This email is already in use by another account.'); - // The phone guard sits behind the email one, so it only surfaces once the - // email is back to an available value - $contact->syncOriginal(); $contact->email = 'free@example.test'; $contact->phone = '+6512345678'; - $contact->syncChanges(); expect(fn () => $observer->saving($contact)) - ->toThrow(Exception::class, 'Phone attempting to update for contact is not available.'); + ->toThrow(Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException::class, 'This phone number is already in use by another account.'); }); test('tracking status request authorizes by session and rejects duplicates', function () { diff --git a/server/tests/Unit/Observers/ObserverListenerSeamsTest.php b/server/tests/Unit/Observers/ObserverListenerSeamsTest.php index febb8bee7..03223f571 100644 --- a/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +++ b/server/tests/Unit/Observers/ObserverListenerSeamsTest.php @@ -98,11 +98,16 @@ function fleetopsObserverSeamInvoke(string $class, string $method, array $argume ->and($connection->table('orders')->where('uuid', 'order-seam-1')->value('driver_assigned_uuid'))->toBeNull() ->and($connection->table('orders')->where('uuid', 'order-seam-2')->value('driver_assigned_uuid'))->toBe('driver-other'); - // The linked account resolves only when it is still a plain user + // The linked account resolves whatever its type: releaseForProfile decides + // whether a managed account is deleted and leaves a team member alone expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$driver])?->uuid)->toBe('user-seam-1'); + $managedDriver = new Driver(); + $managedDriver->setRawAttributes(['uuid' => 'driver-seam-2', 'user_uuid' => 'user-seam-2'], true); + expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$managedDriver])?->uuid)->toBe('user-seam-2'); + $driverWithoutUser = new Driver(); - $driverWithoutUser->setRawAttributes(['uuid' => 'driver-seam-2', 'user_uuid' => 'user-seam-2'], true); + $driverWithoutUser->setRawAttributes(['uuid' => 'driver-seam-3', 'user_uuid' => null], true); expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$driverWithoutUser]))->toBeNull(); }); diff --git a/server/tests/Unit/Support/ProfileAccountManagerTest.php b/server/tests/Unit/Support/ProfileAccountManagerTest.php new file mode 100644 index 000000000..9c1828f49 --- /dev/null +++ b/server/tests/Unit/Support/ProfileAccountManagerTest.php @@ -0,0 +1,643 @@ + str_replace('_', ' ', Illuminate\Support\Str::snake((string) $value))); +} + +use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException; +use Fleetbase\FleetOps\Mail\CustomerCredentialsMail; +use Fleetbase\FleetOps\Mail\DriverCredentialsMail; +use Fleetbase\FleetOps\Models\Contact; +use Fleetbase\FleetOps\Models\Driver; +use Fleetbase\FleetOps\Support\ProfileAccountManager; +use Fleetbase\Models\CompanyUser; +use Fleetbase\Models\User; +use Fleetbase\Services\SmsService; +use Illuminate\Database\ConnectionResolver; +use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Database\SQLiteConnection; + +/** + * Covers ProfileAccountManager against SQLite with real spatie roles: account + * classification, identity lookups, linking a staff member or a managed + * account of the same type, creating managed accounts without invites, proxy + * field sync, releasing an account when its profile is deleted, and sending + * credentials by email or SMS. + */ +class FleetOpsProfileAccountSmsFake extends SmsService +{ + public array $sent = []; + public array $result = ['success' => true]; + + public function __construct() + { + } + + public function send(string $to, string $text, array $options = [], ?string $provider = null): array + { + $this->sent[] = [$to, $text]; + + return $this->result; + } +} + +function fleetopsProfileAccountBoot(): SQLiteConnection +{ + // Model uuid hooks bind to whichever dispatcher exists when the class boots + if (!EloquentModel::getEventDispatcher()) { + EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher()); + } + + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + app()->instance('db.schema', $connection->getSchemaBuilder()); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } + + public function make($value, array $options = []): string + { + return 'hashed:' . $value; + } + + public function check($value, $hashedValue, array $options = []): bool + { + return 'hashed:' . $value === $hashedValue; + } + + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } + }); + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); + + Illuminate\Support\Facades\Mail::swap(new class { + public array $sent = []; + public array $to = []; + + public function to($users) + { + $this->to[] = $users; + + return $this; + } + + public function send($mailable) + { + $this->sent[] = $mailable; + + return null; + } + }); + + $sms = new FleetOpsProfileAccountSmsFake(); + app()->instance(SmsService::class, $sms); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'password', 'status', 'type', 'username', 'avatar_uuid', 'slug', 'timezone', 'country', 'ip_address', 'meta', 'last_login', '_key'], + 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid'], + 'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'type'], + 'companies' => ['uuid', 'public_id', 'name', 'owner_uuid', 'timezone'], + 'company_users' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'status', '_key'], + 'permissions' => ['name', 'guard_name', 'service', 'description'], + 'roles' => ['uuid', 'public_id', 'name', 'guard_name', 'company_uuid', 'service', 'description', '_key'], + 'model_has_roles' => ['role_id', 'model_type', 'model_uuid'], + 'model_has_permissions' => ['permission_id', 'model_type', 'model_uuid'], + 'role_has_permissions' => ['permission_id', 'role_id'], + ]; + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + app()->instance('cache', new class { + public function tags($tags = null) + { + return $this; + } + + public function flush() + { + return true; + } + + public function remember($key, $ttl, $callback) + { + return $callback(); + } + + public function store($name = null) + { + return $this; + } + + public function __call($method, $arguments) + { + return null; + } + }); + Illuminate\Support\Facades\Cache::clearResolvedInstance('cache'); + config()->set('cache.default', 'array'); + config()->set('cache.stores.array', ['driver' => 'array']); + config()->set('permission.cache.expiration_time', 60); + config()->set('permission.cache.key', 'spatie.permission.cache'); + config()->set('permission.cache.store', 'default'); + config()->set('permission.models.permission', Fleetbase\Models\Permission::class); + config()->set('permission.models.role', Fleetbase\Models\Role::class); + config()->set('permission.table_names', ['roles' => 'roles', 'permissions' => 'permissions', 'model_has_permissions' => 'model_has_permissions', 'model_has_roles' => 'model_has_roles', 'role_has_permissions' => 'role_has_permissions']); + config()->set('permission.column_names', ['role_pivot_key' => null, 'permission_pivot_key' => null, 'model_morph_key' => 'model_uuid', 'team_foreign_key' => 'team_id']); + config()->set('permission.teams', false); + config()->set('permission.events_enabled', false); + $cacheManager = new Illuminate\Cache\CacheManager(app()); + app()->instance(Illuminate\Cache\CacheManager::class, $cacheManager); + app()->instance(Spatie\Permission\PermissionRegistrar::class, new Spatie\Permission\PermissionRegistrar($cacheManager)); + config()->set('activitylog.enabled', false); + config()->set('activitylog.default_auth_driver', 'web'); + config()->set('auth.defaults.guard', 'web'); + config()->set('app.name', 'Fleetbase'); + app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config()); + + session(['company' => 'company-1']); + $connection->table('companies')->insert([ + ['uuid' => 'company-1', 'public_id' => 'company_one', 'name' => 'Acme', 'timezone' => 'Asia/Singapore'], + ['uuid' => 'company-2', 'public_id' => 'company_two', 'name' => 'Globex', 'timezone' => null], + ]); + $roles = []; + foreach (['company-1', 'company-2'] as $company) { + foreach (['Driver', 'Fleet-Ops Contact', 'Fleet-Ops Customer'] as $name) { + foreach (['web', 'sanctum'] as $guard) { + $roles[] = ['uuid' => $company . $name . $guard, 'name' => $name, 'guard_name' => $guard, 'company_uuid' => $company]; + } + } + } + $connection->table('roles')->insert($roles); + + return $connection; +} + +function fleetopsProfileAccountRoles(SQLiteConnection $connection, string $userUuid, string $companyUuid): array +{ + return $connection->table('model_has_roles') + ->join('roles', 'roles.id', '=', 'model_has_roles.role_id') + ->join('company_users', 'company_users.uuid', '=', 'model_has_roles.model_uuid') + ->where('company_users.user_uuid', $userUuid) + ->where('company_users.company_uuid', $companyUuid) + ->pluck('roles.name') + ->unique() + ->values() + ->all(); +} + +/** + * Insert rows one at a time: a multi-row insert takes its column list from the + * first row, so rows with different keys would land in the wrong columns. + */ +function fleetopsProfileAccountRows(SQLiteConnection $connection, string $table, array $rows): void +{ + foreach ($rows as $row) { + $connection->table($table)->insert($row); + } +} + +function fleetopsProfileAccountUser(array $attributes): User +{ + app('db')->connection()->table('users')->insert($attributes); + + return User::where('uuid', $attributes['uuid'])->first(); +} + +test('accounts are managed when typed driver contact or customer and staff otherwise', function () { + expect(ProfileAccountManager::isManagedAccount(null))->toBeFalse() + ->and(ProfileAccountManager::isStaffAccount(null))->toBeFalse() + ->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'driver']))->toBeTrue() + ->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'contact']))->toBeTrue() + ->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'customer']))->toBeTrue() + ->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'customer']))->toBeFalse() + ->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'user']))->toBeTrue() + ->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'admin']))->toBeTrue() + ->and(ProfileAccountManager::isStaffAccount((object) []))->toBeTrue() + ->and(ProfileAccountManager::normalizeEmail(' Ada@Example.COM '))->toBe('ada@example.com') + ->and(ProfileAccountManager::normalizeEmail(' '))->toBeNull() + ->and(ProfileAccountManager::normalizeEmail(null))->toBeNull() + ->and(ProfileAccountManager::normalizePhone(' +65 9123-4567 '))->toBe('+6591234567') + ->and(ProfileAccountManager::normalizePhone(''))->toBeNull() + ->and(ProfileAccountManager::normalizePhone(null))->toBeNull(); +}); + +test('identity lookups match email or phone and skip ignored and deleted accounts', function () { + $connection = fleetopsProfileAccountBoot(); + fleetopsProfileAccountRows($connection, 'users', [ + ['uuid' => 'user-email', 'email' => 'ada@example.com', 'type' => 'user'], + ['uuid' => 'user-phone', 'phone' => '+6591234567', 'type' => 'driver'], + ['uuid' => 'user-deleted', 'email' => 'gone@example.com', 'type' => 'driver', 'deleted_at' => '2026-01-01 00:00:00'], + ]); + + expect(ProfileAccountManager::findAccountByIdentity(null, ' '))->toBeNull() + ->and(ProfileAccountManager::findAccountByIdentity('ADA@example.com', null)?->uuid)->toBe('user-email') + ->and(ProfileAccountManager::findAccountByIdentity(null, '+65 9123 4567')?->uuid)->toBe('user-phone') + ->and(ProfileAccountManager::findAccountByIdentity('nobody@example.com', '+6591234567')?->uuid)->toBe('user-phone') + ->and(ProfileAccountManager::findAccountByIdentity('ada@example.com', null, 'user-email'))->toBeNull() + ->and(ProfileAccountManager::findAccountByIdentity('gone@example.com', null))->toBeNull(); +}); + +test('lookup reports the staff member to link or why the identity is unavailable', function () { + $connection = fleetopsProfileAccountBoot(); + fleetopsProfileAccountRows($connection, 'users', [ + ['uuid' => 'staff-1', 'company_uuid' => 'company-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user'], + ['uuid' => 'staff-2', 'company_uuid' => 'company-2', 'email' => 'outsider@example.com', 'type' => 'admin'], + ['uuid' => 'driver-1', 'company_uuid' => 'company-2', 'phone' => '+6590001111', 'type' => 'driver'], + ['uuid' => 'current-1', 'company_uuid' => 'company-1', 'email' => 'me@example.com', 'type' => 'driver'], + ]); + $current = User::where('uuid', 'current-1')->first(); + + $nothing = ProfileAccountManager::lookup('company-1', 'driver', 'free@example.com', null); + $staff = ProfileAccountManager::lookup('company-1', 'driver', 'staff@example.com', null); + $outsider = ProfileAccountManager::lookup('company-1', 'driver', 'outsider@example.com', null); + $customer = ProfileAccountManager::lookup('company-1', 'customer', null, '+6590001111'); + $sameType = ProfileAccountManager::lookup('company-1', 'driver', null, '+6590001111'); + $taken = ProfileAccountManager::lookup('company-1', 'driver', 'staff@example.com', null, $current); + $own = ProfileAccountManager::lookup('company-1', 'driver', 'me@example.com', null, $current); + + expect($nothing)->toBe(['account' => null, 'staff' => null, 'conflict' => null]) + ->and($staff['staff']?->uuid)->toBe('staff-1') + ->and($staff['conflict'])->toBeNull() + ->and($outsider['staff'])->toBeNull() + ->and($outsider['conflict'])->toBe('This email is already in use by another account.') + ->and($customer['conflict'])->toBe('This phone number is already used by a driver.') + // A driver account of another organization is linked, not reported as staff + ->and($sameType['account']?->uuid)->toBe('driver-1') + ->and($sameType['staff'])->toBeNull() + ->and($sameType['conflict'])->toBeNull() + // An existing profile can't swap its account for another one + ->and($taken['conflict'])->toBe('This email is already in use by another account.') + ->and($taken['staff'])->toBeNull() + ->and($own['account'])->toBeNull(); +}); + +test('resolve for profile creates a managed account with the profile role and no invite', function () { + $connection = fleetopsProfileAccountBoot(); + + $driver = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Dana Driver', 'Dana@Example.com', '+65 9000 1111', [ + 'password' => 'chosen-secret', + 'status' => 'active', + 'avatar_uuid' => 'avatar-1', + 'country' => 'SG', + 'ip_address' => '127.0.0.1', + ]); + + $row = $connection->table('users')->where('uuid', $driver->uuid)->first(); + + expect($driver->uuid)->not->toBeNull() + ->and($row->type)->toBe('driver') + ->and($row->email)->toBe('dana@example.com') + ->and($row->phone)->toBe('+6590001111') + ->and($row->password)->toBe('hashed:chosen-secret') + ->and($row->status)->toBe('active') + ->and($row->timezone)->toBe('Asia/Singapore') + ->and($row->avatar_uuid)->toBe('avatar-1') + ->and($row->company_uuid)->toBe('company-1') + ->and(fleetopsProfileAccountRoles($connection, $driver->uuid, 'company-1'))->toBe(['Driver']) + ->and($driver->getRelation('companyUser'))->toBeInstanceOf(CompanyUser::class); + + // A nameless customer in a company without a timezone gets a generated + // username, a random password, the default status and the server timezone + $customer = ProfileAccountManager::resolveForProfile('company-2', 'customer', null, null, '+6590002222'); + $customerRow = $connection->table('users')->where('uuid', $customer->uuid)->first(); + + expect($customerRow->type)->toBe('customer') + ->and($customerRow->status)->toBe('pending') + ->and($customerRow->name)->toBeNull() + ->and($customerRow->username)->not->toBeEmpty() + ->and($customerRow->password)->toStartWith('hashed:') + ->and($customerRow->timezone)->toBe(date_default_timezone_get()) + ->and(fleetopsProfileAccountRoles($connection, $customer->uuid, 'company-2'))->toBe(['Fleet-Ops Customer']); + + // A contact of an organization missing from the table is created without membership + $contact = ProfileAccountManager::createManagedAccount('company-missing', 'contact', 'Carl Contact', 'carl@example.com', null, ['timezone' => 'UTC']); + + expect($connection->table('users')->where('uuid', $contact->uuid)->value('type'))->toBe('contact') + ->and($connection->table('users')->where('uuid', $contact->uuid)->value('timezone'))->toBe('UTC') + ->and($connection->table('company_users')->where('user_uuid', $contact->uuid)->count())->toBe(0); +}); + +test('resolve for profile links staff members and managed accounts of the same type', function () { + $connection = fleetopsProfileAccountBoot(); + fleetopsProfileAccountRows($connection, 'users', [ + ['uuid' => 'staff-1', 'company_uuid' => 'company-2', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user'], + ['uuid' => 'driver-1', 'company_uuid' => 'company-2', 'phone' => '+6590001111', 'type' => 'driver'], + ['uuid' => 'driver-2', 'company_uuid' => null, 'phone' => '+6590003333', 'type' => 'driver'], + ]); + // The staff member belongs to company-1 through a membership row + $connection->table('company_users')->insert(['uuid' => 'cu-staff', 'company_uuid' => 'company-1', 'user_uuid' => 'staff-1', 'status' => 'active']); + + $staff = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Other Name', 'STAFF@example.com', null); + $driver = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Driver', null, '+6590001111'); + $orphan = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Orphan', null, '+6590003333'); + + expect($staff->uuid)->toBe('staff-1') + ->and($staff->type)->toBe('user') + ->and($connection->table('model_has_roles')->where('model_uuid', 'cu-staff')->count())->toBe(0) + ->and($driver->uuid)->toBe('driver-1') + ->and(fleetopsProfileAccountRoles($connection, 'driver-1', 'company-1'))->toBe(['Driver']) + // The account keeps its original organization + ->and($connection->table('users')->where('uuid', 'driver-1')->value('company_uuid'))->toBe('company-2') + // An account without an organization adopts this one + ->and($orphan->uuid)->toBe('driver-2') + ->and($connection->table('users')->where('uuid', 'driver-2')->value('company_uuid'))->toBe('company-1') + ->and($connection->table('users')->count())->toBe(3); +}); + +test('resolve for profile rejects accounts that cannot hold the profile', function () { + $connection = fleetopsProfileAccountBoot(); + fleetopsProfileAccountRows($connection, 'users', [ + ['uuid' => 'staff-2', 'company_uuid' => 'company-2', 'email' => 'outsider@example.com', 'type' => 'admin'], + ['uuid' => 'customer-1', 'company_uuid' => 'company-1', 'phone' => '+6590001111', 'type' => 'customer'], + ['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'type' => 'driver'], + ['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'email' => 'contact@example.com', 'type' => 'contact'], + ]); + $connection->table('drivers')->insert(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'driver-1']); + $connection->table('contacts')->insert(['uuid' => 'c-1', 'company_uuid' => 'company-1', 'user_uuid' => 'contact-1', 'type' => 'contact']); + + $conflict = function (callable $callback): ?ProfileIdentityConflictException { + try { + $callback(); + } catch (ProfileIdentityConflictException $exception) { + return $exception; + } + + return null; + }; + + $outsider = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'outsider@example.com', null)); + $wrong = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'nobody@example.com', '+6590001111')); + $driver = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'driver@example.com', null)); + $contact = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'contact', 'X', 'contact@example.com', null)); + + expect($outsider?->getMessage())->toBe('This email is already in use by another account.') + ->and($outsider?->getField())->toBe('email') + ->and($outsider?->getErrors())->toBe(['email' => ['This email is already in use by another account.']]) + ->and($wrong?->getMessage())->toBe('This phone number is already used by a customer.') + ->and($wrong?->getField())->toBe('phone') + ->and($driver?->getMessage())->toBe('A driver with this email already exists.') + ->and($contact?->getMessage())->toBe('A contact with this email already exists.') + ->and(ProfileAccountManager::hasProfileInCompany(User::where('uuid', 'driver-1')->first(), 'company-2', 'driver'))->toBeFalse() + ->and(ProfileAccountManager::isCompanyMember(User::where('uuid', 'driver-1')->first(), null))->toBeFalse() + ->and(new ProfileIdentityConflictException('Taken'))->getField()->toBe('email'); +}); + +test('sync proxy fields pushes normalized changes to managed accounts and only the name to staff', function () { + $connection = fleetopsProfileAccountBoot(); + $connection->table('users')->insert(['uuid' => 'other-1', 'email' => 'taken@example.com', 'phone' => '+6599990000', 'type' => 'user']); + $managed = fleetopsProfileAccountUser(['uuid' => 'managed-1', 'name' => 'Old', 'email' => 'old@example.com', 'phone' => '+6590000000', 'timezone' => 'UTC', 'type' => 'driver']); + $staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'phone' => '+6591111111', 'type' => 'user']); + + expect(ProfileAccountManager::syncProxyFields(null, ['name' => 'Nobody']))->toBeFalse() + // Unchanged values and fields outside the proxy set are ignored + ->and(ProfileAccountManager::syncProxyFields($managed, ['name' => 'Old', 'email' => 'OLD@example.com', 'type' => 'admin', 'timezone' => null]))->toBeFalse(); + + expect(ProfileAccountManager::syncProxyFields($managed, [ + 'name' => 'New', + 'email' => ' New@Example.com ', + 'phone' => '+65 9000 0001', + 'timezone' => 'Asia/Singapore', + 'password' => 'new-secret', + ]))->toBeTrue(); + + $row = $connection->table('users')->where('uuid', 'managed-1')->first(); + expect($row->name)->toBe('New') + ->and($row->email)->toBe('new@example.com') + ->and($row->phone)->toBe('+6590000001') + ->and($row->timezone)->toBe('Asia/Singapore') + ->and($row->password)->toBe('hashed:new-secret'); + + // Clearing the phone is a change the account takes + expect(ProfileAccountManager::syncProxyFields($managed, ['phone' => null]))->toBeTrue() + ->and($connection->table('users')->where('uuid', 'managed-1')->value('phone'))->toBeNull(); + + // A staff account only takes the name + expect(ProfileAccountManager::syncProxyFields($staff, ['name' => 'Staff Driver', 'email' => 'changed@example.com', 'phone' => null]))->toBeTrue() + ->and($connection->table('users')->where('uuid', 'staff-1')->first()) + ->name->toBe('Staff Driver') + ->email->toBe('staff@example.com') + ->phone->toBe('+6591111111'); + + // Taking another account's email or phone is refused + $emailConflict = null; + try { + ProfileAccountManager::syncProxyFields($managed, ['email' => 'Taken@example.com']); + } catch (ProfileIdentityConflictException $exception) { + $emailConflict = $exception; + } + + $phoneConflict = null; + try { + ProfileAccountManager::syncProxyFields($managed, ['email' => 'new@example.com', 'phone' => '+6599990000']); + } catch (ProfileIdentityConflictException $exception) { + $phoneConflict = $exception; + } + + expect($emailConflict?->getMessage())->toBe('This email is already in use by another account.') + ->and($emailConflict?->getField())->toBe('email') + ->and($phoneConflict?->getMessage())->toBe('This phone number is already in use by another account.') + ->and($phoneConflict?->getField())->toBe('phone') + ->and($connection->table('users')->where('uuid', 'managed-1')->value('email'))->toBe('new@example.com'); +}); + +test('release for profile deletes an unused managed account and leaves staff alone', function () { + $connection = fleetopsProfileAccountBoot(); + config()->set('activitylog.enabled', false); + $lonely = fleetopsProfileAccountUser(['uuid' => 'lonely-1', 'type' => 'driver']); + $shared = fleetopsProfileAccountUser(['uuid' => 'shared-1', 'type' => 'contact']); + $staying = fleetopsProfileAccountUser(['uuid' => 'staying-1', 'type' => 'driver']); + $staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'type' => 'user']); + $connection->table('contacts')->insert(['uuid' => 'c-2', 'company_uuid' => 'company-2', 'user_uuid' => 'shared-1', 'type' => 'contact']); + $connection->table('drivers')->insert(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'staying-1']); + $connection->table('company_users')->insert([ + ['uuid' => 'cu-1', 'company_uuid' => 'company-1', 'user_uuid' => 'shared-1'], + ['uuid' => 'cu-2', 'company_uuid' => 'company-2', 'user_uuid' => 'shared-1'], + ['uuid' => 'cu-3', 'company_uuid' => 'company-1', 'user_uuid' => 'staying-1'], + ]); + + ProfileAccountManager::releaseForProfile(null, 'company-1'); + ProfileAccountManager::releaseForProfile($staff, 'company-1'); + ProfileAccountManager::releaseForProfile($lonely, 'company-1'); + // Already deleted accounts are skipped + ProfileAccountManager::releaseForProfile($lonely, 'company-1'); + ProfileAccountManager::releaseForProfile($shared, 'company-1'); + ProfileAccountManager::releaseForProfile($staying, 'company-1'); + ProfileAccountManager::releaseForProfile($staying); + + expect($connection->table('users')->where('uuid', 'lonely-1')->value('deleted_at'))->not->toBeNull() + ->and($lonely->trashed())->toBeTrue() + ->and($connection->table('users')->where('uuid', 'staff-1')->value('deleted_at'))->toBeNull() + ->and($connection->table('users')->where('uuid', 'shared-1')->value('deleted_at'))->toBeNull() + // The shared account only leaves the organization it has no profile in + ->and($connection->table('company_users')->whereNull('deleted_at')->where('user_uuid', 'shared-1')->pluck('company_uuid')->all())->toBe(['company-2']) + ->and($connection->table('users')->where('uuid', 'staying-1')->value('deleted_at'))->toBeNull() + ->and($connection->table('company_users')->whereNull('deleted_at')->where('user_uuid', 'staying-1')->count())->toBe(1) + ->and(ProfileAccountManager::profileCompanies($shared)->all())->toBe(['company-2']); +}); + +test('attach to company adds membership once and only re-roles managed accounts', function () { + $connection = fleetopsProfileAccountBoot(); + $managed = fleetopsProfileAccountUser(['uuid' => 'managed-1', 'company_uuid' => 'company-2', 'type' => 'customer']); + $staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'company_uuid' => 'company-1', 'type' => 'user']); + $connection->table('company_users')->insert(['uuid' => 'cu-staff', 'company_uuid' => 'company-1', 'user_uuid' => 'staff-1', 'status' => 'active']); + + expect(ProfileAccountManager::attachToCompany($managed, 'company-missing', 'customer'))->toBeNull(); + + $first = ProfileAccountManager::attachToCompany($managed, 'company-1', 'customer'); + $second = ProfileAccountManager::attachToCompany($managed, 'company-1', 'unknown-type'); + $staffMembership = ProfileAccountManager::attachToCompany($staff, 'company-1', 'driver'); + + expect($first->uuid)->toBe($second->uuid) + ->and($connection->table('company_users')->where('user_uuid', 'managed-1')->count())->toBe(1) + // An unknown type falls back to the contact role + ->and(fleetopsProfileAccountRoles($connection, 'managed-1', 'company-1'))->toBe(['Fleet-Ops Contact']) + ->and($staffMembership->uuid)->toBe('cu-staff') + ->and($connection->table('model_has_roles')->where('model_uuid', 'cu-staff')->count())->toBe(0) + ->and($connection->table('users')->where('uuid', 'managed-1')->value('company_uuid'))->toBe('company-2'); +}); + +test('send credentials resets the password activates the account and emails or texts it', function () { + $connection = fleetopsProfileAccountBoot(); + $mail = Illuminate\Support\Facades\Mail::getFacadeRoot(); + $sms = app(SmsService::class); + + $emailUser = fleetopsProfileAccountUser(['uuid' => 'email-1', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'status' => 'pending', 'type' => 'driver']); + $driver = new Driver(); + $driver->setRawAttributes(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'email-1'], true); + $driver->setRelation('company', (object) ['name' => 'Acme']); + + expect(ProfileAccountManager::sendCredentials($driver, $emailUser, 'chosen-secret'))->toBe('email') + ->and($connection->table('users')->where('uuid', 'email-1')->value('password'))->toBe('hashed:chosen-secret') + ->and($connection->table('users')->where('uuid', 'email-1')->value('status'))->toBe('active') + ->and($mail->sent[0])->toBeInstanceOf(DriverCredentialsMail::class) + ->and($mail->to[0])->toBe($emailUser); + + // An active account is not re-activated; a random password is generated + $customerUser = fleetopsProfileAccountUser(['uuid' => 'email-2', 'company_uuid' => 'company-1', 'email' => 'customer@example.com', 'status' => 'active', 'type' => 'customer']); + $customer = new Contact(); + $customer->setRawAttributes(['uuid' => 'c-1', 'company_uuid' => 'company-1', 'type' => 'customer'], true); + + expect(ProfileAccountManager::sendCredentials($customer, $customerUser))->toBe('email') + ->and($connection->table('users')->where('uuid', 'email-2')->value('password'))->toStartWith('hashed:') + ->and($mail->sent[1])->toBeInstanceOf(CustomerCredentialsMail::class); + + // Without an email the credentials are texted, naming the organization + $phoneUser = fleetopsProfileAccountUser(['uuid' => 'phone-1', 'phone' => '+6590001111', 'status' => 'active', 'type' => 'driver']); + expect(ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'texted-secret'))->toBe('sms') + ->and($sms->sent[0])->toBe(['+6590001111', 'Your Acme sign-in details. Login: +6590001111 Password: texted-secret']); + + // An organization-less profile falls back to the app name + $orphan = new Driver(); + $orphan->setRawAttributes(['uuid' => 'd-2'], true); + $orphan->setRelation('company', null); + ProfileAccountManager::deliverCredentials($orphan, $phoneUser, 'app-secret'); + expect($sms->sent[1][1])->toBe('Your Fleetbase sign-in details. Login: +6590001111 Password: app-secret'); + + // A refused text surfaces the provider's reason + $sms->result = ['success' => false, 'error' => 'Invalid number']; + expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x')) + ->toThrow(Exception::class, 'The credentials could not be texted: Invalid number'); + + $sms->result = ['success' => false, 'message' => 'Quota exceeded']; + expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x')) + ->toThrow(Exception::class, 'The credentials could not be texted: Quota exceeded'); + + $sms->result = ['success' => false]; + expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x')) + ->toThrow(Exception::class, 'The credentials could not be texted: the SMS provider refused it.'); + + // A result without a success flag is treated as sent + $sms->result = []; + expect(ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x'))->toBe('sms'); + + $unreachable = fleetopsProfileAccountUser(['uuid' => 'none-1', 'status' => 'active', 'type' => 'driver']); + expect(fn () => ProfileAccountManager::deliverCredentials($driver, $unreachable, 'x')) + ->toThrow(Exception::class, 'This profile has no email or phone to send credentials to.'); +}); + +test('driver credentials mail names the organization and the sign-in identity', function () { + config()->set('app.name', 'Fleetbase'); + $user = new User(); + $user->setRawAttributes(['name' => 'Dana', 'email' => null, 'phone' => '+6590001111'], true); + + $driver = new Driver(); + $driver->setRelation('company', (object) ['name' => 'Acme']); + + $mail = new DriverCredentialsMail('plain-secret', $driver, $user); + $content = $mail->content(); + + expect($mail->envelope()->subject)->toBe('Your Acme driver sign-in details') + ->and($content->markdown)->toBe('fleetops::mail.driver-credentials') + ->and($content->with)->toMatchArray([ + 'driver' => $driver, + 'user' => $user, + 'companyName' => 'Acme', + 'identity' => '+6590001111', + 'plaintextPassword' => 'plain-secret', + ]); + + $user->setRawAttributes(['name' => 'Dana', 'email' => 'dana@example.com'], true); + $orphan = new Driver(); + $orphan->setRelation('company', null); + $orphanMail = new DriverCredentialsMail('plain-secret', $orphan, $user); + + expect($orphanMail->envelope()->subject)->toBe('Your Fleetbase driver sign-in details') + ->and($orphanMail->content()->with)->toMatchArray(['companyName' => 'Fleetbase', 'identity' => 'dana@example.com']); + + $view = file_get_contents(__DIR__ . '/../../../resources/views/mail/driver-credentials.blade.php'); + expect($view)->toContain('{{ $identity }}') + ->toContain('{{ $plaintextPassword }}') + ->toContain('{{ $companyName }}'); +}); diff --git a/tests/integration/components/customer/form-test.js b/tests/integration/components/customer/form-test.js index 907fee6e8..9e08661ef 100644 --- a/tests/integration/components/customer/form-test.js +++ b/tests/integration/components/customer/form-test.js @@ -49,14 +49,24 @@ module('Integration | Component | customer/form', function (hooks) { this.owner.register('component:model-select', setComponentTemplate(hbs`
`, ModelSelectStub)); }); - test('the user account selector only offers customer users without a contact', async function (assert) { - this.set('customer', makeResource({ isNew: true })); + test('it does not offer a user account selector', async function (assert) { + this.set('customer', makeResource({ isNew: false })); await render(hbs``); - const userSelect = modelSelectQueries.find((entry) => entry.modelName === 'user'); + assert.notOk( + modelSelectQueries.find((entry) => entry.modelName === 'user'), + 'login accounts are managed by the server' + ); + }); + + test('email and phone are read-only for a staff-linked customer', async function (assert) { + this.set('customer', makeResource({ isNew: false, is_staff_linked: true, email: 'staff@example.com' })); + + await render(hbs``); - assert.deepEqual(userSelect.query, { doesnt_have_contact: true, is_customer: true }); + assert.dom('input[placeholder="Email"]').isDisabled(); + assert.dom('[data-test-staff-linked-helper]').exists(); }); test('the welcome email option is offered when creating a customer with the portal installed', async function (assert) { diff --git a/tests/integration/components/modals/reset-customer-credentials-test.js b/tests/integration/components/modals/reset-customer-credentials-test.js deleted file mode 100644 index a1152f024..000000000 --- a/tests/integration/components/modals/reset-customer-credentials-test.js +++ /dev/null @@ -1,26 +0,0 @@ -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; -import { hbs } from 'ember-cli-htmlbars'; - -module('Integration | Component | modals/reset-customer-credentials', function (hooks) { - setupRenderingTest(hooks); - - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); - - await render(hbs``); - - assert.dom().hasText(''); - - // Template block usage: - await render(hbs` - - template block text - - `); - - assert.dom().hasText('template block text'); - }); -}); diff --git a/tests/integration/components/modals/reset-profile-credentials-test.js b/tests/integration/components/modals/reset-profile-credentials-test.js new file mode 100644 index 000000000..cb24840c7 --- /dev/null +++ b/tests/integration/components/modals/reset-profile-credentials-test.js @@ -0,0 +1,61 @@ +import Service from '@ember/service'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +let posted; + +class FetchStub extends Service { + post(endpoint, payload, options) { + posted = { endpoint, payload, options }; + return Promise.resolve({ status: 'ok', driver: { user_uuid: 'user-1', is_staff_linked: false, login_status: 'active' } }); + } +} + +class NotificationsStub extends Service { + success() {} + serverError() {} +} + +module('Integration | Component | modals/reset-profile-credentials', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + posted = null; + this.owner.register('service:fetch', FetchStub); + this.owner.register('service:notifications', NotificationsStub); + }); + + test('it posts the new password with the configured endpoint and payload', async function (assert) { + const profile = { + id: 'driver-1', + name: 'Alex Driver', + setProperties(values) { + Object.assign(this, values); + }, + }; + + this.set('options', { + profile, + endpoint: 'drivers/driver-1/reset-credentials', + payload: (subject) => ({ subject: subject.id }), + }); + + await render(hbs``); + + const component = this.options; + await component.confirm({ + startLoading: () => assert.step('loading'), + stopLoading: () => assert.step('stopped'), + done: () => assert.step('done'), + }); + + assert.strictEqual(posted.endpoint, 'drivers/driver-1/reset-credentials'); + assert.strictEqual(posted.payload.subject, 'driver-1'); + assert.true(posted.payload.send_credentials, 'sends credentials by default'); + assert.deepEqual(posted.options, { namespace: 'int/v1' }); + assert.strictEqual(profile.login_status, 'active', 'applies the login status from the response'); + assert.verifySteps(['loading', 'done']); + }); +}); diff --git a/tests/unit/services/driver-actions-test.js b/tests/unit/services/driver-actions-test.js index aafdb64dc..adba110fb 100644 --- a/tests/unit/services/driver-actions-test.js +++ b/tests/unit/services/driver-actions-test.js @@ -128,4 +128,57 @@ module('Unit | Service | driver-actions', function (hooks) { await service.unassignOrders({ id: 'driver-1', name: 'Alex Driver' }); }); + + test('account actions are hidden without a managed login', function (assert) { + const service = this.owner.lookup('service:driver-actions'); + service.intl = { t: (key) => key }; + + assert.deepEqual(service.accountMenuItems({ id: 'driver-1', user_uuid: null }), [], 'no login account'); + assert.deepEqual(service.accountMenuItems({ id: 'driver-1', user_uuid: 'user-1', is_staff_linked: true }), [], 'staff-linked login is managed from IAM'); + }); + + test('account actions toggle deactivate/reactivate by login status', function (assert) { + const service = this.owner.lookup('service:driver-actions'); + service.intl = { t: (key) => key }; + + const active = service.accountMenuItems({ id: 'driver-1', user_uuid: 'user-1', login_status: 'active' }).map((item) => item.text); + assert.deepEqual(active, [undefined, 'profile-account.actions.reset-password', 'profile-account.actions.send-credentials', 'profile-account.actions.deactivate-login']); + + const inactive = service.accountMenuItems({ id: 'driver-1', user_uuid: 'user-1', login_status: 'inactive' }).map((item) => item.text); + assert.true(inactive.includes('profile-account.actions.reactivate-login')); + assert.false(inactive.includes('profile-account.actions.deactivate-login')); + }); + + test('deactivateLogin posts to the driver endpoint and applies the response', async function (assert) { + const service = this.owner.lookup('service:driver-actions'); + const driver = { + id: 'driver-1', + name: 'Alex Driver', + user_uuid: 'user-1', + login_status: 'active', + setProperties(values) { + Object.assign(this, values); + }, + }; + let confirmOptions; + + service.intl = { t: (key) => key }; + service.notifications = { success: () => assert.step('success'), serverError: () => assert.ok(false, 'unexpected call') }; + service.fetch = { + post: async (url, payload, options) => { + assert.strictEqual(url, 'drivers/driver-1/deactivate-login'); + assert.deepEqual(payload, {}); + assert.deepEqual(options, { namespace: 'int/v1' }); + + return { status: 'ok', driver: { id: 'driver-1', user_uuid: 'user-1', is_staff_linked: false, login_status: 'inactive' } }; + }, + }; + service.modalsManager = { confirm: (options) => (confirmOptions = options) }; + + service.deactivateLogin(driver); + await confirmOptions.confirm({ startLoading() {}, stopLoading() {}, done: () => assert.step('done') }); + + assert.strictEqual(driver.login_status, 'inactive'); + assert.verifySteps(['success', 'done']); + }); }); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 1996dca4c..52c01f38f 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -801,11 +801,9 @@ driver: fields: avatar: Avatar driver-details: Driver Details - select-user: Select User select-vehicle: Select Vehicle upload-new-photo: Upload new photo user-account: User Account - user-account-help-text: Each driver profile needs to be connected to a specific user account. This association is essential for handling the authentication process, ensuring that each driver can securely access and manage their profile. vendor: Vendor select-vendor: Select Vendor driver-license: Drivers License @@ -837,6 +835,40 @@ driver: unassign-vehicle-body: This removes {driverName} from {vehicleName}. Order assignments are not changed. unassign-vehicle-success: Unassigned {vehicleName} from {driverName}. issue-title: Issue reported for {driverName} + +profile-account: + staff-linked-helper: Linked to a team member account — manage email and phone from IAM. + will-link-to-staff: Will be linked to existing team member {name}. + actions: + reset-password: Reset Password + send-credentials: Send Credentials + deactivate-login: Deactivate Login + reactivate-login: Reactivate Login + convert-to-vendor: Convert to Vendor + prompts: + send-credentials-title: Send Login Credentials + send-credentials-body: Generate a new temporary password and send {name} their login credentials. + send-credentials-success: Login credentials sent to {name}. + deactivate-login-body: Deactivate the login for {name}. Their profile and history will be preserved. + deactivate-login-success: Login deactivated for {name}. + reactivate-login-body: Reactivate the login for {name}. + reactivate-login-success: Login reactivated for {name}. + send-portal-credentials-title: Send Portal Credentials + send-portal-credentials-body: Generate a new temporary password and email this contact their portal credentials. + send-portal-credentials-success: Portal credentials sent. + deactivate-portal-login-body: Deactivate portal access for this contact. Their profile and history will be preserved. + deactivate-portal-login-success: Portal login deactivated. + reactivate-portal-login-body: Reactivate portal access for this contact. + reactivate-portal-login-success: Portal login reactivated. + reset-password-title: Reset Login Credentials + reset-password-heading: You are about to reset the password for {name} + reset-password-body: Please enter the new password and confirm it below. You can optionally send the new credentials by selecting the checkbox. + reset-password-accept: Reset Credentials + reset-password-success: Password reset. + new-password: New Password + confirm-new-password: Confirm New Password + send-new-credentials: Send the new credentials to this account + device: attachment: title: Attached asset From 0055e88adddc9241b9401ee0a96ccaed704460a8 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 01:13:23 +0800 Subject: [PATCH 2/4] test: restore request authorization coverage and cover orphaned telemetry runs The adoption test rewrite had dropped the unrelated 'auth can resolves real permissions' test, which was the only coverage for several request authorize() lines. This restores it, and adds an explicit case for finishing a sync run whose connection was removed. --- .../Http/AfaqyRealtimeIngestionTest.php | 5 ++ ...iverControllerExistingUserAdoptionTest.php | 61 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php b/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php index 3b1e8960b..5a56b5a40 100644 --- a/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +++ b/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php @@ -596,6 +596,11 @@ public function normalizeTelemetrySnapshot(array $payload): array DB::table('telematic_deliveries')->where('uuid', $id)->update(['status' => 'quarantined', 'applied' => 2, 'failed' => 1]); Inbox::finishRun('run-pending'); expect((array) DB::table('telematic_sync_runs')->first())->toMatchArray(['status' => 'partial', 'applied' => 2, 'failed' => 1]); + + // A finished run whose connection was removed completes without touching connection meta + DB::table('telematic_sync_runs')->insert(['uuid' => 'run-orphan', 'telematic_uuid' => 'missing-connection', 'status' => 'ingesting', 'created_at' => now(), 'updated_at' => now()]); + Inbox::finishRun('run-orphan'); + expect(DB::table('telematic_sync_runs')->where('uuid', 'run-orphan')->value('status'))->not->toBe('ingesting'); }); test('telemetry diagnostics report setup receipt backlog and degraded delivery states', function () { diff --git a/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php b/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php index b8b8bfdb4..38aa20dd3 100644 --- a/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +++ b/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php @@ -538,3 +538,64 @@ public function needsRehash($hashedValue, array $options = []): bool expect($validationFailure->getData(true))->toBe(['error' => ['password' => ['The password is too weak.']]]); }); + +test('auth can resolves real permissions for requests and ping guards', function () { + $connection = fleetopsDriverAdoptionBoot([]); + $connection->table('users')->insert(['uuid' => '77777777-7777-4777-8777-777777777701', 'public_id' => 'user_authcan1', 'company_uuid' => 'company-1', 'name' => 'Permitted User', 'type' => 'user']); + $connection->table('company_users')->insert(['uuid' => '77777777-7777-4777-8777-777777777702', 'company_uuid' => 'company-1', 'user_uuid' => '77777777-7777-4777-8777-777777777701', 'status' => 'active']); + $connection->table('permissions')->insert([ + ['name' => 'fleet-ops update driver', 'guard_name' => 'sanctum'], + ['name' => 'fleet-ops update order', 'guard_name' => 'sanctum'], + ['name' => 'fleet-ops create driver', 'guard_name' => 'sanctum'], + ]); + $companyUserMorph = (new Fleetbase\Models\CompanyUser())->getMorphClass(); + $permissionIds = $connection->table('permissions')->pluck('id', 'name'); + $connection->table('model_has_permissions')->insert([ + ['permission_id' => $permissionIds['fleet-ops update driver'], 'model_type' => $companyUserMorph, 'model_uuid' => '77777777-7777-4777-8777-777777777702'], + ['permission_id' => $permissionIds['fleet-ops update order'], 'model_type' => $companyUserMorph, 'model_uuid' => '77777777-7777-4777-8777-777777777702'], + ['permission_id' => $permissionIds['fleet-ops create driver'], 'model_type' => $companyUserMorph, 'model_uuid' => '77777777-7777-4777-8777-777777777702'], + ]); + session(['company' => 'company-1', 'user' => '77777777-7777-4777-8777-777777777701']); + + // Granted permissions authorize, missing permissions deny + expect(Fleetbase\Support\Auth::can('fleet-ops update driver'))->toBeTrue() + ->and(Fleetbase\Support\Auth::can('fleet-ops delete driver'))->toBeFalse(); + + // The update driver form request authorizes through the same gate + $updateRequest = new Fleetbase\FleetOps\Http\Requests\Internal\UpdateDriverRequest(); + expect($updateRequest->authorize())->toBeTrue(); + + // Create-driver is granted, create-order-config was never granted, so the + // two internal create requests resolve opposite ways through one gate + expect((new Fleetbase\FleetOps\Http\Requests\Internal\CreateDriverRequest())->authorize())->toBeTrue() + ->and((new Fleetbase\FleetOps\Http\Requests\Internal\CreateOrderConfigRequest())->authorize())->toBeFalse(); + + // Fleet action requests resolve their permission through the same gate + $fleetRequest = Fleetbase\FleetOps\Http\Requests\Internal\FleetActionRequest::create('/int/v1/fleets/assign-vehicle', 'POST'); + $canMethod = new ReflectionMethod(Fleetbase\FleetOps\Http\Requests\Internal\FleetActionRequest::class, 'can'); + $canMethod->setAccessible(true); + expect($canMethod->invoke($fleetRequest, 'fleet-ops update driver'))->toBeTrue() + ->and($canMethod->invoke($fleetRequest, 'fleet-ops assign-vehicle-for fleet'))->toBeFalse(); + + // Driver ping authorization resolves through the order permission + $orderController = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrderController(); + $canPing = new ReflectionMethod($orderController, 'canPingDriver'); + $canPing->setAccessible(true); + expect($canPing->invoke($orderController))->toBeTrue(); + + // Creating an order is gated on its own permission, which was never granted + expect((new Fleetbase\FleetOps\Http\Requests\Internal\CreateOrderRequest())->authorize())->toBeFalse(); + + // Global search consults the same gate per result type. This user is not an + // admin and holds no `see` permissions, so every requested type is skipped + // and nothing is searched rather than leaking unpermitted records + $searchController = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\SearchController(); + $searchResponse = $searchController->search(Request::create('/int/v1/search', 'GET', [ + 'query' => 'anything', + 'types' => 'orders,drivers', + ])); + + expect($searchResponse->getData(true))->toBe(['results' => []]); + + session(['user' => null]); +}); From e709e71a578bf1c70fe5b6b5436688e9d9d188f9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 01:14:41 +0800 Subject: [PATCH 3/4] fix: don't log from the profile account conversion migration Migrations can run as a user that can't write the application log. The Postman contract job failed on exactly that. --- ...profile_only_users_to_managed_accounts.php | 8 +------ ...ProfileOnlyUserConversionMigrationTest.php | 23 ++----------------- 2 files changed, 3 insertions(+), 28 deletions(-) diff --git a/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php b/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php index 8d4579d78..4381ebcd1 100644 --- a/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +++ b/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php @@ -2,7 +2,6 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; /* @@ -31,8 +30,6 @@ public function up(): void return; } - $converted = ['driver' => 0, 'customer' => 0]; - DB::table('users') ->select(['uuid', 'meta']) ->where('type', 'user') @@ -48,7 +45,7 @@ public function up(): void $query->selectRaw(1)->from('companies')->whereColumn('companies.owner_uuid', 'users.uuid'); }) ->orderBy('uuid') - ->chunk(500, function ($users) use (&$converted) { + ->chunk(500, function ($users) { foreach ($users as $user) { $type = $this->managedTypeFor($user->uuid); if (!$type) { @@ -60,11 +57,8 @@ public function up(): void $meta['previous_type'] = 'user'; DB::table('users')->where('uuid', $user->uuid)->update(['type' => $type, 'meta' => json_encode($meta)]); - $converted[$type]++; } }); - - Log::info('[fleetops] Converted profile-only user accounts to managed accounts.', $converted); } public function down(): void diff --git a/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php b/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php index 9a69a6196..110354bdb 100644 --- a/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +++ b/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php @@ -4,7 +4,6 @@ use Illuminate\Database\Eloquent\Model as EloquentModel; use Illuminate\Database\SQLiteConnection; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; /** @@ -46,20 +45,6 @@ public function __call($method, $arguments) DB::clearResolvedInstance('db'); Schema::clearResolvedInstance('db.schema'); - $GLOBALS['fleetopsProfileConversionLogs'] = []; - app()->instance('log', new class { - public function info($message, array $context = []): void - { - $GLOBALS['fleetopsProfileConversionLogs'][] = [$message, $context]; - } - - public function __call($method, $arguments) - { - return null; - } - }); - Log::clearResolvedInstance('log'); - $schema = $connection->getSchemaBuilder(); $tables = [ 'users' => ['uuid', 'type', 'meta'], @@ -169,10 +154,7 @@ function fleetopsProfileConversionTypes(SQLiteConnection $connection): array ]) // The previous type is recorded next to any existing meta ->and(json_decode($connection->table('users')->where('uuid', 'a-driver')->value('meta'), true))->toBe(['source' => 'console', 'previous_type' => 'user']) - ->and(json_decode($connection->table('users')->where('uuid', 'm-bad-meta')->value('meta'), true))->toBe(['previous_type' => 'user']) - ->and($GLOBALS['fleetopsProfileConversionLogs'])->toBe([ - ['[fleetops] Converted profile-only user accounts to managed accounts.', ['driver' => 2, 'customer' => 1]], - ]); + ->and(json_decode($connection->table('users')->where('uuid', 'm-bad-meta')->value('meta'), true))->toBe(['previous_type' => 'user']); }); test('down reverts only the accounts up converted', function () { @@ -207,8 +189,7 @@ function fleetopsProfileConversionTypes(SQLiteConnection $connection): array $migration = fleetopsProfileConversionMigration(); $migration->up(); - expect(fleetopsProfileConversionTypes($connection))->toBe(['a-driver' => 'user']) - ->and($GLOBALS['fleetopsProfileConversionLogs'])->toBe([]); + expect(fleetopsProfileConversionTypes($connection))->toBe(['a-driver' => 'user']); $connection->table('users')->where('uuid', 'a-driver')->update(['type' => 'driver', 'meta' => json_encode(['previous_type' => 'user'])]); $connection->getSchemaBuilder()->drop('users'); From 593f1e3098a48c3741d0c3664abc06e0bdeb268a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 12:21:44 +0800 Subject: [PATCH 4/4] fix: repair driver and customer memberships with their profile role FixDriverCompanies and FixCustomerCompanies called assignCompany() without a role. That used core's default, the Administrator role, so repairing a missing membership gave drivers and customers full organization access. They now pass Driver and Fleet-Ops Customer. Core no longer has a default role (fleetbase/core-api#266). --- server/src/Console/Commands/FixCustomerCompanies.php | 2 +- server/src/Console/Commands/FixDriverCompanies.php | 2 +- server/tests/CommandContractsTest.php | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/src/Console/Commands/FixCustomerCompanies.php b/server/src/Console/Commands/FixCustomerCompanies.php index bd3d55436..149176df0 100644 --- a/server/src/Console/Commands/FixCustomerCompanies.php +++ b/server/src/Console/Commands/FixCustomerCompanies.php @@ -68,7 +68,7 @@ public function handle() $this->line('Found user ' . $user->name . ' (' . $user->email . ') which doesnt have correct company assignment.'); $company = $this->companyByUuid($customer->company_uuid); if ($company) { - $user->assignCompany($company); + $user->assignCompany($company, 'Fleet-Ops Customer'); $this->line('User ' . $user->email . ' was assigned to company: ' . $company->name); } } diff --git a/server/src/Console/Commands/FixDriverCompanies.php b/server/src/Console/Commands/FixDriverCompanies.php index 182b7a546..279a8aa4b 100644 --- a/server/src/Console/Commands/FixDriverCompanies.php +++ b/server/src/Console/Commands/FixDriverCompanies.php @@ -49,7 +49,7 @@ public function handle() $this->line('Found driver ' . $user->name . ' (' . $user->email . ') which doesnt have correct company assignment.'); $company = $this->companyByUuid($driver->company_uuid); if ($company) { - $user->assignCompany($company); + $user->assignCompany($company, 'Driver'); $this->line('Driver ' . $user->email . ' was assigned to company: ' . $company->name); } } diff --git a/server/tests/CommandContractsTest.php b/server/tests/CommandContractsTest.php index efad87823..b9c808bbe 100644 --- a/server/tests/CommandContractsTest.php +++ b/server/tests/CommandContractsTest.php @@ -884,7 +884,7 @@ public function syncProperty(string $property, Model $model): bool return true; } - public function assignCompany(Company $company, string $role = 'Administrator'): self + public function assignCompany(Company $company, ?string $role = null): self { $this->assigned[] = [$company->uuid, $role]; @@ -2208,8 +2208,8 @@ public function getNearbyDriversForOrder(Order $order, Point $pickup, int $dista ['email', FleetOpsCustomerCommandFake::class], ['phone', FleetOpsCustomerCommandFake::class], ]) - ->and($createdUser->assigned)->toBe([['company-created', 'Administrator']]) - ->and($existingUser->assigned)->toBe([['company-existing', 'Administrator']]); + ->and($createdUser->assigned)->toBe([['company-created', 'Fleet-Ops Customer']]) + ->and($existingUser->assigned)->toBe([['company-existing', 'Fleet-Ops Customer']]); }); test('fix driver companies command syncs assigned users and missing company assignments', function () { @@ -2236,7 +2236,7 @@ public function getNearbyDriversForOrder(Order $order, Point $pickup, int $dista ['email', Driver::class], ['phone', Driver::class], ]) - ->and($user->assigned)->toBe([['driver-company', 'Administrator']]) + ->and($user->assigned)->toBe([['driver-company', 'Driver']]) ->and($command->messages)->toContain(['line', 'Found driver Driver User (driver@example.test) which doesnt have correct company assignment.']) ->and($command->messages)->toContain(['line', 'Driver driver@example.test was assigned to company: Driver Company']); });