null, 'full_name' => null, 'email' => null, 'password' => null, 'password_confirmation' => null, 'lang_preference' => null, 'type' => null, ]; public $profileTypeOptions = []; public $profileTypeSelected; public $linkBankOptions = []; public $linkBankSelected; public $linkUserOptions = []; public $linkUserSelected; public bool $generateRandomPassword = true; private ?string $generatedPlainTextPassword = null; // Add property to store plain password public $country; public $division; public $city; public $district; // Keep track of whether validation is needed public $validateCountry = false; public $validateDivision = false; public $validateCity = false; public $localeOptions; protected $listeners = ['countryToParent', 'divisionToParent', 'cityToParent', 'districtToParent']; public function mount() { // CRITICAL: Authorize admin access for profile creation component $this->authorizeAdminAccess(); } protected function rules() { $rules = [ 'createProfile' => 'array', 'createProfile.type' => ['required', 'string'], 'country' => 'required_if:validateCountry,true', 'division' => 'required_if:validateDivision,true', 'city' => 'required_if:validateCity,true', 'district' => 'sometimes', ]; // Dynamically add rules based on type if (!empty($this->createProfile['type'])) { $typeKey = 'profile_' . strtolower(class_basename($this->createProfile['type'])); $isUserType = $this->createProfile['type'] === \App\Models\User::class; $isOrgType = $this->createProfile['type'] === \App\Models\Organization::class; $isAdminType = $this->createProfile['type'] === \App\Models\Admin::class; $isBankType = $this->createProfile['type'] === \App\Models\Bank::class; // --- Add rules for common fields like name, full_name, email --- $rules['createProfile.name'] = Rule::when( fn ($input) => isset($input['createProfile']['name']), timebank_config("rules.{$typeKey}.name", []), [] ); $rules['createProfile.full_name'] = Rule::when( fn ($input) => isset($input['createProfile']['full_name']), timebank_config("rules.{$typeKey}.full_name", []), [] ); $rules['createProfile.email'] = Rule::when( fn ($input) => isset($input['createProfile']['email']), timebank_config("rules.{$typeKey}.email", []), [] ); // --- Conditional Password Rules (Only for User type) --- //TODO NEXT: fix manual password confirmation if ($isUserType) { $rules['createProfile.password'] = Rule::when( !$this->generateRandomPassword, // Explicitly add 'confirmed' here for the final validation // Merge with rules from config, ensuring 'confirmed' is present timebank_config("rules.{$typeKey}.password" // Get base rules ), ['nullable', 'string'] // Rules when generating random password ); $rules['createProfile.lang_preference'] = ['string', 'max:3']; } else { $rules['createProfile.password'] = ['nullable', 'string']; $rules['createProfile.password_confirmation'] = ['nullable', 'string']; } // --- Conditional Link Rules --- // Link Bank is required for User and Organization if ($isUserType || $isOrgType) { $rules['createProfile.linkBank'] = ['required', 'integer']; } else { // Ensure it's not required if not rendered $rules['createProfile.linkBank'] = ['nullable', 'integer']; } // Link User is required for Organization, Admin, and Bank if ($isOrgType || $isAdminType || $isBankType) { $rules['createProfile.linkUser'] = ['required', 'integer']; } else { // Ensure it's not required if not rendered $rules['createProfile.linkUser'] = ['nullable', 'integer']; } } else { // Default rules if type is not yet selected (optional, but good practice) $rules['createProfile.linkBank'] = ['nullable', 'integer']; $rules['createProfile.linkUser'] = ['nullable', 'integer']; } return $rules; } public function openCreateModal() { $this->resetErrorBag(); $this->showCreateModal = true; $appLocale = app()->getLocale(); // Get all optional profiles from config $this->profileTypeOptions = collect(timebank_config('profiles')) ->map(function ($data, $key) { return [ 'name' => ucfirst($key), 'value' => 'App\Models\\'. ucfirst($key), ]; }) ->values() ->toArray(); $this->generateRandomPassword = true; // Ensure it's checked on open $this->generateAndSetPassword(); // Generate initial password $this->localeOptions = Language::all()->filter(function ($lang) { return ($lang->lang_code); })->map(function ($lang) { return [ 'lang_code' => $lang->lang_code, 'label' => $lang->flag . ' ' . trans('messages.' . $lang->name), ]; })->toArray(); $this->resetErrorBag(['country', 'division', 'city', 'district']); // Clear location errors } public function updatedCreateProfileType() { $selectedType = $this->createProfile['type'] ?? null; $optionsCollection = collect(); // Initialize empty collection if ($selectedType === \App\Models\User::class || $selectedType === \App\Models\Organization::class) { // Banks higher than level 1 are non-system banks $optionsBankCollection = Bank::where('level', '>=', 2)->get(['id', 'name', 'full_name', 'email', 'profile_photo_path']); // Make email visible if it's hidden and needed for description $optionsBankCollection->each(fn ($item) => $item->makeVisible('email')); // Same procedure for User model $optionsUserCollection = User::get(['id', 'name', 'full_name', 'email', 'profile_photo_path']); $optionsUserCollection->each(fn ($item) => $item->makeVisible('email')); // Map to a plain array structure, needed for the wireUi user-option template $this->linkBankOptions = $optionsBankCollection->map(function ($item) { return [ 'id' => $item->id, 'name' => $item->name, 'email' => $item->email, 'profile_photo_url' => $item->profile_photo_url, ]; })->toArray(); $this->linkUserOptions = $optionsUserCollection->map(function ($item) { return [ 'id' => $item->id, 'name' => $item->name, 'email' => $item->email, 'profile_photo_url' => $item->profile_photo_url, ]; })->toArray(); } elseif ($selectedType) { $optionsUserCollection = User::get(['id', 'name', 'full_name', 'email', 'profile_photo_path']); $optionsUserCollection->each(fn ($item) => $item->makeVisible('email')); $this->linkUserOptions = $optionsUserCollection->map(function ($item) { return [ 'id' => $item->id, 'name' => $item->name, 'email' => $item->email, 'profile_photo_url' => $item->profile_photo_url, ]; })->toArray(); } } public function updated($propertyName) { // Only validate createProfile.* fields when they themselves change if (str_starts_with($propertyName, 'createProfile.') && $propertyName !== 'createProfile.type') { $this->validateOnly($propertyName); } // If the 'type' field specifically was updated, handle that separately if ($propertyName === 'createProfile.type') { $this->resetErrorBag(['createProfile.name', 'createProfile.full_name']); $this->validateOnly('createProfile.name'); $this->validateOnly('createProfile.full_name'); $this->updatedCreateProfileType(); } } // Method called when checkbox state changes public function updatedGenerateRandomPassword(bool $value) { if ($value) { // Checkbox is CHECKED - Generate password $this->generateAndSetPassword(); } else { // Checkbox is UNCHECKED - Clear password fields for manual input $this->createProfile['password'] = null; $this->createProfile['password_confirmation'] = null; // Reset validation errors for password fields $this->resetErrorBag(['createProfile.password', 'createProfile.password_confirmation']); } } // Helper function to generate and set password private function generateAndSetPassword() { $password = Str::password(12, true, true, true, false); $this->generatedPlainTextPassword = $password; // Store plain text $this->createProfile['password'] = $password; // Set for validation/hashing $this->createProfile['passwordConfirmation'] = null; $this->resetErrorBag(['createProfile.password', 'createProfile.password_confirmation']); } public function emitLocationToChildren() { $this->dispatch('countryToChildren', $this->country); $this->dispatch('divisionToChildren', $this->division); $this->dispatch('cityToChildren', $this->city); $this->dispatch('districtToChildren', $this->district); } // --- Listener methods --- // When a location value changes (from child), update the property, // recalculate validation requirements, and trigger validation for that specific field. public function countryToParent($value) { $this->country = $value; if ($value) { // Look up language preference by country, if available $countryLanguage = DB::table('country_languages')->where('country_id', $this->country)->pluck('code'); count($countryLanguage) === 1 ? $this->createProfile['lang_preference'] = $countryLanguage->first() : $this->createProfile['lang_preference'] = null; } $this->setLocationValidationOptions(); $this->validateOnly('country'); // Validate country immediately // Also re-validate division/city as their requirement might change $this->validateOnly('division'); $this->validateOnly('city'); } public function divisionToParent($value) { $this->division = $value; $this->setLocationValidationOptions(); // Recalculate requirements $this->validateOnly('division'); // Validate division immediately } public function cityToParent($value) { $this->city = $value; $this->setLocationValidationOptions(); // Recalculate requirements $this->validateOnly('city'); // Validate city immediately } // District doesn't usually affect others, just validate itself public function districtToParent($value) { $this->district = $value; $this->validateOnly('district'); } // --- End Listener methods --- public function setLocationValidationOptions() { // Store previous state to check if requirements changed $oldValidateDivision = $this->validateDivision; $oldValidateCity = $this->validateCity; // Default to true, then adjust based on country data $this->validateCountry = true; // Country is always potentially required initially $this->validateDivision = true; $this->validateCity = true; if ($this->country) { $countryModel = Country::find($this->country); if ($countryModel) { $countDivisions = $countryModel->divisions()->count(); $countCities = $countryModel->cities()->count(); // Logic based on available sub-locations for the selected country if ($countDivisions > 0 && $countCities < 1) { $this->validateDivision = true; $this->validateCity = false; // City not needed if none exist for country } elseif ($countDivisions < 1 && $countCities > 0) { $this->validateDivision = false; // Division not needed if none exist $this->validateCity = true; } elseif ($countDivisions < 1 && $countCities < 1) { $this->validateDivision = false; // Neither needed if none exist $this->validateCity = false; } elseif ($countDivisions > 0 && $countCities > 0) { // Prefer City over Division if both exist $this->validateDivision = false; // Assuming city is the primary choice here $this->validateCity = true; } } else { // Invalid country selected, potentially keep validation? Or reset? // For now, keep defaults (true) as the country rule itself will fail. } } else { // No country selected, only country is required. $this->validateCountry = true; $this->validateDivision = false; $this->validateCity = false; } // --- Re-validate if requirements changed --- // If the requirement for division/city changed, re-trigger their validation // This helps clear errors if they become non-required. if ($this->validateDivision !== $oldValidateDivision) { $this->validateOnly('division'); } if ($this->validateCity !== $oldValidateCity) { $this->validateOnly('city'); } // --- End Re-validation --- } /** * Handles the save button of the create profile modal. * * @return void */ public function create() { // CRITICAL: Authorize admin access for creating profiles $this->authorizeAdminAccess(); // --- If a user, bank, admin profile will be created, determine the plain password that will be emailed --- if ($this->createProfile['type'] === \App\Models\User::class || $this->createProfile['type'] === \App\Models\Bank::class || $this->createProfile['type'] === \App\Models\Admin::class ) { if ($this->generateRandomPassword) { $this->generateAndSetPassword(); } elseif (!empty($this->createProfile['password'])) { // Capture manually entered password (after trimming) $this->generatedPlainTextPassword = trim($this->createProfile['password']); } else { // Manual mode, but password field is empty $this->generatedPlainTextPassword = null; } } else { // Not a profile type with password, ensure plain password is null $this->generatedPlainTextPassword = null; // Also nullify password fields before validation if not User $this->createProfile['password'] = null; $this->createProfile['passwordConfirmation'] = null; } // Trim password fields if they exist (important for validation) if (isset($this->createProfile['password'])) { $this->createProfile['password'] = trim($this->createProfile['password']); } if (isset($this->createProfile['passwordConfirmation'])) { $this->createProfile['passwordConfirmation'] = trim($this->createProfile['passwordConfirmation']); } // Validate all fields based on current rules $validatedData = $this->validate(); $profileData = $validatedData['createProfile']; // Get the nested profile data // Remove password confirmation if it exists unset($profileData['password_confirmation']); // Add location data to the profile data array for helper methods $profileData['country_id'] = $validatedData['country'] ?? null; $profileData['division_id'] = $validatedData['division'] ?? null; $profileData['city_id'] = $validatedData['city'] ?? null; $profileData['district_id'] = $validatedData['district'] ?? null; $newProfile = null; try { // Use a transaction for creating the new profile and related models DB::transaction(function () use ($profileData, &$newProfile) { switch ($profileData['type']) { case \App\Models\User::class: $newProfile = $this->createUserProfile($profileData); break; case \App\Models\Organization::class: $newProfile = $this->createOrganizationProfile($profileData); break; case \App\Models\Bank::class: $newProfile = $this->createBankProfile($profileData); break; case \App\Models\Admin::class: $newProfile = $this->createAdminProfile($profileData); break; default: throw new \Exception("Unknown profile type: " . $profileData['type']); } // Common logic after profile creation (if any) can go here // e.g., creating a default location if not handled in helpers if ($newProfile && !$newProfile->locations()->exists()) { $this->createDefaultLocation($newProfile, $profileData); } }); // End of transaction if ($newProfile) { // Dispatch RegisteredByAdmin event to send email confirmation / password / welcome event(new RegisteredByAdmin($newProfile, $this->generatedPlainTextPassword)); } // Success $this->notification()->success( __('Profile Created'), __('The profile has been successfully created.') ); $this->showCreateModal = false; $this->dispatch('profileCreated'); $this->resetForm(); } catch (Throwable $e) { // --- Failure --- Log::error('Profile creation failed: ' . $e->getMessage(), [ 'profile_data' => $profileData, // Log data for debugging 'exception' => $e ]); $this->notification()->error( __('Error'), // Provide a generic error message to the user __('Failed to create profile. Please check the details and try again. If the problem persists, contact support.') ); // Keep the modal open for correction } } private function createUserProfile(array $data): User { // Hash password $data['password'] = Hash::make($data['password']); // Add default values from config $data['profile_photo_path'] = timebank_config('profiles.user.profile_photo_path_new'); $data['limit_min'] = timebank_config('profiles.user.limit_min'); $data['limit_max'] = timebank_config('profiles.user.limit_max'); $data['lang_preference'] = $data['lang_preference'] ?? null; // Create the User $profile = User::create($data); // Attach to Bank if (!empty($data['linkBank'])) { $profile->attachBankClient($data['linkBank']); } // Create Account $this->createDefaultAccount($profile, 'user'); // Create Location $this->createDefaultLocation($profile, $data); // TODO: Replace commented rtippin messenger logic with wirechat logic // Attach to Messenger // Messenger::getProviderMessenger($profile); return $profile; } private function createOrganizationProfile(array $data): Organization { // Add default values from config $data['profile_photo_path'] = timebank_config('profiles.organization.profile_photo_path_new'); $data['limit_min'] = timebank_config('profiles.organization.limit_min'); $data['limit_max'] = timebank_config('profiles.organization.limit_max'); $data['lang_preference'] = $data['lang_preference'] ?? null; // Create the profile $profile = Organization::create($data); // Attach to Bank if (!empty($data['linkBank'])) { $profile->attachBankClient($data['linkBank']); } // Attach to profile manager if (!empty($data['linkUser'])) { $profile->managers()->attach($data['linkUser']); // Send email notifications to linked user about the new organization $linkedUser = User::find($data['linkUser']); if ($linkedUser) { $this->sendProfileLinkNotifications($profile, $linkedUser); } } // Create Account $this->createDefaultAccount($profile, 'organization'); // Create Location $this->createDefaultLocation($profile, $data); // TODO: Replace commented rtippin messenger logic with wirechat logic // Attach to Messenger // Messenger::getProviderMessenger($profile); return $profile; } private function createBankProfile(array $data): Bank { // Hash password $data['password'] = Hash::make($data['password']); // Add default values from config $data['profile_photo_path'] = timebank_config('profiles.bank.profile_photo_path_new'); $data['level'] = $data['level'] ?? timebank_config('profiles.bank.level', 1); $data['limit_min'] = timebank_config('profiles.bank.limit_min'); $data['limit_max'] = timebank_config('profiles.bank.limit_max'); $data['lang_preference'] = $data['lang_preference'] ?? null; // Create the profile $profile = Bank::create($data); // Attach to profile manager if (!empty($data['linkUser'])) { $profile->managers()->attach($data['linkUser']); // Send email notifications to linked user about the new bank $linkedUser = User::find($data['linkUser']); if ($linkedUser) { $this->sendProfileLinkNotifications($profile, $linkedUser); } } // Create Account $this->createDefaultAccount($profile, 'bank'); // Create debit account for level 0 (source) banks if ($profile->level === 0) { $this->createDefaultAccount($profile, 'debit'); } // Create Location $this->createDefaultLocation($profile, $data); // TODO: Replace commented rtippin messenger logic with wirechat logic // Attach to Messenger // Messenger::getProviderMessenger($profile); return $profile; } private function createAdminProfile(array $data): Admin { // Hash password $data['password'] = Hash::make($data['password']); // Add default values from config $data['profile_photo_path'] = timebank_config('profiles.admin.profile_photo_path_new'); $data['limit_min'] = timebank_config('profiles.admin.limit_min'); $data['limit_max'] = timebank_config('profiles.admin.limit_max'); $data['lang_preference'] = $data['lang_preference'] ?? null; // Create the profile $profile = Admin::create($data); // Attach to User if (!empty($data['linkUser'])) { $profile->users()->attach($data['linkUser']); $linkedUser = User::find($data['linkUser']); $linkedUser->assignRole('admin'); // Create and assign scoped Admin role (required by getCanManageProfiles()) $scopedRoleName = "Admin\\{$profile->id}\\admin"; $scopedRole = \Spatie\Permission\Models\Role::findOrCreate($scopedRoleName, 'web'); $scopedRole->syncPermissions(\Spatie\Permission\Models\Permission::all()); $linkedUser->assignRole($scopedRoleName); // Send email notifications to linked user about the new admin profile $this->sendProfileLinkNotifications($profile, $linkedUser); } // Create Location $this->createDefaultLocation($profile, $data); // TODO: Replace commented rtippin messenger logic with wirechat logic // Attach to Messenger // Messenger::getProviderMessenger($profile); return $profile; } // --- Helper function to create Default Location --- private function createDefaultLocation($profileModel, array $data): void { if (empty($data['country_id'])) { return; } // Don't create if no country $location = new Location(); $location->name = __('Default location'); $location->country_id = $data['country_id']; $location->division_id = $data['division_id'] ?? null; $location->city_id = $data['city_id'] ?? null; $location->district_id = $data['district_id'] ?? null; $profileModel->locations()->save($location); } // --- Helper function to create Default Account --- private function createDefaultAccount($profileModel, string $type): void { // Check if accounts are enabled for this type and config exists $accountConfig = timebank_config("accounts.{$type}"); if (!$accountConfig) { Log::info("Account creation skipped for type '{$type}': No config found."); return; } $account = new Account(); $account->name = __(timebank_config("accounts.{$type}.name", 'default Account')); $account->limit_min = timebank_config("accounts.{$type}.limit_min", 0); $account->limit_max = timebank_config("accounts.{$type}.limit_max", 0); // Associate account with the profile model (assuming polymorphic relation 'accounts') $profileModel->accounts()->save($account); } /** * Resets the form fields to their initial state. */ public function resetForm() { $this->showCreateModal = false; // Reset the main profile data array $this->createProfile = [ 'name' => null, 'full_name' => null, 'email' => null, 'password' => null, 'password_confirmation' => null, 'phone' => null, 'comment' => null, 'lang_preference' => null, 'type' => 'user', // Reset type back to default 'linkBank' => null, // Add linkBank if it's part of the array 'linkUser' => null, // Add linkUser if it's part of the array ]; // Reset select options and selections $this->profileTypeOptions = []; $this->profileTypeSelected = null; $this->linkBankOptions = []; $this->linkBankSelected = null; $this->linkUserOptions = []; $this->linkUserSelected = null; // Reset password generation flag $this->generateRandomPassword = true; $this->generatedPlainTextPassword = null; // Clear stored password // Reset location properties $this->country = null; $this->division = null; $this->city = null; $this->district = null; // Reset location validation flags $this->validateCountry = true; $this->validateDivision = true; $this->validateCity = true; // Clear validation errors $this->resetErrorBag(); // Re-fetch initial options if needed when resetting $this->updatedCreateProfileType(); } /** * Send profile link notification emails to both the profile and the linked user * * @param mixed $profile The newly created profile (Organization/Bank/Admin) * @param User $linkedUser The user being linked to the profile * @return void */ private function sendProfileLinkNotifications($profile, User $linkedUser): void { Log::info('ProfileLinkCreated: Sending email notifications', [ 'profile_id' => $profile->id, 'profile_type' => get_class($profile), 'profile_name' => $profile->name, 'linked_user_id' => $linkedUser->id, 'linked_user_email' => $linkedUser->email ?? 'NO EMAIL', ]); // Send email to the linked user $userMessageSetting = \App\Models\MessageSetting::where('message_settingable_id', $linkedUser->id) ->where('message_settingable_type', get_class($linkedUser)) ->first(); $sendUserEmail = $userMessageSetting ? $userMessageSetting->system_message : true; Log::info('ProfileLinkCreated: Linked user message setting check', [ 'has_message_setting' => $userMessageSetting ? 'yes' : 'no', 'system_message' => $userMessageSetting ? $userMessageSetting->system_message : 'default:true', 'will_send_email' => $sendUserEmail ? 'yes' : 'no', ]); if ($sendUserEmail) { \App\Jobs\SendProfileLinkChangedMail::dispatch($linkedUser, $profile, 'attached'); Log::info('ProfileLinkCreated: Dispatched email to linked user', [ 'recipient_email' => $linkedUser->email, 'profile_name' => $profile->name, ]); } else { Log::info('ProfileLinkCreated: Skipped email to linked user (system_message disabled)'); } // Send email to the newly created profile $profileMessageSetting = \App\Models\MessageSetting::where('message_settingable_id', $profile->id) ->where('message_settingable_type', get_class($profile)) ->first(); $sendProfileEmail = $profileMessageSetting ? $profileMessageSetting->system_message : true; Log::info('ProfileLinkCreated: Profile message setting check', [ 'has_message_setting' => $profileMessageSetting ? 'yes' : 'no', 'system_message' => $profileMessageSetting ? $profileMessageSetting->system_message : 'default:true', 'will_send_email' => $sendProfileEmail ? 'yes' : 'no', ]); if ($sendProfileEmail) { \App\Jobs\SendProfileLinkChangedMail::dispatch($profile, $linkedUser, 'attached'); Log::info('ProfileLinkCreated: Dispatched email to profile', [ 'recipient_email' => $profile->email, 'linked_user_name' => $linkedUser->name, ]); } else { Log::info('ProfileLinkCreated: Skipped email to profile (system_message disabled)'); } } }