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