'$refresh', // $refresh is a magic method that forces a re-render 'selectedProfile', 'toAccountId' => 'setDonationAccountId' ]; public function mount() { // Admin Authorization - Prevent IDOR attacks and cross-guard access $activeProfileType = session('activeProfileType'); $activeProfileId = session('activeProfileId'); if (!$activeProfileType || !$activeProfileId) { abort(403, __('No active profile selected')); } $profile = $activeProfileType::find($activeProfileId); if (!$profile) { abort(403, __('Active profile not found')); } // Validate profile ownership using ProfileAuthorizationHelper (prevents cross-guard attacks) \App\Helpers\ProfileAuthorizationHelper::authorize($profile); // Verify admin or central bank permissions if ($profile instanceof \App\Models\Admin) { // Admin access OK } elseif ($profile instanceof \App\Models\Bank) { // Only central bank (level 0) can access profile management if ($profile->level !== 0) { abort(403, __('Central bank access required for profile management')); } } else { abort(403, __('Admin or central bank access required')); } // Log admin access for security monitoring \Log::info('Profiles management access', [ 'component' => 'Profiles\\Manage', 'profile_id' => $profile->id, 'profile_type' => get_class($profile), 'authenticated_guard' => \Auth::getDefaultDriver(), 'ip_address' => request()->ip(), ]); } 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 || !isset($this->deleteProfileData['totalBalance']) || $this->deleteProfileData['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->deleteProfileData['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 organization account can only receive up to :amount more. Please select a different organization or delete the 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 the balance instead.'); } } } /** * Called when balanceHandlingOption is updated. * * @return void */ public function updatedBalanceHandlingOption() { $this->checkDonationLimits(); } protected function rules() { $modelKey = $this->editProfile['model'] ?? 'user'; // Default or handle if not set $baseRules = [ 'editProfile.inactive_at' => ['nullable', 'date'], 'editProfile.name' => timebank_config("rules.profile_{$modelKey}.name"), 'editProfile.full_name' => timebank_config("rules.profile_{$modelKey}.full_name"), 'editProfile.email' => timebank_config("rules.profile_{$modelKey}.email"), 'editProfile.about_short' => timebank_config("rules.profile_{$modelKey}.about_short"), 'editProfile.about' => timebank_config("rules.profile_{$modelKey}.about"), 'editProfile.motivation' => timebank_config("rules.profile_{$modelKey}.motivation"), 'editProfile.phone' => timebank_config('rules.phone'), 'editProfile.website' => timebank_config("rules.profile_{$modelKey}.website"), 'editProfile.comment' => timebank_config('rules.comment'), 'editAttachProfile.comment' => timebank_config('rules.comment'), 'editAttachProfile.profile' => ['required', 'array'], 'editAttachProfile.profile.id' => ['required', 'integer'], 'editAttachProfile.profile.model' => ['required', 'string'], 'editAttachProfile.profiles.*.role' => ['nullable', 'string', 'in:organization-manager,organization-coordinator,bank-manager,bank-coordinator'], 'editAttachProfile.newProfile.role' => ['nullable', 'string', 'in:organization-manager,organization-coordinator,bank-manager,bank-coordinator,administrator'], 'confirmString' => ['required', function (string $attribute, $value, $fail) { $expected = strtolower(__('messages.confirm_input_string')); if (strtolower($value) !== $expected) { $fail(__('The confirmation keyword is incorrect.')); } }, ], ]; // Add level validation for Bank models only $modelClass = $this->editProfile['modelClass'] ?? null; if ($modelClass === \App\Models\Bank::class) { $baseRules['editProfile.level'] = ['required', 'integer', 'in:1,2']; } // Ensure all loaded rules are arrays and handle nulls from config foreach ($baseRules as $key => $ruleSet) { if (is_string($ruleSet)) { $baseRules[$key] = explode('|', $ruleSet); } elseif (is_null($ruleSet)) { $baseRules[$key] = []; // Default to empty array if config key is missing or null } } return $baseRules; } protected $queryString = [ 'search' => ['except' => ''], 'typeFilter' => ['except' => ''], 'activeFilter' => ['except' => ''], 'emailVerifiedFilter' => ['except' => ''], 'sortField' => ['except' => 'created_at'], // Fixed to match actual default 'sortDirection' => ['except' => 'desc'], 'perPage' => ['except' => 10], 'page' => ['except' => 1] ]; public function openEditAccountsModal($profileId, $modelName) { $this->confirmString = ''; $profile = $modelName::with('accounts')->find($profileId); if (!$profile) { $modelBasename = class_basename($modelName); throw new \Exception("{$modelBasename} profile with ID {$profileId} not found."); } $this->editAccounts = $profile->makeVisible([ 'id', 'name', 'full_name', 'email', 'email_verified_at', 'limit_min', 'limit_max', 'lang_preference', 'last_login_at', 'last_login_ip', 'inactive_at', 'deleted_at', 'updated_at', 'created_at', ])->toArray(); $this->editAccounts['model'] = strtolower(class_basename($modelName)); $this->editAccounts['location'] = $profile->getLocationFirst()['name']; $this->editAccounts['accounts'] = $this->getAccountsInfo($modelName, $profileId)->toArray(); // Use the AccountInfoTrait $this->editAccounts['totals'] = $this->getAccountsTotals($modelName, $profileId); // Use the AccountInfoTrait, include totals for past 12 months $this->editAccounts['totalsPastYear'] = $this->getAccountsTotals($modelName, $profileId, timebank_config('account_info.account_totals.countTransfersSince')); // Use the AccountInfoTrait, include totals for past 12 months $this->initAccounts = $this->editAccounts; // Store the initial profile data for comparison later $this->modalEditAccounts = true; } public function openEditProfileModal($profileId, $modelName) { $this->confirmString = ''; $profile = $modelName::find($profileId); if (!$profile) { $modelBasename = class_basename($modelName); throw new \Exception("{$modelBasename} profile with ID {$profileId} not found."); } // $this->confirmString = strtolower(__('messages.confirm_input_string')); $visibleFields = [ 'id', 'name', 'full_name', 'email', 'email_verified_at', 'profile_photo_path', 'about', 'about_short', 'motivation', 'website', 'phone', 'phone_public', 'limit_min', 'limit_max', 'comment', 'lang_preference', 'last_login_at', 'last_login_ip', 'inactive_at', 'deleted_at', 'updated_at', 'created_at', ]; // Add 'level' field only for Bank models if ($modelName === \App\Models\Bank::class) { $visibleFields[] = 'level'; } $this->editProfile = $profile->makeVisible($visibleFields)->only($visibleFields); $this->editProfile['model'] = strtolower(class_basename($modelName)); $this->editProfile['modelClass'] = $modelName; // Store full class name for type checking $this->editProfile['location'] = $profile->getLocationFirst()['name']; // Cast level to string for WireUI select compatibility if (isset($this->editProfile['level'])) { $this->editProfile['level'] = (string) $this->editProfile['level']; } if ($this->editProfile['inactive_at']) { $this->reActivate = false; // If the profile is inactive, set reActivate to true } else { $this->reActivate = true; // Otherwise, set it to false } $this->initProfile = $this->editProfile; // Store the initial profile data for comparison later $this->modalEditProfile = true; } public function openAttachProfilesModal($profileId, $modelName) { $this->editAttachProfile['newProfile'] = null; $this->initAttachProfile['newProfile'] = null; $this->confirmString = ''; $profile = $modelName::find($profileId); if (!$profile) { $modelBasename = class_basename($modelName); throw new \Exception("{$modelBasename} profile with ID {$profileId} not found."); } // $this->confirmString = strtolower(__('messages.confirm_input_string')); $this->editAttachProfile = $profile->makeVisible([ 'id', 'full_name', 'comment', 'lang_preference', 'last_login_at', 'last_login_ip', 'inactive_at', 'deleted_at', 'updated_at', 'created_at', ]) ->toArray(); $this->editAttachProfile['model'] = strtolower(class_basename($modelName)); $this->editAttachProfile['location'] = $profile->getLocationFirst()['name']; $profiles = []; // Map and merge all attached profiles into a single array if (class_basename($modelName) === 'User') { $this->newTypesAvailable = [Organization::class, Bank::class, Admin::class]; $profileWithRelations = $modelName::with(['organizations', 'banksManaged', 'admins'])->find($profileId); foreach ($profileWithRelations->organizations as $org) { $arr = $org->toArray(); $arr['typeName'] = __('organization'); $arr['username'] = $arr['name'] ?? ''; $arr['inactiveAt'] = $arr['inactive_at'] ?? null; $arr['type'] = 'Organization'; $arr['role'] = $this->resolveLinkedUserRole($profileWithRelations, 'Organization', $org->id); $profiles[] = $arr; } foreach ($profileWithRelations->banksManaged as $bank) { $arr = $bank->toArray(); $arr['typeName'] = __('bank'); $arr['username'] = $arr['name'] ?? ''; $arr['inactiveAt'] = $arr['inactive_at'] ?? null; $arr['type'] = 'Bank'; $arr['role'] = $this->resolveLinkedUserRole($profileWithRelations, 'Bank', $bank->id); $profiles[] = $arr; } foreach ($profileWithRelations->admins as $admin) { $arr = $admin->toArray(); $arr['typeName'] = __('admin'); $arr['username'] = $arr['name'] ?? ''; $arr['inactiveAt'] = $arr['inactive_at'] ?? null; $arr['type'] = 'Admin'; $arr['role'] = 'administrator'; $profiles[] = $arr; } } elseif (class_basename($modelName) === 'Organization' || class_basename($modelName) === 'Admin') { $this->newTypesAvailable = [User::class]; $profileWithRelations = $modelName::with(['users'])->find($profileId); $parentType = class_basename($modelName); foreach ($profileWithRelations->users as $user) { $arr = $user->toArray(); $arr['typeName'] = __('user'); $arr['username'] = $arr['name'] ?? ''; $arr['inactiveAt'] = $arr['inactive_at'] ?? null; $arr['type'] = 'User'; $arr['role'] = $this->resolveLinkedUserRole($user, $parentType, $profileId); $profiles[] = $arr; } } elseif (class_basename($modelName) === 'Bank') { $this->newTypesAvailable = [User::class]; $profileWithRelations = $modelName::with(['managers'])->find($profileId); foreach ($profileWithRelations->managers as $manager) { $arr = $manager->toArray(); $arr['username'] = $arr['name'] ?? ''; $arr['inactiveAt'] = $arr['inactive_at'] ?? null; $arr['type'] = 'User'; $arr['role'] = $this->resolveLinkedUserRole($manager, 'Bank', $profileId); $profiles[] = $arr; } } $this->editAttachProfile['profiles'] = $profiles; $this->initAttachProfile['profiles'] = $profiles; $this->initAttachProfile = $this->editAttachProfile; $this->modalAttachProfile = true; } /** * Resolve the current role of a linked user for a given parent profile type/id. * Returns 'organization-manager', 'organization-coordinator', 'bank-manager', 'bank-coordinator', or the manager default. */ protected function resolveLinkedUserRole($user, string $parentType, int $parentId): string { $coordinatorMap = [ 'Organization' => 'organization-coordinator', 'Bank' => 'bank-coordinator', ]; $managerMap = [ 'Organization' => 'organization-manager', 'Bank' => 'bank-manager', ]; $coordinatorRole = isset($coordinatorMap[$parentType]) ? "{$parentType}\\{$parentId}\\{$coordinatorMap[$parentType]}" : null; if ($coordinatorRole && $user->hasRole($coordinatorRole)) { return $coordinatorMap[$parentType]; } return $managerMap[$parentType] ?? 'organization-manager'; } public function selectedProfile($profile) { if ($profile) { $profile['role'] = null; $this->editAttachProfile['newProfile'] = $profile; } else { $this->editAttachProfile['newProfile'] = null; } $this->syncAttachProfileChangedState(); } public function updatedReActivate() { // If reActivate is set, we assume the user wants to reactivate the profile if ($this->reActivate) { $this->editProfile['inactive_at'] = null; // Clear the inactive date } else { $this->editProfile['inactive_at'] = $this->initProfile['inactive_at']; // Restore the inactive date } $this->syncEditProfileChangedState(); } public function updatedConfirmString($value) { // always assume “bad” until proven good $this->buttonDisabled = true; // explicitly grab the confirmString rules from rules() $ruleSet = ['confirmString' => $this->rules()['confirmString']]; try { // validateOnly needs the keyed map $this->validateOnly('confirmString', $ruleSet); // if no exception, it’s good $this->buttonDisabled = false; // clear out any existing validation error for that field $this->resetErrorBag('confirmString'); } catch (\Illuminate\Validation\ValidationException $e) { // leave it disabled $this->buttonDisabled = true; } } public function updatedEditProfile($newValue, $fieldKey) // $fieldKey is 'name', 'email' etc. { $fieldPath = 'editProfile.' . $fieldKey; // Handle specific field side-effects (e.g., messages) if ($fieldKey === 'email') { if ($newValue !== ($this->initProfile['email'] ?? null)) { $this->editProfileMessages['email'] = __('The email address will be marked as unverified.'); } else { unset($this->editProfileMessages['email']); } } // Real-time validation for the updated field $rules = $this->getProcessedRulesForField($fieldPath, true); if (! empty($rules)) { // ← wrap the rules in a map keyed by the field $this->validateOnly($fieldPath, [ $fieldPath => $rules ]); } $this->syncEditProfileChangedState(); } /** * Recalculate the overall "changed" state. * This method iterates over the list of fields that can be modified. */ protected function syncEditProfileChangedState(): void { $keysToCheck = [ 'inactive_at', 'name', 'full_name', 'email', 'about_short', 'about', 'motivation', 'website', 'phone', 'comment', 'level', // Include level for Bank models ]; $this->editProfileChanged = false; // assume no changes initially foreach ($keysToCheck as $key) { // If a key does not exist in one of the arrays, consider them different. $current = $this->editProfile[$key] ?? null; $initial = $this->initProfile[$key] ?? null; if ($current !== $initial) { $this->editProfileChanged = true; break; } } // Also consider pending email suppression toggle as a change if (!$this->editProfileChanged && isset($this->editProfile['email_suppression_pending'])) { $this->editProfileChanged = true; } } public function updatedEditAttachProfile($newValue, $fieldKey) // $fieldKey is 'name', 'email' etc. { $fieldPath = 'editAttachProfile.' . $fieldKey; $rules = $this->getProcessedRulesForField($fieldPath, true); if (! empty($rules)) { $this->validateOnly($fieldPath, [ $fieldPath => $rules ]); } $this->syncAttachProfileChangedState(); } /** * Recalculate the overall "changed" state. * This method iterates over the list of fields that can be modified. */ protected function syncAttachProfileChangedState(): void { $keysToCheck = ['comment', 'newProfile', 'profiles']; $this->editAttachProfileChanged = false; foreach ($keysToCheck as $key) { $current = $this->editAttachProfile[$key] ?? null; $initial = $this->initAttachProfile[$key] ?? null; if (is_array($current) || is_array($initial)) { if (json_encode($current) !== json_encode($initial)) { $this->editAttachProfileChanged = true; break; } } else { if ($current !== $initial) { $this->editAttachProfileChanged = true; break; } } } } /** * Processes and returns the validation rules for a given field path. * Removes the unique rule when the field has not been updated. * * @param string $fieldPath The dot-notated path to the field (e.g., 'editProfile.email'). * @param bool $isUpdateContext Whether the rules are being processed in an update context. * @return array The processed validation rules for the field. */ protected function getProcessedRulesForField(string $fieldPath, bool $isUpdateContext = false): array { $allBaseRules = $this->rules(); if (!isset($allBaseRules[$fieldPath])) { return []; } $fieldRulesArray = $allBaseRules[$fieldPath]; if ($isUpdateContext && isset($this->editProfile['id']) && isset($this->editProfile['model'])) { $profileId = $this->editProfile['id']; $modelKey = $this->editProfile['model']; $modelClassString = 'App\\Models\\' . Str::studly($modelKey); if (!class_exists($modelClassString)) { return $fieldRulesArray; } $table = (new $modelClassString())->getTable(); $actualFieldName = Str::after($fieldPath, 'editProfile.'); $processedRules = []; $uniqueRuleForCurrentTableAdded = false; foreach ($fieldRulesArray as $rule) { if (is_string($rule) && Str::startsWith(trim($rule), 'unique:')) { // If it's the unique rule for the current table and field, mark it to be replaced if (preg_match("/^unique:{$table},{$actualFieldName}(,|$)/", trim($rule))) { // We will add the ->ignore() version later, so skip this string version $uniqueRuleForCurrentTableAdded = true; // Mark that we've handled this specific one continue; } else { // Keep unique rules for OTHER tables (cross-table uniqueness checks) $processedRules[] = $rule; } } else { $processedRules[] = $rule; // Keep non-unique rules } } // Only add unique rule if there was a unique rule in the original rules if ($uniqueRuleForCurrentTableAdded) { $processedRules[] = Rule::unique($table, $actualFieldName)->ignore($profileId); } return array_values(array_unique($processedRules, SORT_REGULAR)); // Remove duplicates } return $fieldRulesArray; } /** * Update a profile * * @param mixed $translationId * @return void */ public function updateProfile() { // CRITICAL: Authorize admin access for updating profile $this->authorizeAdminAccess(); $this->buttonDisabled = true; // Disable the button until validation is done $profileId = $this->editProfile['id'] ?? null; $modelKey = $this->editProfile['model'] ?? null; if (!$profileId || !$modelKey) { $this->notification()->error(__('Error!'), __('Profile data is incomplete.')); return; } if (!$this->editProfileChanged) { $this->notification()->info(__('No changes'), __('No changes were saved to the profile.')); $this->modalEditProfile = false; return; } $rulesForProfileFields = []; $allBaseRules = $this->rules(); // Get all defined base rules, including for 'confirmString' // Identify changed fields in editProfile and prepare their rules for validation foreach ($this->editProfile as $field => $currentValue) { if (!array_key_exists($field, $this->initProfile) || $currentValue !== $this->initProfile[$field]) { $fieldPath = 'editProfile.' . $field; if (isset($allBaseRules[$fieldPath])) { $processedRules = $this->getProcessedRulesForField($fieldPath, true); if (!empty($processedRules)) { $rulesForProfileFields[$fieldPath] = $processedRules; } } } } // Prepare all rules to be validated in one go. $allRulesToValidate = $rulesForProfileFields; if (isset($allBaseRules['confirmString'])) { $allRulesToValidate['confirmString'] = $allBaseRules['confirmString']; } try { if (empty($allRulesToValidate)) { $this->notification()->info(__('No Changes'), __('No changes requiring validation were made.')); $this->modalEditProfile = false; return; } $this->validate($allRulesToValidate); // If validation passes (including confirmString), proceed with DB transaction DB::transaction(function () use ($modelKey, $rulesForProfileFields) { // Pass only profile field rules for DB update logic $modelClass = 'App\\Models\\' . Str::studly($modelKey); $profile = $modelClass::find($this->editProfile['id']); if ($profile) { $changedFields = []; // Iterate over the keys for fields that were actually part of $this->editProfile and had rules foreach (array_keys($rulesForProfileFields) as $validatedFieldPath) { $actualFieldKey = Str::after($validatedFieldPath, 'editProfile.'); if (array_key_exists($actualFieldKey, $this->editProfile)) { if ($actualFieldKey === 'email' && $profile->email !== $this->editProfile['email']) { $profile->email_verified_at = null; } $profile->{$actualFieldKey} = $this->editProfile[$actualFieldKey]; $changedFields[] = $actualFieldKey; } } $profile->save(); // Send email notification to profile owner about the changes Log::info('ProfileEdited: Checking if email should be sent', [ 'profile_id' => $profile->id, 'profile_type' => get_class($profile), 'changed_fields' => $changedFields, ]); $messageSetting = \App\Models\MessageSetting::where('message_settingable_id', $profile->id) ->where('message_settingable_type', get_class($profile)) ->first(); $sendEmail = $messageSetting ? $messageSetting->system_message : true; Log::info('ProfileEdited: Message setting check', [ 'has_message_setting' => $messageSetting ? 'yes' : 'no', 'system_message' => $messageSetting ? $messageSetting->system_message : 'default:true', 'will_send_email' => $sendEmail ? 'yes' : 'no', ]); if ($sendEmail && !empty($changedFields)) { \App\Jobs\SendProfileEditedByAdminMail::dispatch($profile, $changedFields); Log::info('ProfileEdited: Dispatched email notification', [ 'recipient_email' => $profile->email ?? 'NO EMAIL', 'changed_fields' => $changedFields, ]); } else { Log::info('ProfileEdited: Skipped email notification', [ 'reason' => $sendEmail ? 'no_fields_changed' : 'system_message_disabled', ]); } // Apply pending email suppression toggle if set if (isset($this->editProfile['email_suppression_pending'])) { $emailToToggle = $profile->email; if ($emailToToggle) { if ($this->editProfile['email_suppression_pending']) { \App\Models\MailingBounce::suppressEmail($emailToToggle, 'Manually suppressed by admin'); } else { \App\Models\MailingBounce::where('email', $emailToToggle)->update(['is_suppressed' => false]); } } } $this->resetForm(); $this->notification()->success(__('Saved'), __('The profile has been saved successfully!')); $this->modalEditProfile = false; } else { $this->notification()->error(__('Error!'), __('Profile not found.')); } }); } catch (\Illuminate\Validation\ValidationException $e) { $this->notification()->warning(__('Validation Error'), __('Please correct the errors in the form.')); } catch (\Exception $e) { $this->notification()->error(__('Error!'), __('Oops, we have an error: the profile was not saved!') . ' ' . $e->getMessage()); } } public function toggleEmailSuppression(): void { $this->authorizeAdminAccess(); $current = $this->editProfile['email_suppression_pending'] ?? null; if ($current === null) { // First toggle: flip the live DB state $email = $this->editProfile['email'] ?? null; $liveState = $email ? \App\Models\MailingBounce::isSuppressed($email) : false; $this->editProfile['email_suppression_pending'] = !$liveState; } else { // Subsequent toggles: flip the pending state $this->editProfile['email_suppression_pending'] = !$current; } $this->syncEditProfileChangedState(); } public function openDeleteProfileModal($profileId, $modelName) { $this->adminPassword = ''; $this->balanceHandlingOption = 'delete'; $this->donationAccountId = null; $profile = $modelName::find($profileId); if (!$profile) { $this->notification()->error(__('Error!'), __('Profile not found.')); return; } $this->selectedProfile = $profile; $this->selectedProfileId = $profileId; // Load account information $accounts = collect(); $totalBalance = 0; $hasNegativeBalance = false; if (method_exists($profile, 'accounts')) { $userAccounts = $profile->accounts()->active()->notRemoved()->get(); foreach ($userAccounts as $account) { \Cache::forget("account_balance_{$account->id}"); $balance = $account->balance; $accounts->push([ 'id' => $account->id, 'name' => __('messages.' . $account->name . '_account'), 'balance' => $balance, 'balanceFormatted' => tbFormat($balance), ]); $totalBalance += $balance; if ($balance < 0) { $hasNegativeBalance = true; } } } // Check if central bank $isCentralBank = ($profile instanceof \App\Models\Bank && isset($profile->level) && $profile->level == 0); // Check if final admin $isFinalAdmin = false; if ($profile instanceof \App\Models\Admin) { $activeAdminCount = \App\Models\Admin::whereNull('deleted_at')->count(); if ($activeAdminCount <= 1) { $isFinalAdmin = true; } } $this->deleteProfileData = [ 'name' => $profile->name, 'full_name' => $profile->full_name ?? '', 'email' => $profile->email, 'type' => class_basename($profile), 'accounts' => $accounts->toArray(), 'totalBalance' => $totalBalance, 'hasNegativeBalance' => $hasNegativeBalance, 'isCentralBank' => $isCentralBank, 'isFinalAdmin' => $isFinalAdmin, 'showBalanceOptions' => timebank_config('delete_profile.account_balances.donate_balances_to_organization_account_specified', false), ]; $this->modalDeleteProfile = true; } public function openRestoreProfileModal($profileId, $modelName) { $this->adminPassword = ''; // Models don't use SoftDeletes trait, so we can query directly $profile = $modelName::find($profileId); if (!$profile) { $this->notification()->error(__('Error!'), __('Profile not found.')); return; } if (!$profile->deleted_at) { $this->notification()->error(__('Error!'), __('Profile is not deleted.')); return; } // Check if profile can be restored if (!$this->isProfileRestorable($profile)) { $this->notification()->error(__('Error!'), __('Profile permanently deleted - cannot be restored')); return; } $this->selectedProfile = $profile; $this->selectedProfileId = $profileId; // Calculate grace period information $gracePeriodDays = timebank_config('delete_profile.grace_period_days', 30); $deletedAt = \Carbon\Carbon::parse($profile->deleted_at); $gracePeriodExpiry = $deletedAt->copy()->addDays($gracePeriodDays); $now = \Carbon\Carbon::now(); // Calculate time remaining $secondsRemaining = $now->diffInSeconds($gracePeriodExpiry, false); if ($secondsRemaining <= 0) { $timeRemaining = __('Expired'); } elseif ($secondsRemaining >= 86400 * 7) { $weeks = round($secondsRemaining / (86400 * 7)); $timeRemaining = trans_choice('weeks_remaining', $weeks, ['count' => $weeks]); } elseif ($secondsRemaining >= 86400) { $days = round($secondsRemaining / 86400); $timeRemaining = trans_choice('days_remaining', $days, ['count' => $days]); } elseif ($secondsRemaining >= 3600) { $hours = round($secondsRemaining / 3600); $timeRemaining = trans_choice('hours_remaining', $hours, ['count' => $hours]); } else { $minutes = max(1, round($secondsRemaining / 60)); $timeRemaining = trans_choice('minutes_remaining', $minutes, ['count' => $minutes]); } $this->restoreProfileData = [ 'name' => $profile->name, 'full_name' => $profile->full_name ?? '', 'email' => $profile->email, 'type' => class_basename($profile), 'deletedAt' => $deletedAt->translatedFormat('j F Y, H:i'), 'gracePeriodExpiry' => $gracePeriodExpiry->translatedFormat('j F Y, H:i'), 'timeRemaining' => $timeRemaining, ]; $this->modalRestoreProfile = true; } /** * Attach a profile * * @param mixed $translationId * @return void */ public function attachProfile() { // CRITICAL: Authorize admin access for attaching profile $this->authorizeAdminAccess(); $this->buttonDisabled = true; $profileId = $this->editAttachProfile['id'] ?? null; $modelKey = $this->editAttachProfile['model'] ?? null; if (!$profileId || !$modelKey) { $this->notification()->error(__('Error!'), __('Profile data is incomplete.')); return; } if (!$this->editAttachProfileChanged) { $this->notification()->info(__('No changes'), __('No changes were saved to the profile.')); $this->modalAttachProfile = false; return; } // --- ALWAYS add newProfile to profiles array before validation/sync --- if (!empty($this->editAttachProfile['newProfile'])) { $selectedProfile = $this->editAttachProfile['newProfile']; $selectedProfileId = $selectedProfile['id'] ?? null; $selectedProfileModel = class_basename($selectedProfile['type'] ?? ''); if (!$profileId || !$modelKey || !$selectedProfileId || !$selectedProfileModel) { $this->notification()->error(__('Error!'), __('Profile data is incomplete!')); return; } // Validate role selection before proceeding $roleValue = $selectedProfile['role'] ?? null; $roleRequired = !in_array($selectedProfileModel, ['User', 'Admin']); if ($roleRequired && empty($roleValue)) { $this->addError('editAttachProfile.newProfile.role', __('A role is required for the selected profile.')); $this->buttonDisabled = false; return; } $alreadyExists = collect($this->editAttachProfile['profiles'] ?? []) ->contains(function ($profile) use ($selectedProfileId, $selectedProfileModel) { return $profile['id'] == $selectedProfileId && $profile['type'] == $selectedProfileModel; }); if (!$alreadyExists) { $this->editAttachProfile['profiles'][] = [ 'id' => $selectedProfileId, 'type' => $selectedProfileModel, 'role' => $roleValue, ]; } $this->editAttachProfile['newProfile'] = null; } $rulesForProfileFields = []; $allBaseRules = $this->rules(); foreach ($this->editAttachProfile as $field => $currentValue) { if (!array_key_exists($field, $this->initAttachProfile) || $currentValue !== $this->initAttachProfile[$field]) { $fieldPath = 'editAttachProfile.' . $field; if (isset($allBaseRules[$fieldPath])) { $processedRules = $this->getProcessedRulesForField($fieldPath, true); if (!empty($processedRules)) { $rulesForProfileFields[$fieldPath] = $processedRules; } } } } $allRulesToValidate = $rulesForProfileFields; if (isset($allBaseRules['confirmString'])) { $allRulesToValidate['confirmString'] = $allBaseRules['confirmString']; } try { if (empty($allRulesToValidate)) { $this->notification()->info(__('No Changes'), __('No changes requiring validation were made.')); $this->modalAttachProfile = false; return; } $this->validate($allRulesToValidate); DB::transaction(function () use ($modelKey, $rulesForProfileFields) { $modelClass = 'App\\Models\\' . Str::studly($modelKey); $profile = $modelClass::find($this->editAttachProfile['id']); if ($profile) { foreach (array_keys($rulesForProfileFields) as $validatedFieldPath) { $actualFieldKey = Str::after($validatedFieldPath, 'editAttachProfile.'); if (array_key_exists($actualFieldKey, $this->editAttachProfile)) { $profile->{$actualFieldKey} = $this->editAttachProfile[$actualFieldKey]; } } $comment = $this->editAttachProfile['comment'] ?? null; $profile->comment = $comment; $profile->save(); $relationMap = [ 'user' => [ 'Organization' => 'organizations', 'Bank' => 'banksManaged', 'Admin' => 'admins', ], 'organization' => [ 'User' => 'users', ], 'bank' => [ 'User' => 'managers', ], 'admin' => [ 'User' => 'users', ], ]; $roleMap = [ 'Organization' => ['organization-manager'], 'Bank' => ['bank-manager'], 'Admin' => ['admin'], ]; // Allowed roles per profile type (for validation). // Admin is intentionally excluded: admins always receive the 'admin' role // regardless of any client-submitted role value (safe by design). $allowedRolesMap = [ 'Organization' => ['organization-manager', 'organization-coordinator'], 'Bank' => ['bank-manager', 'bank-coordinator'], ]; $currentProfiles = collect($this->editAttachProfile['profiles'] ?? []) ->reject(fn($profile) => !empty($profile['removed'])) ->values() ->all(); $initialProfiles = $this->initAttachProfile['profiles'] ?? []; $modelRelations = $relationMap[$modelKey] ?? []; $roles = []; foreach ($modelRelations as $type => $relation) { $currentIds = $this->getIdsByType($currentProfiles, $type); $profile->{$relation}()->sync($currentIds); // Sync attached profiles if ($profile instanceof User) { // For each attached profile, create and assign a profile-specific role foreach ($currentIds as $attachedId) { if (isset($roleMap[$type])) { // Read the role selected in the modal for this linked profile entry $profileEntry = collect($currentProfiles) ->first(fn($p) => $p['id'] == $attachedId && $p['type'] === $type); $selectedRole = $profileEntry['role'] ?? null; if (isset($allowedRolesMap[$type]) && in_array($selectedRole, $allowedRolesMap[$type])) { $baseRole = $selectedRole; } else { $baseRole = $roleMap[$type][0]; // default: manager } // Remove any previous coordinator/manager role before assigning new one if (isset($allowedRolesMap[$type])) { foreach ($allowedRolesMap[$type] as $oldBase) { $oldRoleName = "{$type}\\{$attachedId}\\{$oldBase}"; if ($profile->hasRole($oldRoleName)) { $profile->removeRole($oldRoleName); } } } $roleName = "{$type}\\{$attachedId}\\{$baseRole}"; $role = Role::findOrCreate($roleName, 'web'); $permissionName = 'manage ' . strtolower($type) . 's'; $role->givePermissionTo($permissionName); $roles[] = $roleName; if ($baseRole === 'admin') { $role->syncPermissions(\Spatie\Permission\Models\Permission::all()); } } } } else { // $profile is Organization, Bank, or Admin $parentType = class_basename($profile); // e.g. 'Organization' $parentId = $profile->id; foreach ($currentIds as $attachedUserId) { $user = \App\Models\User::find($attachedUserId); if ($user) { // Determine selected role from profile entry, fall back to manager default $profileEntry = collect($currentProfiles) ->first(fn($p) => $p['id'] == $attachedUserId && $p['type'] === 'User'); $selectedRole = $profileEntry['role'] ?? null; if (isset($allowedRolesMap[$parentType]) && in_array($selectedRole, $allowedRolesMap[$parentType])) { $baseRole = $selectedRole; } else { $baseRole = $roleMap[$parentType][0] ?? 'organization-manager'; } // Remove any previous coordinator/manager role for this user+profile before assigning new one if (isset($allowedRolesMap[$parentType])) { foreach ($allowedRolesMap[$parentType] as $oldBase) { $oldRoleName = "{$parentType}\\{$parentId}\\{$oldBase}"; if ($user->hasRole($oldRoleName)) { $user->removeRole($oldRoleName); } } } $roleName = "{$parentType}\\{$parentId}\\{$baseRole}"; $role = Role::findOrCreate($roleName, 'web'); $permissionName = 'manage ' . strtolower($parentType) . 's'; $role->givePermissionTo($permissionName); if ($baseRole === 'admin') { $role->syncPermissions(\Spatie\Permission\Models\Permission::all()); } // Assign the role to the user $user->assignRole($roleName); } } } } // --- Also assign / revoke / clean-up roles when $profile is a User --- // // Remove duplicate roles $roles = array_unique($roles); // Remember roles that were previously assigned to this profile $previousRoles = $profile->roles->pluck('name')->toArray(); if ($profile instanceof User) { $user = $profile; $willHavePermission = false; // Test: Will the user have 'manage profiles' after sync? // 1. Get all permissions from the roles that will be assigned $newRoles = Role::whereIn('name', $roles)->get(); foreach ($newRoles as $role) { if ($role->hasPermissionTo('manage profiles', 'web')) { $willHavePermission = true; break; } } // 2. If user currently has permission but will lose it, abort if ($user->hasPermissionTo('manage profiles', 'web') && !$willHavePermission && $profile->id === Auth::guard('web')->user()->id) { throw new \Exception(__('This action would remove your own "manage profiles" permission. Login with another account that can manage profiles and try again.')); } // Now sync roles $profile->syncRoles($roles); } // 1. Find detached profiles (present in initialProfiles but not in currentProfiles) $detachedProfiles = collect($initialProfiles) ->reject(function ($prev) use ($currentProfiles) { return collect($currentProfiles)->contains(function ($curr) use ($prev) { return $curr['id'] == $prev['id'] && $curr['type'] == $prev['type']; }); }) ->values() ->all(); // 2. Remove the corresponding role from each detached profile $parentType = class_basename($profile); // e.g. 'Organization' $parentId = $profile->id; // All possible base roles per type (manager + coordinator) $allRolesMap = [ 'Organization' => ['organization-manager', 'organization-coordinator'], 'Bank' => ['bank-manager', 'bank-coordinator'], 'Admin' => ['admin'], ]; foreach ($detachedProfiles as $detached) { // Only process if detached profile is a User if (($detached['type'] ?? '') === 'User') { $user = \App\Models\User::find($detached['id']); if ($user && isset($allRolesMap[$parentType])) { foreach ($allRolesMap[$parentType] as $baseRole) { $roleName = "{$parentType}\\{$parentId}\\{$baseRole}"; $user->removeRole($roleName); $role = Role::where('name', $roleName)->first(); if ($role && $role->users()->count() === 0) { $role->delete(); } } } } } // 3. Find roles that were removed and delete any stray roles that do not have a related profile $removedRoles = array_diff($previousRoles, $roles); foreach ($removedRoles as $roleName) { $role = Role::where('name', $roleName)->first(); if ($role && $role->users()->count() === 0) { $role->delete(); } } // Send email notifications for profile link changes Log::info('ProfileLinkChanged: Starting email notification process', [ 'profile_id' => $profile->id, 'profile_type' => get_class($profile), 'profile_name' => $profile->name, ]); // 1. Find newly attached profile(s) $newlyAttached = []; foreach ($modelRelations as $type => $relation) { $currentIds = $this->getIdsByType($currentProfiles, $type); $initialIds = $this->getIdsByType($initialProfiles, $type); // Find IDs that are in current but not in initial (i.e., newly attached) $addedIds = array_diff($currentIds, $initialIds); foreach ($addedIds as $addedId) { $newlyAttached[] = [ 'id' => $addedId, 'type' => $type, ]; } } Log::info('ProfileLinkChanged: Found newly attached profiles', [ 'count' => count($newlyAttached), 'attached' => $newlyAttached, ]); // 2. Send emails for newly attached profiles foreach ($newlyAttached as $attached) { $attachedModelClass = 'App\\Models\\' . $attached['type']; $attachedProfile = $attachedModelClass::find($attached['id']); if ($attachedProfile) { Log::info('ProfileLinkChanged: Processing attached profile', [ 'attached_profile_id' => $attachedProfile->id, 'attached_profile_type' => get_class($attachedProfile), 'attached_profile_name' => $attachedProfile->name, 'attached_profile_email' => $attachedProfile->email ?? 'NO EMAIL', ]); // Send email to the attached profile $messageSetting = \App\Models\MessageSetting::where('message_settingable_id', $attachedProfile->id) ->where('message_settingable_type', get_class($attachedProfile)) ->first(); $sendEmail = $messageSetting ? $messageSetting->system_message : true; Log::info('ProfileLinkChanged: Attached profile message setting check', [ 'has_message_setting' => $messageSetting ? 'yes' : 'no', 'system_message' => $messageSetting ? $messageSetting->system_message : 'default:true', 'will_send_email' => $sendEmail ? 'yes' : 'no', ]); if ($sendEmail) { \App\Jobs\SendProfileLinkChangedMail::dispatch($attachedProfile, $profile, 'attached'); Log::info('ProfileLinkChanged: Dispatched email to attached profile', [ 'recipient_email' => $attachedProfile->email, 'linked_profile_name' => $profile->name, ]); } else { Log::info('ProfileLinkChanged: Skipped email to attached profile (system_message disabled)'); } // Send email to the main profile $profileMessageSetting = \App\Models\MessageSetting::where('message_settingable_id', $profile->id) ->where('message_settingable_type', get_class($profile)) ->first(); $sendProfileEmail = $profileMessageSetting ? $profileMessageSetting->system_message : true; Log::info('ProfileLinkChanged: Main profile message setting check', [ 'has_message_setting' => $profileMessageSetting ? 'yes' : 'no', 'system_message' => $profileMessageSetting ? $profileMessageSetting->system_message : 'default:true', 'will_send_email' => $sendProfileEmail ? 'yes' : 'no', ]); if ($sendProfileEmail) { \App\Jobs\SendProfileLinkChangedMail::dispatch($profile, $attachedProfile, 'attached'); Log::info('ProfileLinkChanged: Dispatched email to main profile', [ 'recipient_email' => $profile->email ?? 'NO EMAIL', 'linked_profile_name' => $attachedProfile->name, ]); } else { Log::info('ProfileLinkChanged: Skipped email to main profile (system_message disabled)'); } } else { Log::warning('ProfileLinkChanged: Attached profile not found', [ 'attached_id' => $attached['id'], 'attached_type' => $attached['type'], ]); } } // 3. Send emails for detached profiles Log::info('ProfileLinkChanged: Found detached profiles', [ 'count' => count($detachedProfiles), 'detached' => $detachedProfiles, ]); foreach ($detachedProfiles as $detached) { $detachedModelClass = 'App\\Models\\' . $detached['type']; $detachedProfile = $detachedModelClass::find($detached['id']); if ($detachedProfile) { Log::info('ProfileLinkChanged: Processing detached profile', [ 'detached_profile_id' => $detachedProfile->id, 'detached_profile_type' => get_class($detachedProfile), 'detached_profile_name' => $detachedProfile->name, 'detached_profile_email' => $detachedProfile->email ?? 'NO EMAIL', ]); // Send email to the detached profile $messageSetting = \App\Models\MessageSetting::where('message_settingable_id', $detachedProfile->id) ->where('message_settingable_type', get_class($detachedProfile)) ->first(); $sendEmail = $messageSetting ? $messageSetting->system_message : true; Log::info('ProfileLinkChanged: Detached profile message setting check', [ 'has_message_setting' => $messageSetting ? 'yes' : 'no', 'system_message' => $messageSetting ? $messageSetting->system_message : 'default:true', 'will_send_email' => $sendEmail ? 'yes' : 'no', ]); if ($sendEmail) { \App\Jobs\SendProfileLinkChangedMail::dispatch($detachedProfile, $profile, 'detached'); Log::info('ProfileLinkChanged: Dispatched email to detached profile', [ 'recipient_email' => $detachedProfile->email, 'linked_profile_name' => $profile->name, ]); } else { Log::info('ProfileLinkChanged: Skipped email to detached profile (system_message disabled)'); } // Send email to the main profile $profileMessageSetting = \App\Models\MessageSetting::where('message_settingable_id', $profile->id) ->where('message_settingable_type', get_class($profile)) ->first(); $sendProfileEmail = $profileMessageSetting ? $profileMessageSetting->system_message : true; Log::info('ProfileLinkChanged: Main profile message setting check (detached)', [ 'has_message_setting' => $profileMessageSetting ? 'yes' : 'no', 'system_message' => $profileMessageSetting ? $profileMessageSetting->system_message : 'default:true', 'will_send_email' => $sendProfileEmail ? 'yes' : 'no', ]); if ($sendProfileEmail) { \App\Jobs\SendProfileLinkChangedMail::dispatch($profile, $detachedProfile, 'detached'); Log::info('ProfileLinkChanged: Dispatched email to main profile (detached)', [ 'recipient_email' => $profile->email ?? 'NO EMAIL', 'unlinked_profile_name' => $detachedProfile->name, ]); } else { Log::info('ProfileLinkChanged: Skipped email to main profile (system_message disabled)'); } } else { Log::warning('ProfileLinkChanged: Detached profile not found', [ 'detached_id' => $detached['id'], 'detached_type' => $detached['type'], ]); } } $this->resetForm(); $this->notification()->success(__('Saved'), __('The profile has been saved successfully!')); // Uncomment to send wirechat message to the new Profile when it is added to the $profile // This block is commented because currently no other models than User could chat with chat. // // 1. Find newly attached profile(s) // $newlyAttached = []; // foreach ($modelRelations as $type => $relation) { // $currentIds = $this->getIdsByType($currentProfiles, $type); // $initialIds = $this->getIdsByType($initialProfiles, $type); // // Find IDs that are in current but not in initial (i.e., newly attached) // $addedIds = array_diff($currentIds, $initialIds); // foreach ($addedIds as $addedId) { // $newlyAttached[] = [ // 'id' => $addedId, // 'type' => $type, // ]; // } // } // // 2. Get the model instance for the first newly attached profile (if any) // if (!empty($newlyAttached)) { // $recipientInfo = $newlyAttached[0]; // $recipientModelClass = 'App\\Models\\' . $recipientInfo['type']; // $recipient = $recipientModelClass::find($recipientInfo['id']); // if ($recipient) { // $sender = $profile; // $messageLocale = $recipient->lang_preference ?? $sender->lang_preference; // if (!Lang::has('messages.manage_profiles.attached_profile_chat_message', $messageLocale)) { // $messageLocale = config('app.fallback_locale'); // } // $chatMessage = __('messages.manage_profiles.attached_profile_chat_message', [], $messageLocale); // // Send Wirechat message // $message = $sender->sendMessageTo($recipient, $chatMessage); // // Broadcast the NotifyParticipant event to wirechat messenger // broadcast(new NotifyParticipant($recipient, $message)); // } // } $this->modalAttachProfile = false; } else { $this->notification()->error(__('Error!'), __('Profile not found.')); } }); } catch (\Illuminate\Validation\ValidationException $e) { $this->notification()->warning(__('Validation Error'), __('Please correct the errors in the form.')); } catch (\Exception $e) { $this->notification()->error(__('Error!'), __('Oops, we have an error: the profile was not saved!') . ' ' . $e->getMessage()); } } // Helper function for attachProfile method private function getIdsByType($profiles, $type) { return collect($profiles) ->where('type', $type) ->pluck('id') ->map(fn($id) => (int)$id) ->unique() ->values() ->toArray(); } public function removeAttachedProfile($id, $type) { $this->editAttachProfile['profiles'] = array_map(function ($profile) use ($id, $type) { if ( isset($profile['id'], $profile['type']) && $profile['id'] == $id && strtolower($profile['type']) == strtolower($type) ) { $profile['removed'] = true; // Mark as removed } return $profile; }, $this->editAttachProfile['profiles']); $this->syncAttachProfileChangedState(); } /** * Delete the tag * * @param mixed $translationId * @return void */ public function deleteProfile() { // CRITICAL: Authorize admin access for deleting profile $this->authorizeAdminAccess(); $this->validate([ 'adminPassword' => ['required', 'string'], ]); // Check if donation would exceed limits if ($this->balanceHandlingOption === 'donate' && $this->donationExceedsLimit) { $this->addError('donationAccountId', $this->donationLimitError ?? __('The selected organization account cannot receive this donation amount.')); return; } try { $profile = $this->selectedProfile; if (!$profile) { throw new \Exception('Profile not found.'); } // Verify admin password $admin = getActiveProfile(); if (!$admin instanceof \App\Models\Admin) { throw new \Exception('Only administrators can delete profiles.'); } if (!\Hash::check($this->adminPassword, $admin->password)) { $this->addError('adminPassword', __('This password does not match our records.')); return; } // Get the profile's updated_at timestamp for the email $profileTable = $profile->getTable(); $time = DB::table($profileTable) ->where('id', $profile->id) ->pluck('updated_at') ->first(); $time = \Carbon\Carbon::parse($time); // Get the admin username who is deleting the profile $deletedByUsername = \Auth::guard('web')->user()->name ?? null; // Use the DeleteUser action $deleter = app(\Laravel\Jetstream\Contracts\DeletesUsers::class); $result = $deleter->delete( $profile->fresh(), $this->balanceHandlingOption, $this->donationAccountId, false, // isAutoDeleted = false $deletedByUsername // admin username ); if ($result['status'] === 'success') { // Prepare email data $result['time'] = $time->translatedFormat('j F Y, H:i'); $result['deletedUser'] = $profile; $result['mail'] = $profile->email; $result['balanceHandlingOption'] = $this->balanceHandlingOption; $result['totalBalance'] = $this->deleteProfileData['totalBalance'] ?? 0; $result['donationAccountId'] = $this->donationAccountId; // 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; } } // Send confirmation email Log::notice('Profile deleted by admin: ' . $profile->name); \Mail::to($profile->email)->queue(new \App\Mail\UserDeletedMail($result)); $this->notification()->success( __('Deleted'), __('Profile was deleted successfully!') ); $this->resetForm(); $this->resetPage(); $this->dispatch('scroll-to-top'); } else { $this->notification()->error( __('Error!'), __('There was an error deleting the profile: ') . $result['message'] ); } } catch (\Exception $e) { $this->notification()->error( __('Error!'), __('Could not delete the profile: ') . $e->getMessage() ); Log::error('Admin profile deletion failed', [ 'profile_id' => $this->selectedProfileId, 'error' => $e->getMessage() ]); } $this->modalDeleteProfile = false; } /** * Restore a deleted profile * * @return void */ public function restoreProfile() { // CRITICAL: Authorize admin access for restoring profile $this->authorizeAdminAccess(); $this->validate([ 'adminPassword' => ['required', 'string'], ]); try { $profile = $this->selectedProfile; if (!$profile) { throw new \Exception('Profile not found.'); } // Verify admin password $admin = getActiveProfile(); if (!$admin instanceof \App\Models\Admin) { throw new \Exception('Only administrators can restore profiles.'); } if (!\Hash::check($this->adminPassword, $admin->password)) { $this->addError('adminPassword', __('This password does not match our records.')); return; } // Use the RestoreProfile action $restorer = new \App\Actions\Jetstream\RestoreProfile(); $result = $restorer->restore($profile->fresh()); if ($result['status'] === 'success') { $this->notification()->success( __('Restored'), __('Profile was restored successfully!') ); Log::notice('Profile restored by admin: ' . $profile->name, [ 'profile_id' => $profile->id, 'profile_type' => get_class($profile), 'admin_id' => $admin->id, 'admin_name' => $admin->name, ]); $this->resetForm(); $this->resetPage(); $this->dispatch('scroll-to-top'); } else { $this->notification()->error( __('Error!'), __('There was an error restoring the profile: ') . $result['message'] ); } } catch (\Exception $e) { $this->notification()->error( __('Error!'), __('Could not restore the profile: ') . $e->getMessage() ); Log::error('Admin profile restoration failed', [ 'profile_id' => $this->selectedProfileId, 'error' => $e->getMessage() ]); } $this->modalRestoreProfile = false; } public function deleteSelected() { // CRITICAL: Authorize admin access for bulk deleting profiles $this->authorizeAdminAccess(); $this->validateOnly('confirmString'); try { $tags = User::whereIn('id', $this->bulkSelected); if ($tags) { $tags->delete(); $this->notification()->success( $title = __('Deleted'), $description = __('Selected tags were deleted successfully!') ); } else { $this->notification()->error( $title = __('Error!'), $description = __('The selected tags were not found.') ); } } catch (\Exception $e) { $this->notification()->error( $title = __('Error!'), $description = __('Oops, could not delete the selected tags') . '!' . $e->getMessage() ); } $this->resetPage(); $this->dispatch('scroll-to-top'); $this->confirmString = ''; } public function resetForm() { $this->reset([ 'selectedProfileId', 'selectedProfile', 'modalEditProfile', 'modalAttachProfile', 'modalEditAccounts', 'modalDeleteProfile', 'modalRestoreProfile', 'initProfile', 'initAttachProfile', 'initAccounts', 'editProfile', 'editAttachProfile', 'editAccounts', 'editProfileChanged', 'editAttachProfileChanged', 'editAccountsChanged', 'editProfileMessages', 'editAccountsMessages', 'confirmString', 'buttonDisabled', 'reActivate', 'deleteProfileData', 'restoreProfileData', 'adminPassword', 'balanceHandlingOption', 'donationAccountId', 'donationExceedsLimit', 'donationLimitError', ]); $this->resetErrorBag(); } public function sortBy(string $field): void { if ($this->sortField === $field) { $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; } else { $this->sortDirection = 'asc'; } $this->sortField = $field; $this->resetPage(); $this->dispatch('scroll-to-top'); } public function searchProfiles() { $this->resetPage(); $this->dispatch('scroll-to-top'); } public function handleSearchEnter() { if (!$this->showModal) { $this->searchProfiles(); } } public function updatingSearch() { $this->resetPage(); } public function resetSearch() { $this->search = ''; $this->searchProfiles(); $this->dispatch('scroll-to-top'); } public function updatedPage() { $this->dispatch('scroll-to-top'); } public function updatingPerPage() { $this->resetPage(); $this->dispatch('scroll-to-top'); } public function updatedTypeFilter() { $this->resetPage(); $this->dispatch('scroll-to-top'); } public function updatedActiveFilter() { $this->resetPage(); $this->dispatch('scroll-to-top'); } public function updatedEmailVerifiedFilter() { $this->resetPage(); $this->dispatch('scroll-to-top'); } /** * Get available profile type options for the filter dropdown. * * @return array */ public function getTypeOptionsProperty(): array { return [ ['id' => 'user', 'name' => __('User')], ['id' => 'organization', 'name' => __('Organization')], ['id' => 'bank', 'name' => __('Bank')], ['id' => 'admin', 'name' => __('Admin')], ]; } /** * Get available active status options for the filter dropdown. * * @return array */ public function getActiveOptionsProperty(): array { return [ ['id' => 'active', 'name' => __('Active')], ['id' => 'inactive', 'name' => __('Inactive')], ['id' => 'deleted', 'name' => __('Deleted')], ]; } /** * Get available email verification options for the filter dropdown. * * @return array */ public function getEmailVerifiedOptionsProperty(): array { return [ ['id' => 'verified', 'name' => __('Verified')], ['id' => 'unverified', 'name' => __('Not verified')], ['id' => 'blocked', 'name' => __('Blocked')], ]; } /** * Check if a deleted profile can be restored. * * @param mixed $profile * @return bool */ protected function isProfileRestorable($profile) { // If profile is not deleted, it can't be restored if (!$profile->deleted_at) { return false; } // Check if profile has been permanently deleted (anonymized email) if (str_starts_with($profile->email, 'removed-') && str_ends_with($profile->email, '@remove.ed')) { return false; } // Check if password is empty (permanently deleted) if (empty($profile->password)) { return false; } // Check if grace period has expired $gracePeriodDays = timebank_config('delete_profile.grace_period_days', 30); $gracePeriodExpiry = \Carbon\Carbon::parse($profile->deleted_at)->addDays($gracePeriodDays); if (now()->isAfter($gracePeriodExpiry)) { return false; } return true; } public function render() { // --- 1. Define Base Queries --- $usersQuery = User::query() ->select('id', 'name', 'full_name', 'email', 'password', 'profile_photo_path', 'comment', 'inactive_at', 'email_verified_at', 'last_login_at', 'deleted_at', 'created_at', 'updated_at'); $organizationsQuery = Organization::query() ->select('id', 'name', 'full_name', 'email', 'password', 'profile_photo_path', 'comment', 'inactive_at', 'email_verified_at', 'last_login_at', 'deleted_at', 'created_at', 'updated_at'); $banksQuery = Bank::query() ->select('id', 'name', 'full_name', 'email', 'password', 'profile_photo_path', 'comment', 'inactive_at', 'email_verified_at', 'last_login_at', 'deleted_at', 'created_at', 'updated_at'); $adminsQuery = Admin::query() ->select('id', 'name', 'full_name', 'email', 'password', 'profile_photo_path', 'comment', 'inactive_at', 'email_verified_at', 'last_login_at', 'deleted_at', 'created_at', 'updated_at'); // --- 2. Apply Search --- if ($this->search) { $searchTerm = '%' . $this->search . '%'; $usersQuery->where(function (Builder $query) use ($searchTerm) { $query->where('name', 'like', $searchTerm) ->orWhere('full_name', 'like', $searchTerm) ->orWhere('email', 'like', $searchTerm) ->orWhere('comment', 'like', $searchTerm) ->orWhere('id', 'like', $searchTerm); }); $organizationsQuery->where(function (Builder $query) use ($searchTerm) { $query->where('name', 'like', $searchTerm) ->orWhere('full_name', 'like', $searchTerm) ->orWhere('email', 'like', $searchTerm) ->orWhere('comment', 'like', $searchTerm) ->orWhere('id', 'like', $searchTerm); }); $banksQuery->where(function (Builder $query) use ($searchTerm) { $query->where('name', 'like', $searchTerm) ->orWhere('full_name', 'like', $searchTerm) ->orWhere('email', 'like', $searchTerm) ->orWhere('comment', 'like', $searchTerm) ->orWhere('id', 'like', $searchTerm); }); $adminsQuery->where(function (Builder $query) use ($searchTerm) { $query->where('name', 'like', $searchTerm) ->orWhere('full_name', 'like', $searchTerm) ->orWhere('email', 'like', $searchTerm) ->orWhere('comment', 'like', $searchTerm) ->orWhere('id', 'like', $searchTerm); }); } // --- 3. Fetch Data (conditionally based on type filter) --- $users = collect(); $organizations = collect(); $banks = collect(); $admins = collect(); if (!$this->typeFilter || $this->typeFilter === 'user') { $users = $usersQuery->get(); } if (!$this->typeFilter || $this->typeFilter === 'organization') { $organizations = $organizationsQuery->get(); } if (!$this->typeFilter || $this->typeFilter === 'bank') { $banks = $banksQuery->get(); } if (!$this->typeFilter || $this->typeFilter === 'admin') { $admins = $adminsQuery->get(); } // --- 4. Combine Collections & Add Type --- // Pre-fetch all suppressed emails in one query to avoid N+1 $allEmails = collect([$users, $organizations, $banks, $admins]) ->flatten() ->pluck('email') ->filter() ->unique() ->values(); $suppressedEmails = \App\Models\MailingBounce::whereIn('email', $allEmails) ->where('is_suppressed', true) ->pluck('email') ->flip(); // flip for O(1) lookup $combined = new Collection(); foreach ($users as $user) { $user->type = __('User'); $user->model = 'App\Models\User'; $user->inactive = $this->dateStatus($user->inactive_at); $user->email_suppressed = isset($suppressedEmails[$user->email]); $user->email_verif = $user->email_suppressed ? __('Blocked') : $this->dateStatus($user->email_verified_at); $user->deleted = $this->dateStatus($user->deleted_at); $user->is_restorable = $this->isProfileRestorable($user); $combined->push($user); } foreach ($organizations as $org) { $org->type = __('Organization'); $org->model = 'App\Models\Organization'; $org->inactive = $this->dateStatus($org->inactive_at); $org->email_suppressed = isset($suppressedEmails[$org->email]); $org->email_verif = $org->email_suppressed ? __('Blocked') : $this->dateStatus($org->email_verified_at); $org->deleted = $this->dateStatus($org->deleted_at); $org->is_restorable = $this->isProfileRestorable($org); $combined->push($org); } foreach ($banks as $bank) { $bank->type = __('Bank'); $bank->model = 'App\Models\Bank'; $bank->inactive = $this->dateStatus($bank->inactive_at); $bank->email_suppressed = isset($suppressedEmails[$bank->email]); $bank->email_verif = $bank->email_suppressed ? __('Blocked') : $this->dateStatus($bank->email_verified_at); $bank->deleted = $this->dateStatus($bank->deleted_at); $bank->is_restorable = $this->isProfileRestorable($bank); $combined->push($bank); } foreach ($admins as $admin) { $admin->type = __('Admin'); $admin->model = 'App\Models\Admin'; $admin->inactive = $this->dateStatus($admin->inactive_at); $admin->email_suppressed = isset($suppressedEmails[$admin->email]); $admin->email_verif = $admin->email_suppressed ? __('Blocked') : $this->dateStatus($admin->email_verified_at); $admin->deleted = $this->dateStatus($admin->deleted_at); $admin->is_restorable = $this->isProfileRestorable($admin); $combined->push($admin); } // --- 5. Apply Active and Email Verified Filters --- if ($this->activeFilter) { $combined = $combined->filter(function ($profile) { if ($this->activeFilter === 'active') { return !$profile->inactive_at && !$profile->deleted_at; } elseif ($this->activeFilter === 'inactive') { return $profile->inactive_at && !$profile->deleted_at; } elseif ($this->activeFilter === 'deleted') { return $profile->deleted_at !== null; } return true; }); } if ($this->emailVerifiedFilter) { $combined = $combined->filter(function ($profile) { if ($this->emailVerifiedFilter === 'verified') { return $profile->email_verified_at !== null && !$profile->email_suppressed; } elseif ($this->emailVerifiedFilter === 'unverified') { return $profile->email_verified_at === null && !$profile->email_suppressed; } elseif ($this->emailVerifiedFilter === 'blocked') { return $profile->email_suppressed; } return true; }); } // --- 6. Sort Combined Collection --- $sortField = $this->sortField; $sortDirection = $this->sortDirection === 'asc' ? false : true; if (in_array($sortField, ['name', 'inactive', 'email_verif', 'created_at', 'updated_at', 'type', 'id', 'last_login_at', 'comment'])) { $combined = $combined->sortBy($sortField, SORT_REGULAR, $sortDirection); } else { $combined = $combined->sortBy('created_at', SORT_REGULAR, $sortDirection); } // --- 7. Use proper Livewire pagination --- // Get current page using the correct method $currentPage = $this->getPage(); $perPage = $this->perPage; $currentPageItems = $combined->slice(($currentPage - 1) * $perPage, $perPage)->values(); $profilesPaginator = new LengthAwarePaginator( $currentPageItems, $combined->count(), $perPage, $currentPage, [ 'path' => request()->url(), 'pageName' => 'page', ] ); return view('livewire.profiles.manage', [ 'profiles' => $profilesPaginator, ]); } }