Initial commit

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

View File

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