From e0f167c14d89a379fbba24f824db66a458fc46fe Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Oct 2024 00:04:37 +0800 Subject: [PATCH] core critical fixes for user and auth --- composer.json | 2 +- src/Auth/Schemas/IAM.php | 2 +- src/Console/Commands/Recovery.php | 462 ++++++++++++++++++ src/Expansions/Builder.php | 4 +- .../Internal/v1/CompanyController.php | 120 ++++- .../Internal/v1/OnboardController.php | 2 +- .../Internal/v1/UserController.php | 39 +- src/Http/Filter/UserFilter.php | 2 +- src/Http/Resources/Organization.php | 11 + src/Http/Resources/User.php | 60 +-- src/Models/Company.php | 142 +++++- src/Models/User.php | 112 ++++- src/Providers/CoreServiceProvider.php | 1 + src/routes.php | 3 + 14 files changed, 887 insertions(+), 75 deletions(-) create mode 100644 src/Console/Commands/Recovery.php diff --git a/composer.json b/composer.json index 15e1be8..9cbed44 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.5.10", + "version": "1.5.11", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", diff --git a/src/Auth/Schemas/IAM.php b/src/Auth/Schemas/IAM.php index b794c1b..d8f9a56 100644 --- a/src/Auth/Schemas/IAM.php +++ b/src/Auth/Schemas/IAM.php @@ -34,7 +34,7 @@ class IAM ], [ 'name' => 'user', - 'actions' => ['deactivate', 'activate', 'export'], + 'actions' => ['deactivate', 'activate', 'verify', 'export'], ], [ 'name' => 'role', diff --git a/src/Console/Commands/Recovery.php b/src/Console/Commands/Recovery.php new file mode 100644 index 0000000..25ea592 --- /dev/null +++ b/src/Console/Commands/Recovery.php @@ -0,0 +1,462 @@ +choice('Which recovery option would you like to perform?', $actions); + $actionFn = Str::camel($action); + + $this->alert('Recovery Action: ' . $action); + try { + $this->{$actionFn}(); + } catch (\Throwable $e) { + $this->error($e->getMessage()); + } + + return Command::SUCCESS; + } + + /** + * Sets a specific role for a user within a company. + * + * This method allows the organization owner to assign a predefined role to a user for a particular company. + * If the user is not already associated with the company, it prompts the owner to assign the user to the company first. + * + * **Workflow:** + * 1. Prompt for the user if not provided. + * 2. Prompt for the company associated with the user if not provided. + * 3. Retrieve the company-user pivot record. + * 4. If the user is not a member of the company, offer to assign the user to the company. + * 5. Prompt for the role to assign. + * 6. Confirm the assignment with the organization owner. + * 7. Assign the role and provide feedback. + * + * **Usage Example:** + * ```bash + * php artisan fleetbase:recovery + * ``` + * + * @param \Fleetbase\Models\User|null $user The user instance. Defaults to null, prompting the owner to select a user. + * @param \Fleetbase\Models\Company|null $company The company instance. Defaults to null, prompting the owner to select a company. + * + * @return void + * + * @throws \Exception if an error occurs while assigning the role + * + * @see User + * @see Company + * @see Role + */ + public function setRoleForUser(?User $user = null, ?Company $company = null) + { + $user = $user ? $user : $this->promptForUser(); + if (!$user) { + return $this->error('No user selected to set role for.'); + } + + $company = $company ? $company : $this->promptForUserCompany($user, 'Select the which company to assign the role for'); + if (!$company) { + return $this->error('No company selected to set role for.'); + } + + // Get the company user + $companyUser = $company->getCompanyUserPivot($user); + if (!$companyUser) { + $this->error('User is not a member of the selected company.'); + $tryAssign = $this->confirm('Would you like to try to assign this user to a company?'); + + return $tryAssign ? $this->assignUserToCompany($user, $company) : null; + } + + $roleName = $this->anticipate('Input the role you wish to set for this user', function ($input) { + $results = Role::where(DB::raw('lower(name)'), 'like', '%' . str_replace('.', '%', str_replace(',', '%', $input)) . '%')->get(); + + return $results->map(function ($role) { + return $role->name; + })->values()->toArray(); + }); + $confirm = $this->confirm('Assign the role (' . $roleName . ') to user (' . $user->name . ') for the company (' . $company->name . ')?'); + + if ($confirm) { + try { + $companyUser->assignSingleRole($roleName); + $this->info('Role ' . $roleName . ' assigned to user (' . $user->name . ') for the company (' . $company->name . ')'); + } catch (\Throwable $e) { + $this->error($e->getMessage()); + } + } + + $this->info('Done'); + } + + /** + * Assigns a user to a company with a specified role. + * + * This method facilitates the assignment of a user to a company by associating them with a defined role. + * It ensures that the user and company exist and prompts the organization owner to confirm the assignment. + * + * **Workflow:** + * 1. Prompt for the user if not provided. + * 2. Prompt for the company if not provided. + * 3. Prompt for the role to assign. + * 4. Confirm the assignment with the organization owner. + * 5. Assign the user to the company with the specified role and provide feedback. + * + * **Usage Example:** + * ```bash + * php artisan fleetbase:recovery + * ``` + * + * @param \Fleetbase\Models\User|null $user The user instance. Defaults to null, prompting the owner to select a user. + * @param \Fleetbase\Models\Company|null $company The company instance. Defaults to null, prompting the owner to select a company. + * + * @return void + * + * @throws \Exception if an error occurs while assigning the user to the company + * + * @see User + * @see Company + * @see Role + */ + public function assignUserToCompany(?User $user = null, ?Company $company = null) + { + $user = $user ? $user : $this->promptForUser(); + if (!$user) { + return $this->error('No user selected to assign to a company.'); + } + + $company = $company ? $company : $this->promptForCompany(); + if (!$company) { + return $this->error('No company selected to assign user to.'); + } + + $roleName = $this->anticipate('Input the role you wish to set for this user', function ($input) { + $results = Role::where(DB::raw('lower(name)'), 'like', '%' . str_replace('.', '%', str_replace(',', '%', $input)) . '%')->get(); + + return $results->map(function ($role) { + return $role->name; + })->values()->toArray(); + }); + $confirm = $this->confirm('Assign the user (' . $user->name . ') with the role (' . $roleName . ') to the company (' . $company->name . ')?'); + + if ($confirm) { + try { + $user->assignCompany($company, $roleName); + $user->setCompany($company); + $this->info('User (' . $user->name . ') assigned to company (' . $company->name . ')'); + } catch (\Throwable $e) { + $this->error($e->getMessage()); + } + } + + $this->info('Done'); + } + + /** + * Assigns a user as the owner of a company. + * + * This method designates a user as the administrator or owner of a specific company. It ensures that both + * the user and the company exist and prompts the organization owner to confirm the ownership assignment. + * + * **Workflow:** + * 1. Prompt for the user if not provided. + * 2. Prompt for the company if not provided. + * 3. Confirm the ownership assignment with the organization owner. + * 4. Assign the user as the owner of the company and provide feedback. + * + * **Usage Example:** + * ```bash + * php artisan fleetbase:recovery + * ``` + * + * @param \Fleetbase\Models\User|null $user The user instance. Defaults to null, prompting the owner to select a user. + * @param \Fleetbase\Models\Company|null $company The company instance. Defaults to null, prompting the owner to select a company. + * + * @return void + * + * @throws \Exception if an error occurs while assigning the owner to the company + * + * @see User + * @see Company + * @see Role + */ + public function assignOwnerToCompany(?User $user = null, ?Company $company = null) + { + $user = $user ? $user : $this->promptForUser(); + if (!$user) { + return $this->error('No user selected to assign as owner of a company.'); + } + + $company = $company ? $company : $this->promptForCompany(); + if (!$company) { + return $this->error('No company selected to set owner for.'); + } + + $confirm = $this->confirm('Set the user (' . $user->name . ') as the owner of the company (' . $company->name . ')?'); + if ($confirm) { + try { + $user->assignCompany($company, 'Administrator'); + $company->setOwner($user); + $this->info('User (' . $user->name . ') made owner of the company (' . $company->name . ')'); + } catch (\Throwable $e) { + $this->error($e->getMessage()); + } + } + + $this->info('Done'); + } + + /** + * Prompts the organization owner to select a user based on input criteria. + * + * This method assists in identifying and selecting a user by allowing the organization owner to search + * using the user's name, email, username, public ID, or UUID. It presents a list of matching users for + * selection and returns the selected user instance. + * + * **Workflow:** + * 1. Prompt the owner to input search criteria. + * 2. Display a list of users matching the criteria. + * 3. Allow the owner to select a user from the list. + * 4. Return the selected user instance. + * + * **Usage Example:** + * ```php + * $user = $this->promptForUser(); + * ``` + * + * @param string $prompt The prompt message for user input. Defaults to 'Find user by searching for name, email or ID'. + * + * @return \Fleetbase\Models\User|null the selected user instance or null if no user is selected + * + * @throws \Exception if an error occurs while retrieving the user + * + * @see User + */ + public function promptForUser(string $prompt = 'Find user by searching for name, email or ID') + { + $selectedUser = null; + $identifier = $this->anticipate($prompt, function ($input) { + $results = User::where(function ($query) use ($input) { + $query->where(DB::raw('lower(name)'), 'like', str_replace('.', '%', str_replace(',', '%', $input)) . '%'); + $query->orWhere(DB::raw('lower(email)'), 'like', str_replace('.', '%', str_replace(',', '%', $input)) . '%'); + $query->orWhere(DB::raw('lower(username)'), 'like', str_replace('.', '%', str_replace(',', '%', $input)) . '%'); + $query->orWhere(DB::raw('lower(public_id)'), 'like', str_replace('.', '%', str_replace(',', '%', $input)) . '%'); + })->get(); + + return $results->map(function ($user) use ($input) { + if (Str::startsWith(strtolower($user->name), strtolower($input))) { + return $user->name; + } + + if (Str::startsWith(strtolower($user->username), strtolower($input))) { + return $user->username; + } + + if (Str::startsWith(strtolower($user->public_id), strtolower($input))) { + return $user->public_id; + } + + return $user->email; + })->values()->toArray(); + }); + $users = User::where(function ($query) use ($identifier) { + $query->where(DB::raw('lower(name)'), 'like', '%' . str_replace('.', '%', str_replace(',', '%', $identifier)) . '%'); + $query->orWhere(DB::raw('lower(email)'), 'like', '%' . str_replace('.', '%', str_replace(',', '%', $identifier)) . '%'); + $query->orWhere(DB::raw('lower(username)'), 'like', '%' . str_replace('.', '%', str_replace(',', '%', $identifier)) . '%'); + $query->orWhere('public_id', $identifier); + $query->orWhere('uuid', $identifier); + })->get(); + $userSelections = $users->map(function ($user) { + return $user->name . ' - ' . $user->email . ' - ' . $user->public_id; + })->values()->toArray(); + + $selectedUserValue = $this->choice('Found ' . Str::plural('user', $users->count()) . ', select the user below to run action for', $userSelections); + if ($selectedUserValue) { + $selectedUserSegments = explode('-', $selectedUserValue); + $selectedUserId = trim($selectedUserSegments[2]); + $selectedUser = User::where('public_id', $selectedUserId)->first(); + } + + return $selectedUser; + } + + /** + * Prompts the organization owner to select a company based on input criteria. + * + * This method assists in identifying and selecting a company by allowing the organization owner to search + * using the company's name, public ID, or UUID. It presents a list of matching companies for selection + * and returns the selected company instance. + * + * **Workflow:** + * 1. Prompt the owner to input search criteria. + * 2. Display a list of companies matching the criteria. + * 3. Allow the owner to select a company from the list. + * 4. Return the selected company instance. + * + * **Usage Example:** + * ```php + * $company = $this->promptForCompany(); + * ``` + * + * @param string $prompt The prompt message for company search. Defaults to 'Find company by searching for name or ID'. + * + * @return \Fleetbase\Models\Company|null the selected company instance or null if no company is selected + * + * @throws \Exception if an error occurs while retrieving the company + * + * @see Company + */ + public function promptForCompany($prompt = 'Find company by searching for name or ID') + { + $selectedCompany = null; + $identifier = $this->anticipate($prompt, function ($input) { + $results = Company::where(function ($query) use ($input) { + $query->where(DB::raw('lower(name)'), 'like', str_replace('.', '%', str_replace(',', '%', $input)) . '%'); + $query->orWhere(DB::raw('lower(public_id)'), 'like', str_replace('.', '%', str_replace(',', '%', $input)) . '%'); + })->get(); + + return $results->map(function ($company) use ($input) { + if (Str::startsWith(strtolower($company->name), strtolower($input))) { + return $company->name; + } + + return $company->public_id; + })->values()->toArray(); + }); + $companies = Company::where(function ($query) use ($identifier) { + $query->where(DB::raw('lower(name)'), 'like', '%' . str_replace('.', '%', str_replace(',', '%', $identifier)) . '%'); + $query->orWhere('public_id', $identifier); + $query->orWhere('uuid', $identifier); + })->get(); + $companySelections = $companies->map(function ($company) { + return $company->name . ' - ' . $company->public_id; + })->values()->toArray(); + + $selectedCompanyValue = $this->choice('Found ' . Str::plural('user', $companies->count()) . ', select the company below:', $companySelections); + if ($selectedCompanyValue) { + $selectedCompanySegments = explode('-', $selectedCompanyValue); + $selectedCompanyId = trim($selectedCompanySegments[1]); + $selectedCompany = Company::where('public_id', $selectedCompanyId)->first(); + } + + return $selectedCompany; + } + + /** + * Prompts the organization owner to select a company associated with a specific user. + * + * This method is used to select one of the companies that a user is already associated with. It presents + * a list of the user's companies for selection and returns the chosen company instance. + * + * **Workflow:** + * 1. Retrieve the companies associated with the specified user. + * 2. Display a list of these companies to the owner. + * 3. Allow the owner to select a company from the list. + * 4. Return the selected company instance. + * + * **Usage Example:** + * ```php + * $company = $this->promptForUserCompany($user); + * ``` + * + * @param User $user the user instance whose associated companies are to be listed + * @param string $prompt The prompt message for company selection. Defaults to 'Select the users company'. + * + * @return \Fleetbase\Models\Company|null the selected company instance or null if no company is selected + * + * @throws \Exception if an error occurs while retrieving the user's companies + * + * @see User + * @see Company + */ + public function promptForUserCompany(User $user, $prompt = 'Select the users company') + { + $selectedCompany = null; + $user->loadMissing('companies'); + $userCompanies = $user->companies; + $companySelections = $userCompanies->map(function ($company) { + return $company->name . ' - ' . $company->public_id; + })->values()->toArray(); + + $selectedCompanyValue = $this->choice('Found ' . Str::plural('user', $userCompanies->count()) . ', ' . $prompt, $companySelections); + if ($selectedCompanyValue) { + $selectedCompanySegments = explode('-', $selectedCompanyValue); + $selectedCompanyId = trim($selectedCompanySegments[1]); + $selectedCompany = Company::where('public_id', $selectedCompanyId)->first(); + } + + return $selectedCompany; + } +} diff --git a/src/Expansions/Builder.php b/src/Expansions/Builder.php index 2a464d6..26684db 100644 --- a/src/Expansions/Builder.php +++ b/src/Expansions/Builder.php @@ -175,13 +175,13 @@ public function applySortFromRequest() } if (Str::startsWith($sort, '-')) { - list($param, $direction) = Http::useSort($request); + list($param, $direction) = Http::useSort($sort); $this->orderBy($param, $direction); continue; } - list($param, $direction) = Http::useSort($request); + list($param, $direction) = Http::useSort($sort); $this->orderBy($param, $direction); } diff --git a/src/Http/Controllers/Internal/v1/CompanyController.php b/src/Http/Controllers/Internal/v1/CompanyController.php index 20c5e63..27c17cb 100644 --- a/src/Http/Controllers/Internal/v1/CompanyController.php +++ b/src/Http/Controllers/Internal/v1/CompanyController.php @@ -6,9 +6,11 @@ use Fleetbase\Http\Controllers\FleetbaseController; use Fleetbase\Http\Requests\ExportRequest; use Fleetbase\Http\Resources\Organization; +use Fleetbase\Http\Resources\User as UserResource; use Fleetbase\Models\Company; use Fleetbase\Models\CompanyUser; use Fleetbase\Models\Invite; +use Fleetbase\Models\User; use Fleetbase\Support\Auth; use Fleetbase\Support\TwoFactorAuth; use Illuminate\Http\Request; @@ -99,14 +101,19 @@ public function users(string $id, Request $request) { $searchQuery = $request->searchQuery(); $limit = $request->input(['limit', 'nestedLimit'], 20); - // $page = $request->or(['page', 'nestedPage'], 1); - $paginate = $request->boolean('paginate'); + $paginate = $request->boolean('paginate'); + $exclude = $request->array('exclude'); // Start user query - $usersQuery = CompanyUser::whereHas('company', function ($query) use ($id) { - $query->where('public_id', $id); - $query->orWhere('uuid', $id); - })->with(['user']); + $usersQuery = CompanyUser::whereHas('company', + function ($query) use ($id) { + $query->where('public_id', $id); + $query->orWhere('uuid', $id); + } + ) + ->whereHas('user') + ->whereNotIn('user_uuid', $exclude) + ->with(['user']); // Search query if ($searchQuery) { @@ -131,7 +138,7 @@ public function users(string $id, Request $request) $users->setCollection($transformedItems); return response()->json([ - 'users' => $users->getCollection(), + 'users' => UserResource::collection($users->getCollection()), 'meta' => [ 'current_page' => $users->currentPage(), 'from' => $users->firstItem(), @@ -146,12 +153,15 @@ public function users(string $id, Request $request) // get users $users = $usersQuery->get(); + // fix results $users = $users->map(function ($companyUser) { + $companyUser->loadMissing('user'); + return $companyUser->user; }); - return response()->json(['users' => $users]); + return UserResource::collection($users); } /** @@ -167,4 +177,98 @@ public function export(ExportRequest $request) return Excel::download(new CompanyExport($selections), $fileName); } + + /** + * Transfer ownership of company to another member, and make them the Administrator. + * + * @return \Illuminate\Http\Response + */ + public function transferOwnership(Request $request) + { + $companyId = $request->input('company'); + $newOwnerId = $request->input('newOwner'); + $leave = $request->boolean('leave'); + + // Get and validate organization + $company = Company::where('uuid', $companyId)->first(); + if (!$company) { + return response()->error('No organization found to transfer ownership for.'); + } + + // Get and validate the new owner + $newOwner = $company->getCompanyUser($newOwnerId); + if (!$newOwner) { + return response()->error('The new owner provided could not be found for transfer of ownership.'); + } + + // Change the company owner + $company->assignOwner($newOwner); + + // If the current user has opted to leave, remove them from the organization + if ($leave) { + $currentUser = $request->user(); + if ($currentUser) { + $currentCompanyUser = $company->getCompanyUserPivot($currentUser); + if ($currentCompanyUser) { + $currentCompanyUser->delete(); + } + // Switch organization + $nextOrganization = $currentUser->companies()->where('companies.uuid', '!=', $company->uuid)->first(); + if ($nextOrganization) { + $currentUser->setCompany($nextOrganization); + } + } + } + + return response()->json([ + 'status' => 'ok', + 'newOwner' => $newOwner, + 'currentUserLeft' => $leave, + ]); + } + + /** + * Remove the current user, or user selected via request param from an organization. + * + * @return \Illuminate\Http\Response + */ + public function leaveOrganization(Request $request) + { + $companyId = $request->input('company'); + $currentUser = $request->input('user', $request->user()); + + // If user id is passed + if (Str::isUuid($currentUser)) { + $currentUser = User::where('uuid', $currentUser)->first(); + } + + // If not current user - error + if (!$currentUser) { + return response()->error('Unable to leave organization.'); + } + + // Get and validate organization + $company = Company::where('uuid', $companyId)->first(); + if (!$company) { + return response()->error('No organization found for user to leave.'); + } + + $currentCompanyUser = $company->getCompanyUserPivot($currentUser); + if (!$currentCompanyUser) { + return response()->error('User selected to leave organization is not a member of this organization.'); + } + + // Remove user from organization + $currentCompanyUser->delete(); + + // Switch organization + $nextOrganization = $currentUser->companies()->where('uuid', '!=', $company->uuid)->first(); + if ($nextOrganization) { + $currentUser->setCompany($nextOrganization); + } + + return response()->json([ + 'status' => 'ok', + ]); + } } diff --git a/src/Http/Controllers/Internal/v1/OnboardController.php b/src/Http/Controllers/Internal/v1/OnboardController.php index a618a94..180fc00 100644 --- a/src/Http/Controllers/Internal/v1/OnboardController.php +++ b/src/Http/Controllers/Internal/v1/OnboardController.php @@ -71,7 +71,7 @@ public function createAccount(OnboardRequest $request) $company->setOwner($user)->save(); // assign user to organization - $user->assignCompany($company); + $user->assignCompany($company, 'Administrator'); // assign admin role $user->assignSingleRole('Administrator'); diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 6dc0592..6b1ef6b 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -79,11 +79,11 @@ public function createRecord(Request $request) $user->setUserType('user'); // Assign to user - $user->assignCompany($company); + $user->assignCompany($company, $request->input('user.role_uuid')); // Assign role if set - if ($request->filled('user.role')) { - $user->assignSingleRole($request->input('user.role')); + if ($request->filled('user.role_uuid')) { + $user->assignSingleRole($request->input('user.role_uuid')); } // Sync Permissions @@ -101,6 +101,8 @@ public function createRecord(Request $request) return ['user' => new $this->resource($record)]; } catch (\Exception $e) { + dd($e); + return response()->error($e->getMessage()); } catch (\Illuminate\Database\QueryException $e) { return response()->error($e->getMessage()); @@ -400,6 +402,33 @@ public function activate($id) ]); } + /** + * Verify a user manually. + * + * @return \Illuminate\Http\Response + */ + public function verify($id) + { + if (!$id) { + return response()->error('No user to activate', 401); + } + + $user = User::where('uuid', $id)->first(); + + if (!$user) { + return response()->error('No user found', 401); + } + + $user->manualVerify(); + $user = $user->refresh(); + + return response()->json([ + 'message' => 'User verified', + 'email_verified_at' => $user->email_verified_at, + 'status' => 'ok', + ]); + } + /** * Removes this user from the current company. * @@ -426,13 +455,13 @@ public function removeFromCompany($id) } /** @var \Illuminate\Support\Collection */ - $userCompanies = $user->companies()->get(); + $userCompanies = $user->companyUsers()->get(); // only a member to one company then delete the user if ($userCompanies->count() === 1) { $user->delete(); } else { - $user->companies()->where('company_uuid', $company->uuid)->delete(); + $user->companyUsers()->where('company_uuid', $company->uuid)->delete(); // trigger event user removed from company event(new UserRemovedFromCompany($user, $company)); diff --git a/src/Http/Filter/UserFilter.php b/src/Http/Filter/UserFilter.php index a55c3ea..390ae34 100644 --- a/src/Http/Filter/UserFilter.php +++ b/src/Http/Filter/UserFilter.php @@ -10,7 +10,7 @@ public function queryForInternal() function ($query) { $query ->whereHas( - 'companies', + 'companyUsers', function ($query) { $query->where('company_uuid', $this->session->get('company')); } diff --git a/src/Http/Resources/Organization.php b/src/Http/Resources/Organization.php index cc200af..7845629 100644 --- a/src/Http/Resources/Organization.php +++ b/src/Http/Resources/Organization.php @@ -19,6 +19,7 @@ public function toArray($request) return [ 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'owner_uuid' => $this->when(Http::isInternalRequest(), $this->owner_uuid), 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), 'name' => $this->name, 'description' => $this->description, @@ -33,6 +34,16 @@ public function toArray($request) 'owner' => new User($this->owner), 'slug' => $this->slug, 'status' => $this->status, + 'joined_at' => $this->when(Http::isInternalRequest() && $request->session()->has('user'), function () { + if ($this->resource->joined_at) { + return $this->resource->joined_at; + } + + $currentCompanyUser = $this->resource->getCompanyUserPivot(session('user')); + if ($currentCompanyUser) { + return $currentCompanyUser->created_at; + } + }), 'updated_at' => $this->updated_at, 'created_at' => $this->created_at, ]; diff --git a/src/Http/Resources/User.php b/src/Http/Resources/User.php index 83af50f..5db268d 100644 --- a/src/Http/Resources/User.php +++ b/src/Http/Resources/User.php @@ -16,35 +16,37 @@ class User extends FleetbaseResource public function toArray($request) { return [ - 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), - 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), - 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), - 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), - 'company' => $this->when(Http::isPublicRequest(), $this->company ? $this->company->public_id : null), - 'name' => $this->name, - 'username' => $this->username, - 'email' => $this->email, - 'phone' => $this->phone, - 'country' => $this->country, - 'timezone' => $this->timezone, - 'avatar_url' => $this->avatar_url, - 'meta' => $this->meta, - 'role' => $this->when(Http::isInternalRequest(), new Role($this->role), null), - 'policies' => $this->when(Http::isInternalRequest(), Policy::collection($this->policies), []), - 'permissions' => $this->when(Http::isInternalRequest(), $this->serializePermissions($this->permissions), []), - 'role_name' => $this->when(Http::isInternalRequest(), $this->role ? $this->role->name : null), - 'type' => $this->type, - 'locale' => $this->getLocale(), - 'types' => $this->when(Http::isInternalRequest(), $this->types ?? []), - 'company_name' => $this->when(Http::isInternalRequest(), $this->company_name), - 'session_status' => $this->when(Http::isInternalRequest(), $this->session_status), - 'is_admin' => $this->when(Http::isInternalRequest(), $this->is_admin), - 'is_online' => $this->is_online, - 'date_of_birth' => $this->date_of_birth, - 'last_seen_at' => $this->last_seen_at, - 'last_login' => $this->last_login, - 'updated_at' => $this->updated_at, - 'created_at' => $this->created_at, + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'company' => $this->when(Http::isPublicRequest(), $this->company ? $this->company->public_id : null), + 'name' => $this->name, + 'username' => $this->username, + 'email' => $this->email, + 'phone' => $this->phone, + 'country' => $this->country, + 'timezone' => $this->timezone, + 'avatar_url' => $this->avatar_url, + 'meta' => $this->meta, + 'role' => $this->when(Http::isInternalRequest(), new Role($this->role), null), + 'policies' => $this->when(Http::isInternalRequest(), Policy::collection($this->policies), []), + 'permissions' => $this->when(Http::isInternalRequest(), $this->serializePermissions($this->permissions), []), + 'role_name' => $this->when(Http::isInternalRequest(), $this->role ? $this->role->name : null), + 'type' => $this->type, + 'locale' => $this->getLocale(), + 'types' => $this->when(Http::isInternalRequest(), $this->types ?? []), + 'company_name' => $this->when(Http::isInternalRequest(), $this->company_name), + 'session_status' => $this->when(Http::isInternalRequest(), $this->session_status), + 'is_admin' => $this->when(Http::isInternalRequest(), $this->is_admin), + 'is_online' => $this->is_online, + 'date_of_birth' => $this->date_of_birth, + 'email_verified_at' => $this->email_verified_at, + 'phone_verified_at' => $this->phone_verified_at, + 'last_seen_at' => $this->last_seen_at, + 'last_login' => $this->last_login, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, ]; } diff --git a/src/Models/Company.php b/src/Models/Company.php index 9d1b0f9..162ae3f 100644 --- a/src/Models/Company.php +++ b/src/Models/Company.php @@ -228,13 +228,16 @@ public function loadCompanyOwner(): self * Assigns the owner of the company. * * @method assignOwner - * - * @return void */ - public function assignOwner(User $user) + public function assignOwner(User $user): User { $this->owner_uuid = $user->uuid; $this->save(); + + // Set owner role to Administrator + $this->changeUserRole($user, 'Administrator'); + + return $user; } /** @@ -349,16 +352,16 @@ public static function currentSession(): ?Company * If a role name is provided, it assigns that role to the CompanyUser. If no role name is specified, * 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 $roleName The name of the role to assign to the user. Defaults to the user's current role if null. - * @param string $status The status of the user within the company. Defaults 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 $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 $roleName = null, string $status = 'active'): CompanyUser + public function addUser(User $user, string $role = 'Administrator', string $status = 'active'): CompanyUser { // Get the currentuser role - $roleName = $roleName ?? $user->getRoleName(); + $role = $role; $companyUser = CompanyUser::firstOrCreate( [ @@ -373,25 +376,78 @@ public function addUser(User $user, ?string $roleName = null, string $status = ' ); // Assign the role to the new user - $companyUser->assignSingleRole($roleName); + $companyUser->assignSingleRole($role); return $companyUser; } + /** + * Changes the role of a specified user within the company. + * + * This method assigns a new role to a user associated with the company. It first retrieves the + * `CompanyUser` pivot model for the given user using the `getCompanyUserPivot` method. If the + * pivot exists, it assigns the specified role using the `assignSingleRole` method and returns + * `true` to indicate a successful role change. If the user is not associated with the company, + * the method returns `false`. + * + * **Usage Example:** + * ```php + * $company = Company::find($companyId); + * $user = User::find($userId); + * $roleName = 'Administrator'; + * + * try { + * if ($company->changeUserRole($user, $roleName)) { + * // Role changed successfully + * } else { + * // Failed to change role (e.g., user not found in the company) + * } + * } catch (\Exception $e) { + * // Handle exception (e.g., log error, notify user) + * Log::error('Role change failed: ' . $e->getMessage()); + * } + * ``` + * + * @param User $user the user whose role is to be changed within the company + * @param string $roleName the name of the new role to assign to the user + * + * @return bool returns `true` if the role was successfully changed; otherwise, returns `false` + * + * @throws \InvalidArgumentException if the provided user is not associated with the company + * @throws \Exception If the role assignment fails due to underlying issues (e.g., database errors). + * + * @see getCompanyUserPivot() + * @see CompanyUser::assignSingleRole() + */ + public function changeUserRole(User $user, string $roleName): bool + { + $companyUser = $this->getCompanyUserPivot($user); + if (!$companyUser) { + throw new \InvalidArgumentException('The specified user is not associated with the company.'); + } + + try { + $companyUser->assignSingleRole($roleName); + + return true; + } catch (\Exception $e) { + throw new \Exception('Role assignment failed. Please try again later.'); + } + } + /** * Assigns a user to the company and sets their role. * * This method adds a user to the company using the addUser method and then associates the company with the user. * It optionally allows specifying a role name for the user within the company. * - * @param User $user the user to assign to the company - * @param string|null $roleName The name of the role to assign to the user. If null, defaults to the user's current role. + * @param User $user the user to assign to the company * * @return CompanyUser the CompanyUser instance representing the association between the user and the company */ - public function assignUser(User $user, ?string $roleName = null): CompanyUser + public function assignUser(User $user, ?string $role = null): CompanyUser { - $companyUser = $this->addUser($user, $roleName); + $companyUser = $this->addUser($user, $role); $user->assignCompany($this); return $companyUser; @@ -408,4 +464,64 @@ public function getLastUserLogin() { return $this->companyUsers()->max('last_login'); } + + /** + * Retrieves the associated User instance for a given user identifier or User object. + * + * This method accepts either a user's UUID as a string or a User instance and returns the corresponding + * User associated with the company. It performs a lookup within the company's users based on the provided + * identifier. + * + * **Usage Example:** + * ```php + * // Using a User instance + * $user = User::find($userId); + * $companyUser = $company->getCompanyUser($user); + * + * // Using a user UUID + * $companyUser = $company->getCompanyUser('uuid-string-here'); + * ``` + * + * @param string|User $user the user identifier (UUID) or a User instance to retrieve the associated User + * + * @return User|null the User instance associated with the company, or null if not found + * + * @throws \InvalidArgumentException if the provided argument is neither a string nor an instance of User + */ + public function getCompanyUser(string|User $user): ?User + { + $id = $user instanceof User ? $user->uuid : $user; + + return $this->companyUsers()->where('user_uuid', $id)->first(); + } + + /** + * Retrieves the CompanyUser pivot instance for a given user identifier or User object. + * + * This method accepts either a user's UUID as a string or a User instance and returns the corresponding + * CompanyUser pivot model that represents the association between the company and the user. It performs + * a direct lookup in the CompanyUser model based on the company's UUID and the provided user identifier. + * + * **Usage Example:** + * ```php + * // Using a User instance + * $user = User::find($userId); + * $companyUserPivot = $company->getCompanyUserPivot($user); + * + * // Using a user UUID + * $companyUserPivot = $company->getCompanyUserPivot('uuid-string-here'); + * ``` + * + * @param string|User $user the user identifier (UUID) or a User instance to retrieve the associated CompanyUser pivot + * + * @return CompanyUser|null the CompanyUser pivot instance representing the association, or null if not found + * + * @throws \InvalidArgumentException if the provided argument is neither a string nor an instance of User + */ + public function getCompanyUserPivot(string|User $user): ?CompanyUser + { + $id = $user instanceof User ? $user->uuid : $user; + + return CompanyUser::where(['company_uuid' => $this->uuid, 'user_uuid' => $id])->first(); + } } diff --git a/src/Models/User.php b/src/Models/User.php index 74f8179..47ebbf6 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -164,7 +164,7 @@ class User extends Authenticatable * * @var array */ - protected $hidden = ['password', 'remember_token', 'secret', 'avatar', 'username', 'company', 'companies']; + protected $hidden = ['password', 'remember_token', 'secret', 'avatar', 'username', 'company', 'companyUsers', 'companies']; /** * Dynamic attributes that are appended to object. @@ -277,18 +277,61 @@ public function devices(): HasMany } /** - * Defines the relationship between the user and the companies they are associated with. + * Retrieves all CompanyUser pivot records associated with the user. * - * This method establishes a `HasMany` relationship, indicating that the user can be associated with multiple companies - * through the `CompanyUser` model. + * This method defines a one-to-many relationship between the User model and the CompanyUser model. + * It allows fetching all pivot records that link the user to various companies through the + * `company_users` pivot table using the `user_uuid` foreign key. * - * @return HasMany the relationship instance between the User and the CompanyUser model + * **Usage Example:** + * ```php + * $user = User::find($userId); + * $companyUsers = $user->companyUsers; + * foreach ($companyUsers as $companyUser) { + * echo $companyUser->company->name; + * } + * ``` + * + * @return HasMany the HasMany relationship instance + * + * @throws \LogicException if the relationship is improperly defined or the models do not exist + * + * @see CompanyUser + * @see Company */ - public function companies(): HasMany + public function companyUsers(): HasMany { return $this->hasMany(CompanyUser::class, 'user_uuid'); } + /** + * Retrieves all companies associated with the user through the CompanyUser pivot table. + * + * This method defines a HasManyThrough relationship between the User model and the Company model + * via the CompanyUser pivot table. It allows fetching all companies that the user is associated with + * through their entries in the CompanyUser pivot. + * + * **Usage Example:** + * ```php + * $user = User::find($userId); + * $companies = $user->companies; + * foreach ($companies as $company) { + * echo $company->name; + * } + * ``` + * + * @return HasManyThrough the HasManyThrough relationship instance + * + * @throws \LogicException if the relationship is improperly defined or the models do not exist + * + * @see CompanyUser + * @see Company + */ + public function companies(): HasManyThrough + { + return $this->hasManyThrough(Company::class, CompanyUser::class, 'company_uuid', 'uuid', 'uuid', 'user_uuid'); + } + /** * Defines the relationship between the user and their current company user record. * @@ -328,9 +371,38 @@ public function groups(): HasManyThrough return $this->hasManyThrough(Group::class, GroupUser::class, 'user_uuid', 'uuid', 'uuid', 'group_uuid'); } + /** + * Retrieves the locale setting for the company. + * + * This method fetches the locale preference associated with the company using the company's UUID. + * It utilizes the `Setting::lookup` method to retrieve the locale value from the settings storage. + * If no locale is set for the company, it defaults to `'en-us'`. + * + * **Usage Example:** + * ```php + * try { + * $company = Company::find($companyId); + * $locale = $company->getLocale(); + * // $locale might return 'en-us' or any other locale set for the company + * } catch (\Exception $e) { + * // Handle exception (e.g., log error, notify user) + * Log::error('Failed to retrieve company locale: ' . $e->getMessage()); + * } + * ``` + * + * @return string The locale code for the company (e.g., 'en-us', 'fr-fr'). + * + * @throws \Exception if there is an issue accessing the settings storage + * + * @see Setting::lookup() + */ public function getLocale(): string { - return Setting::lookup('user.' . $this->uuid . '.locale', 'en-us'); + try { + return Setting::lookup('user.' . $this->uuid . '.locale', 'en-us'); + } catch (\Exception $e) { + throw new \Exception('Unable to retrieve user locale setting at this time.', 0, $e); + } } /** @@ -365,7 +437,7 @@ public static function generateUsername(string $name): string */ public function getCompanyUser(?Company $company = null): ?CompanyUser { - $this->loadMissing(['companyUser', 'companies']); + $this->loadMissing(['companyUser', 'companyUsers']); if ($this->companyUser) { return $this->companyUser; } @@ -375,7 +447,7 @@ public function getCompanyUser(?Company $company = null): ?CompanyUser return null; } - $companyUser = $this->companies()->where('company_uuid', $companyUuid)->first(); + $companyUser = $this->companyUsers()->where('company_uuid', $companyUuid)->first(); if ($companyUser) { $this->setRelation('companyUser', $companyUser); @@ -422,7 +494,7 @@ public function loadCompanyUser(): self */ public function setCompanyUserRelation(Company $company): void { - $companyUser = $this->companies()->where('company_uuid', $company->uuid)->first(); + $companyUser = $this->companyUsers()->where('company_uuid', $company->uuid)->first(); if ($companyUser) { $this->setRelation('companyUser', $companyUser); } @@ -436,17 +508,18 @@ public function setCompanyUserRelation(Company $company): void * and is not the company owner, they will be invited to join the company and the company owner * will be notified that a user has been created. * - * @param Company $company the company to assign the user to + * @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. * * @return self returns the current User instance */ - public function assignCompany(Company $company): self + public function assignCompany(Company $company, string $role = 'Administrator'): self { $this->company_uuid = $company->uuid; // Create company user record if (CompanyUser::where(['company_uuid' => $company->uuid, 'user_uuid' => $this->uuid])->doesntExist()) { - $companyUser = $company->addUser($this); + $companyUser = $company->addUser($this, $role); $this->setRelation('companyUser', $companyUser); } @@ -665,7 +738,7 @@ public function getAvatarUrlAttribute(): string /** * Get the users's company name. */ - public function getCompanyNameAttribute(): string + public function getCompanyNameAttribute(): ?string { return data_get($this, 'company.name'); } @@ -934,6 +1007,17 @@ public function verify(VerificationCode|string $code): self return $this; } + /** + * Manually verify the user's email . + */ + public function manualVerify(): self + { + $this->email_verified_at = Carbon::now(); + $this->save(); + + return $this; + } + /** * Get the date and time when the user was verified. * diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 7fa4ce5..64ee102 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -63,6 +63,7 @@ class CoreServiceProvider extends ServiceProvider * @var array */ public $commands = [ + \Fleetbase\Console\Commands\Recovery::class, \Fleetbase\Console\Commands\AssignAdminRoles::class, \Fleetbase\Console\Commands\ForceResetDatabase::class, \Fleetbase\Console\Commands\CreateDatabase::class, diff --git a/src/routes.php b/src/routes.php index 39054de..e10c7e2 100644 --- a/src/routes.php +++ b/src/routes.php @@ -202,6 +202,8 @@ function ($router, $controller) { $router->fleetbaseRoutes('companies', null, ['middleware' => [Spatie\ResponseCache\Middlewares\DoNotCacheResponse::class]], function ($router, $controller) { $router->get('two-fa', $controller('getTwoFactorSettings')); $router->post('two-fa', $controller('saveTwoFactorSettings')); + $router->post('transfer-ownership', $controller('transferOwnership')); + $router->post('leave', $controller('leave')); $router->match(['get', 'post'], 'export', $controller('export')); $router->get('{id}/users', $controller('users')); }); @@ -210,6 +212,7 @@ function ($router, $controller) { $router->match(['get', 'post'], 'export', $controller('export')); $router->patch('deactivate/{id}', $controller('deactivate')); $router->patch('activate/{id}', $controller('activate')); + $router->patch('verify/{id}', $controller('verify')); $router->delete('remove-from-company/{id}', $controller('removeFromCompany')); $router->delete('bulk-delete', $controller('bulkDelete')); $router->post('resend-invite', $controller('resendInvitation'));