From 96f6900306f791a0fb47ecf377e2021d713221df Mon Sep 17 00:00:00 2001 From: DariusIII Date: Mon, 15 Jun 2026 11:36:03 +0200 Subject: [PATCH] Add user bulk actions --- .../Controllers/Admin/AdminUserController.php | 50 +++++++- app/Models/User.php | 12 ++ .../js/alpine/components/admin/features.js | 90 ++++++++++++++- resources/views/admin/users/index.blade.php | 68 ++++++++++- routes/web.php | 1 + .../Feature/Admin/AdminUserControllerTest.php | 108 ++++++++++++++++++ 6 files changed, 318 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/Admin/AdminUserController.php b/app/Http/Controllers/Admin/AdminUserController.php index 0ba372ae8..5ec7d012a 100644 --- a/app/Http/Controllers/Admin/AdminUserController.php +++ b/app/Http/Controllers/Admin/AdminUserController.php @@ -45,6 +45,7 @@ class AdminUserController extends BasePageController 'email' => $request->has('email') ? $request->input('email') : '', 'host' => $request->has('host') ? $request->input('host') : '', 'role' => $request->has('role') ? $request->input('role') : '', + 'verified' => in_array($request->input('verified'), ['0', '1'], true) ? $request->input('verified') : '', 'created_from' => $request->has('created_from') ? $request->input('created_from') : '', 'created_to' => $request->has('created_to') ? $request->input('created_to') : '', ]; @@ -58,10 +59,11 @@ class AdminUserController extends BasePageController $variables['host'], $variables['role'], $variables['created_from'], - $variables['created_to'] + $variables['created_to'], + $variables['verified'] ); - $results = $this->paginate($result, User::getCount($variables['role'], $variables['username'], $variables['host'], $variables['email'], $variables['created_from'], $variables['created_to']), config('nntmux.items_per_page'), $page, $request->url(), $request->query()); + $results = $this->paginate($result, User::getCount($variables['role'], $variables['username'], $variables['host'], $variables['email'], $variables['created_from'], $variables['created_to'], $variables['verified']), config('nntmux.items_per_page'), $page, $request->url(), $request->query()); // Build order by URLs $orderByUrls = []; @@ -74,6 +76,7 @@ class AdminUserController extends BasePageController 'email' => $variables['email'], 'host' => $variables['host'], 'role' => $variables['role'], + 'verified' => $variables['verified'], 'created_from' => $variables['created_from'], 'created_to' => $variables['created_to'], 'role_ids' => array_keys($roles), @@ -311,6 +314,49 @@ class AdminUserController extends BasePageController return view('admin.users.edit', $this->viewData); } + public function bulkAction(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'action' => ['required', 'string', 'in:delete,verify'], + 'user_ids' => ['required', 'array', 'min:1'], + 'user_ids.*' => ['integer', 'exists:users,id'], + ]); + + $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(); + + Cache::forget(AdminDashboardSnapshotService::CACHE_KEY); + + return redirect()->back()->with('success', $count.' user(s) soft-deleted 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'); + }) + ->get() + ->each(function (User $user) use (&$count): void { + if ($user->markEmailAsVerified()) { + $count++; + } + }); + + return redirect()->back()->with('success', $count.' user(s) marked as verified successfully.'); + } + public function destroy(Request $request): RedirectResponse { if ($request->has('id')) { diff --git a/app/Models/User.php b/app/Models/User.php index 8d94935b2..6b2055596 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -753,6 +753,7 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha ?string $email = '', ?string $createdFrom = '', ?string $createdTo = '', + ?string $verified = '', ): int { return self::query() ->withTrashed() @@ -763,6 +764,11 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha ->when($email, fn (Builder $q) => $q->where('email', 'like', "%{$email}%")) ->when($createdFrom, fn (Builder $q) => $q->where('created_at', '>=', "{$createdFrom} 00:00:00")) ->when($createdTo, fn (Builder $q) => $q->where('created_at', '<=', "{$createdTo} 23:59:59")) + ->when($verified === '1', fn (Builder $q) => $q->where(function (Builder $verifiedQuery): void { + $verifiedQuery->where('verified', true) + ->orWhereNotNull('email_verified_at'); + })) + ->when($verified === '0', fn (Builder $q) => $q->where('verified', false)->whereNull('email_verified_at')) ->count(); } @@ -1345,6 +1351,7 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha ?string $role = '', ?string $createdFrom = '', ?string $createdTo = '', + ?string $verified = '', ): Collection { $order = self::getBrowseOrder($orderBy); @@ -1362,6 +1369,11 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha ->when($role, fn (Builder $q) => $q->where('roles_id', $role)) ->when($createdFrom, fn (Builder $q) => $q->where('created_at', '>=', "{$createdFrom} 00:00:00")) ->when($createdTo, fn (Builder $q) => $q->where('created_at', '<=', "{$createdTo} 23:59:59")) + ->when($verified === '1', fn (Builder $q) => $q->where(function (Builder $verifiedQuery): void { + $verifiedQuery->where('verified', true) + ->orWhereNotNull('email_verified_at'); + })) + ->when($verified === '0', fn (Builder $q) => $q->where('verified', false)->whereNull('email_verified_at')) ->withCount([ 'requests as daily_api_count' => fn (Builder $q) => $q->where('timestamp', '>', now()->subDay()), 'downloads as daily_download_count' => fn (Builder $q) => $q->where('timestamp', '>', now()->subDay()), diff --git a/resources/js/alpine/components/admin/features.js b/resources/js/alpine/components/admin/features.js index 0082f49b3..318e88e2e 100644 --- a/resources/js/alpine/components/admin/features.js +++ b/resources/js/alpine/components/admin/features.js @@ -218,18 +218,96 @@ Alpine.data('verifyUser', () => ({ // User List Scroll Sync Alpine.data('adminUserList', () => ({ + allChecked: false, + init() { const top = document.getElementById('topScroll'); const bottom = document.getElementById('bottomScroll'); const content = document.getElementById('topScrollContent'); const table = bottom?.querySelector('table'); - if (!top || !bottom || !content || !table) return; + if (top && bottom && content && table) { + const sync = () => { content.style.width = table.scrollWidth + 'px'; }; + sync(); + window.addEventListener('resize', sync); + top.addEventListener('scroll', function() { if (!top._syncing) { bottom._syncing = true; bottom.scrollLeft = top.scrollLeft; bottom._syncing = false; } }); + bottom.addEventListener('scroll', function() { if (!bottom._syncing) { top._syncing = true; top.scrollLeft = bottom.scrollLeft; bottom._syncing = false; } }); + } - const sync = () => { content.style.width = table.scrollWidth + 'px'; }; - sync(); - window.addEventListener('resize', sync); - top.addEventListener('scroll', function() { if (!top._syncing) { bottom._syncing = true; bottom.scrollLeft = top.scrollLeft; bottom._syncing = false; } }); - bottom.addEventListener('scroll', function() { if (!bottom._syncing) { top._syncing = true; top.scrollLeft = bottom.scrollLeft; top._syncing = false; } }); + const selectAll = this.$el.querySelector('#selectAllUserList'); + const form = this.$el.querySelector('#bulkUserActionForm'); + + if (selectAll) { + selectAll.addEventListener('change', () => this.toggleAll()); + } + + this.$el.querySelectorAll('.user-list-checkbox').forEach(cb => { + cb.addEventListener('change', () => this.onCheckboxChange()); + }); + + if (form) { + form.addEventListener('submit', e => this.submitBulkAction(e)); + } + }, + + _checkboxes() { + return Array.from(this.$el.querySelectorAll('.user-list-checkbox:not(:disabled)')); + }, + + _checkedBoxes() { + return this._checkboxes().filter(cb => cb.checked); + }, + + _setValidationError(message) { + const errEl = document.getElementById('validationError'); + const errMsg = document.getElementById('validationErrorMessage'); + if (errEl && errMsg) { + errMsg.textContent = message; + errEl.classList.remove('hidden'); + } + }, + + _clearValidationError() { + const errEl = document.getElementById('validationError'); + if (errEl) errEl.classList.add('hidden'); + }, + + toggleAll() { + const selectAll = this.$el.querySelector('#selectAllUserList'); + this._checkboxes().forEach(cb => { cb.checked = selectAll?.checked ?? false; }); + this.onCheckboxChange(); + }, + + onCheckboxChange() { + const selectAll = this.$el.querySelector('#selectAllUserList'); + const boxes = this._checkboxes(); + const checked = this._checkedBoxes(); + this.allChecked = boxes.length > 0 && checked.length === boxes.length; + + if (selectAll) { + selectAll.checked = this.allChecked; + selectAll.indeterminate = checked.length > 0 && checked.length < boxes.length; + } + }, + + submitBulkAction(e) { + e.preventDefault(); + this._clearValidationError(); + + const form = this.$el.querySelector('#bulkUserActionForm'); + const action = this.$el.querySelector('#bulkUserAction')?.value; + const checkedCount = this._checkedBoxes().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'; + 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', + 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 8a60e9292..ec434e7d8 100644 --- a/resources/views/admin/users/index.blade.php +++ b/resources/views/admin/users/index.blade.php @@ -1,7 +1,7 @@ @extends('layouts.admin') @section('content') -
+
@@ -59,6 +59,16 @@ @endforeach
+
+ + +
@endif + @if($errors->any()) +
+

