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,452 @@
<?php
namespace App\Http\Livewire\Profile;
use App\Mail\UserDeletedMail;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\DeletesUsers;
use Livewire\Component;
use WireUi\Traits\WireUiActions;
class DeleteUserForm extends Component
{
use WireUiActions;
/**
* Indicates if user deletion is being confirmed.
*
* @var bool
*/
public $confirmingUserDeletion = false;
/**
* The user's current password.
*
* @var string
*/
public $password = '';
/**
* Balance handling option selected by user.
*
* @var string
*/
public $balanceHandlingOption = 'delete';
/**
* Selected organization account ID for balance donation.
*
* @var int|null
*/
public $donationAccountId = null;
/**
* User's accounts with balances.
*
* @var \Illuminate\Support\Collection
*/
public $accounts;
/**
* Total balance across all accounts.
*
* @var float|int
*/
public $totalBalance = 0;
/**
* Whether any account has a negative balance.
*
* @var bool
*/
public $hasNegativeBalance = false;
/**
* Whether the profile is a central bank (level = 0).
*
* @var bool
*/
public $isCentralBank = false;
/**
* Whether the profile is the final admin.
*
* @var bool
*/
public $isFinalAdmin = false;
/**
* Whether the donation would exceed the receiving account's limit.
*
* @var bool
*/
public $donationExceedsLimit = false;
/**
* Error message for donation limit exceeded.
*
* @var string|null
*/
public $donationLimitError = null;
/**
* Listener to receive toAccountId from ToAccount component.
*/
protected $listeners = ['toAccountId' => 'setDonationAccountId'];
/**
* Called when balanceHandlingOption is updated.
*
* @return void
*/
public function updatedBalanceHandlingOption()
{
$this->checkDonationLimits();
}
/**
* Set the donation account ID from ToAccount component.
*
* @param int|null $accountId
* @return void
*/
public function setDonationAccountId($accountId)
{
$this->donationAccountId = $accountId;
$this->checkDonationLimits();
}
/**
* Check if the donation would exceed the receiving account's limits.
*
* @return void
*/
protected function checkDonationLimits()
{
// Reset error state
$this->donationExceedsLimit = false;
$this->donationLimitError = null;
// If no donation account selected or no balance to donate, skip check
if (!$this->donationAccountId || $this->totalBalance <= 0) {
return;
}
// Get the donation account
$donationAccount = \App\Models\Account::find($this->donationAccountId);
if (!$donationAccount) {
return;
}
// Clear cache for fresh balance
\Cache::forget("account_balance_{$donationAccount->id}");
// Get current balance of the receiving account
$currentBalance = $donationAccount->balance;
// Calculate the maximum receivable amount
// limitMaxTo = limit_max - limit_min (similar to Pay.php logic)
$limitMaxTo = $donationAccount->limit_max - $donationAccount->limit_min;
// Calculate available budget for receiving
$transferBudgetTo = $limitMaxTo - $currentBalance;
// Check if donation amount exceeds the receiving account's budget
if ($this->totalBalance > $transferBudgetTo) {
$this->donationExceedsLimit = true;
// Check if the receiving account holder's balance is public
$holderType = $donationAccount->accountable_type;
$balancePublic = timebank_config('account_info.' . strtolower(class_basename($holderType)) . '.balance_public', false);
if ($balancePublic) {
$this->donationLimitError = __('The selected account cannot receive this donation amount due to account limits. Please select a different account or delete your balance instead.', [
'amount' => tbFormat($transferBudgetTo)
]);
} else {
$this->donationLimitError = __('The selected organization account cannot receive this donation amount due to account limits. Please select a different organization or delete your balance instead.');
}
}
}
/**
* Confirm that the user would like to delete their account.
*
* @return void
*/
public function confirmUserDeletion()
{
$this->resetErrorBag();
$this->password = '';
$this->dispatch('confirming-delete-user');
$this->confirmingUserDeletion = true;
}
/**
* Delete the current user.
*
* @param \Illuminate\Http\Request $request
* @param \Laravel\Jetstream\Contracts\DeletesUsers $deleter
* @param \Illuminate\Contracts\Auth\StatefulGuard $auth
* @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse
*/
public function deleteUser(Request $request, DeletesUsers $deleter, StatefulGuard $auth)
{
$this->resetErrorBag();
// Get the active profile using helper
$profile = getActiveProfile();
if (!$profile) {
throw new \Exception('No active profile found.');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
// This prevents IDOR (Insecure Direct Object Reference) attacks where
// a user manipulates session data to delete profiles they don't own
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
// Check if trying to delete a central bank
if ($profile instanceof \App\Models\Bank && isset($profile->level) && $profile->level == 0) {
throw ValidationException::withMessages([
'password' => [__('Central bank (level 0) cannot be deleted. Central banks are essential for currency creation and management.')],
]);
}
// Check if trying to delete the final admin
if ($profile instanceof \App\Models\Admin) {
$activeAdminCount = \App\Models\Admin::whereNull('deleted_at')->count();
if ($activeAdminCount <= 1) {
throw ValidationException::withMessages([
'password' => [__('Final administrator cannot be deleted. At least one administrator account must remain active in the system.')],
]);
}
}
// Determine which password to check based on profile type
if ($profile instanceof \App\Models\User || $profile instanceof \App\Models\Organization) {
// User or Organization: validate against base user password
// Organizations don't have their own password (passwordless)
$authenticatedUser = Auth::user();
if (! Hash::check($this->password, $authenticatedUser->password)) {
throw ValidationException::withMessages([
'password' => [__('This password does not match our records.')],
]);
}
} elseif ($profile instanceof \App\Models\Bank || $profile instanceof \App\Models\Admin) {
// Bank or Admin: validate against their own password
if (! Hash::check($this->password, $profile->password)) {
throw ValidationException::withMessages([
'password' => [__('This password does not match our records.')],
]);
}
} else {
throw new \Exception('Unknown profile type.');
}
// Validate balance handling option if donation is selected
if ($this->balanceHandlingOption === 'donate' && !$this->donationAccountId) {
throw ValidationException::withMessages([
'donationAccountId' => [__('Please select an organization account to donate your balance to.')],
]);
}
// Check if donation would exceed limits
if ($this->balanceHandlingOption === 'donate' && $this->donationExceedsLimit) {
throw ValidationException::withMessages([
'donationAccountId' => [$this->donationLimitError ?? __('The selected organization account cannot receive this donation amount.')],
]);
}
// Determine table name based on profile type
$profileTable = $profile->getTable();
// Get the profile's updated_at timestamp
$time = DB::table($profileTable)
->where('id', $profile->id)
->pluck('updated_at')
->first();
$time = Carbon::parse($time); // Convert the time to a Carbon instance
// Pass balance handling options to the deleter
$result = $deleter->delete(
$profile->fresh(),
$this->balanceHandlingOption,
$this->donationAccountId
);
$this->confirmingUserDeletion = false;
if ($result['status'] === 'success') {
$result['time'] = $time->translatedFormat('j F Y, H:i');
$result['deletedUser'] = $profile;
$result['mail'] = $profile->email;
$result['balanceHandlingOption'] = $this->balanceHandlingOption;
$result['totalBalance'] = $this->totalBalance;
$result['donationAccountId'] = $this->donationAccountId;
$result['gracePeriodDays'] = timebank_config('delete_profile.grace_period_days', 30);
// Get donation account details if donated
if ($this->balanceHandlingOption === 'donate' && $this->donationAccountId) {
$donationAccount = \App\Models\Account::find($this->donationAccountId);
if ($donationAccount && $donationAccount->accountable) {
$result['donationAccountName'] = $donationAccount->name;
$result['donationOrganizationName'] = $donationAccount->accountable->name;
}
}
Log::notice('Profile deleted: ' . $result['deletedUser']);
Mail::to($profile->email)->queue(new UserDeletedMail($result));
// Handle logout based on profile type
if ($profile instanceof \App\Models\User) {
// User deletion: logout completely from all guards
$auth->logout();
session()->invalidate();
session()->regenerateToken();
// Re-flash the result data after session invalidation
session()->flash('result', $result);
} else {
// Flash result for non-user profiles
session()->flash('result', $result);
// Non-user profile deletion (Organization/Bank/Admin):
// Only logout from the specific guard and switch back to base user
$profileType = strtolower(class_basename($profile));
if ($profileType === 'organization') {
Auth::guard('organization')->logout();
} elseif ($profileType === 'bank') {
Auth::guard('bank')->logout();
} elseif ($profileType === 'admin') {
Auth::guard('admin')->logout();
}
// Switch back to base user profile
$baseUser = Auth::guard('web')->user();
if ($baseUser) {
session(['activeProfileType' => 'App\\Models\\User']);
session(['activeProfileId' => $baseUser->id]);
session(['activeProfileName' => $baseUser->name]);
session(['activeProfilePhoto' => $baseUser->profile_photo_path]);
session(['active_guard' => 'web']);
}
}
return redirect()->route('goodbye-deleted-user');
} else {
// Trigger WireUi error notification
$this->notification()->error(
$title = __('Deletion Failed'),
$description = __('There was an error deleting your profile: ') . $result['message']
);
Log::warning('Profile deletion failed for profile ID: ' . $profile->id . ' (Type: ' . get_class($profile) . ')');
Log::error('Error message: ' . $result['message']);
return redirect()->back();
}
}
/**
* Load user accounts and calculate balances.
*
* @return void
*/
public function mount()
{
$this->loadAccounts();
}
/**
* Load and calculate account balances.
*
* @return void
*/
public function loadAccounts()
{
// Get the active profile using helper (could be User, Organization, Bank, etc.)
$profile = getActiveProfile();
// Check if profile is a central bank (level = 0)
if ($profile instanceof \App\Models\Bank && isset($profile->level) && $profile->level == 0) {
$this->isCentralBank = true;
}
// Check if profile is the final admin
if ($profile instanceof \App\Models\Admin) {
$activeAdminCount = \App\Models\Admin::whereNull('deleted_at')->count();
if ($activeAdminCount <= 1) {
$this->isFinalAdmin = true;
}
}
// Check if profile exists and has accounts method
if (!$profile || !method_exists($profile, 'accounts')) {
$this->accounts = collect();
$this->totalBalance = 0;
$this->hasNegativeBalance = false;
return;
}
// Get all active, non-removed accounts
$userAccounts = $profile->accounts()
->active()
->notRemoved()
->get();
// Clear cache and calculate balances
$this->accounts = collect();
$this->totalBalance = 0;
$this->hasNegativeBalance = false;
foreach ($userAccounts as $account) {
// Clear cache for fresh balance
Cache::forget("account_balance_{$account->id}");
$balance = $account->balance;
$this->accounts->push([
'id' => $account->id,
'name' => __('messages.' . $account->name . '_account'),
'balance' => $balance,
'balanceFormatted' => tbFormat($balance),
]);
$this->totalBalance += $balance;
if ($balance < 0) {
$this->hasNegativeBalance = true;
}
}
}
/**
* Render the component.
*
* @return \Illuminate\View\View
*/
public function render()
{
$showBalanceOptions = timebank_config('delete_profile.account_balances.donate_balances_to_organization_account_specified', false);
return view('profile.delete-user-form', [
'showBalanceOptions' => $showBalanceOptions,
]);
}
}

View File

@@ -0,0 +1,897 @@
<?php
namespace App\Http\Livewire\Profile;
use App\Exports\ProfileContactsExport;
use App\Exports\ProfileDataExport;
use App\Exports\ProfileMessagesExport;
use App\Exports\ProfileTagsExport;
use App\Exports\ProfileTransactionsExport;
use App\Helpers\ProfileAuthorizationHelper;
use App\Models\Account;
use App\Models\Tag;
use App\Models\Transaction;
use App\Models\User;
use App\Models\Organization;
use App\Models\Bank;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Livewire\Component;
use Namu\WireChat\Enums\ConversationType;
use Namu\WireChat\Models\Conversation;
use Namu\WireChat\Models\Message;
use Namu\WireChat\Models\Participant;
class ExportProfileData extends Component
{
/**
* Export all transactions from all accounts associated with the profile
*/
public function exportTransactions($type)
{
set_time_limit(0);
// Get active profile
$profileType = session('activeProfileType');
$profileId = session('activeProfileId');
$profile = $profileType::find($profileId);
if (!$profile) {
session()->flash('error', __('Profile not found'));
return;
}
// Validate authorization - ensure authenticated user owns/manages this profile
ProfileAuthorizationHelper::authorize($profile);
// Validate export type
if (!in_array($type, ['xlsx', 'ods', 'csv', 'json'])) {
session()->flash('error', __('Invalid export format'));
return;
}
// Get all account IDs for this profile
$accountIds = $profile->accounts()->pluck('id')->toArray();
if (empty($accountIds)) {
session()->flash('error', __('No accounts found for this profile'));
return;
}
// Get all transactions from all accounts
$transactions = Transaction::with([
'accountTo.accountable:id,name,full_name,profile_photo_path',
'accountFrom.accountable:id,name,full_name,profile_photo_path',
'transactionType:id,name'
])
->where(function ($query) use ($accountIds) {
$query->whereIn('to_account_id', $accountIds)
->orWhereIn('from_account_id', $accountIds);
})
->orderBy('created_at', 'desc')
->get();
// Transform data for export
$data = $transactions->map(function ($transaction) use ($accountIds) {
// Determine if this is debit or credit for this profile
$isDebit = in_array($transaction->from_account_id, $accountIds);
// Get the account and counter account
$account = $isDebit ? $transaction->accountFrom : $transaction->accountTo;
$counterAccount = $isDebit ? $transaction->accountTo : $transaction->accountFrom;
// Get relation (the other party)
$relation = $counterAccount->accountable;
return [
'trans_id' => $transaction->id,
'datetime' => $transaction->created_at->format('Y-m-d H:i:s'),
'amount' => $transaction->amount,
'c/d' => $isDebit ? 'Debit' : 'Credit',
'account_id' => $account->id,
'account_name' => $account->name,
'account_counter_id' => $counterAccount->id,
'account_counter_name' => $counterAccount->name,
'relation' => $relation->name ?? '',
'relation_full_name' => $relation->full_name ?? $relation->name ?? '',
'type' => $transaction->transactionType->name ?? '',
'description' => $transaction->description ?? '',
];
});
$this->dispatch('saved');
// Handle JSON export differently
if ($type === 'json') {
$fileName = 'profile-transactions.json';
// Transform data for JSON export
$jsonData = $data->map(function ($item) {
// Rename amount to amount_minutes and add amount_hours
$amountMinutes = $item['amount'];
unset($item['amount']);
$item['amount_minutes'] = $amountMinutes;
$item['amount_hours'] = round($amountMinutes / 60, 4);
return $item;
})->toArray();
$json = json_encode($jsonData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
return response()->streamDownload(function () use ($json) {
echo $json;
}, $fileName, [
'Content-Type' => 'application/json',
]);
}
return (new ProfileTransactionsExport($data))->download('profile-transactions.' . $type);
}
/**
* Export profile data
*/
public function exportProfileData($type)
{
// Get active profile
$profileType = session('activeProfileType');
$profileId = session('activeProfileId');
$profile = $profileType::find($profileId);
if (!$profile) {
session()->flash('error', __('Profile not found'));
return;
}
// Validate authorization - ensure authenticated user owns/manages this profile
ProfileAuthorizationHelper::authorize($profile);
// Validate export type
if (!in_array($type, ['xlsx', 'ods', 'csv', 'json'])) {
session()->flash('error', __('Invalid export format'));
return;
}
// Get location string if available
$location = '';
if ($profile->location) {
$locationParts = [];
if ($profile->location->district) {
$locationParts[] = $profile->location->district->name;
}
if ($profile->location->city) {
$locationParts[] = $profile->location->city->name;
}
if ($profile->location->division) {
$locationParts[] = $profile->location->division->name;
}
if ($profile->location->country) {
$locationParts[] = $profile->location->country->name;
}
$location = implode(', ', $locationParts);
}
// Get location first name
$locationFirstName = '';
$locationFirst = $profile->getLocationFirst();
if ($locationFirst) {
$locationFirstName = $locationFirst['name'] ?? '';
}
// Get social media accounts formatted as comma-separated string
$socials = [];
if ($profile->socials) {
foreach ($profile->socials as $social) {
$isBlueSky = $social->id == 3;
$isFullUrl = str_starts_with($social->pivot->user_on_social, 'https://');
if ($isBlueSky) {
$socials[] = '@' . $social->pivot->user_on_social;
} elseif ($isFullUrl) {
$socials[] = $social->pivot->user_on_social;
} elseif ($social->pivot->server_of_social) {
$socials[] = '@' . $social->pivot->user_on_social . '@' . $social->pivot->server_of_social;
} else {
$socials[] = '@' . $social->pivot->user_on_social;
}
}
}
$socialsString = implode("\n", $socials);
// Transform profile data to array
$data = collect([[
'name' => $profile->name,
'full_name' => $profile->full_name ?? '',
'email' => $profile->email ?? '',
'about' => strip_tags($profile->about ?? ''),
'about_short' => $profile->about_short ?? '',
'motivation' => strip_tags($profile->motivation ?? ''),
'website' => $profile->website ?? '',
'phone' => $profile->phone ?? '',
'phone_public' => $profile->phone_public ?? false,
'location' => $location,
'location_first' => $locationFirstName,
'social_media' => $socialsString,
'profile_photo_path' => $profile->profile_photo_path ?? '',
'lang_preference' => $profile->lang_preference ?? '',
'created_at' => $profile->created_at ? (is_object($profile->created_at) ? $profile->created_at->format('Y-m-d H:i:s') : $profile->created_at) : '',
'updated_at' => $profile->updated_at ? (is_object($profile->updated_at) ? $profile->updated_at->format('Y-m-d H:i:s') : $profile->updated_at) : '',
'last_login_at' => $profile->last_login_at ? (is_object($profile->last_login_at) ? $profile->last_login_at->format('Y-m-d H:i:s') : $profile->last_login_at) : '',
]]);
$profileTypeName = strtolower(class_basename($profileType));
$this->dispatch('saved');
// Handle JSON export differently
if ($type === 'json') {
$fileName = 'profile-data.json';
// Transform data for JSON export
$jsonData = $data->map(function ($item) {
// Remove location key
unset($item['location']);
// Replace newlines with commas in social_media
if (isset($item['social_media'])) {
$item['social_media'] = str_replace("\n", ', ', $item['social_media']);
}
// Reorder keys: put phone_visible_for_platform_users right after phone
$ordered = [];
foreach ($item as $key => $value) {
$ordered[$key] = $value;
if ($key === 'phone') {
// Rename and insert phone_public right after phone
$ordered['phone_visible_for_platform_users'] = $item['phone_public'];
}
}
// Remove the old phone_public key
unset($ordered['phone_public']);
return $ordered;
})->toArray();
$json = json_encode($jsonData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
return response()->streamDownload(function () use ($json) {
echo $json;
}, $fileName, [
'Content-Type' => 'application/json',
]);
}
return (new ProfileDataExport($data, $profileTypeName))->download('profile-data.' . $type);
}
/**
* Export all messages from conversations the profile participated in
*/
public function exportMessages($type)
{
set_time_limit(0);
// Get active profile
$profileType = session('activeProfileType');
$profileId = session('activeProfileId');
$profile = $profileType::find($profileId);
if (!$profile) {
session()->flash('error', __('Profile not found'));
return;
}
// Validate authorization - ensure authenticated user owns/manages this profile
ProfileAuthorizationHelper::authorize($profile);
// Validate export type
if (!in_array($type, ['xlsx', 'ods', 'csv', 'json'])) {
session()->flash('error', __('Invalid export format'));
return;
}
// Get all conversation IDs where the profile is a participant
$conversationIds = Participant::where('participantable_type', $profileType)
->where('participantable_id', $profileId)
->pluck('conversation_id');
if ($conversationIds->isEmpty()) {
session()->flash('error', __('No conversations found for this profile'));
return;
}
// Get all messages from those conversations with sender information
$messages = Message::with([
'conversation:id,type',
'sendable:id,name,full_name' // Load sender information
])
->whereIn('conversation_id', $conversationIds)
->orderBy('conversation_id', 'asc')
->orderBy('created_at', 'asc')
->get();
// Transform data for export
$data = $messages->map(function ($message) {
$conversationType = '';
if ($message->conversation && $message->conversation->type) {
$conversationType = is_object($message->conversation->type)
? $message->conversation->type->value
: $message->conversation->type;
}
// Get sender information
$senderName = '';
$senderType = '';
if ($message->sendable) {
$senderName = $message->sendable->full_name ?? $message->sendable->name ?? '';
$senderType = class_basename($message->sendable_type);
}
return [
'conversation_id' => $message->conversation_id,
'conversation_type' => $conversationType,
'id' => $message->id,
'created_at' => $message->created_at ? (is_object($message->created_at) ? $message->created_at->format('Y-m-d H:i:s') : $message->created_at) : '',
'sender_name' => $senderName,
'sender_type' => $senderType,
'body' => $message->body ?? '',
'reply_id' => $message->reply_id ?? '',
];
});
$this->dispatch('saved');
// Handle JSON export differently
if ($type === 'json') {
$fileName = 'profile-messages.json';
$json = json_encode($data->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
return response()->streamDownload(function () use ($json) {
echo $json;
}, $fileName, [
'Content-Type' => 'application/json',
]);
}
return (new ProfileMessagesExport($data))->download('profile-messages.' . $type);
}
/**
* Export all tags (skills) associated with the profile
* Tags are exported in the profile's language preference or fallback locale
*/
public function exportTags($type)
{
// Get active profile
$profileType = session('activeProfileType');
$profileId = session('activeProfileId');
$profile = $profileType::find($profileId);
if (!$profile) {
session()->flash('error', __('Profile not found'));
return;
}
// Validate authorization - ensure authenticated user owns/manages this profile
ProfileAuthorizationHelper::authorize($profile);
// Validate export type
if (!in_array($type, ['xlsx', 'ods', 'csv', 'json'])) {
session()->flash('error', __('Invalid export format'));
return;
}
// Determine which locale to use for tag translation
$locale = $profile->lang_preference ?? App::getLocale();
$fallbackLocale = App::getFallbackLocale();
// Get all tag IDs from the profile
$tagIds = $profile->tags->pluck('tag_id');
if ($tagIds->isEmpty()) {
session()->flash('error', __('No tags found for this profile'));
return;
}
// Translate tags to the profile's language preference (or fallback)
$translatedTags = collect((new Tag())->translateTagIdsWithContexts($tagIds));
// Transform data for export
$data = $translatedTags->map(function ($tag) {
return [
'tag_id' => $tag['tag_id'] ?? '',
'tag' => $tag['tag'] ?? '',
'category' => $tag['category'] ?? '',
'category_path' => $tag['category_path'] ?? '',
'locale' => $tag['locale'] ?? '',
];
});
$this->dispatch('saved');
// Handle JSON export differently
if ($type === 'json') {
$fileName = 'profile-tags.json';
$json = json_encode($data->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
return response()->streamDownload(function () use ($json) {
echo $json;
}, $fileName, [
'Content-Type' => 'application/json',
]);
}
return (new ProfileTagsExport($data))->download('profile-tags.' . $type);
}
/**
* Export all contacts associated with the profile
*/
public function exportContacts($type)
{
set_time_limit(0);
// Get active profile
$profileType = session('activeProfileType');
$profileId = session('activeProfileId');
$profile = $profileType::find($profileId);
if (!$profile) {
session()->flash('error', __('Profile not found'));
return;
}
// Validate authorization - ensure authenticated user owns/manages this profile
ProfileAuthorizationHelper::authorize($profile);
// Validate export type
if (!in_array($type, ['xlsx', 'ods', 'csv', 'json'])) {
session()->flash('error', __('Invalid export format'));
return;
}
// Initialize contacts collection
$contactsData = collect();
// Get the reacter_id and reactant_id for the active profile
$reacterId = $profile->love_reacter_id;
$reactantId = $profile->love_reactant_id;
// 1. Get profiles the active profile has reacted to (stars, bookmarks)
if ($reacterId) {
$reactedProfiles = $this->getReactedProfilesForExport($reacterId);
$contactsData = $contactsData->merge($reactedProfiles);
}
// 2. Get profiles that have transacted with the active profile
$transactionProfiles = $this->getTransactionProfilesForExport($profile);
$contactsData = $contactsData->merge($transactionProfiles);
// 3. Get profiles from private WireChat conversations
$conversationProfiles = $this->getConversationProfilesForExport($profile);
$contactsData = $contactsData->merge($conversationProfiles);
// Group by profile and merge interaction data
$contacts = $contactsData->groupBy('profile_key')->map(function ($group) {
$first = $group->first();
return [
'profile_id' => $first['profile_id'],
'profile_type' => $first['profile_type'],
'profile_type_name' => $first['profile_type_name'],
'name' => $first['name'],
'full_name' => $first['full_name'],
'location' => $first['location'],
'profile_photo' => $first['profile_photo'],
'has_star' => $group->contains('interaction_type', 'star'),
'has_bookmark' => $group->contains('interaction_type', 'bookmark'),
'has_transaction' => $group->contains('interaction_type', 'transaction'),
'has_conversation' => $group->contains('interaction_type', 'conversation'),
'last_interaction' => $group->max('last_interaction'),
'star_count' => $group->where('interaction_type', 'star')->sum('count'),
'bookmark_count' => $group->where('interaction_type', 'bookmark')->sum('count'),
'transaction_count' => $group->where('interaction_type', 'transaction')->sum('count'),
'message_count' => $group->where('interaction_type', 'conversation')->sum('count'),
];
})->values();
// Sort by last interaction (most recent first)
$contacts = $contacts->sortByDesc('last_interaction')->values();
$this->dispatch('saved');
// Handle JSON export differently
if ($type === 'json') {
$fileName = 'profile-contacts.json';
$json = json_encode($contacts->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
return response()->streamDownload(function () use ($json) {
echo $json;
}, $fileName, [
'Content-Type' => 'application/json',
]);
}
return (new ProfileContactsExport($contacts))->download('profile-contacts.' . $type);
}
/**
* Get profiles the active profile has reacted to.
*/
private function getReactedProfilesForExport($reacterId)
{
// Get all reactions by this reacter, grouped by reactant type
$reactions = DB::table('love_reactions')
->join('love_reactants', 'love_reactions.reactant_id', '=', 'love_reactants.id')
->where('love_reactions.reacter_id', $reacterId)
->select(
'love_reactants.type as reactant_type',
DB::raw('CAST(SUBSTRING_INDEX(love_reactants.type, "\\\", -1) AS CHAR) as reactant_model')
)
->groupBy('love_reactants.type')
->get();
$profiles = collect();
foreach ($reactions as $reaction) {
// Only process User, Organization, and Bank models
if (!in_array($reaction->reactant_model, ['User', 'Organization', 'Bank'])) {
continue;
}
$modelClass = "App\\Models\\{$reaction->reactant_model}";
// Get all profiles of this type that were reacted to, with reaction type breakdown
$reactedToProfiles = DB::table('love_reactions')
->join('love_reactants', 'love_reactions.reactant_id', '=', 'love_reactants.id')
->join(
DB::raw("(SELECT id, love_reactant_id, name,
full_name,
profile_photo_path
FROM " . strtolower($reaction->reactant_model) . "s) as profiles"),
'love_reactants.id',
'=',
'profiles.love_reactant_id'
)
->where('love_reactions.reacter_id', $reacterId)
->where('love_reactants.type', $reaction->reactant_type)
->select(
'profiles.id as profile_id',
'profiles.name',
'profiles.full_name',
'profiles.profile_photo_path',
DB::raw("'{$modelClass}' as profile_type"),
DB::raw("'{$reaction->reactant_model}' as profile_type_name"),
'love_reactions.reaction_type_id',
DB::raw('MAX(love_reactions.created_at) as last_interaction'),
DB::raw('COUNT(*) as count')
)
->groupBy('profiles.id', 'profiles.name', 'profiles.full_name', 'profiles.profile_photo_path', 'love_reactions.reaction_type_id')
->get();
// Batch load locations for all profiles of this type
$profileIds = $reactedToProfiles->pluck('profile_id');
$locations = $this->batchLoadLocationsForExport($modelClass, $profileIds);
foreach ($reactedToProfiles as $profile) {
// Get location from batch-loaded data
$location = $locations[$profile->profile_id] ?? '';
// Determine reaction type (1 = Star, 2 = Bookmark)
$interactionType = $profile->reaction_type_id == 1 ? 'star' : ($profile->reaction_type_id == 2 ? 'bookmark' : 'reaction');
$profiles->push([
'profile_key' => $modelClass . '_' . $profile->profile_id,
'profile_id' => $profile->profile_id,
'profile_type' => $profile->profile_type,
'profile_type_name' => $profile->profile_type_name,
'name' => $profile->name,
'full_name' => $profile->full_name,
'location' => $location,
'profile_photo' => $profile->profile_photo_path,
'interaction_type' => $interactionType,
'last_interaction' => $profile->last_interaction,
'count' => $profile->count,
]);
}
}
return $profiles;
}
/**
* Get profiles that have transacted with the active profile.
*/
private function getTransactionProfilesForExport($activeProfile)
{
// Get all accounts belonging to the active profile
$accountIds = DB::table('accounts')
->where('accountable_type', get_class($activeProfile))
->where('accountable_id', $activeProfile->id)
->pluck('id');
if ($accountIds->isEmpty()) {
return collect();
}
// Get all transactions involving these accounts
$transactions = DB::table('transactions')
->whereIn('from_account_id', $accountIds)
->orWhereIn('to_account_id', $accountIds)
->select(
'from_account_id',
'to_account_id',
DB::raw('MAX(created_at) as last_interaction'),
DB::raw('COUNT(*) as count')
)
->groupBy('from_account_id', 'to_account_id')
->get();
// Group counter accounts by type for batch loading
$counterAccountsByType = collect();
foreach ($transactions as $transaction) {
// Determine the counter account (the other party in the transaction)
$counterAccountId = null;
if ($accountIds->contains($transaction->from_account_id) && !$accountIds->contains($transaction->to_account_id)) {
$counterAccountId = $transaction->to_account_id;
} elseif ($accountIds->contains($transaction->to_account_id) && !$accountIds->contains($transaction->from_account_id)) {
$counterAccountId = $transaction->from_account_id;
}
if ($counterAccountId) {
$transaction->counter_account_id = $counterAccountId;
}
}
// Get all counter account details in one query
$counterAccountIds = $transactions->pluck('counter_account_id')->filter()->unique();
$accounts = DB::table('accounts')
->whereIn('id', $counterAccountIds)
->select('id', 'accountable_type', 'accountable_id')
->get()
->keyBy('id');
// Group profile IDs by type
$profileIdsByType = [];
foreach ($accounts as $account) {
$profileTypeName = class_basename($account->accountable_type);
if (!isset($profileIdsByType[$profileTypeName])) {
$profileIdsByType[$profileTypeName] = [];
}
$profileIdsByType[$profileTypeName][] = $account->accountable_id;
}
// Batch load profile data and locations for each type
$profileDataByType = [];
$locationsByType = [];
foreach ($profileIdsByType as $typeName => $ids) {
$tableName = strtolower($typeName) . 's';
$modelClass = "App\\Models\\{$typeName}";
// Load profile data
$profileDataByType[$typeName] = DB::table($tableName)
->whereIn('id', $ids)
->select('id', 'name', 'full_name', 'profile_photo_path')
->get()
->keyBy('id');
// Batch load locations
$locationsByType[$typeName] = $this->batchLoadLocationsForExport($modelClass, $ids);
}
// Build final profiles collection
$profiles = collect();
foreach ($transactions as $transaction) {
if (!isset($transaction->counter_account_id)) {
continue; // Skip self-transactions
}
$account = $accounts->get($transaction->counter_account_id);
if (!$account) {
continue;
}
$profileModel = $account->accountable_type;
$profileId = $account->accountable_id;
$profileTypeName = class_basename($profileModel);
$profile = $profileDataByType[$profileTypeName][$profileId] ?? null;
if (!$profile) {
continue;
}
$location = $locationsByType[$profileTypeName][$profileId] ?? '';
$profileKey = $profileModel . '_' . $profileId;
$profiles->push([
'profile_key' => $profileKey,
'profile_id' => $profileId,
'profile_type' => $profileModel,
'profile_type_name' => $profileTypeName,
'name' => $profile->name,
'full_name' => $profile->full_name,
'location' => $location,
'profile_photo' => $profile->profile_photo_path,
'interaction_type' => 'transaction',
'last_interaction' => $transaction->last_interaction,
'count' => $transaction->count,
]);
}
return $profiles;
}
/**
* Get profiles from private WireChat conversations.
*/
private function getConversationProfilesForExport($activeProfile)
{
// Get all private conversations the active profile is participating in
$participantType = get_class($activeProfile);
$participantId = $activeProfile->id;
// Get participant record for active profile
$myParticipants = DB::table('wirechat_participants')
->join('wirechat_conversations', 'wirechat_participants.conversation_id', '=', 'wirechat_conversations.id')
->where('wirechat_participants.participantable_type', $participantType)
->where('wirechat_participants.participantable_id', $participantId)
->where('wirechat_conversations.type', ConversationType::PRIVATE->value)
->whereNull('wirechat_participants.deleted_at')
->select(
'wirechat_participants.conversation_id',
'wirechat_participants.last_active_at'
)
->get();
if ($myParticipants->isEmpty()) {
return collect();
}
$conversationIds = $myParticipants->pluck('conversation_id');
// Get all other participants in one query
$otherParticipants = DB::table('wirechat_participants')
->whereIn('conversation_id', $conversationIds)
->where(function ($query) use ($participantType, $participantId) {
$query->where('participantable_type', '!=', $participantType)
->orWhere('participantable_id', '!=', $participantId);
})
->whereNull('deleted_at')
->get()
->keyBy('conversation_id');
// Get message counts for all conversations in one query
$messageCounts = DB::table('wirechat_messages')
->whereIn('conversation_id', $conversationIds)
->whereNull('deleted_at')
->select(
'conversation_id',
DB::raw('COUNT(DISTINCT DATE(created_at)) as day_count')
)
->groupBy('conversation_id')
->get()
->keyBy('conversation_id');
// Get last messages for all conversations in one query
$lastMessages = DB::table('wirechat_messages as wm1')
->whereIn('wm1.conversation_id', $conversationIds)
->whereNull('wm1.deleted_at')
->whereRaw('wm1.created_at = (SELECT MAX(wm2.created_at) FROM wirechat_messages wm2 WHERE wm2.conversation_id = wm1.conversation_id AND wm2.deleted_at IS NULL)')
->select('wm1.conversation_id', 'wm1.created_at')
->get()
->keyBy('conversation_id');
// Group profile IDs by type
$profileIdsByType = [];
foreach ($otherParticipants as $participant) {
$profileTypeName = class_basename($participant->participantable_type);
if (!isset($profileIdsByType[$profileTypeName])) {
$profileIdsByType[$profileTypeName] = [];
}
$profileIdsByType[$profileTypeName][] = $participant->participantable_id;
}
// Batch load profile data and locations for each type
$profileDataByType = [];
$locationsByType = [];
foreach ($profileIdsByType as $typeName => $ids) {
$tableName = strtolower($typeName) . 's';
$modelClass = "App\\Models\\{$typeName}";
// Load profile data
$profileDataByType[$typeName] = DB::table($tableName)
->whereIn('id', $ids)
->select('id', 'name', 'full_name', 'profile_photo_path')
->get()
->keyBy('id');
// Batch load locations
$locationsByType[$typeName] = $this->batchLoadLocationsForExport($modelClass, $ids);
}
// Build final profiles collection
$profiles = collect();
foreach ($myParticipants as $myParticipant) {
$otherParticipant = $otherParticipants->get($myParticipant->conversation_id);
if (!$otherParticipant) {
continue;
}
$profileModel = $otherParticipant->participantable_type;
$profileId = $otherParticipant->participantable_id;
$profileTypeName = class_basename($profileModel);
$profile = $profileDataByType[$profileTypeName][$profileId] ?? null;
if (!$profile) {
continue;
}
$messageCount = $messageCounts->get($myParticipant->conversation_id)->day_count ?? 0;
$lastMessage = $lastMessages->get($myParticipant->conversation_id);
$location = $locationsByType[$profileTypeName][$profileId] ?? '';
$profileKey = $profileModel . '_' . $profileId;
$profiles->push([
'profile_key' => $profileKey,
'profile_id' => $profileId,
'profile_type' => $profileModel,
'profile_type_name' => $profileTypeName,
'name' => $profile->name,
'full_name' => $profile->full_name,
'location' => $location,
'profile_photo' => $profile->profile_photo_path,
'interaction_type' => 'conversation',
'last_interaction' => $lastMessage ? $lastMessage->created_at : $myParticipant->last_active_at,
'count' => $messageCount,
]);
}
return $profiles;
}
/**
* Batch load locations for multiple profiles of the same type.
*/
private function batchLoadLocationsForExport($modelClass, $profileIds)
{
if (empty($profileIds)) {
return [];
}
// Ensure it's an array
if ($profileIds instanceof \Illuminate\Support\Collection) {
$profileIds = $profileIds->toArray();
}
// Load all profiles with their location relationships
$profiles = $modelClass::with([
'locations.city.translations',
'locations.district.translations',
'locations.division.translations',
'locations.country.translations'
])
->whereIn('id', $profileIds)
->get();
// Build location map
$locationMap = [];
foreach ($profiles as $profile) {
if (method_exists($profile, 'getLocationFirst')) {
$locationData = $profile->getLocationFirst(false);
$locationMap[$profile->id] = $locationData['name'] ?? $locationData['name_short'] ?? '';
} else {
$locationMap[$profile->id] = '';
}
}
return $locationMap;
}
public function render()
{
return view('livewire.profile.export-profile-data');
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Livewire\Profile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class LanguagesDropdown extends Component
{
public $state = [];
public $langSelected = [];
public $langSelectedOptions = [];
public $langOptions;
public $languages;
public string $label;
/**
* Prepare the component.
*
* @return void
*/
public function mount($languages)
{
// 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));
$this->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),
];
});
$this->langSelectedOptions = $languages;
$this->langSelected = $this->langSelectedOptions->pluck('id');
$type = getActiveProfileType();
if ( $type == 'Organization') {
$this->label = __('What language(s) does your organization use?');
} elseif ( $type == 'Bank') {
$this->label = __('What language(s) does your bank use?');
} else {
// Users, or other types
$this->label = __('What language(s) do you speak?');
}
}
/**
* When component is updated, create a selected language collection that holds the selected languages with their selected competences
*
* @return void
*/
public function updated()
{
// Get selected options
$selected = collect($this->langOptions)->whereIn('id', $this->langSelected);
// Group by langId and keep only the one with the lowest compId for each language
$filtered = $selected
->groupBy('langId')
->map(function ($group) {
return $group->sortBy('compId')->first();
})
->values();
// Update selected options and selected ids
$this->langSelectedOptions = $filtered;
$this->langSelected = $filtered->pluck('id')->toArray();
$this->dispatch('languagesToParent', $this->langSelectedOptions);
}
public function render()
{
return view('livewire.profile.languages-dropdown');
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Livewire\Profile;
use App\Http\Livewire\MainPage\SkillsCardFull;
use App\Models\Language;
/**
* Class MigrateProfilesProfileSkillsForm
*
* This class extends the SkillsCardFull class and is responsible for rendering
* the update profile skills form view in the Livewire component.
*
* @package App\Http\Livewire\Profile
*/
class MigrateCyclosProfileSkillsForm extends SkillsCardFull
{
public bool $showCyclosReference = true;
public $skillsCardLabel;
public function mount($label = null)
{
$lang = __(Language::where('lang_code', app()->getLocale())->first()->name) ?? null;
if ($lang) {
if (app()->getLocale() != 'en' ) {
$translationWarning = '(' . __('messages.now_in_language', ['lang' => $lang]) . ')';
} else {
$translationWarning = '(' . __('messages.in_English') . ')';
}
} else {
$translationWarning = '';
}
$this->skillsCardLabel = __('Skills on this website') . ' ' . $translationWarning;
}
public function removeCyclosTags()
{
$profile = getActiveProfile();
if (!$profile) {
return;
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
$profile->cyclos_skills = null;
$profile->save();
$this->showCyclosReference = false;
$this->dispatch('refreshSkills');
$this->skillsCardLabel = null;
}
public function render()
{
return view('livewire.profile.migrate-cyclos-profile-skills-form');
}
}

View File

@@ -0,0 +1,148 @@
<?php
namespace App\Http\Livewire\Profile;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Support\Facades\Storage;
use Livewire\Component;
class SelectProfile extends Component
{
public $label = '';
public $placeholder = '';
public $typesAvailable = [];
public $search;
public $searchResults = [];
public $showDropdown = false;
public $selected = [];
protected $listeners = [
'resetForm',
'organizerExists'
];
public function mount()
{
$this->placeholder = implode(', ', array_map(function($class) {
// Convert to lowercase and use as translation key
return __((strtolower(class_basename($class))));
}, $this->typesAvailable));
}
public function inputBlur()
{
$this->showDropdown = false;
$this->search = '';
}
public function resetForm()
{
$this->reset();
}
public function selectProfile($value)
{
$this->selected = collect($this->searchResults)->where('id', '=', $value)->first();
$this->showDropdown = false;
$this->search = '';
$this->dispatch('selectedProfile', $this->selected);
}
/**
* updatedSearch: Search available profiles
*
* @param mixed $newValue
* @return void
*/
public function updatedSearch($newValue)
{
$this->showDropdown = true;
$search = $this->search;
$results = collect();
// Loop through typesAvailable and query each model
foreach ($this->typesAvailable as $type) {
if ($type === User::class) {
$users = User::where('name', 'like', '%' . $search . '%')
->select('id', 'name', 'profile_photo_path')
->get()
->map(function ($item) {
return [
'id' => $item['id'],
'type' => User::class,
'name' => $item['name'],
'description' => '',
'profile_photo_path' => url(Storage::url($item['profile_photo_path']))
];
});
$results = $results->merge($users);
}
if ($type === Organization::class) {
$organizations = Organization::where('name', 'like', '%' . $search . '%')
->select('id', 'name', 'profile_photo_path')
->get()
->map(function ($item) {
return [
'id' => $item['id'],
'type' => Organization::class,
'name' => $item['name'],
'description' => __('Organization'),
'profile_photo_path' => url(Storage::url($item['profile_photo_path']))
];
});
$results = $results->merge($organizations);
}
if (class_exists('App\\Models\\Bank') && $type === Bank::class) {
$banks = Bank::where('name', 'like', '%' . $search . '%')
->select('id', 'name', 'profile_photo_path')
->get()
->map(function ($item) {
return [
'id' => $item['id'],
'type' => \App\Models\Bank::class,
'name' => $item['name'],
'description' => __('Bank'),
'profile_photo_path' => url(Storage::url($item['profile_photo_path']))
];
});
$results = $results->merge($banks);
}
if (class_exists('App\\Models\\Admin') && $type === Admin::class) {
$admins = Admin::where('name', 'like', '%' . $search . '%')
->select('id', 'name', 'profile_photo_path')
->get()
->map(function ($item) {
return [
'id' => $item['id'],
'type' => \App\Models\Admin::class,
'name' => $item['name'],
'description' => __('Admin'),
'profile_photo_path' => url(Storage::url($item['profile_photo_path']))
];
});
$results = $results->merge($admins);
}
}
$this->searchResults = $results->take(6)->values();
}
public function removeSelectedProfile()
{
$this->selected = [];
$this->dispatch('selectedProfile', null);
}
public function render()
{
return view('livewire.profile.select-profile');
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Http\Livewire\Profile;
use App\Models\Account;
use App\Models\Tag;
use App\Models\User;
use App\Traits\ActiveStatesTrait;
use App\Traits\LocationTrait;
use App\Traits\ProfileTrait;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
class Show extends Component
{
use LocationTrait;
use ActiveStatesTrait;
use ProfileTrait;
public $profile;
public $type;
public $inactive = false;
public bool $hidden = false;
public bool $inactiveLabel = false;
public string $inactiveSince;
public bool $emailUnverifiedLabel = false;
public bool $isIncomplete = false;
public bool $incompleteLabel = false;
public bool $noExchangesYetLabel = false;
public string $removedSince;
public $showAboutFullText = false;
public $age;
public $location = [];
public $friend;
public $pendingFriend;
public $phone;
public $languagesWithCompetences = [];
public $skills;
public $lastLoginAt;
public $lastExchangeAt;
public $registeredSince;
public $onlineStatus;
public $accountsTotals;
public $socials;
/**
* The mount method is called when the component is mounted.
*
*/
public function mount()
{
$this->type = strtolower(class_basename($this->profile));
// $this->onlineStatus = $this->getOnlineStatus();
$this->location = $this->getLocation($this->profile);
$this->age = $this->getAge($this->profile) ? ' (' . $this->getAge($this->profile) . ')' : '';
$this->phone = $this->getPhone($this->profile);
$this->lastLoginAt = $this->getLastLogin($this->profile);
$this->accountsTotals = $this->getAccountsTotals($this->profile);
$this->lastExchangeAt = $this->getLastExchangeAt($this->accountsTotals, $this->profile);
$this->registeredSince = $this->getRegisteredSince($this->profile);
$this->profile->languages = $this->getLanguages($this->profile);
$this->profile->lang_preference = $this->getLangPreference($this->profile);
$this->skills = $this->getSkills($this->profile);
$this->socials = $this->getSocials($this->profile);
}
public function toggleAboutText()
{
$this->showAboutFullText = !$this->showAboutFullText;
}
/**
* Redirects to the payment page to this user.
*
* @return \Illuminate\Http\RedirectResponse
*/
public function payButton()
{
if ($this->profile->isRemoved()) {
return;
}
return redirect()->route('pay-to-name', ['name' => $this->profile->name]);
}
/**
* Start a conversation with this user.
*/
public function createConversation()
{
if ($this->profile->isRemoved()) {
return ;
}
$recipient = $this->profile;
$conversation = getActiveProfile()->createConversationWith($recipient);
return redirect()->route('chat', ['conversation' => $conversation->id]);
}
/**
* Render the view for the ProfileUser Show component.
*
* @return \Illuminate\Contracts\View\View
*/
public function render()
{
return view('livewire.profile.show');
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Http\Livewire\Profile;
use App\Models\Social;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class SocialsForm extends Component
{
public $socialsOptions;
public $socialsOptionSelected;
public $socials;
public $userOnSocial;
public $serverOfSocial;
public $sociables_id;
public $updateMode = false;
public $selectedPlaceholder;
private function resetInputFields()
{
$this->reset(['updateMode', 'socialsOptionSelected', 'userOnSocial', 'serverOfSocial']);
}
public function mount()
{
$this->socialsOptions = Social::select("*")->orderBy("name")->get();
$this->getSocials();
}
public function getSocials()
{
$this->socials = getActiveProfile()->socials;
}
public function socialsUpdated()
{
$this->resetErrorBag(); // clears all validation errors
}
public function store()
{
$validatedSocial = $this->validate([
'socialsOptionSelected' => 'required|integer',
'userOnSocial' => 'required|string|max:150',
]);
$activeProfile = getActiveProfile();
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
// Limit to max 10 socials per profile
$currentCount = $activeProfile->socials()->count();
if ($currentCount >= $limit = 10) {
$this->addError('socialsOptionSelected', __('validation.custom.social_limit', ['limit' => $limit]));
return;
}
DB::table('sociables')->insert([
'social_id' => $this->socialsOptionSelected,
'sociable_type' => session('activeProfileType'),
'sociable_id' => session('activeProfileId'),
'user_on_social' => $this->formatUserHandle($this->socialsOptionSelected, $this->userOnSocial),
'server_of_social' => $this->formatServer($this->socialsOptionSelected, $this->serverOfSocial),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
]);
session()->flash('message', __('Saved'));
$this->resetInputFields();
$this->getSocials();
}
public function edit($id)
{
$activeProfile = getActiveProfile();
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
$this->sociables_id = $id;
$this->socialsOptionSelected = $activeProfile->socials->where('pivot.id', $id)->first()->pivot->social_id;
$this->userOnSocial = $activeProfile->socials->where('pivot.id', $id)->first()->pivot->user_on_social;
$this->serverOfSocial = $activeProfile->socials->where('pivot.id', $id)->first()->pivot->server_of_social;
$this->selectedPlaceholder = Social::find($this->socialsOptionSelected);
$this->updateMode = true;
$this->dispatch('contentChanged');
}
public function cancel()
{
$this->updateMode = false;
$this->resetInputFields();
$this->resetErrorBag(); // clears all validation errors
}
public function update()
{
$validatedDate = $this->validate([
'socialsOptionSelected' => 'required|integer',
'userOnSocial' => 'required|string|max:150',
]);
$activeProfile = getActiveProfile();
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
if ($this->sociables_id) {
DB::table('sociables')->insert([
'social_id' => $this->socialsOptionSelected,
'sociable_type' => session('activeProfileType'),
'sociable_id' => session('activeProfileId'),
'user_on_social' => $this->formatUserHandle($this->socialsOptionSelected, $this->userOnSocial),
'server_of_social' => $this->formatServer($this->socialsOptionSelected, $this->serverOfSocial),
'updated_at' => Carbon::now(),
]);
$this->dispatch('emitSaved');
$this->resetInputFields();
}
}
public function delete($id)
{
$activeProfile = getActiveProfile();
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
if ($id) {
DB::table('sociables')->where('id', $id)->delete();
// refresh the local list
$this->getSocials();
$this->resetErrorBag(); // clears all validation errors
$this->dispatch('emitSaved');
session()->flash('message', __('Deleted'));
}
}
private function formatUserHandle($socialId, $handle)
{
$handle = str_replace('@', '', $handle);
// For Blue Sky, the handle already contains the domain
if ($socialId == 3) { // Blue Sky ID is 3
return $handle; // handle.bsky.social
}
return $handle;
}
private function formatServer($socialId, $server)
{
$server = str_replace('@', '', $server);
// Only Mastodon (4) and Blue Sky (3) use server info
if (!in_array($socialId, [3, 4])) {
return null;
}
// For Blue Sky, extract domain from handle if needed
if ($socialId == 3) {
if (str_contains($this->userOnSocial, '.')) {
return substr(strrchr($this->userOnSocial, '.'), 1);
}
return $server ?: 'bsky.social';
}
return $server;
}
public function getSocialUrlAttribute()
{
$urlStructure = $this->social->url_structure;
switch ($this->social_id) {
case 3: // Blue Sky
return "https://bsky.app/profile/{$this->user_on_social}";
case 4: // Mastodon
return str_replace('#', $this->server_of_social, $urlStructure) . $this->user_on_social;
default:
return $urlStructure . $this->user_on_social;
}
}
public function render()
{
$this->getSocials();
return view('livewire.profile.socials-form');
}
}

View File

@@ -0,0 +1,244 @@
<?php
namespace App\Http\Livewire\Profile;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Laravel\Fortify\Features;
use Laravel\Jetstream\ConfirmsPasswords;
use Livewire\Component;
class TwoFactorAuthenticationForm extends Component
{
use ConfirmsPasswords;
/**
* Indicates if two factor authentication QR code is being displayed.
*
* @var bool
*/
public $showingQrCode = false;
/**
* Indicates if the two factor authentication confirmation input and button are being displayed.
*
* @var bool
*/
public $showingConfirmation = false;
/**
* Indicates if two factor authentication recovery codes are being displayed.
*
* @var bool
*/
public $showingRecoveryCodes = false;
/**
* The OTP code for confirming two factor authentication.
*
* @var string|null
*/
public $code;
/**
* Mount the component.
*
* @return void
*/
public function mount()
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
// This prevents unauthorized 2FA management via session manipulation
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm') &&
is_null(Auth::user()->two_factor_confirmed_at)) {
app(DisableTwoFactorAuthentication::class)(Auth::user());
}
}
/**
* Enable two factor authentication for the user.
*
* @param \Laravel\Fortify\Actions\EnableTwoFactorAuthentication $enable
* @return void
*/
public function enableTwoFactorAuthentication(EnableTwoFactorAuthentication $enable)
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate authorization before enabling 2FA
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')) {
$this->ensurePasswordIsConfirmed();
}
$enable(Auth::user());
$this->showingQrCode = true;
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm')) {
$this->showingConfirmation = true;
} else {
$this->showingRecoveryCodes = true;
}
}
/**
* Confirm two factor authentication for the user.
*
* @param \Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication $confirm
* @return void
*/
public function confirmTwoFactorAuthentication(ConfirmTwoFactorAuthentication $confirm)
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate authorization before confirming 2FA
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')) {
$this->ensurePasswordIsConfirmed();
}
$confirm(Auth::user(), $this->code);
$this->showingQrCode = false;
$this->showingConfirmation = false;
$this->showingRecoveryCodes = true;
}
/**
* Display the user's recovery codes.
*
* @return void
*/
public function showRecoveryCodes()
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate authorization before showing recovery codes
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')) {
$this->ensurePasswordIsConfirmed();
}
$this->showingRecoveryCodes = true;
}
/**
* Generate new recovery codes for the user.
*
* @param \Laravel\Fortify\Actions\GenerateNewRecoveryCodes $generate
* @return void
*/
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generate)
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate authorization before regenerating recovery codes
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')) {
$this->ensurePasswordIsConfirmed();
}
$generate(Auth::user());
$this->showingRecoveryCodes = true;
}
/**
* Disable two factor authentication for the user.
*
* @param \Laravel\Fortify\Actions\DisableTwoFactorAuthentication $disable
* @return void
*/
public function disableTwoFactorAuthentication(DisableTwoFactorAuthentication $disable)
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate authorization before disabling 2FA
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')) {
$this->ensurePasswordIsConfirmed();
}
$disable(Auth::user());
$this->showingQrCode = false;
$this->showingConfirmation = false;
$this->showingRecoveryCodes = false;
}
/**
* Get the current user of the application.
*
* @return mixed
*/
public function getUserProperty()
{
return Auth::user();
}
/**
* Determine if two factor authentication is enabled.
*
* @return bool
*/
public function getEnabledProperty()
{
return ! empty($this->user->two_factor_secret);
}
/**
* Render the component.
*
* @return \Illuminate\View\View
*/
public function render()
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Re-validate authorization on every render
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
return view('profile.two-factor-authentication-form');
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Http\Livewire\Profile;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class TwoFactorMainPageCard extends Component
{
/**
* Indicates if two factor authentication is confirmed and active.
*
* @var bool
*/
public $enabled;
/**
* Mount the component.
*
* @return void
*/
public function mount()
{
$user = Auth::guard('web')->user();
// $this->enabled strictly means 2FA is fully confirmed in the DB
$this->enabled = $user && !empty($user->two_factor_confirmed_at);
}
/**
* Redirect the user to the admin settings page.
*
* @return \Illuminate\Http\RedirectResponse
*/
public function redirectToSettings()
{
$route = '';
$anchor = '#two-factor-authentication-form';
if (session('activeProfileType') == 'App\Models\Organization') {
$route = 'profile.settings';
}
elseif (session('activeProfileType') == 'App\Models\Bank') {
$route = 'profile.bank.settings';
}
elseif (session('activeProfileType') == 'App\Models\Admin') {
$route = 'profile.admin.settings';
}
else {
$route = 'profile.user.settings';
}
// Generate the URL for the route and append the anchor
$url = route($route) . $anchor;
return redirect()->to($url);
}
/**
* Get the current user of the application.
*
* @return mixed
*/
public function getUserProperty()
{
return Auth::guard('web')->user();
}
/**
* Render the component.
*
* @return \Illuminate\View\View
*/
public function render()
{
return view('livewire.profile.two-factor-main-page-card');
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Http\Livewire\Profile;
use Livewire\Component;
class UpdateMessageSettingsForm extends Component
{
public bool $systemMessage;
public bool $paymentReceived;
public bool $starReceived;
public bool $localNewsletter;
public bool $generalNewsletter;
public bool $personalChat;
public bool $groupChat;
public int $chatUnreadDelay;
public bool $callExpiry;
protected $rules = [
'systemMessage' => 'boolean',
'paymentReceived' => 'boolean',
'starReceived' => 'boolean',
'localNewsletter' => 'boolean',
'generalNewsletter' => 'boolean',
'personalChat' => 'boolean',
'groupChat' => 'boolean',
'chatUnreadDelay' => 'integer|min:0|max:99', // 168 hours is one week
'callExpiry' => 'boolean',
];
public function mount()
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
// Load current settings or create default
$settings = $profile->message_settings()->first();
if (!$settings) {
// Create default settings
$settings = $profile->message_settings()->create([
'system_message' => true,
'payment_received' => true,
'star_received' => true,
'local_newsletter' => true,
'general_newsletter' => true,
'personal_chat' => true,
'group_chat' => true,
'chat_unread_delay' => timebank_config('messenger.default_unread_mail_delay'),
'call_expiry' => true,
]);
}
$this->systemMessage = $settings->system_message;
$this->paymentReceived = $settings->payment_received;
$this->starReceived = $settings->star_received;
$this->localNewsletter = $settings->local_newsletter;
$this->generalNewsletter = $settings->general_newsletter;
$this->personalChat = $settings->personal_chat;
$this->groupChat = $settings->group_chat;
$this->chatUnreadDelay = $settings->chat_unread_delay;
$this->callExpiry = (bool) ($settings->call_expiry ?? true);
}
public function updateMessageSettings()
{
$this->validate();
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
$profile->message_settings()->updateOrCreate(
[],
[
'system_message' => $this->systemMessage,
'payment_received' => $this->paymentReceived,
'star_received' => $this->starReceived,
'local_newsletter' => $this->localNewsletter,
'general_newsletter' => $this->generalNewsletter,
'personal_chat' => $this->personalChat,
'group_chat' => $this->groupChat,
'chat_unread_delay' => $this->chatUnreadDelay,
'call_expiry' => $this->callExpiry,
]
);
$this->dispatch('saved');
}
public function render()
{
return view('livewire.profile.update-message-settings-form');
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Livewire\Profile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class UpdateNonUserPasswordForm extends Component
{
public $state = [
'current_password' => '',
'password' => '',
'password_confirmation' => '',
];
public function updatePassword()
{
$profileName = strtolower(getActiveProfileType());
$this->validate([
'state.current_password' => ['required', 'string'],
'state.password' => timebank_config('rules.profile_' . $profileName . '.password'),
]);
$activeProfile = getActiveprofile();
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
// Check if the current password matches
if (!Hash::check($this->state['current_password'], $activeProfile->password)) {
$this->addError('state.current_password', __('The provided password does not match your current password.'));
return;
}
// Update the password
$activeProfile->forceFill([
'password' => Hash::make($this->state['password']),
])->save();
activity()
->useLog(class_basename(getActiveProfileType()))
->performedOn($activeProfile)
->causedBy(Auth::guard('web')->user())
->event('password_changed')
->log('Password changed for ' . $activeProfile->name);
// Dispatch a success message
$this->dispatch('saved');
}
public function render()
{
return view('livewire.profile.update-non-user-password-form');
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Http\Livewire\Profile;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
use Livewire\Component;
class UpdatePasswordForm extends Component
{
/**
* The component's state.
*
* @var array
*/
public $state = [
'current_password' => '',
'password' => '',
'password_confirmation' => '',
];
/**
* Mount the component.
*
* @return void
*/
public function mount()
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
// This prevents unauthorized password changes via session manipulation
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
}
/**
* Update the user's password.
*
* @param \Laravel\Fortify\Contracts\UpdatesUserPasswords $updater
* @return void
*/
public function updatePassword(UpdatesUserPasswords $updater)
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate authorization before password update
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
$this->resetErrorBag();
$updater->update(Auth::user(), $this->state);
if (request()->hasSession()) {
request()->session()->put([
'password_hash_'.Auth::getDefaultDriver() => Auth::user()->getAuthPassword(),
]);
}
$this->state = [
'current_password' => '',
'password' => '',
'password_confirmation' => '',
];
$this->dispatch('saved');
}
/**
* Get the current user of the application.
*
* @return mixed
*/
public function getUserProperty()
{
return Auth::user();
}
/**
* Render the component.
*
* @return \Illuminate\View\View
*/
public function render()
{
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Re-validate authorization on every render
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
return view('profile.update-password-form');
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace App\Http\Livewire\Profile;
use Illuminate\Config\Repository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
use Propaganistas\LaravelPhone\PhoneNumber;
class UpdateProfilePhoneForm extends Component
{
public $phoneCodeOptions;
public $phonecode;
public $state = [];
protected $rules = [
'state.phone' => [ 'phone:phonecode,mobile,strict', 'regex:/^[\d+()\s-]+$/', ],
'phonecode' => 'required_with:state.phone,mobile',
'state.phone_public' =>'boolean|nullable',
];
/**
* Prepare the component.
*
* @return void
*/
public function mount(Request $request, Repository $config)
{
$activeProfile = getActiveProfile();
$phonePublic = isset($activeProfile->phone_public);
$this->state = array_merge([
'phone' => $activeProfile->phone,
'phone_public' => $phonePublic == true ? $activeProfile->phone_public : null,
], $activeProfile->withoutRelations()->toArray());
$phoneCodeOptions = DB::table('countries')->get()->sortBy('code');
$this->phoneCodeOptions = $phoneCodeOptions->Map(function ($options, $key) {
return [
'id' => $options->id,
'code' => $options->code,
'label' => $options->flag,
];
});
$this->getPhonecode();
}
public function getPhonecode()
{
$activeProfile = getActiveProfile();
// Fill country code dropdown
$this->phoneCodeOptions->toArray();
// Ensure the profile is authenticated and retrieve the phone field
$profilePhone = $activeProfile->phone ?? '';
if (!empty($profilePhone)) {
try {
$country = new PhoneNumber($profilePhone);
$this->phonecode = $country->getCountry();
$phone = new PhoneNumber($profilePhone, $this->phonecode);
$this->state['phone'] = $phone->formatNational();
} catch (\Exception) {
// If phone parsing fails, reset to empty and set default country
$this->state['phone'] = '';
$this->setDefaultCountryCode($activeProfile);
}
} else {
$this->setDefaultCountryCode($activeProfile);
}
}
private function setDefaultCountryCode($activeProfile)
{
// Try to get country from profile locations if available
$countryIds = [];
if (method_exists($activeProfile, 'locations')) {
$countryIds = get_class($activeProfile)::find($this->state['id'])->locations()
->with('city:id,country_id')
->get()
->pluck('city.country_id')
->filter() // Remove null values
->toArray();
}
$countries = ($this->phoneCodeOptions)->pluck('id')->toArray();
// Get the first valid country ID from the profile's locations
$firstCountryId = !empty($countryIds) ? $countryIds[0] : null;
if ($firstCountryId && in_array($firstCountryId, $countries)) {
$this->phonecode = DB::table('countries')->select('code')->where('id', $firstCountryId)->pluck('code')->first();
} else {
// Fallback to first available country code
$this->phonecode = $this->phoneCodeOptions[0]['code'] ?? 'NL';
}
}
/**
* Validate phone field when updated.
* This is the 1st validation method on this form.
*
* @return void
*/
public function updatedStatePhone()
{
if (!empty($this->state['phone']) && !empty($this->phonecode)) {
try {
$this->validateOnly('state.phone');
$phone = new PhoneNumber($this->state['phone'], $this->phonecode);
$this->state['phone'] = $phone->formatNational();
} catch (\Exception) {
$this->addError('state.phone', __('Invalid phone number format.'));
}
}
}
/**
* Update the profile's phone information.
*
* @return void
*/
public function updateProfilePhone()
{
$activeProfile = getActiveProfile();
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
if (!empty($this->state['phone'])) {
$this->validate(); // 2nd validation, just before save method
$this->resetErrorBag();
try {
$phone = new PhoneNumber($this->state['phone'], $this->phonecode);
$activeProfile->phone = $phone;
// Check for the existence of phone_public column and update if exists
if (in_array('phone_public', $activeProfile->getFillable())) {
$activeProfile->phone_public = $this->state['phone_public'] ?? false;
}
} catch (\Exception) {
$this->addError('state.phone', __('Invalid phone number format.'));
return;
}
} else {
$this->resetErrorBag();
$activeProfile->phone = null;
// Clear the phone_public field if it exists
if (in_array('phone_public', $activeProfile->getFillable())) {
$activeProfile->phone_public = false;
}
}
$activeProfile->save();
$this->dispatch('saved');
}
/**
* Get the current active profile of the application.
*
* @return mixed
*/
public function getUserProperty()
{
return getActiveProfile();
}
public function render()
{
return view('livewire.profile.update-profile-phone-form');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Livewire\Profile;
use App\Http\Livewire\MainPage\SkillsCardFull;
/**
* Class UpdateProfileSkillsForm
*
* This class extends the SkillsCardFull class and is responsible for rendering
* the update profile skills form view in the Livewire component.
*
* @package App\Http\Livewire\Profile
*/
class UpdateProfileSkillsForm extends SkillsCardFull
{
public function render()
{
return view('livewire.profile.update-profile-skills-form');
}
}

View File

@@ -0,0 +1,326 @@
<?php
namespace App\Http\Livewire\Profile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
use Livewire\Component;
use Livewire\WithFileUploads;
class UpdateSettingsForm extends Component
{
use WithFileUploads;
/**
* The component's state.
*
* @var array
*/
public $state;
/**
* The new avatar for the active profile.
*
* @var mixed
*/
public $photo;
/**
* Determine if the verification email was sent.
*
* @var bool
*/
public $verificationLinkSent = false;
/**
* Prepare the component.
*
* @return void
*/
public function mount()
{
$activeProfile = getActiveProfile();
// --- Check roles and permissions --- //
// Permissions are assigned to Users (web guard), not to Organizations/Banks/Admins
$webUser = Auth::guard('web')->user();
if (!$webUser) {
abort(403, 'Unauthorized action.');
}
$authorized =
($activeProfile instanceof \App\Models\User &&
($webUser->can('manage users') ||
$webUser->id === $activeProfile->id))
||
($activeProfile instanceof \App\Models\Organization &&
($webUser->can('manage organizations') ||
$webUser->hasRole('Organization\\' . $activeProfile->id . '\\organization-manager') ||
$webUser->organizations()->where('organization_user.organization_id', $activeProfile->id)->exists()))
||
($activeProfile instanceof \App\Models\Bank &&
($webUser->can('manage banks') ||
$webUser->hasRole('Bank\\' . $activeProfile->id . '\\bank-manager') ||
$webUser->banksManaged()->where('bank_user.bank_id', $activeProfile->id)->exists()))
||
($activeProfile instanceof \App\Models\Admin &&
($webUser->can('manage admins') ||
$webUser->hasRole('Admin\\' . $activeProfile->id . '\\admin') ||
$webUser->admins()->where('admin_user.admin_id', $activeProfile->id)->exists()));
if (!$authorized) {
abort(403, 'Unauthorized action.');
}
$this->state = array_merge([
'email' => $activeProfile->email,
'full_name' => $activeProfile->full_name,
], $activeProfile->withoutRelations()->toArray());
}
/**
* Update the active profile's profile information.
*
* @param \Laravel\Fortify\Contracts\UpdatesUserProfileInformation $updater
* @return void
*/
public function updateProfileInformation()
{
$this->resetErrorBag();
$activeProfile = getActiveProfile();
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
// Determine the profile type and table
$profileType = get_class($activeProfile);
$modelKey = match ($profileType) {
'App\Models\User' => 'user',
'App\Models\Organization' => 'organization',
'App\Models\Bank' => 'bank',
'App\Models\Admin' => 'admin',
default => 'user',
};
$tableName = (new $profileType())->getTable();
// Get validation rules from platform config
$emailRules = timebank_config("rules.profile_{$modelKey}.email");
$fullNameRules = timebank_config("rules.profile_{$modelKey}.full_name");
$photoRules = timebank_config("rules.profile_{$modelKey}.profile_photo");
// Convert string rules to arrays if needed
if (is_string($emailRules)) {
$emailRules = explode('|', $emailRules);
}
if (is_string($fullNameRules)) {
$fullNameRules = explode('|', $fullNameRules);
}
if (is_string($photoRules)) {
$photoRules = explode('|', $photoRules);
}
// Process email rules to handle unique constraint for current profile
$processedEmailRules = [];
foreach ($emailRules as $rule) {
if (is_string($rule) && \Illuminate\Support\Str::startsWith(trim($rule), 'unique:')) {
// Check if this is the unique rule for the current table
if (preg_match("/^unique:{$tableName},email(,|$)/", trim($rule))) {
// Replace with a Rule object that ignores current profile
$processedEmailRules[] = \Illuminate\Validation\Rule::unique($tableName, 'email')->ignore($activeProfile->id);
} else {
// Keep unique rules for other tables
$processedEmailRules[] = $rule;
}
} else {
$processedEmailRules[] = $rule;
}
}
// Process full_name rules to handle unique constraint for current profile
$processedFullNameRules = [];
if ($fullNameRules) {
foreach ($fullNameRules as $rule) {
if (is_string($rule) && \Illuminate\Support\Str::startsWith(trim($rule), 'unique:')) {
// Check if this is a unique rule for the current table (any column)
if (preg_match("/^unique:{$tableName},(\w+)(,|$)/", trim($rule), $matches)) {
$column = $matches[1];
// Replace with a Rule object that ignores current profile
$processedFullNameRules[] = \Illuminate\Validation\Rule::unique($tableName, $column)->ignore($activeProfile->id);
} else {
// Keep unique rules for other tables
$processedFullNameRules[] = $rule;
}
} else {
$processedFullNameRules[] = $rule;
}
}
}
// Prepare validation rules
$rules = [
'state.email' => $processedEmailRules,
];
// Add full_name validation for non-User profiles
if (!($activeProfile instanceof \App\Models\User) && $processedFullNameRules) {
$rules['state.full_name'] = $processedFullNameRules;
}
// Add photo validation if a photo is being uploaded
if ($this->photo && $photoRules) {
$rules['photo'] = $photoRules;
}
// Validate the input
$this->validate($rules, [
'state.email.required' => __('The email field is required.'),
'state.email.email' => __('Please enter a valid email address.'),
'state.email.unique' => __('This email address is already in use.'),
'state.full_name.required' => __('The full name field is required.'),
'state.full_name.max' => __('The full name must not exceed the maximum length.'),
'photo.image' => __('The file must be an image.'),
'photo.max' => __('The image size exceeds the maximum allowed.'),
]);
// Check if the email has changed
$emailChanged = $this->state['email'] !== $activeProfile->email;
if ($this->photo) {
// Delete old file if it doesn't start with "app-images/" (as those are default images)
if ($activeProfile->profile_photo_path
&& !Str::startsWith($activeProfile->profile_photo_path, 'app-images/')) {
Storage::disk('public')->delete($activeProfile->profile_photo_path);
}
// Store the new file
$photoPath = $this->photo->store('profile-photos', 'public');
$this->state['profile_photo_path'] = $photoPath;
}
// Remove protected fields from state to prevent changes
$updateData = $this->state;
unset($updateData['name']); // Username is always read-only
// Full name is read-only for Users, but editable for Organizations, Banks, and Admins
if ($activeProfile instanceof \App\Models\User) {
unset($updateData['full_name']);
}
// Update records of active profile
$activeProfile->update($updateData);
// Refresh the component state with the updated model data
$activeProfile = $activeProfile->fresh();
$this->state = $activeProfile->toArray();
$this->state['email'] = $activeProfile->email;
$this->state['full_name'] = $activeProfile->full_name;
// Update the session variable so the Blade view can display the new photo
session(['activeProfilePhoto' => $this->state['profile_photo_path']]);
// Send email verification if the email has changed
if ($emailChanged) {
$activeProfile->forceFill(['email_verified_at' => null])->save();
$activeProfile->sendEmailVerificationNotification();
// Refresh state after email verification changes
$activeProfile = $activeProfile->fresh();
$this->state = $activeProfile->toArray();
$this->state['email'] = $activeProfile->email;
$this->state['full_name'] = $activeProfile->full_name;
}
if (isset($this->photo)) {
return redirect()->route('profile.settings');
}
$this->dispatch('saved');
}
/**
* Delete active profile's profile photo.
*
* @return void
*/
public function deleteProfilePhoto()
{
$activeProfile = getActiveProfile();
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
// If the existing photo path is not one of the default images, delete it
if ($activeProfile->profile_photo_path
&& !Str::startsWith($activeProfile->profile_photo_path, 'app-images/')) {
Storage::disk('public')->delete($activeProfile->profile_photo_path);
}
// Set the profile photo path to the configured default in your config file
$defaultPath = timebank_config('profiles.' . strtolower(getActiveProfileType()) . '.profile_photo_path_default');
$this->state['profile_photo_path'] = $defaultPath;
// Update the active profiles record
$activeProfile->update(['profile_photo_path' => $defaultPath]);
// Refresh the component state with the updated model data
$this->state = $activeProfile->fresh()->toArray();
// Update the session variable so the Blade view can display the new photo
session(['activeProfilePhoto' => $defaultPath]);
redirect()->route('profile.settings');
// Dispatch any events if desired, for example:
$this->dispatch('saved');
$this->dispatch('refresh-navigation-menu');
}
/**
* Send the email verification.
*
* @return void
*/
public function sendEmailVerification()
{
$activeProfile = getActiveProfile();
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
$activeProfile->sendEmailVerificationNotification();
$this->verificationLinkSent = true;
}
/**
* Get the current active profile of the application.
*
* @return mixed
*/
public function getUserProperty()
{
return getActiveProfile();
}
public function render()
{
return view('livewire.profile.update-settings-form');
}
}