timebank_config('rules.profile_bank.profile_photo'), 'state.about' => timebank_config('rules.profile_bank.about', 400), 'state.about_short' => timebank_config('rules.profile_bank.about_short', 150), 'state.motivation' => timebank_config('rules.profile_bank.motivation', 300), 'languages' => timebank_config('rules.profile_bank.languages', 'required'), 'languages.id' => timebank_config('rules.profile_bank.languages_id', 'int'), 'state.date_of_birth' => timebank_config('rules.profile_bank.date_of_birth', 'nullable|date'), 'website' => timebank_config('rules.profile_bank.website', 'nullable|regex:/^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/i'), ]; } /** * Prepare the component. * * @return void */ public function mount() { // --- Check roles and permissions --- // $activeProfile = getActiveProfile(); // Check if active profile is a Bank if (!($activeProfile instanceof \App\Models\Bank)) { abort(403, 'Unauthorized action.'); } // Check if web user (who owns the bank) has permission or bank manager role // Permissions are assigned to Users (web guard), not to Banks $webUser = Auth::guard('web')->user(); // User is authorized if ANY of these conditions are true: // 1. Has global "manage banks" permission (admin) // 2. Has bank manager role for this specific bank // 3. Is linked to this bank (owner/member) $authorized = ($webUser && ( $webUser->can('manage banks') || $webUser->hasRole('Bank\\' . $activeProfile->id . '\\bank-manager') || $webUser->banksManaged()->where('bank_user.bank_id', $activeProfile->id)->exists() )); if (!$authorized) { abort(403, 'Unauthorized action.'); } $this->state = Bank::find(session('activeProfileId'))->toArray(); $this->website = $this->state['website']; $this->bank = Bank::find(session('activeProfileId')); $this->getLanguages(); } /** * Get the profile photo URL for the bank * * @return string */ public function getProfilePhotoUrlProperty() { if (!$this->bank) { return ''; } // Use asset() for app-images, Storage::url() for uploaded photos if (str_starts_with($this->bank->profile_photo_path, 'app-images/')) { return asset('storage/' . $this->bank->profile_photo_path); } return url(Storage::url($this->bank->profile_photo_path)); } public function getLanguages() { // Create a language options collection that combines all language and competence options $langOptions = DB::table('languages')->get(['id','name']); $compOptions = DB::table('language_competences')->get(['id','name']); $langOptions = collect(Arr::crossJoin($langOptions, $compOptions)); $langOptions = $langOptions->Map(function ($language, $key) { return [ 'id' => $key, // index key is needed to select values in dropdown (option-value) 'langId' => $language[0]->id, 'compId' => $language[1]->id, 'name' => trans($language[0]->name) . ' - ' . trans($language[1]->name), ]; }); // Create an array of the pre-selected language options $languages = $this->bank->languages; $languages = $languages->map(function ($language, $key) use ($langOptions) { $competence = DB::table('language_competences')->find($language->pivot->competence); $langSelected = collect($langOptions)->where('name', trans($language->name) . ' - ' . trans($competence->name)); return [ $langSelected->keys() ]; }); $languages = $languages->flatten(); // Create a selected language collection that holds the selected languages with their selected competences $this->languages = collect($langOptions)->whereIn('id', $languages)->values(); } public function languagesToParent($values) { $this->languages = $values; $this->validateOnly('languages'); } /** * Validate a single field when updated. * This is the 1st validation method on this form. * * @param mixed $field * @return void */ public function updated($field) { if ($field == 'website') { // If website is not empty, add URL scheme if (!empty($this->website)) { $this->website = $this->addUrlScheme($this->website); } else { // If website is empty, remove 'https://' prefix $this->website = str_replace('https://', '', $this->website); } } $this->validateOnly($field); } /** * Update the bank's profile contact information. * * @return void */ public function updateProfilePersonalForm() { $bank = getActiveProfile(); // CRITICAL SECURITY: Validate user has ownership/access to this profile \App\Helpers\ProfileAuthorizationHelper::authorize($bank); if (isset($this->photo)) { $bank->updateProfilePhoto($this->photo); // Trait (use HasProfilePhoto) needs to attached to Bank model for this to work } $this->validate(); // 2nd validation, just before save method $bank->about = $this->state['about']; $bank->about_short = $this->state['about_short']; $bank->motivation = $this->state['motivation']; $bank->website = str_replace(['http://', 'https://', ], '', $this->website); if (isset($this->languages)) { $languages = collect($this->languages)->Map(function ($lang, $key) use ($bank) { return [ 'language_id' => $lang['langId'], 'competence' => $lang['compId'], 'languagable_type' => Bank::class, 'languagable_id' => $bank->id, ]; })->toArray(); $bank->languages()->detach(); // Remove all languages of this bank before inserting the new ones DB::table('languagables')->insert($languages); } $bank->save(); $this->dispatch('saved'); session(['activeProfilePhoto' => $bank->profile_photo_path ]); redirect()->route('profile.edit'); } /** * Delete the bank's profile photo. * * @return void */ public function deleteProfilePhoto() { $bank = getActiveProfile(); // CRITICAL SECURITY: Validate user has ownership/access to this profile \App\Helpers\ProfileAuthorizationHelper::authorize($bank); if (! Features::managesProfilePhotos()) { return; } if (is_null($bank->profile_photo_path)) { return; } $defaultPath = timebank_config('profiles.bank.profile_photo_path_default'); // Delete uploaded photos (profile-photos/) and reset to default if (str_starts_with($bank->profile_photo_path, 'profile-photos/')) { Storage::disk(isset($_ENV['VAPOR_ARTIFACT_NAME']) ? 's3' : config('jetstream.profile_photo_disk', 'public'))->delete($bank->profile_photo_path); $bank->forceFill([ 'profile_photo_path' => $defaultPath, ])->save(); Session(['activeProfilePhoto'=> $bank->profile_photo_path ]); } // If current path is app-images but not the correct default, update it elseif (str_starts_with($bank->profile_photo_path, 'app-images/') && $bank->profile_photo_path !== $defaultPath) { $bank->forceFill([ 'profile_photo_path' => $defaultPath, ])->save(); Session(['activeProfilePhoto'=> $bank->profile_photo_path ]); } $this->dispatch('saved'); redirect()->route('profile.edit'); } public function addUrlScheme($url, $scheme = 'https://') { return parse_url($url, PHP_URL_SCHEME) === null ? $scheme . $url : $url; } /** * Gets the label for the "about_short" input field, including a character counter if applicable. * The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`. * * @return string The label for the "about" field, optionally including the remaining character count. */ public function getAboutLabelProperty() { $maxInput = timebank_config('rules.profile_bank.about_max_input'); $baseLabel = __('Introduce your bank in a few sentences'); $counter = $this->characterLeftCounter($this->state['about'] ?? '', $maxInput); return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel; } /** * Gets the label for the "about_short" input field, including a character counter if applicable. * The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`. * * @return string The label for the "about_short" field, optionally including the remaining character count. */ public function getAboutShortLabelProperty() { $maxInput = timebank_config('rules.profile_bank.about_short_max_input'); $baseLabel = __('Introduction in one sentence'); $counter = $this->characterLeftCounter($this->state['about_short'] ?? '', $maxInput); return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel; } /** * Gets the label for the "motivation" input field, including a character counter if applicable. * The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`. * * @return string The label for the "about_short" field, optionally including the remaining character count. */ public function getMotivationLabelProperty() { $maxInput = timebank_config('rules.profile_bank.motivation_max_input'); $baseLabel = __('What is your motivation to start a ' . platform_name_short() . '?'); $counter = $this->characterLeftCounter($this->state['motivation'] ?? '', $maxInput); return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel; } public function render() { return view('livewire.profile-bank.update-profile-bank-form'); } }