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..4381ebcd1 --- /dev/null +++ b/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php @@ -0,0 +1,132 @@ + 'Driver', + 'customer' => 'Fleet-Ops Customer', + ]; + + public function up(): void + { + if (!$this->hasRequiredTables()) { + return; + } + + 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) { + 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)]); + } + }); + } + + 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/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/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/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']); }); 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/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/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..110354bdb --- /dev/null +++ b/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php @@ -0,0 +1,201 @@ + $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'); + + $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']); +}); + +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']); + + $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..38aa20dd3 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,104 +476,67 @@ 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', - ])); +function fleetopsDriverAdoptionThrowingHasher(Throwable $exception): void +{ + $GLOBALS['fleetopsDriverAdoptionHashException'] = $exception; + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } - 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); -}); + public function make($value, array $options = []): string + { + throw $GLOBALS['fleetopsDriverAdoptionHashException']; + } -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 check($value, $hashedValue, array $options = []): bool + { + return false; + } - public function getActionMethod() - { - return 'updateRecord'; - } + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } + }); + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); +} - public function getName() - { - return 'internal.drivers.update'; - } +test('create record reports a missing organization', function () { + fleetopsDriverAdoptionBoot([]); + session(['company' => 'company-missing']); - public function uri() - { - return 'int/v1/drivers/{id}'; - } + $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Nowhere Driver'])); - public function parameters() - { - return ['id' => 'driver_updone1']; - } + expect($result->getData(true))->toBe(['error' => 'Unable to create driver.']); +}); - public function parameter($name = null, $default = null) - { - return $name === 'id' ? 'driver_updone1' : $default; - } - }; - }); +test('create record syncs custom field values onto the new driver', function () { + $connection = fleetopsDriverAdoptionBoot([]); + $connection->table('custom_fields')->insert(['uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'company_uuid' => 'company-1', 'name' => 'badge', 'label' => 'Badge']); - $result = (new DriverController())->updateRecord($request, 'driver_updone1'); + $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']], + ]])); - expect($result)->toBeArray()->toHaveKey('driver') - ->and($connection->table('users')->value('name'))->toBe('New Name') - ->and($connection->table('drivers')->value('status'))->toBe('available'); + expect($result)->toBeArray() + ->and($connection->table('custom_field_values')->where('subject_uuid', $result['driver']->resource->uuid)->value('value'))->toBe('B-12'); }); -test('create with user uuids provisions users guards conflicts and companies', function () { +test('create record reports database and request validation failures', function () { $connection = fleetopsDriverAdoptionBoot([]); - Illuminate\Support\Facades\Cache::clearResolvedInstance('cache'); + $connection->getSchemaBuilder()->drop('drivers'); - // 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'); + // 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'); - // 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']); + fleetopsDriverAdoptionThrowingHasher(new Fleetbase\Exceptions\FleetbaseRequestValidationException(['password' => ['The password is too weak.']])); + $validationFailure = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Weak Password', 'password' => 'weak-password'])); + + expect($validationFailure->getData(true))->toBe(['error' => ['password' => ['The password is too weak.']]]); }); test('auth can resolves real permissions for requests and ping guards', function () { @@ -558,153 +599,3 @@ public function parameter($name = null, $default = null) session(['user' => null]); }); - -function fleetopsDriverAdoptionUpdateRequest(string $publicId, array $driver): Request -{ - $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'; - } - - public function uri() - { - return 'int/v1/drivers/{id}'; - } - - public function parameters() - { - return ['id' => $this->publicId]; - } - - public function parameter($name = null, $default = null) - { - return $name === 'id' ? $this->publicId : $default; - } - }; - }); - - return $request; -} - -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 () { - fleetopsDriverAdoptionBoot([]); - - // 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(); - - // 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.']); -}); - -test('updates apply photo avatars and sync custom field values', 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); -}); - -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.']); - - // 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.'); -}); - -test('update rejects requests that fail validation before persistence', function () { - fleetopsDriverAdoptionBoot(['status' => ['The selected status is invalid.']]); - - // The error-response seam raises the validation failure once the rejection - // branch executes, so the request never reaches persistence - $failure = null; - - try { - (new DriverController())->updateRecord(fleetopsDriverAdoptionUpdateRequest('driver_missing1', ['status' => 'bogus']), 'driver_missing1'); - } catch (Illuminate\Validation\ValidationException $exception) { - $failure = $exception; - } - - expect($failure?->errors())->toBe(['status' => ['The selected status is invalid.']]); -}); 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