From d9c4cf2d7eec19ef34e5d8e3eaa4f43f57952573 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Mon, 15 Jun 2026 12:15:10 +0200 Subject: [PATCH] Update bulk user actions --- .../Controllers/Admin/AdminUserController.php | 62 +++++++++++++------ .../js/alpine/components/admin/features.js | 60 ++++++++++++++++-- resources/views/admin/users/index.blade.php | 20 +++--- .../Feature/Admin/AdminUserControllerTest.php | 27 ++++++++ 4 files changed, 136 insertions(+), 33 deletions(-) diff --git a/app/Http/Controllers/Admin/AdminUserController.php b/app/Http/Controllers/Admin/AdminUserController.php index 5ec7d012a..653d300da 100644 --- a/app/Http/Controllers/Admin/AdminUserController.php +++ b/app/Http/Controllers/Admin/AdminUserController.php @@ -13,6 +13,7 @@ use App\Models\UserDownload; use App\Models\UserRequest; use App\Services\AdminDashboardSnapshotService; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -317,7 +318,7 @@ class AdminUserController extends BasePageController public function bulkAction(Request $request): RedirectResponse { $validated = $request->validate([ - 'action' => ['required', 'string', 'in:delete,verify'], + 'action' => ['required', 'string', 'in:delete,verify,resend_verification'], 'user_ids' => ['required', 'array', 'min:1'], 'user_ids.*' => ['integer', 'exists:users,id'], ]); @@ -325,36 +326,57 @@ class AdminUserController extends BasePageController $userIds = array_values(array_unique(array_map('intval', $validated['user_ids']))); if ($validated['action'] === 'delete') { - $count = User::query() - ->whereIn('id', $userIds) - ->where('roles_id', '!=', UserRole::ADMIN->value) - ->whereDoesntHave('role', fn ($query) => $query->where('name', UserRole::ADMIN->label())) - ->whereDoesntHave('roles', fn ($query) => $query->where('name', UserRole::ADMIN->label())) - ->delete(); + $count = $this->bulkActionUsersQuery($userIds)->delete(); Cache::forget(AdminDashboardSnapshotService::CACHE_KEY); return redirect()->back()->with('success', $count.' user(s) soft-deleted successfully.'); } + if ($validated['action'] === 'verify') { + $count = 0; + $this->bulkActionUsersQuery($userIds) + ->where('verified', false) + ->whereNull('email_verified_at') + ->get() + ->each(function (User $user) use (&$count): void { + if ($user->markEmailAsVerified()) { + $count++; + } + }); + + return redirect()->back()->with('success', $count.' user(s) marked as verified successfully.'); + } + $count = 0; - User::query() - ->whereIn('id', $userIds) - ->where('roles_id', '!=', UserRole::ADMIN->value) - ->whereDoesntHave('role', fn ($query) => $query->where('name', UserRole::ADMIN->label())) - ->whereDoesntHave('roles', fn ($query) => $query->where('name', UserRole::ADMIN->label())) - ->where(function ($query): void { - $query->where('verified', false) - ->orWhereNull('email_verified_at'); - }) + $this->bulkActionUsersQuery($userIds) + ->where('verified', false) + ->whereNull('email_verified_at') ->get() ->each(function (User $user) use (&$count): void { - if ($user->markEmailAsVerified()) { - $count++; - } + $user->sendEmailVerificationNotification(); + $count++; }); - return redirect()->back()->with('success', $count.' user(s) marked as verified successfully.'); + return redirect()->back()->with('success', $count.' verification email(s) queued successfully.'); + } + + /** + * @param list $userIds + * @return Builder + */ + private function bulkActionUsersQuery(array $userIds): Builder + { + $adminRoleIds = Role::query() + ->where('name', UserRole::ADMIN->label()) + ->pluck('id') + ->all(); + + return User::query() + ->whereIn('id', $userIds) + ->when($adminRoleIds !== [], fn (Builder $query): Builder => $query->whereNotIn('roles_id', $adminRoleIds)) + ->whereDoesntHave('role', fn (Builder $query): Builder => $query->where('name', UserRole::ADMIN->label())) + ->whereDoesntHave('roles', fn (Builder $query): Builder => $query->where('name', UserRole::ADMIN->label())); } public function destroy(Request $request): RedirectResponse diff --git a/resources/js/alpine/components/admin/features.js b/resources/js/alpine/components/admin/features.js index 318e88e2e..6f539dd35 100644 --- a/resources/js/alpine/components/admin/features.js +++ b/resources/js/alpine/components/admin/features.js @@ -257,6 +257,10 @@ Alpine.data('adminUserList', () => ({ return this._checkboxes().filter(cb => cb.checked); }, + _checkedUnverifiedBoxes() { + return this._checkedBoxes().filter(cb => cb.dataset.isVerified === '0'); + }, + _setValidationError(message) { const errEl = document.getElementById('validationError'); const errMsg = document.getElementById('validationErrorMessage'); @@ -295,17 +299,61 @@ Alpine.data('adminUserList', () => ({ const form = this.$el.querySelector('#bulkUserActionForm'); const action = this.$el.querySelector('#bulkUserAction')?.value; - const checkedCount = this._checkedBoxes().length; + const checked = this._checkedBoxes(); + const checkedCount = checked.length; if (!action) { this._setValidationError('Please select an action.'); return; } if (checkedCount === 0) { this._setValidationError('Please select at least one non-admin active user.'); return; } - const isDelete = action === 'delete'; + const actions = { + delete: { + title: 'Soft Delete Users', + actionText: 'soft-delete', + type: 'danger', + confirmText: 'Soft Delete' + }, + verify: { + title: 'Verify Users', + actionText: 'mark as verified', + type: 'success', + confirmText: 'Verify' + }, + resend_verification: { + title: 'Resend Verification Emails', + actionText: 'send account verification emails to', + type: 'warning', + confirmText: 'Send Emails' + } + }; + + const config = actions[action]; + if (!config) { this._setValidationError('Please select a valid action.'); return; } + + let targetCount = checkedCount; + let message = 'Are you sure you want to ' + config.actionText + ' ' + targetCount + ' user(s)?'; + + if (action === 'resend_verification') { + const unverifiedCount = this._checkedUnverifiedBoxes().length; + const skippedCount = checkedCount - unverifiedCount; + + if (unverifiedCount === 0) { + this._setValidationError('Please select at least one not verified user to resend verification email.'); + return; + } + + targetCount = unverifiedCount; + message = 'Are you sure you want to send account verification emails to ' + targetCount + ' not verified user(s)?'; + + if (skippedCount > 0) { + message += ' ' + skippedCount + ' already verified user(s) will be skipped.'; + } + } + showConfirm({ - title: isDelete ? 'Soft Delete Users' : 'Verify Users', - message: 'Are you sure you want to ' + (isDelete ? 'soft-delete' : 'mark as verified') + ' ' + checkedCount + ' user(s)?', - type: isDelete ? 'danger' : 'success', - confirmText: isDelete ? 'Soft Delete' : 'Verify', + title: config.title, + message: message, + type: config.type, + confirmText: config.confirmText, onConfirm: function() { if (form) form.submit(); } }); } diff --git a/resources/views/admin/users/index.blade.php b/resources/views/admin/users/index.blade.php index ec434e7d8..97639e602 100644 --- a/resources/views/admin/users/index.blade.php +++ b/resources/views/admin/users/index.blade.php @@ -146,6 +146,7 @@ - - - +
+ @csrf + + +
@endif
@csrf diff --git a/tests/Feature/Admin/AdminUserControllerTest.php b/tests/Feature/Admin/AdminUserControllerTest.php index 1b9a738eb..f04a5ffb7 100644 --- a/tests/Feature/Admin/AdminUserControllerTest.php +++ b/tests/Feature/Admin/AdminUserControllerTest.php @@ -6,8 +6,10 @@ namespace Tests\Feature\Admin; use App\Http\Middleware\Google2FAMiddleware; use App\Models\User; +use App\Notifications\VerifyEmailBranded; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Spatie\Permission\Models\Role; @@ -146,6 +148,31 @@ class AdminUserControllerTest extends TestCase $this->assertNull($unverifiedUser->verification_token); } + public function test_admin_can_bulk_resend_verification_email_to_not_verified_users(): void + { + Notification::fake(); + + $admin = $this->createUserWithRole('Admin', false); + $unverifiedUser = $this->createUserWithRole('User', true); + $alreadyVerifiedUser = $this->createUserWithRole('User', true); + + $unverifiedUser->forceFill([ + 'verified' => false, + 'email_verified_at' => null, + ])->save(); + + $response = $this->actingAs($admin)->post(route('admin.user-list.bulk'), [ + 'action' => 'resend_verification', + 'user_ids' => [$unverifiedUser->id, $alreadyVerifiedUser->id], + ]); + + $response->assertRedirect(); + $response->assertSessionHas('success', '1 verification email(s) queued successfully.'); + + Notification::assertSentTo($unverifiedUser, VerifyEmailBranded::class); + Notification::assertNotSentTo($alreadyVerifiedUser, VerifyEmailBranded::class); + } + public function test_bulk_user_action_rejects_role_updates(): void { $admin = $this->createUserWithRole('Admin', false);