+ {{ $errors->first() }} +

+
+ @endif + + + @if(count($userlist) > 0) +
+ @csrf +
+ + + +

+ Select non-admin active users below. Deleted users are managed from the Deleted Users page. +

+
+
+
@@ -124,6 +168,9 @@ + @foreach($userlist as $user) + @php + $isAdminUser = (int) $user->roles_id === \App\Enums\UserRole::ADMIN->value + || ($user->rolename ?? null) === \App\Enums\UserRole::ADMIN->label() + || $user->roles->contains('name', \App\Enums\UserRole::ADMIN->label()); + @endphp + + @php($isVerified = $user->hasVerifiedEmail())
+ + ID @@ -192,7 +239,21 @@
+ @if(!$user->deleted_at && !$isAdminUser) + + @elseif($isAdminUser) + + @else + + @endif + {{ $user->id }}
{{ $user->username }}
@@ -270,8 +331,9 @@ N/A @endif
- @if($user->verified) + @if($isVerified) Yes @@ -319,7 +381,7 @@ title="Role History"> - @if(!$user->verified) + @if(!$isVerified)
@csrf diff --git a/routes/web.php b/routes/web.php index 25b4e4baf..5af73c596 100644 --- a/routes/web.php +++ b/routes/web.php @@ -248,6 +248,7 @@ Route::middleware(['role:Admin', '2fa'])->prefix('admin')->group(function () { Route::match(['GET', 'POST'], 'category-edit', [AdminCategoryController::class, 'edit'])->name('admin.category-edit'); Route::get('category-delete', [AdminCategoryController::class, 'destroy'])->name('admin.category-delete'); Route::get('user-list', [AdminUserController::class, 'index'])->name('admin.user-list'); + Route::post('user-list/bulk', [AdminUserController::class, 'bulkAction'])->name('admin.user-list.bulk'); Route::match(['GET', 'POST'], 'user-edit', [AdminUserController::class, 'edit'])->name('admin.user-edit'); Route::delete('user-passkey/{passkey}', [AdminUserController::class, 'destroyPasskey'])->name('admin.user-passkey.destroy'); Route::post('user-passkeys/wipe', [AdminUserController::class, 'wipePasskeys'])->name('admin.user-passkeys.wipe'); diff --git a/tests/Feature/Admin/AdminUserControllerTest.php b/tests/Feature/Admin/AdminUserControllerTest.php index b72a02531..1b9a738eb 100644 --- a/tests/Feature/Admin/AdminUserControllerTest.php +++ b/tests/Feature/Admin/AdminUserControllerTest.php @@ -83,6 +83,84 @@ class AdminUserControllerTest extends TestCase $this->assertDatabaseMissing('users', ['email' => 'new-user@example.test']); } + public function test_admin_user_list_can_filter_by_verified_status(): void + { + $admin = $this->createUserWithRole('Admin', false); + $verifiedUser = $this->createUserWithRole('User', true); + $unverifiedUser = $this->createUserWithRole('User', true); + $unverifiedUser->forceFill([ + 'verified' => false, + 'email_verified_at' => null, + ])->save(); + + $unverifiedResponse = $this->actingAs($admin)->get(route('admin.user-list', ['verified' => '0'])); + $unverifiedResponse->assertOk(); + $unverifiedResponse->assertSee($unverifiedUser->username); + $unverifiedResponse->assertDontSee($verifiedUser->username); + + $verifiedResponse = $this->actingAs($admin)->get(route('admin.user-list', ['verified' => '1'])); + $verifiedResponse->assertOk(); + $verifiedResponse->assertSee($verifiedUser->username); + $verifiedResponse->assertDontSee($unverifiedUser->username); + } + + public function test_admin_can_bulk_soft_delete_selected_users(): void + { + $admin = $this->createUserWithRole('Admin', false); + $firstUser = $this->createUserWithRole('User', true); + $secondUser = $this->createUserWithRole('User', true); + + $response = $this->actingAs($admin)->post(route('admin.user-list.bulk'), [ + 'action' => 'delete', + 'user_ids' => [$firstUser->id, $secondUser->id], + ]); + + $response->assertRedirect(); + $response->assertSessionHas('success', '2 user(s) soft-deleted successfully.'); + $this->assertSoftDeleted('users', ['id' => $firstUser->id]); + $this->assertSoftDeleted('users', ['id' => $secondUser->id]); + } + + public function test_admin_can_bulk_mark_selected_users_as_verified(): void + { + $admin = $this->createUserWithRole('Admin', false); + $unverifiedUser = $this->createUserWithRole('User', true); + $alreadyVerifiedUser = $this->createUserWithRole('User', true); + $unverifiedUser->forceFill([ + 'verified' => false, + 'email_verified_at' => null, + 'verification_token' => 'pending-token', + ])->save(); + + $response = $this->actingAs($admin)->post(route('admin.user-list.bulk'), [ + 'action' => 'verify', + 'user_ids' => [$unverifiedUser->id, $alreadyVerifiedUser->id], + ]); + + $response->assertRedirect(); + $response->assertSessionHas('success', '1 user(s) marked as verified successfully.'); + + $unverifiedUser->refresh(); + $this->assertTrue($unverifiedUser->verified); + $this->assertNotNull($unverifiedUser->email_verified_at); + $this->assertNull($unverifiedUser->verification_token); + } + + public function test_bulk_user_action_rejects_role_updates(): void + { + $admin = $this->createUserWithRole('Admin', false); + $user = $this->createUserWithRole('User', true); + + $response = $this->actingAs($admin)->post(route('admin.user-list.bulk'), [ + 'action' => 'role', + 'user_ids' => [$user->id], + 'role' => $admin->roles_id, + ]); + + $response->assertSessionHasErrors('action'); + $this->assertSame($user->roles_id, $user->fresh()->roles_id); + } + private function createSchema(): void { Schema::create('settings', function (Blueprint $table): void { @@ -122,6 +200,7 @@ class AdminUserControllerTest extends TestCase $table->boolean('verified')->default(true); $table->boolean('can_post')->default(true); $table->timestamp('email_verified_at')->nullable(); + $table->string('verification_token')->nullable(); $table->timestamp('lastlogin')->nullable(); $table->rememberToken(); $table->timestamps(); @@ -148,6 +227,35 @@ class AdminUserControllerTest extends TestCase $table->primary(['permission_id', 'role_id']); }); + Schema::create('user_requests', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->string('request')->default(''); + $table->string('hosthash')->default(''); + $table->timestamp('timestamp')->nullable(); + }); + + Schema::create('user_downloads', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->unsignedInteger('releases_id')->default(0); + $table->string('hosthash')->default(''); + $table->timestamp('timestamp')->nullable(); + }); + + Schema::create('content', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->string('url')->nullable(); + $table->text('body')->nullable(); + $table->string('metadescription')->default(''); + $table->string('metakeywords')->default(''); + $table->integer('contenttype')->default(1); + $table->integer('status')->default(1); + $table->integer('ordinal')->nullable(); + $table->integer('role')->default(0); + }); + Schema::create('user_activities', function (Blueprint $table): void { $table->increments('id'); $table->unsignedInteger('user_id')->nullable();