Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4c65e40
fix: restore cross-organisation invite flow for existing users
Apr 21, 2026
561d3cd
fix: correct fleetbase.db.connection config key typo in Model and Set…
Apr 21, 2026
3865ec7
fix: remove Model/Setting constructors that broke sandbox connection …
Apr 21, 2026
e1814fc
fix: correct config key typo and pin Invite to production DB via getC…
Apr 21, 2026
676fabc
fix: remove connection-overriding constructors from Model and Setting
Apr 21, 2026
f39a144
revert: restore Model.php and Setting.php to match main exactly
Apr 21, 2026
20f392c
fix: update fleetbase.connection.db in setSandboxSession when switchi…
Apr 21, 2026
5341ce9
fix: pin Invite::getConnectionName() to env DB_CONNECTION, not runtim…
Apr 21, 2026
6702f59
fix: correct fleetbase.db.connection config key typo in Model and Set…
Apr 21, 2026
7a520ad
fix: resolve 500 on /users/me after invite acceptance
Apr 21, 2026
04fb3fe
fix: persist and apply role_uuid through the full invite flow
Apr 21, 2026
c0e1df6
fix: restore phone uniqueness rule in CreateUserRequest
Apr 21, 2026
1363cd4
refactor: store invite role_uuid in meta JSON via HasMetaAttributes
Apr 21, 2026
0d4320c
fix: include pending-invite users in IAM user list
Apr 21, 2026
7c5d1ca
fix: sync company_users to sandbox so invited users appear in IAM list
Apr 21, 2026
a7294ad
fix: pin User, Company, CompanyUser to production connection via getC…
Apr 21, 2026
9f92a01
refactor: remove redundant invite subquery from UserFilter
Apr 21, 2026
684d01c
feat: include CompanyUser in sandbox:sync for structural consistency
Apr 21, 2026
d5ffc7a
fix: query users from production connection in UserController only
Apr 21, 2026
bb1d8a4
Ran linter and fixed invite flow
roncodes Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@
* 'pending' is retained for backwards compatibility (e.g. manually-created items
* that have not yet been confirmed).
*/
return new class extends Migration
{
return new class extends Migration {
public function up(): void
{
DB::statement("
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
public function up(): void
Expand All @@ -13,13 +13,13 @@ public function up(): void
});

// Backfill from the parent schedule
DB::statement("
DB::statement('
UPDATE schedule_items si
JOIN schedules s ON s.uuid = si.schedule_uuid
SET si.company_uuid = s.company_uuid
WHERE si.company_uuid IS NULL
AND si.schedule_uuid IS NOT NULL
");
');
}

public function down(): void
Expand Down
36 changes: 36 additions & 0 deletions migrations/2026_04_21_000001_add_role_uuid_to_invites_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Run the migrations.
*
* Adds a nullable JSON `meta` column to the `invites` table so that
* arbitrary key-value data (e.g. role_uuid for user invitations) can be
* stored on an invite record without requiring dedicated columns for each
* use-case. The HasMetaAttributes trait is used to read and write values.
*
* @return void
*/
public function up()
{
Schema::table('invites', function (Blueprint $table) {
$table->json('meta')->nullable()->after('reason');
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('invites', function (Blueprint $table) {
$table->dropColumn('meta');
});
}
};
5 changes: 4 additions & 1 deletion src/Console/Commands/SyncSandbox.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,13 @@ public function handle()
DB::connection('sandbox')
->table('api_credentials')
->truncate();
DB::connection('sandbox')
->table('company_users')
->truncate();
}

// Models that need to be synced from Production to Sandbox
$syncable = [\Fleetbase\Models\User::class, \Fleetbase\Models\Company::class, \Fleetbase\Models\ApiCredential::class];
$syncable = [\Fleetbase\Models\User::class, \Fleetbase\Models\Company::class, \Fleetbase\Models\CompanyUser::class, \Fleetbase\Models\ApiCredential::class];

// Sync each syncable data model
foreach ($syncable as $model) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ class ScheduleExceptionController extends FleetbaseController

/**
* The ScheduleService instance.
*
* @var ScheduleService
*/
protected ScheduleService $scheduleService;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ class ScheduleTemplateController extends FleetbaseController

/**
* The ScheduleService instance.
*
* @var ScheduleService
*/
protected ScheduleService $scheduleService;

Expand Down
218 changes: 185 additions & 33 deletions src/Http/Controllers/Internal/v1/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,74 @@ class UserController extends FleetbaseController
*/
public $updateRequest = UpdateUserRequest::class;

/**
* Query users always against the production database.
*
* Users are authoritative in production. The sandbox database contains
* only a mirrored copy. Temporarily restoring the production connection
* for this query ensures the IAM list is correct regardless of whether
* the console is in sandbox mode, without affecting any other sandbox
* queries in the same request lifecycle.
*
* @return \Illuminate\Http\Response
*/
public function queryRecord(Request $request)
{
$isSandbox = config('fleetbase.connection.db') === 'sandbox';

if ($isSandbox) {
config([
'database.default' => env('DB_CONNECTION', 'mysql'),
'fleetbase.connection.db' => env('DB_CONNECTION', 'mysql'),
]);
}

$response = parent::queryRecord($request);

if ($isSandbox) {
config([
'database.default' => 'sandbox',
'fleetbase.connection.db' => 'sandbox',
]);
}

return $response;
}

/**
* Creates a record with request payload.
*
* If the supplied email address already belongs to an existing user the
* request is treated as a cross-organisation invitation rather than a
* duplicate-creation attempt. The existing user is invited to join the
* current company and the response includes `invited: true` so the
* frontend can display the appropriate success message.
*
* @return \Illuminate\Http\Response
*/
public function createRecord(Request $request)
{
$this->validateRequest($request);

// Detect whether the email already belongs to an existing user.
// If so, redirect to the cross-organisation invite flow instead of
// attempting to create a duplicate user record.
$email = strtolower((string) $request->input('user.email', ''));
$existingUser = $email ? User::where('email', $email)->whereNull('deleted_at')->first() : null;

if ($existingUser) {
// Guard: the user is already a member of the current organisation.
$alreadyMember = $existingUser->companyUsers()
->where('company_uuid', session('company'))
->exists();

if ($alreadyMember) {
return response()->error('This user is already a member of your organisation.');
}

return $this->inviteExistingUser($existingUser, $request);
}

try {
$record = $this->model->createRecordFromRequest($request, function (&$request, &$input) {
// Get user properties
Expand Down Expand Up @@ -268,63 +327,126 @@ public function saveTwoFactorSettings(Request $request)
}

/**
* Creates a user, adds the user to company and sends an email to user about being added.
* Invite a user (new or existing) to join the current organisation.
*
* - If the email belongs to an existing user in another organisation, a
* cross-organisation invitation is issued without creating a new user.
* - If the email is brand-new, a pending user record is created and the
* invitation email is sent so they can set a password on acceptance.
*
* @return \Illuminate\Http\Response
*/
#[SkipAuthorizationCheck]
public function inviteUser(InviteUserRequest $request)
{
// $data = $request->input(['name', 'email', 'phone', 'status', 'country', 'date_of_birth']);
$data = $request->input('user');
$email = strtolower($data['email']);
$data = $request->input('user');
$email = strtolower($data['email']);
$company = Auth::getCompany();

if (!$company) {
return response()->error('Unable to determine the current organisation.');
}

// set company
$data['company_uuid'] = session('company');
$data['status'] = 'pending'; // pending acceptance
$data['type'] = 'user'; // set type as regular user
$data['created_at'] = Carbon::now(); // jic
// Check if user already exists in the system.
$user = User::where('email', $email)->whereNull('deleted_at')->first();

// make sure user isn't already invited
$isAlreadyInvited = Invite::where([
'company_uuid' => session('company'),
'subject_uuid' => session('company'),
'protocol' => 'email',
'reason' => 'join_company',
])->whereJsonContains('recipients', $email)->exists();
if ($user) {
// Guard: already a member of this organisation.
$alreadyMember = $user->companyUsers()
->where('company_uuid', $company->uuid)
->exists();

if ($isAlreadyInvited) {
return response()->error('This user has already been invited to join this organization.');
if ($alreadyMember) {
return response()->error('This user is already a member of your organisation.');
}

// Existing user from another org — issue a cross-org invite.
return $this->inviteExistingUser($user, $request);
}

// get the company inviting
$company = Company::where('uuid', session('company'))->first();
// Brand-new user — create a pending record then invite.
$data['company_uuid'] = $company->uuid;
$data['status'] = 'pending';
$data['type'] = 'user';
$data['created_at'] = Carbon::now();

// check if user exists already
$user = User::where('email', $email)->first();
$user = User::create($data);

// if new user, create user
if (!$user) {
$user = User::create($data);
// Set user type
$user->setUserType('user');

// Assign to user
$user->assignCompany($company, $request->input('user.role_uuid'));

// Assign role if set
if ($request->filled('user.role_uuid')) {
$user->assignSingleRole($request->input('user.role_uuid'));
}

// create invitation
$invitation = Invite::create([
'company_uuid' => session('company'),
'company_uuid' => $company->uuid,
'created_by_uuid' => session('user'),
'subject_uuid' => $company->uuid,
'subject_type' => Utils::getMutationType($company),
'protocol' => 'email',
'recipients' => [$user->email],
'reason' => 'join_company',
'meta' => array_filter(['role_uuid' => $request->input('user.role_uuid') ?? $request->input('user.role')]),
'expires_at' => now()->addHours(48),
]);

// notify user
$user->notify(new UserInvited($invitation));

return response()->json(['user' => new $this->resource($user)]);
}

/**
* Issue a join-company invitation to a user who already exists in the
* system but belongs to a different organisation.
*
* This private helper is shared by both `createRecord()` (which detects
* an existing email during the standard "New User" flow) and `inviteUser()`
* (the dedicated invite endpoint). Keeping the logic in one place ensures
* both paths behave identically.
*
* @param User $user the existing user to invite
* @param Request $request the originating HTTP request
*/
private function inviteExistingUser(User $user, Request $request): \Illuminate\Http\JsonResponse
{
$company = Auth::getCompany();

if (!$company) {
return response()->error('Unable to determine the current organisation.');
}

// Guard: prevent duplicate invitations using the model helper.
if (Invite::isAlreadySentToJoinCompany($user, $company)) {
return response()->error('This user has already been invited to join your organisation.');
}

$invitation = Invite::create([
'company_uuid' => $company->uuid,
'created_by_uuid' => session('user'),
'subject_uuid' => $company->uuid,
'subject_type' => Utils::getMutationType($company),
'protocol' => 'email',
'recipients' => [$user->email],
'reason' => 'join_company',
'meta' => array_filter(['role_uuid' => $request->input('user.role_uuid') ?? $request->input('user.role')]),
'expires_at' => now()->addHours(48),
]);

$user->notify(new UserInvited($invitation));

// Return `invited: true` so the frontend can distinguish between
// a newly created user and a cross-organisation invite.
return response()->json([
'user' => new $this->resource($user),
'invited' => true,
]);
}

/**
* Resend invitation to pending user.
*
Expand All @@ -345,6 +467,7 @@ public function resendInvitation(ResendUserInvite $request)
'protocol' => 'email',
'recipients' => [$user->email],
'reason' => 'join_company',
'expires_at' => now()->addHours(48),
]);

// notify user
Expand Down Expand Up @@ -384,11 +507,40 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request)
// determine if user needs to set password (when status pending)
$isPending = $needsPassword = $user->status === 'pending';

// add user to company
CompanyUser::create([
'user_uuid' => $user->uuid,
'company_uuid' => $company->uuid,
]);
// Add user to company only if they are not already a member.
// This guards against double-acceptance (e.g. clicking the invite
// link twice) creating a duplicate company_users row.
$alreadyMember = CompanyUser::where('user_uuid', $user->uuid)
->where('company_uuid', $company->uuid)
->exists();

if (!$alreadyMember) {
// Use Company::addUser() so that role assignment is handled in
// one place. The role stored in the invite meta takes precedence;
// if none was set the default 'Administrator' role is used.
$roleIdentifier = $invite->getMeta('role_uuid', 'Administrator');
$companyUser = $company->addUser($user, $roleIdentifier);
$user->setRelation('companyUser', $companyUser);
} else {
// User is already a member — ensure the companyUser relation is
// loaded so that role assignment below can still be applied if
// the invite carries a role (e.g. re-sent invite with a new role).
$user->loadCompanyUser();
$roleUuid = $invite->getMeta('role_uuid');
if ($user->companyUser && $roleUuid) {
$user->companyUser->assignSingleRole($roleUuid);
}
}

// Delete the invite
$invite->delete();

// Switch the user's active company to the one they just joined.
// This ensures that subsequent calls to /users/me resolve the
// companyUser relationship (and therefore role/policies) against
// the correct company rather than the user's previous company.
$user->company_uuid = $company->uuid;
$user->save();

// activate user
if ($isPending) {
Expand Down
1 change: 1 addition & 0 deletions src/Http/Filter/ScheduleExceptionFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public function subjectType(?string $type)

if (Str::contains($type, '\\')) {
$this->builder->where('subject_type', $type);

return;
}

Expand Down
Loading
Loading