Initial commit
This commit is contained in:
452
app/Http/Livewire/Profile/DeleteUserForm.php
Normal file
452
app/Http/Livewire/Profile/DeleteUserForm.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
897
app/Http/Livewire/Profile/ExportProfileData.php
Normal file
897
app/Http/Livewire/Profile/ExportProfileData.php
Normal 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');
|
||||
}
|
||||
}
|
||||
83
app/Http/Livewire/Profile/LanguagesDropdown.php
Normal file
83
app/Http/Livewire/Profile/LanguagesDropdown.php
Normal 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');
|
||||
}
|
||||
}
|
||||
60
app/Http/Livewire/Profile/MigrateCyclosProfileSkillsForm.php
Normal file
60
app/Http/Livewire/Profile/MigrateCyclosProfileSkillsForm.php
Normal 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');
|
||||
}
|
||||
}
|
||||
148
app/Http/Livewire/Profile/SelectProfile.php
Normal file
148
app/Http/Livewire/Profile/SelectProfile.php
Normal 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');
|
||||
}
|
||||
}
|
||||
118
app/Http/Livewire/Profile/Show.php
Normal file
118
app/Http/Livewire/Profile/Show.php
Normal 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');
|
||||
}
|
||||
}
|
||||
203
app/Http/Livewire/Profile/SocialsForm.php
Normal file
203
app/Http/Livewire/Profile/SocialsForm.php
Normal 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');
|
||||
}
|
||||
}
|
||||
244
app/Http/Livewire/Profile/TwoFactorAuthenticationForm.php
Normal file
244
app/Http/Livewire/Profile/TwoFactorAuthenticationForm.php
Normal 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');
|
||||
}
|
||||
}
|
||||
76
app/Http/Livewire/Profile/TwoFactorMainPageCard.php
Normal file
76
app/Http/Livewire/Profile/TwoFactorMainPageCard.php
Normal 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');
|
||||
}
|
||||
}
|
||||
112
app/Http/Livewire/Profile/UpdateMessageSettingsForm.php
Normal file
112
app/Http/Livewire/Profile/UpdateMessageSettingsForm.php
Normal 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');
|
||||
}
|
||||
}
|
||||
56
app/Http/Livewire/Profile/UpdateNonUserPasswordForm.php
Normal file
56
app/Http/Livewire/Profile/UpdateNonUserPasswordForm.php
Normal 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');
|
||||
}
|
||||
}
|
||||
104
app/Http/Livewire/Profile/UpdatePasswordForm.php
Normal file
104
app/Http/Livewire/Profile/UpdatePasswordForm.php
Normal 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');
|
||||
}
|
||||
}
|
||||
187
app/Http/Livewire/Profile/UpdateProfilePhoneForm.php
Normal file
187
app/Http/Livewire/Profile/UpdateProfilePhoneForm.php
Normal 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');
|
||||
}
|
||||
}
|
||||
21
app/Http/Livewire/Profile/UpdateProfileSkillsForm.php
Normal file
21
app/Http/Livewire/Profile/UpdateProfileSkillsForm.php
Normal 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');
|
||||
}
|
||||
}
|
||||
326
app/Http/Livewire/Profile/UpdateSettingsForm.php
Normal file
326
app/Http/Livewire/Profile/UpdateSettingsForm.php
Normal 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 profile’s 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user