diff --git a/src/Console/Commands/FixUserCompanies.php b/src/Console/Commands/FixUserCompanies.php index f1358421..c5f853e1 100644 --- a/src/Console/Commands/FixUserCompanies.php +++ b/src/Console/Commands/FixUserCompanies.php @@ -41,7 +41,8 @@ public function handle() $this->line('Found user ' . $user->name . ' (' . $user->email . ') which doesnt have correct company assignment.'); $company = Company::where('uuid', $user->company_uuid)->first(); if ($company) { - $user->assignCompany($company); + // Only the company owner is restored as an Administrator + $user->assignCompany($company, $company->owner_uuid === $user->uuid ? 'Administrator' : null); $this->line('User ' . $user->email . ' was assigned to company: ' . $company->name); } } diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index 56faac1f..e0058520 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -19,6 +19,7 @@ use Fleetbase\Models\Company; use Fleetbase\Models\CompanyUser; use Fleetbase\Models\Invite; +use Fleetbase\Models\Role; use Fleetbase\Models\User; use Fleetbase\Models\VerificationCode; use Fleetbase\Notifications\UserForgotPassword; @@ -62,8 +63,8 @@ public function login(LoginRequest $request) $tokenOwner instanceof User && ($tokenOwner->email === $identity || $tokenOwner->phone === $identity) ) { - if ($tokenOwner->type === 'customer') { - return response()->error('Customer accounts must sign in through the customer portal.', 403, ['code' => 'customer_login_not_allowed']); + if ($denied = Auth::denyConsoleLogin($tokenOwner)) { + return $denied; } return response()->json([ @@ -83,8 +84,8 @@ public function login(LoginRequest $request) $query->where('email', $identity)->orWhere('phone', $identity); })->first(); - if ($user && $user->type === 'customer') { - return response()->error('Customer accounts must sign in through the customer portal.', 403, ['code' => 'customer_login_not_allowed']); + if ($denied = Auth::denyConsoleLogin($user)) { + return $denied; } // If the user exists but has no password set (e.g. SSO-invited or provisioned @@ -138,7 +139,7 @@ public function session(Request $request) $session = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($request) { $user = $request->user(); - if (!$user) { + if (!$user || !$user->canHoldConsoleSession()) { return null; } @@ -196,6 +197,11 @@ public function bootstrap(Request $request) { $user = $request->user(); $token = $request->bearerToken(); + + if ($denied = Auth::denyConsoleSession($user)) { + return $denied; + } + $cacheKey = "auth_bootstrap_{$user->uuid}_{$token}"; // Cache for 5 minutes @@ -491,7 +497,7 @@ public function verifyEmail(Request $request) $user->activate(); // If authenticate is set, generate and return a token - if ($authenticate) { + if ($authenticate && $user->canHoldConsoleSession()) { $user->updateLastLogin(); $token = $user->createToken($user->uuid); @@ -872,8 +878,8 @@ public function joinOrganization(JoinOrganizationRequest $request) $user = Auth::getUserFromSession($request); // Make sure user has been invited to join organizations - $isAlreadyInvited = Invite::isAlreadySentToJoinCompany($user, $company); - if (!$isAlreadyInvited) { + $invite = Invite::findSentToJoinCompany($user, $company); + if (!$invite) { return response()->error('User has not been invited to join this organization.'); } @@ -882,7 +888,8 @@ public function joinOrganization(JoinOrganizationRequest $request) return response()->error('User is already a member of this organization.'); } - $company->assignUser($user); + // Join with the role the invite carries; never a default one + $company->assignUser($user, $this->inviteRoleId($invite, $company)); Auth::setSession($user); return response()->json(['status' => 'ok']); @@ -894,6 +901,24 @@ public function joinOrganization(JoinOrganizationRequest $request) // @codeCoverageIgnoreEnd } + /** + * The id of the role an invite grants in the organization, when it names one + * that belongs to the organization or is global. + */ + private function inviteRoleId(Invite $invite, Company $company): ?string + { + $roleId = $invite->getMeta('role_uuid'); + if (!$roleId) { + return null; + } + + return Role::where(function ($query) use ($roleId) { + $query->where('id', $roleId)->orWhere('name', $roleId); + })->where(function ($query) use ($company) { + $query->where('company_uuid', $company->uuid)->orWhereNull('company_uuid'); + })->value('id'); + } + /** * Allows user to create a new organization. * @@ -1019,6 +1044,10 @@ public function impersonate(AdminRequest $request) return response()->error('The selected user to impersonate was not found.'); } + if ($denied = Auth::denyConsoleSession($targetUser)) { + return $denied; + } + try { Auth::setSession($targetUser); session()->put('impersonator', $currentUser->uuid); diff --git a/src/Http/Controllers/Internal/v1/TwoFaController.php b/src/Http/Controllers/Internal/v1/TwoFaController.php index e2647bc2..f81c7d58 100644 --- a/src/Http/Controllers/Internal/v1/TwoFaController.php +++ b/src/Http/Controllers/Internal/v1/TwoFaController.php @@ -4,9 +4,11 @@ use Fleetbase\Http\Controllers\Controller; use Fleetbase\Http\Requests\TwoFaValidationRequest; +use Fleetbase\Support\Auth; use Fleetbase\Support\TwoFactorAuth; use Illuminate\Http\Request; use Illuminate\Support\Str; +use Laravel\Sanctum\PersonalAccessToken; /** * Class TwoFaController. @@ -103,6 +105,15 @@ public function verifyCode(Request $request) try { $authToken = TwoFactorAuth::verifyCode($code, $token, $clientToken); + // Driver and contact accounts cannot sign in to the console. Customers + // are left to the customer portal, which shares this route path. + $accessToken = PersonalAccessToken::findToken($authToken); + if ($denied = Auth::denyConsoleSession($accessToken?->tokenable)) { + $accessToken->delete(); + + return $denied; + } + return response()->json([ 'authToken' => $authToken, ]); diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 90e88710..c2f18f83 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -164,6 +164,19 @@ public function findRecord(Request $request, $id) */ public function createRecord(Request $request) { + // A role is always chosen explicitly: access is never granted by default. + $role = $this->resolveRequestedRole($request); + if ($role instanceof \Illuminate\Http\JsonResponse) { + return $role; + } + + // A driver/customer/contact account in this organisation with the same + // email or phone is promoted to a team member instead of duplicated. + $managedUser = $this->findPromotableAccount($request->input('user.email'), $request->input('user.phone')); + if ($managedUser) { + return $this->promoteManagedAccount($managedUser, $request, $role); + } + $this->validateRequest($request); // Detect whether the email already belongs to an existing user. @@ -182,11 +195,7 @@ public function createRecord(Request $request) return response()->error('This user is already a member of your organisation.'); } - return $this->inviteExistingUser($existingUser, $request); - } - - if ($request->filled('user.role_uuid') && !$this->resolveAssignableRole($request->input('user.role_uuid'))) { - return response()->error('The selected role is not available for this organisation.', 404); + return $this->inviteExistingUser($existingUser, $request, $role); } try { @@ -204,22 +213,15 @@ public function createRecord(Request $request) 'ip_address' => $request->ip(), 'timezone' => $timezone, ])); - }, function (&$request, &$user) { + }, function (&$request, &$user) use ($role) { // Make sure to assign to current company $company = Auth::getCompany(); // Set user type $user->setUserType('user'); - $role = $this->resolveAssignableRole($request->input('user.role_uuid')); - - // Assign to user - $user->assignCompany($company, $role ? $role->id : 'Administrator'); - - // Assign role if set - if ($role) { - $user->assignSingleRole($role); - } + // Assign to company with the chosen role + $user->assignCompany($company, $role->id); // Sync Permissions if ($request->isArray('user.permissions')) { @@ -286,6 +288,10 @@ public function updateRecord(Request $request, string $id) if (!$roleToAssign) { return response()->error('The selected role is not available for this organisation.', 404); } + + if ($denied = $this->denyRoleGrant($roleToAssign, $request)) { + return $denied; + } } $record->update(Arr::except($input, ['uuid', 'public_id', 'deleted_at', 'updated_at', 'created_at'])); @@ -437,6 +443,41 @@ private function identityValueMatches(string $field, mixed $incoming, mixed $cur /** * Resolve a role assignable by the active company. */ + /** + * Resolve the role a new or invited user joins with. A role is required and + * must be assignable by the current user. + */ + private function resolveRequestedRole(Request $request): Role|\Illuminate\Http\JsonResponse + { + $roleIdentifier = $request->input('user.role_uuid') ?? $request->input('user.role'); + if (is_array($roleIdentifier)) { + $roleIdentifier = data_get($roleIdentifier, 'id') ?? data_get($roleIdentifier, 'uuid'); + } + + if (!$roleIdentifier) { + return response()->error('Select a role for this user.', 422); + } + + $role = $this->resolveAssignableRole($roleIdentifier); + if (!$role) { + return response()->error('The selected role is not available for this organisation.', 404); + } + + return $this->denyRoleGrant($role, $request) ?? $role; + } + + /** + * Only administrators may grant the Administrator role. + */ + private function denyRoleGrant(Role $role, Request $request): ?\Illuminate\Http\JsonResponse + { + if (Auth::canGrantRole($role, Auth::getUserFromSession($request))) { + return null; + } + + return response()->error('Only administrators can grant the Administrator role.', 403); + } + private function resolveAssignableRole(string|Role|null $role, ?string $companyUuid = null): ?Role { $companyUuid ??= session('company'); @@ -694,8 +735,17 @@ public function inviteUser(InviteUserRequest $request) return response()->error('Unable to determine the current organisation.'); } - if ($request->filled('user.role_uuid') && !$this->resolveAssignableRole($request->input('user.role_uuid'))) { - return response()->error('The selected role is not available for this organisation.', 404); + // A role is always chosen explicitly: access is never granted by default. + $role = $this->resolveRequestedRole($request); + if ($role instanceof \Illuminate\Http\JsonResponse) { + return $role; + } + + // A driver/customer/contact account in this organisation with the same + // email is promoted to a team member instead of invited. + $managedUser = $this->findPromotableAccount($email, data_get($data, 'phone')); + if ($managedUser) { + return $this->promoteManagedAccount($managedUser, $request, $role); } // Check if user already exists in the system. @@ -712,7 +762,7 @@ public function inviteUser(InviteUserRequest $request) } // Existing user from another org — issue a cross-org invite. - return $this->inviteExistingUser($user, $request); + return $this->inviteExistingUser($user, $request, $role); } // Brand-new user — create a pending record; assignCompany() below issues the join invite + notification. @@ -726,17 +776,87 @@ public function inviteUser(InviteUserRequest $request) // Set user type $user->setUserType('user'); - $role = $this->resolveAssignableRole($request->input('user.role_uuid')); + // Assign to company with the chosen role + $user->assignCompany($company, $role->id); + + return response()->json(['user' => new $this->resource($user)]); + } + + /** + * Find a profile-managed account (driver, customer or contact) in the + * current organisation matching the given email or phone. + */ + private function findPromotableAccount(?string $email, ?string $phone): ?User + { + $email = $email ? strtolower(trim($email)) : null; + $phone = $phone ? trim($phone) : null; + if (!$email && !$phone) { + return null; + } + + return User::managed() + ->where(function ($query) use ($email, $phone) { + if ($email) { + $query->orWhere('email', $email); + } + if ($phone) { + $query->orWhere('phone', $phone); + } + }) + ->whereHas('companyUsers', function ($query) { + $query->where('company_uuid', session('company')); + }) + ->first(); + } + + /** + * Promote a profile-managed account to a team member of the current + * organisation. Its driver/customer profiles stay linked, so the person + * keeps a single account for the console and their app. A join invite is + * sent so they can set a console password. + */ + private function promoteManagedAccount(User $user, Request $request, Role $role): \Illuminate\Http\JsonResponse + { + $company = Auth::getCompany(); + if (!$company) { + return response()->error('Unable to determine the current organisation.'); + } + + $previousType = $user->getType(); + $user->meta = array_merge((array) ($user->meta ?? []), ['promoted_from' => $previousType]); + $user->setUserType('user'); + $user->assignSingleRole($role); + + if ($request->isArray('user.permissions')) { + $user->syncPermissions(Permission::whereIn('id', $request->array('user.permissions'))->get()); + } - // Assign to user - $user->assignCompany($company, $role ? $role->id : 'Administrator'); + if ($request->isArray('user.policies')) { + $user->syncPolicies($this->getAssignablePolicies($request->array('user.policies'))); + } + + if ($user->email && !Invite::isAlreadySentToJoinCompany($user, $company)) { + $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' => $role->id, 'promoted_from' => $previousType]), + 'expires_at' => now()->addHours(48), + ]); - // Assign role if set - if ($role) { - $user->assignSingleRole($role); + $user->notify(new UserInvited($invitation)); } - return response()->json(['user' => new $this->resource($user)]); + UserCacheService::invalidateUser($user); + + return response()->json([ + 'user' => new $this->resource($user), + 'promoted_from' => $previousType, + ]); } /** @@ -750,8 +870,9 @@ public function inviteUser(InviteUserRequest $request) * * @param User $user the existing user to invite * @param Request $request the originating HTTP request + * @param Role $role the role the user joins with */ - private function inviteExistingUser(User $user, Request $request): \Illuminate\Http\JsonResponse + private function inviteExistingUser(User $user, Request $request, Role $role): \Illuminate\Http\JsonResponse { $company = Auth::getCompany(); @@ -764,11 +885,6 @@ private function inviteExistingUser(User $user, Request $request): \Illuminate\H return response()->error('This user has already been invited to join your organisation.'); } - $roleIdentifier = $request->input('user.role_uuid') ?? $request->input('user.role'); - if ($roleIdentifier && !$this->resolveAssignableRole($roleIdentifier)) { - return response()->error('The selected role is not available for this organisation.', 404); - } - $invitation = Invite::create([ 'company_uuid' => $company->uuid, 'created_by_uuid' => session('user'), @@ -777,7 +893,7 @@ private function inviteExistingUser(User $user, Request $request): \Illuminate\H 'protocol' => 'email', 'recipients' => [$user->email], 'reason' => 'join_company', - 'meta' => array_filter(['role_uuid' => $roleIdentifier]), + 'meta' => ['role_uuid' => $role->id], 'expires_at' => now()->addHours(48), ]); @@ -858,6 +974,14 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request) // determine if user needs to set password (when status pending) $isPending = $needsPassword = $user->status === 'pending'; + // Invites come from IAM, so accepting one makes a profile-managed + // account (driver, customer, contact) a team member. Its password was + // generated for the app, so it sets a console password. + if ($user->isManagedAccount() || $invite->getMeta('promoted_from')) { + $user->setUserType('user'); + $needsPassword = true; + } + // 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. @@ -867,11 +991,11 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request) 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. - $role = $this->resolveAssignableRole($invite->getMeta('role_uuid'), $company->uuid); - $roleIdentifier = $role ? $role->id : 'Administrator'; - $companyUser = $company->addUser($user, $roleIdentifier); + // one place. The user joins with the role stored in the invite; an + // invite without one (issued before roles were required) joins with + // no role, so access is never granted by default. + $role = $this->resolveAssignableRole($invite->getMeta('role_uuid'), $company->uuid); + $companyUser = $company->addUser($user, $role?->id); $user->setRelation('companyUser', $companyUser); } else { // User is already a member — ensure the companyUser relation is diff --git a/src/Models/Company.php b/src/Models/Company.php index 963acbcc..0e202cce 100644 --- a/src/Models/Company.php +++ b/src/Models/Company.php @@ -369,16 +369,14 @@ public static function currentSession(): ?Company * it defaults to the user's current role. The status can also be specified, defaulting to 'active'. * * @param User $user the user to be added to the company - * @param string|null $role The name or ID of the role to assign to the user. Defaults to the user's current role if null. + * @param string|null $role The name or ID of the role to assign to the user. No role is assigned when null: + * access is only ever granted explicitly. * @param string $status The status of the user within the company. Defaults to 'active'. * * @return CompanyUser the CompanyUser instance representing the association between the user and the company */ - public function addUser(User $user, string $role = 'Administrator', string $status = 'active'): CompanyUser + public function addUser(User $user, ?string $role = null, string $status = 'active'): CompanyUser { - // Get the currentuser role - $role = $role; - $companyUser = CompanyUser::firstOrCreate( [ 'company_uuid' => $this->uuid, @@ -392,7 +390,9 @@ public function addUser(User $user, string $role = 'Administrator', string $stat ); // Assign the role to the new user - $companyUser->assignSingleRole($role); + if ($role) { + $companyUser->assignSingleRole($role); + } return $companyUser; } @@ -461,7 +461,7 @@ public function changeUserRole(User $user, string $roleName): bool * * @return CompanyUser the CompanyUser instance representing the association between the user and the company */ - public function assignUser(User $user, string $role = 'Administrator'): CompanyUser + public function assignUser(User $user, ?string $role = null): CompanyUser { $companyUser = $this->addUser($user, $role); $user->assignCompany($this); diff --git a/src/Models/Invite.php b/src/Models/Invite.php index 03c93883..15e6dabe 100644 --- a/src/Models/Invite.php +++ b/src/Models/Invite.php @@ -168,6 +168,23 @@ public static function isAlreadySentToJoinCompany(User $user, Company $company): return static::isAlreadySent($company, $user->email, 'join_company'); } + /** + * The invite sent to the user to join the company, if any. + */ + public static function findSentToJoinCompany(User $user, Company $company): ?self + { + if (!$user->email) { + return null; + } + + return static::where([ + 'company_uuid' => $company->uuid, + 'subject_uuid' => $company->uuid, + 'protocol' => 'email', + 'reason' => 'join_company', + ])->whereJsonContains('recipients', $user->email)->latest()->first(); + } + public static function isAlreadySent(Company $company, string $email, string $reason, string $protocol = 'email'): bool { return static::where([ diff --git a/src/Models/User.php b/src/Models/User.php index a6fd8301..411b1396 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -69,6 +69,23 @@ class User extends Authenticatable use ClearsHttpCache; use HasSessionAttributes; + /** + * User types whose account is owned and managed by a profile record (a + * FleetOps driver, contact or customer) rather than managed in IAM. + * + * @var array + */ + public const MANAGED_TYPES = ['driver', 'customer', 'contact']; + + /** + * Managed types that have no console surface at all. Customers are excluded + * because the customer portal runs inside the console and restores its + * session through the core session endpoints. + * + * @var array + */ + public const SESSIONLESS_TYPES = ['driver', 'contact']; + /** * The database connection to use. * @@ -525,11 +542,12 @@ public function setCompanyUserRelation(Company $company): void * will be notified that a user has been created. * * @param Company $company the company to assign the user to - * @param string|null $role The name or ID of the role to assign to the user. Defaults to the user's current role if null. + * @param string|null $role The name or ID of the role to assign to the user. No role is assigned when null: + * access is only ever granted explicitly. * * @return self returns the current User instance */ - public function assignCompany(Company $company, string $role = 'Administrator'): self + public function assignCompany(Company $company, ?string $role = null): self { $this->company_uuid = $company->uuid; @@ -823,6 +841,47 @@ public function isNotType(string|array $type): bool return !$this->isType($type); } + /** + * Checks if the account is owned by a driver, contact or customer profile. + */ + public function isManagedAccount(): bool + { + return $this->isType(static::MANAGED_TYPES); + } + + /** + * Checks if the account is a staff account (managed in IAM). + */ + public function isStaffAccount(): bool + { + return !$this->isManagedAccount(); + } + + /** + * Checks if the account may sign in to the console. + */ + public function canAccessConsole(): bool + { + return $this->isStaffAccount(); + } + + /** + * Checks if the account may hold a console session. Customers hold one for + * the customer portal; drivers and contacts never do. + */ + public function canHoldConsoleSession(): bool + { + return $this->isNotType(static::SESSIONLESS_TYPES); + } + + /** + * Scope a query to profile-managed accounts only. + */ + public function scopeManaged(Builder $query): Builder + { + return $query->whereIn($this->qualifyColumn('type'), static::MANAGED_TYPES); + } + /** * Adds a boolean dynamic property to check if user is an admin. * diff --git a/src/Observers/UserObserver.php b/src/Observers/UserObserver.php index c369fd04..3c825cb9 100644 --- a/src/Observers/UserObserver.php +++ b/src/Observers/UserObserver.php @@ -40,6 +40,11 @@ public function deleted(User $user) if (session('company')) { CompanyUser::where(['company_uuid' => session('company'), 'user_uuid' => $user->uuid])->delete(); } + + // Free the email and phone so they can be used by a new account + if (!$user->isForceDeleting()) { + $this->releaseIdentity($user); + } } /** @@ -47,6 +52,8 @@ public function deleted(User $user) */ public function restored(User $user): void { + $this->restoreIdentity($user); + // Invalidate user cache when user is restored UserCacheService::invalidateUser($user); @@ -54,6 +61,47 @@ public function restored(User $user): void $this->invalidateOrganizationsCache($user); } + /** + * Move a deleted user's email and phone into meta so they no longer block + * a new account, including lookups that include trashed users. + */ + private function releaseIdentity(User $user): void + { + $identity = array_filter(['email' => $user->email, 'phone' => $user->phone]); + if (empty($identity)) { + return; + } + + $meta = (array) ($user->meta ?? []); + $meta['deleted_identity'] = $identity; + + $user->forceFill(['email' => null, 'phone' => null, 'meta' => $meta])->saveQuietly(); + } + + /** + * Put a restored user's email and phone back when no other account has + * taken them in the meantime. + */ + private function restoreIdentity(User $user): void + { + $meta = (array) ($user->meta ?? []); + $identity = (array) data_get($meta, 'deleted_identity', []); + if (empty($identity)) { + return; + } + + foreach (['email', 'phone'] as $column) { + $value = $identity[$column] ?? null; + if ($value && !$user->{$column} && User::where($column, $value)->where('uuid', '!=', $user->uuid)->doesntExist()) { + $user->{$column} = $value; + } + } + + unset($meta['deleted_identity']); + $user->meta = $meta; + $user->saveQuietly(); + } + /** * Invalidate organizations cache for the user. * diff --git a/src/Support/Auth.php b/src/Support/Auth.php index e1dad804..f5a21a4f 100644 --- a/src/Support/Auth.php +++ b/src/Support/Auth.php @@ -44,11 +44,56 @@ public static function register($owner, $company) ->setOwner($owner) ->saveInstance(); - $owner->assignCompany($company); + $owner->assignCompany($company, 'Administrator'); return $owner; } + /** + * Returns an error response when the user may not sign in to the console, + * or null when access is allowed. Driver, contact and customer accounts are + * managed through their profile and sign in through their own apps. + */ + public static function denyConsoleLogin(?User $user): ?\Illuminate\Http\JsonResponse + { + if (!$user instanceof User || $user->canAccessConsole()) { + return null; + } + + if ($user->isType('customer')) { + return response()->error('Customer accounts must sign in through the customer portal.', 403, ['code' => 'customer_login_not_allowed']); + } + + return response()->error('This account cannot sign in to the console.', 403, ['code' => 'console_access_not_allowed']); + } + + /** + * Returns an error response when the user may not hold a console session, + * or null when allowed. Customers keep sessions for the customer portal. + */ + public static function denyConsoleSession(?User $user): ?\Illuminate\Http\JsonResponse + { + if (!$user instanceof User || $user->canHoldConsoleSession()) { + return null; + } + + return response()->error('This account cannot sign in to the console.', 403, ['code' => 'console_access_not_allowed', 'restore' => false]); + } + + /** + * Whether the actor may grant the role to another user. The Administrator + * role gives full access to the organization, so only admins and users + * holding it may grant it. + */ + public static function canGrantRole(Role $role, ?User $actor): bool + { + if ($role->name !== 'Administrator') { + return true; + } + + return $actor instanceof User && ($actor->isAdmin() || $actor->hasRole('Administrator')); + } + /** * Set session variables for user. * diff --git a/tests/Unit/Console/AdminMaintenanceCommandsTest.php b/tests/Unit/Console/AdminMaintenanceCommandsTest.php index ed70729b..499a0e3f 100644 --- a/tests/Unit/Console/AdminMaintenanceCommandsTest.php +++ b/tests/Unit/Console/AdminMaintenanceCommandsTest.php @@ -264,3 +264,35 @@ public function clear(array $tags = []): void ])->exists())->toBeTrue() ->and($db->table('model_has_roles')->where('role_id', 'role-admin')->exists())->toBeTrue(); }); + +it('repairs a member who does not own the company without granting a role', function () { + $capsule = admin_maintenance_database(); + $db = $capsule->getConnection('mysql'); + + $db->table('users')->insert([ + 'uuid' => 'user-missing-member', + 'company_uuid' => 'company-1', + 'name' => 'Plain Member', + 'email' => 'member@example.test', + 'type' => 'admin', + 'status' => 'active', + 'created_at' => '2026-07-18 00:00:00', + 'updated_at' => '2026-07-18 00:00:00', + ]); + $db->table('companies')->insert([ + 'uuid' => 'company-1', + 'owner_id' => 'someone-else', + 'owner_uuid' => 'someone-else', + 'name' => 'Acme Logistics', + 'created_at' => '2026-07-18 00:00:00', + 'updated_at' => '2026-07-18 00:00:00', + ]); + + $command = new FixUserCompanies(); + $command->setLaravel(app()); + $tester = new CommandTester($command); + + expect($tester->execute([]))->toBe(0) + ->and($db->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => 'user-missing-member'])->exists())->toBeTrue() + ->and($db->table('model_has_roles')->count())->toBe(0); +}); diff --git a/tests/Unit/Console/RecoveryCommandTest.php b/tests/Unit/Console/RecoveryCommandTest.php index e46694f1..5f8e9996 100644 --- a/tests/Unit/Console/RecoveryCommandTest.php +++ b/tests/Unit/Console/RecoveryCommandTest.php @@ -133,7 +133,7 @@ public function changePassword($newPassword): self return $this; } - public function assignCompany(Company $company, string $role = 'Administrator'): self + public function assignCompany(Company $company, ?string $role = null): self { if (in_array('assignCompany', $this->throwOn, true)) { throw new RuntimeException('assign company failed'); diff --git a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php index 55febb4c..1008904d 100644 --- a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php +++ b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php @@ -1345,3 +1345,91 @@ function auth_controller_sms_request(array $input): Request ->and(app('redis')->get($key))->toBeNull() ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(1); }); + +test('login rejects driver and contact identities from the console', function (string $type) { + $capsule = auth_controller_login_bootstrap_database(); + auth_controller_login_insert_user($capsule, [ + 'type' => $type, + ]); + + $response = (new AuthController())->login(auth_controller_login_request([ + 'identity' => 'auth@example.test', + 'password' => 'correct-password', + ])); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true))->toBe([ + 'errors' => ['This account cannot sign in to the console.'], + 'code' => 'console_access_not_allowed', + ]) + ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(0); +})->with(['driver', 'contact']); + +test('bootstrap rejects driver and contact sessions but keeps customer portal sessions', function (string $type, int $status) { + $capsule = auth_controller_login_bootstrap_database(); + auth_controller_login_insert_user($capsule, [ + 'type' => $type, + ]); + + $user = User::find('11111111-1111-4111-8111-111111111111'); + $response = (new AuthController())->bootstrap(auth_controller_bootstrap_request($user, 'bootstrap-token')); + + expect($response->getStatusCode())->toBe($status); + if ($status === 403) { + expect($response->getData(true)['code'])->toBe('console_access_not_allowed'); + } +})->with([ + ['driver', 403], + ['contact', 403], + ['customer', 200], +]); + +test('admin impersonation refuses driver accounts', function () { + $capsule = auth_controller_login_bootstrap_database(); + auth_controller_login_insert_user($capsule, ['uuid' => 'admin-user', 'email' => 'admin@example.test', 'type' => 'admin']); + auth_controller_login_insert_user($capsule, ['uuid' => 'driver-user', 'email' => 'driver@example.test', 'type' => 'driver']); + + $response = (new AuthController())->impersonate(auth_controller_authenticated_request('POST', [ + 'user' => 'driver-user', + ], User::find('admin-user'), '/int/v1/auth/impersonate', AdminRequest::class)); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['code'])->toBe('console_access_not_allowed') + ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->where('tokenable_id', 'driver-user')->count())->toBe(0); +}); + +test('join organization grants the invite role and never a default one', function () { + $capsule = auth_controller_login_bootstrap_database(); + auth_controller_insert_administrator_role($capsule, 'company-join'); + auth_controller_insert_administrator_role($capsule, 'company-legacy'); + $capsule->getConnection('mysql')->table('roles')->insert([ + 'id' => '33333333-3333-4333-8333-333333333399', 'company_uuid' => 'company-join', 'name' => 'Dispatcher', 'guard_name' => 'sanctum', + 'created_at' => '2026-07-18 10:00:00', 'updated_at' => '2026-07-18 10:00:00', 'deleted_at' => null, + ]); + auth_controller_login_insert_user($capsule, ['uuid' => 'joining-user', 'email' => 'joining@example.test', 'company_uuid' => 'company-current', 'type' => 'user']); + auth_controller_insert_company($capsule, ['uuid' => 'company-join', 'public_id' => 'company_join_public']); + auth_controller_insert_company($capsule, ['uuid' => 'company-legacy', 'public_id' => 'company_legacy_public', 'slug' => 'legacy-company']); + auth_controller_insert_join_invite($capsule, 'company-join', 'joining@example.test'); + $capsule->getConnection('mysql')->getSchemaBuilder()->table('invites', fn ($table) => $table->text('meta')->nullable()); + $capsule->getConnection('mysql')->table('invites')->where('company_uuid', 'company-join')->update(['meta' => json_encode(['role_uuid' => '33333333-3333-4333-8333-333333333399'])]); + $capsule->getConnection('mysql')->table('invites')->insert([ + 'uuid' => 'invite-legacy', 'company_uuid' => 'company-legacy', 'subject_uuid' => 'company-legacy', 'subject_type' => Fleetbase\Models\Company::class, + 'created_by_uuid' => 'owner-user', 'protocol' => 'email', 'reason' => 'join_company', 'recipients' => json_encode(['joining@example.test']), + 'expires_at' => Carbon::now()->addDay()->toDateTimeString(), 'deleted_at' => null, 'created_at' => '2026-07-18 10:00:00', 'updated_at' => '2026-07-18 10:00:00', + ]); + + $joined = (new AuthController())->joinOrganization(auth_controller_join_organization_request(User::find('joining-user'), 'company_join_public')); + $legacy = (new AuthController())->joinOrganization(auth_controller_join_organization_request(User::find('joining-user'), 'company_legacy_public')); + + $db = $capsule->getConnection('mysql'); + $joinPivot = $db->table('company_users')->where(['user_uuid' => 'joining-user', 'company_uuid' => 'company-join'])->value('uuid'); + $legacyPivot = $db->table('company_users')->where(['user_uuid' => 'joining-user', 'company_uuid' => 'company-legacy'])->value('uuid'); + + expect($joined->getStatusCode())->toBe(200) + ->and($db->table('model_has_roles')->where('model_uuid', $joinPivot)->pluck('role_id')->all())->toBe(['33333333-3333-4333-8333-333333333399']) + ->and($legacy->getStatusCode())->toBe(200) + ->and($legacyPivot)->not->toBeNull() + ->and($db->table('model_has_roles')->where('model_uuid', $legacyPivot)->count())->toBe(0) + // An account without an email can't have been sent an invite + ->and(Fleetbase\Models\Invite::findSentToJoinCompany(new User(), Fleetbase\Models\Company::find('company-join')))->toBeNull(); +}); diff --git a/tests/Unit/Http/TwoFaControllerTest.php b/tests/Unit/Http/TwoFaControllerTest.php index 44173aa0..f9e77ba7 100644 --- a/tests/Unit/Http/TwoFaControllerTest.php +++ b/tests/Unit/Http/TwoFaControllerTest.php @@ -448,3 +448,24 @@ function two_fa_controller_verification_code(User $user, Carbon $expiresAt, stri expect($response->getData(true))->toBe(['shouldEnforce' => true]); }); + +test('two fa controller verify does not issue console tokens to driver accounts', function () { + two_fa_controller_database(); + app('db')->connection()->getSchemaBuilder()->table('users', fn ($table) => $table->string('type')->nullable()); + app('db')->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->update(['type' => 'driver']); + $user = two_fa_controller_user(); + TwoFactorAuth::saveTwoFaSettingsForUser($user, ['enabled' => true, 'method' => 'email']); + $token = TwoFactorAuth::start($user->email, 10); + $verificationCode = two_fa_controller_verification_code($user, Carbon::now()->addMinutes(5)); + $clientToken = TwoFactorAuth::createClientSessionToken($verificationCode); + + $response = two_fa_controller()->verifyCode(Request::create('/int/v1/two-fa/verify', 'POST', [ + 'code' => '123456', + 'token' => $token, + 'clientToken' => $clientToken, + ])); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['code'])->toBe('console_access_not_allowed') + ->and(app('db')->table('personal_access_tokens')->where('tokenable_id', $user->uuid)->count())->toBe(0); +}); diff --git a/tests/Unit/Http/UserControllerTest.php b/tests/Unit/Http/UserControllerTest.php index 77ad97ff..8a9d5749 100644 --- a/tests/Unit/Http/UserControllerTest.php +++ b/tests/Unit/Http/UserControllerTest.php @@ -605,6 +605,16 @@ public function clear(): void return $capsule; } +/** + * Give the owner the Administrator role, as a real organization owner has. + */ +function user_controller_owner_is_administrator(Capsule $capsule): void +{ + $capsule->getConnection('mysql')->table('model_has_roles')->insert([ + 'role_id' => 'Administrator', 'model_type' => Fleetbase\Models\CompanyUser::class, 'model_uuid' => 'pivot-owner-1', + ]); +} + function user_controller(): UserController { return new UserController(); @@ -927,12 +937,14 @@ function user_controller_assert_created_user_response(mixed $response): object|a }); test('user controller create record rejects duplicate active-company members and unavailable roles', function () { - user_controller_database(); + $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); $duplicateMember = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ 'user' => [ - 'email' => 'member@example.test', - 'name' => 'Member One', + 'email' => 'member@example.test', + 'name' => 'Member One', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'createRecord')); $invalidRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ @@ -951,6 +963,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a test('user controller create record invites existing users from another organization', function () { $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); EloquentModel::setEventDispatcher(new Dispatcher(app())); $invite = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ @@ -969,14 +982,17 @@ function user_controller_assert_created_user_response(mixed $response): object|a }); test('user controller create record reports existing-user invite precondition failures', function () { - user_controller_database(); + $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); - session()->flush(); - $missingCompany = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + $missingCompanyRequest = user_controller_request('POST', [ 'user' => [ - 'email' => 'foreign@example.test', + 'email' => 'foreign@example.test', + 'role_uuid' => 'Administrator', ], - ], user_controller_user('owner-1'), 'createRecord')); + ], user_controller_user('owner-1'), 'createRecord'); + session()->flush(); + $missingCompany = user_controller_without_request_validation()->createRecord($missingCompanyRequest); session(['company' => 'company-1', 'user' => 'owner-1']); $invalidRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ @@ -1137,7 +1153,8 @@ function user_controller_assert_created_user_response(mixed $response): object|a }); test('user controller formats create and update exception responses by exception type', function () { - user_controller_database(); + $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); $queryException = new Illuminate\Database\QueryException( 'mysql', @@ -1150,8 +1167,9 @@ function user_controller_assert_created_user_response(mixed $response): object|a $createDatabaseFailure->model = new UserControllerThrowingModel($queryException); $createDatabaseResponse = $createDatabaseFailure->createRecord(user_controller_request('POST', [ 'user' => [ - 'email' => 'database-failure@example.test', - 'name' => 'Database Failure', + 'email' => 'database-failure@example.test', + 'name' => 'Database Failure', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'createRecord')); @@ -1161,8 +1179,9 @@ function user_controller_assert_created_user_response(mixed $response): object|a ])); $createValidationResponse = $createValidationFailure->createRecord(user_controller_request('POST', [ 'user' => [ - 'email' => 'validation-failure@example.test', - 'name' => 'Validation Failure', + 'email' => 'validation-failure@example.test', + 'name' => 'Validation Failure', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'createRecord')); @@ -1170,8 +1189,9 @@ function user_controller_assert_created_user_response(mixed $response): object|a $createGenericFailure->model = new UserControllerThrowingModel(new RuntimeException('generic create failure')); $createGenericResponse = $createGenericFailure->createRecord(user_controller_request('POST', [ 'user' => [ - 'email' => 'generic-failure@example.test', - 'name' => 'Generic Failure', + 'email' => 'generic-failure@example.test', + 'name' => 'Generic Failure', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'createRecord')); @@ -1637,6 +1657,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a test('user controller invites a brand new user and prevents duplicate organization invitations', function () { $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); EloquentModel::setEventDispatcher(new Dispatcher(app())); $invite = user_controller()->inviteUser(user_controller_request('POST', [ @@ -1649,8 +1670,9 @@ function user_controller_assert_created_user_response(mixed $response): object|a ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); $duplicate = user_controller()->inviteUser(user_controller_request('POST', [ 'user' => [ - 'email' => 'fresh@example.test', - 'name' => 'Fresh User', + 'email' => 'fresh@example.test', + 'name' => 'Fresh User', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); @@ -1675,6 +1697,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a test('user controller invites existing users from another organization without creating duplicates', function () { $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); EloquentModel::setEventDispatcher(new Dispatcher(app())); $invite = user_controller()->inviteUser(user_controller_request('POST', [ @@ -1685,7 +1708,8 @@ function user_controller_assert_created_user_response(mixed $response): object|a ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); $duplicateInvite = user_controller()->inviteUser(user_controller_request('POST', [ 'user' => [ - 'email' => 'foreign@example.test', + 'email' => 'foreign@example.test', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); @@ -1905,3 +1929,171 @@ function user_controller_assert_created_user_response(mixed $response): object|a ->and($missingUser->getStatusCode())->toBe(400) ->and($missingUser->getData(true))->toBe(['errors' => ['Unable to locate the user for this invitation.']]); }); + +test('user controller promotes a managed driver account in the organization instead of inviting a duplicate', function (string $method) { + $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); + $connection = $capsule->getConnection('mysql'); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + $now = '2026-07-18 10:00:00'; + $connection->table('users')->insert(['uuid' => 'driver-1', 'public_id' => 'user_driver_1', 'company_uuid' => 'company-1', 'email' => 'driver@example.test', 'phone' => '+15555550199', 'name' => 'Driver One', 'type' => 'driver', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + $connection->table('company_users')->insert(['uuid' => 'pivot-driver-1', 'company_uuid' => 'company-1', 'user_uuid' => 'driver-1', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + + $payload = ['user' => ['email' => 'Driver@Example.test', 'name' => 'Driver One', 'role_uuid' => 'Administrator']]; + $response = $method === 'inviteUser' + ? user_controller()->inviteUser(user_controller_request('POST', $payload, user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)) + : user_controller_without_request_validation()->createRecord(user_controller_request('POST', $payload, user_controller_user('owner-1'), 'createRecord')); + + $driver = User::find('driver-1'); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true)['promoted_from'])->toBe('driver') + ->and($response->getData(true)['user']['uuid'])->toBe('driver-1') + ->and($driver->type)->toBe('user') + ->and(User::where('email', 'driver@example.test')->count())->toBe(1) + ->and($connection->table('invites')->where('company_uuid', 'company-1')->where('reason', 'join_company')->count())->toBe(1); +})->with(['inviteUser', 'createRecord']); + +test('user controller accepting an invite promotes a managed driver account and asks for a console password', function () { + $capsule = user_controller_database(); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + + $driver = User::create([ + 'uuid' => 'driver-invitee', + 'public_id' => 'user_driver_invitee', + 'email' => 'driver-invitee@example.test', + 'name' => 'Driver Invitee', + 'company_uuid' => 'company-2', + 'status' => 'active', + ]); + $driver->setType('driver'); + + $capsule->getConnection('mysql')->table('invites')->insert([ + 'uuid' => 'invite-driver', + 'public_id' => 'invite_public_driver', + 'code' => 'DRIVER123', + 'uri' => 'driver123', + 'company_uuid' => 'company-1', + 'created_by_uuid' => 'owner-1', + 'subject_uuid' => 'company-1', + 'subject_type' => Fleetbase\Support\Utils::getMutationType(Fleetbase\Models\Company::where('uuid', 'company-1')->first()), + 'protocol' => 'email', + 'recipients' => json_encode(['driver-invitee@example.test']), + 'reason' => 'join_company', + 'meta' => json_encode(['role_uuid' => 'Administrator']), + 'expires_at' => now()->addHours(48), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $accepted = user_controller()->acceptCompanyInvite(user_controller_request('POST', [ + 'code' => 'DRIVER123', + ], $driver, 'acceptCompanyInvite', AcceptCompanyInvite::class)); + + expect($accepted->getStatusCode())->toBe(200) + ->and($accepted->getData(true)['needs_password'])->toBeTrue() + ->and($capsule->getConnection('mysql')->table('users')->where('uuid', 'driver-invitee')->value('type'))->toBe('user'); +}); + +test('user controller promotion syncs permissions and policies and reports organization and role failures', function () { + $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); + $db = $capsule->getConnection('mysql'); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + $now = '2026-07-18 10:00:00'; + $db->table('users')->insert(['uuid' => 'customer-1', 'public_id' => 'user_customer_1', 'company_uuid' => 'company-1', 'email' => 'customer@example.test', 'phone' => '+15555550188', 'name' => 'Customer One', 'type' => 'customer', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + $db->table('company_users')->insert(['uuid' => 'pivot-customer-1', 'company_uuid' => 'company-1', 'user_uuid' => 'customer-1', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + $db->table('permissions')->insert(['id' => 'permission-promote', 'name' => 'iam promote user', 'guard_name' => 'sanctum', 'description' => 'Promote', 'created_at' => $now, 'updated_at' => $now]); + $db->table('policies')->insert(['id' => 'policy-promote', 'company_uuid' => 'company-1', 'name' => 'Promote policy', 'guard_name' => 'sanctum', 'service' => 'iam', 'description' => 'Promote', 'created_at' => $now, 'updated_at' => $now]); + + $findPromotable = new ReflectionMethod(UserController::class, 'findPromotableAccount'); + $noIdentity = $findPromotable->invoke(user_controller(), null, ' '); + + $invalidRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['phone' => '+15555550188', 'role_uuid' => 'role-other-company'], + ], user_controller_user('owner-1'), 'createRecord')); + + $promoted = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => [ + 'phone' => '+15555550188', + 'role_uuid' => 'Administrator', + 'permissions' => ['permission-promote'], + 'policies' => ['policy-promote'], + ], + ], user_controller_user('owner-1'), 'createRecord')); + + $db->table('users')->insert(['uuid' => 'driver-2', 'public_id' => 'user_driver_2', 'company_uuid' => 'company-1', 'email' => 'driver2@example.test', 'name' => 'Driver Two', 'type' => 'driver', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + $promote = new ReflectionMethod(UserController::class, 'promoteManagedAccount'); + $promoteRequest = user_controller_request('POST', ['user' => []], user_controller_user('owner-1'), 'createRecord'); + session()->flush(); + $noCompany = $promote->invoke(user_controller(), User::find('driver-2'), $promoteRequest, Role::find('Administrator')); + + expect($noIdentity)->toBeNull() + ->and($invalidRole->getStatusCode())->toBe(404) + ->and($promoted->getStatusCode())->toBe(200) + ->and($promoted->getData(true)['promoted_from'])->toBe('customer') + ->and(User::find('customer-1')->type)->toBe('user') + ->and($db->table('model_has_permissions')->where('model_uuid', 'pivot-customer-1')->where('permission_id', 'permission-promote')->exists())->toBeTrue() + ->and($db->table('model_has_policies')->where('model_uuid', 'pivot-customer-1')->where('policy_id', 'policy-promote')->exists())->toBeTrue() + ->and($noCompany->getData(true))->toBe(['errors' => ['Unable to determine the current organisation.']]); +}); + +test('user controller requires a role and only lets administrators grant the Administrator role', function () { + $capsule = user_controller_database(); + $db = $capsule->getConnection('mysql'); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + $db->table('roles')->insert(['id' => 'Dispatcher', 'company_uuid' => 'company-1', 'name' => 'Dispatcher', 'guard_name' => 'sanctum', 'created_at' => '2026-07-18 10:00:00', 'updated_at' => '2026-07-18 10:00:00']); + + $createWithoutRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['email' => 'no-role@example.test', 'name' => 'No Role'], + ], user_controller_user('owner-1'), 'createRecord')); + $inviteWithoutRole = user_controller()->inviteUser(user_controller_request('POST', [ + 'user' => ['email' => 'no-role-invite@example.test', 'name' => 'No Role'], + ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); + + // owner-1 has no Administrator role in this organization, so it may not grant it + $createAdministrator = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['email' => 'escalate@example.test', 'name' => 'Escalate', 'role_uuid' => 'Administrator'], + ], user_controller_user('owner-1'), 'createRecord')); + $updateToAdministrator = user_controller_without_request_validation()->updateRecord(user_controller_request('PATCH', [ + 'user' => ['role' => 'Administrator'], + ], user_controller_user('owner-1'), 'updateRecord'), 'member-1'); + $objectRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['email' => 'object-role@example.test', 'name' => 'Object Role', 'role' => ['id' => 'Dispatcher']], + ], user_controller_user('owner-1'), 'createRecord')); + + expect($createWithoutRole->getStatusCode())->toBe(422) + ->and($createWithoutRole->getData(true))->toBe(['errors' => ['Select a role for this user.']]) + ->and($inviteWithoutRole->getStatusCode())->toBe(422) + ->and(User::where('email', 'no-role-invite@example.test')->exists())->toBeFalse() + ->and($createAdministrator->getStatusCode())->toBe(403) + ->and($createAdministrator->getData(true))->toBe(['errors' => ['Only administrators can grant the Administrator role.']]) + ->and(User::where('email', 'escalate@example.test')->exists())->toBeFalse() + ->and($updateToAdministrator->getStatusCode())->toBe(403) + ->and($objectRole)->not->toBeInstanceOf(JsonResponse::class); + + $created = User::where('email', 'object-role@example.test')->first(); + $pivot = $db->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => $created->uuid])->value('uuid'); + + expect($db->table('model_has_roles')->where('model_uuid', $pivot)->pluck('role_id')->all())->toBe(['Dispatcher']); +}); + +test('user controller accepting an invite without a role joins without one', function () { + $capsule = user_controller_database(); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + + $invitee = User::create(['uuid' => 'legacy-invitee', 'public_id' => 'user_legacy_invitee', 'email' => 'legacy@example.test', 'name' => 'Legacy Invitee', 'company_uuid' => 'company-2', 'status' => 'active']); + $capsule->getConnection('mysql')->table('invites')->insert([ + 'uuid' => 'invite-legacy', 'public_id' => 'invite_public_legacy', 'code' => 'LEGACY1', 'uri' => 'legacy1', 'company_uuid' => 'company-1', + 'created_by_uuid' => 'owner-1', 'subject_uuid' => 'company-1', 'subject_type' => Fleetbase\Support\Utils::getMutationType(Fleetbase\Models\Company::where('uuid', 'company-1')->first()), + 'protocol' => 'email', 'recipients' => json_encode(['legacy@example.test']), 'reason' => 'join_company', 'meta' => json_encode([]), + 'expires_at' => now()->addHours(48), 'created_at' => now(), 'updated_at' => now(), + ]); + + $accepted = user_controller()->acceptCompanyInvite(user_controller_request('POST', ['code' => 'LEGACY1'], $invitee, 'acceptCompanyInvite', AcceptCompanyInvite::class)); + $pivot = $capsule->getConnection('mysql')->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => 'legacy-invitee'])->value('uuid'); + + expect($accepted->getStatusCode())->toBe(200) + ->and($pivot)->not->toBeNull() + ->and($capsule->getConnection('mysql')->table('model_has_roles')->where('model_uuid', $pivot)->count())->toBe(0); +}); diff --git a/tests/Unit/Models/CompanyModelTest.php b/tests/Unit/Models/CompanyModelTest.php index 07ab3382..5a946362 100644 --- a/tests/Unit/Models/CompanyModelTest.php +++ b/tests/Unit/Models/CompanyModelTest.php @@ -76,7 +76,7 @@ public function __construct(?CompanyUser $companyUser = null) $this->setRawAttributes(['uuid' => 'company-1'], true); } - public function addUser(User $user, string $role = 'Administrator', string $status = 'active'): CompanyUser + public function addUser(User $user, ?string $role = null, string $status = 'active'): CompanyUser { $this->addedUsers[] = [$user->uuid, $role, $status]; @@ -348,7 +348,7 @@ function company_model_create_users_table(): void $user = new class extends User { public array $assignedCompanies = []; - public function assignCompany(Company $company, string $role = 'Administrator'): User + public function assignCompany(Company $company, ?string $role = null): User { $this->assignedCompanies[] = [$company->uuid, $role]; @@ -362,7 +362,7 @@ public function assignCompany(Company $company, string $role = 'Administrator'): expect($company->assignUser($user, 'Dispatcher'))->toBe($companyUser) ->and($company->addedUsers)->toBe([['user-1', 'Dispatcher', 'active']]) - ->and($user->assignedCompanies)->toBe([['company-1', 'Administrator']]); + ->and($user->assignedCompanies)->toBe([['company-1', null]]); }); it('resolves the current company from session using the configured connection', function () { diff --git a/tests/Unit/Observers/UserObserverTest.php b/tests/Unit/Observers/UserObserverTest.php index 13e413e0..dbe50a45 100644 --- a/tests/Unit/Observers/UserObserverTest.php +++ b/tests/Unit/Observers/UserObserverTest.php @@ -68,6 +68,9 @@ function user_observer_database(): Capsule $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); $schema->create('users', function ($table) { $table->string('uuid')->primary(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->json('meta')->nullable(); $table->timestamp('updated_at')->nullable(); $table->timestamp('deleted_at')->nullable(); }); @@ -153,3 +156,35 @@ function user_observer_subject(): array ->and($remaining)->not->toContain('session-company-user') ->and($deletedAt)->not->toBeNull(); }); + +it('frees the email and phone of a soft deleted user and keeps them in meta', function () { + [$user, , , $capsule, $observer] = user_observer_subject(); + $capsule->getConnection('mysql')->table('users')->where('uuid', 'user-1')->update(['email' => 'driver@example.test', 'phone' => '+15555550100']); + $user->refresh(); + + $observer->deleted($user); + + $row = $capsule->getConnection('mysql')->table('users')->where('uuid', 'user-1')->first(); + + expect($row->email)->toBeNull() + ->and($row->phone)->toBeNull() + ->and(json_decode($row->meta, true))->toBe(['deleted_identity' => ['email' => 'driver@example.test', 'phone' => '+15555550100']]); +}); + +it('restores a released identity only where no other account has taken it', function () { + [$user, , , $capsule, $observer] = user_observer_subject(); + $connection = $capsule->getConnection('mysql'); + $connection->table('users')->where('uuid', 'user-1')->update([ + 'meta' => json_encode(['deleted_identity' => ['email' => 'driver@example.test', 'phone' => '+15555550100']]), + ]); + $connection->table('users')->insert(['uuid' => 'user-2', 'phone' => '+15555550100']); + $user->refresh(); + + $observer->restored($user); + + $row = $connection->table('users')->where('uuid', 'user-1')->first(); + + expect($row->email)->toBe('driver@example.test') + ->and($row->phone)->toBeNull() + ->and(json_decode($row->meta, true))->toBe([]); +});