Files
Ronald Huynen 2547717edb Initial commit
2026-03-23 21:37:59 +01:00

2155 lines
89 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Livewire\Profiles;
use App\Http\Livewire\Traits\RequiresAdminAuthorization;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use App\Traits\AccountInfoTrait;
use App\Traits\DateTimeTrait;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Component;
use Livewire\WithPagination;
use Namu\WireChat\Events\NotifyParticipant;
use Spatie\Permission\Models\Role;
use WireUi\Traits\WireUiActions;
class Manage extends Component
{
use WithPagination;
use WireUiActions;
use DateTimeTrait;
use AccountInfoTrait;
use RequiresAdminAuthorization;
public string $search = '';
public ?string $typeFilter = null;
public ?string $activeFilter = null;
public ?string $emailVerifiedFilter = null;
public bool $showModal = false;
public $createTranslation;
public int $profileId;
public bool $buttonDisabled = true;
public string $confirmString;
public int $categoryId;
public int $profile;
public bool $modalEditAccounts = false;
public bool $modalEditProfile = false;
public bool $modalAttachProfile = false;
public bool $modalDeleteProfile = false;
public bool $modalRestoreProfile = false;
public $selectedProfile;
public int $selectedProfileId; // Needed for the modals
public $deleteProfileData = null;
public $restoreProfileData = null;
public string $balanceHandlingOption = 'delete';
public $donationAccountId = null;
public string $adminPassword = '';
public bool $donationExceedsLimit = false;
public $donationLimitError = null;
public $initProfile = [];
public $editProfile = [];
public bool $editProfileChangedName = false;
public $editProfileMessages = [];
public bool $editProfileChanged = false;
public bool $reActivate = false;
public $initAttachProfile = [];
public $editAttachProfile = [];
public $newTypesAvailable = [];
public bool $editAttachProfileChanged = false;
public $initAccounts = [];
public $editAccounts = [];
public $editAccountsMessages = [];
public bool $editAccountsChanged = false;
public string $sortField = 'created_at'; // Default sort field
public string $sortDirection = 'desc'; // Default sort direction
public $perPage = 10; // default 10 results per page
public $page;
protected $listeners = [
'profileCreated' => '$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, its 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,
]);
}
}