Initial commit

This commit is contained in:
Ronald Huynen
2026-03-23 21:37:59 +01:00
commit 2547717edb
2193 changed files with 972171 additions and 0 deletions

View File

@@ -0,0 +1,326 @@
<?php
namespace App\Http\Livewire\ProfileOrganization;
use App\Models\Organization;
use App\Traits\FormHelpersTrait;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Laravel\Jetstream\Features;
use Laravel\Jetstream\HasProfilePhoto;
use Livewire\Component;
use Livewire\WithFileUploads;
class UpdateProfileOrganizationForm extends Component
{
use WithFileUploads;
use HasProfilePhoto;
use FormHelpersTrait;
public $state = [];
public $organization;
public $photo;
public $languages;
public $website;
// Important
// This component is only used by the Organization model, so the user must be an organization manager or have the manage organizations permission.
// Authorization is handled in mount() method instead of middleware to support multi-guard system
// protected $middleware = [
// 'can:manage organizations',
// ];
protected $listeners = ['languagesToParent'];
public function rules()
{
return [
'photo' => timebank_config('rules.profile_organization.profile_photo'),
'state.about' => timebank_config('rules.profile_organization.about', 400),
'state.about_short' => timebank_config('rules.profile_organization.about_short', 150),
'state.motivation' => timebank_config('rules.profile_organization.motivation', 300),
'languages' => timebank_config('rules.profile_organization.languages', 'required'),
'languages.id' => timebank_config('rules.profile_organization.languages_id', 'int'),
'state.date_of_birth' => timebank_config('rules.profile_organization.date_of_birth', 'nullable|date'),
'website' => timebank_config('rules.profile_organization.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 an Organization
if (!($activeProfile instanceof \App\Models\Organization)) {
abort(403, 'Unauthorized action.');
}
// Check if web user (who owns the organization) has permission or organization manager role
// Permissions are assigned to Users (web guard), not to Organizations
$webUser = Auth::guard('web')->user();
// User is authorized if ANY of these conditions are true:
// 1. Has global "manage organizations" permission (admin)
// 2. Has organization manager role for this specific organization
// 3. Is linked to this organization (owner/member)
$authorized = ($webUser && (
$webUser->can('manage organizations') ||
$webUser->hasRole('Organization\\' . $activeProfile->id . '\\organization-manager') ||
$webUser->organizations()->where('organization_user.organization_id', $activeProfile->id)->exists()
));
if (!$authorized) {
abort(403, 'Unauthorized action.');
}
$this->state = Organization::find(session('activeProfileId'))->toArray();
$this->website = $this->state['website'];
$this->organization = Organization::find(session('activeProfileId'));
$this->getLanguages();
}
/**
* Get the profile photo URL for the organization
*
* @return string
*/
public function getProfilePhotoUrlProperty()
{
if (!$this->organization) {
return '';
}
// Use asset() for app-images, Storage::url() for uploaded photos
if (str_starts_with($this->organization->profile_photo_path, 'app-images/')) {
return asset('storage/' . $this->organization->profile_photo_path);
}
return url(Storage::url($this->organization->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->organization->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 organization's profile contact information.
*
* @return void
*/
public function updateProfilePersonalForm()
{
$org = getActiveProfile();
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($org);
if (isset($this->photo)) {
$org->updateProfilePhoto($this->photo); // Trait (use HasProfilePhoto) needs to attached to Organization model for this to work
}
$this->validate(); // 2nd validation, just before save method
$org->about = $this->state['about'];
$org->about_short = $this->state['about_short'];
$org->motivation = $this->state['motivation'];
$org->website = str_replace(['http://', 'https://', ], '', $this->website);
if (isset($this->languages)) {
$languages = collect($this->languages)->Map(function ($lang, $key) use ($org) {
return [
'language_id' => $lang['langId'],
'competence' => $lang['compId'],
'languagable_type' => Organization::class,
'languagable_id' => $org->id,
];
})->toArray();
$org->languages()->detach(); // Remove all languages of this organization before inserting the new ones
DB::table('languagables')->insert($languages);
}
$org->save();
$this->dispatch('saved');
session(['activeProfilePhoto' => $org->profile_photo_path ]);
redirect()->route('profile.edit');
}
/**
* Delete organization's profile photo.
*
* @return void
*/
public function deleteProfilePhoto()
{
$org = getActiveProfile();
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($org);
if (! Features::managesProfilePhotos()) {
return;
}
if (is_null($org->profile_photo_path)) {
return;
}
$defaultPath = timebank_config('profiles.organization.profile_photo_path_default');
// Delete uploaded photos (profile-photos/) and reset to default
if (str_starts_with($org->profile_photo_path, 'profile-photos/')) {
Storage::disk(isset($_ENV['VAPOR_ARTIFACT_NAME']) ? 's3' : config('jetstream.profile_photo_disk', 'public'))->delete($org->profile_photo_path);
$org->forceFill([
'profile_photo_path' => $defaultPath,
])->save();
Session(['activeProfilePhoto'=> $org->profile_photo_path ]);
}
// If current path is app-images but not the correct default, update it
elseif (str_starts_with($org->profile_photo_path, 'app-images/') && $org->profile_photo_path !== $defaultPath) {
$org->forceFill([
'profile_photo_path' => $defaultPath,
])->save();
Session(['activeProfilePhoto'=> $org->profile_photo_path ]);
}
$this->dispatch('saved');
return 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_organization.about_max_input');
$baseLabel = __('Introduce your organization 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_organization.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_organization.motivation_max_input');
$baseLabel = __('Why is your organization using ' . platform_name_short() . '?');
$counter = $this->characterLeftCounter($this->state['motivation'] ?? '', $maxInput);
return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel;
}
public function render()
{
return view('livewire.profile-organization.update-profile-organization-form');
}
}