Add user bulk actions

This commit is contained in:
DariusIII
2026-06-15 11:36:03 +02:00
parent a7a6479e93
commit 96f6900306
6 changed files with 318 additions and 11 deletions
@@ -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')) {
+12
View File
@@ -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()),
@@ -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; top._syncing = false; } });
bottom.addEventListener('scroll', function() { if (!bottom._syncing) { top._syncing = true; top.scrollLeft = bottom.scrollLeft; bottom._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(); }
});
}
}));
+65 -3
View File
@@ -1,7 +1,7 @@
@extends('layouts.admin')
@section('content')
<div class="space-y-6">
<div class="space-y-6" x-data="adminUserList">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<!-- Header -->
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
@@ -59,6 +59,16 @@
@endforeach
</select>
</div>
<div>
<label for="verified" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verified</label>
<select id="verified"
name="verified"
class="w-full px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400">
<option value="" {{ ($verified ?? '') === '' ? 'selected' : '' }}>All Users</option>
<option value="1" {{ ($verified ?? '') === '1' ? 'selected' : '' }}>Verified</option>
<option value="0" {{ ($verified ?? '') === '0' ? 'selected' : '' }}>Not Verified</option>
</select>
</div>
<div>
<label for="created_from" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Registered From</label>
<input type="date"
@@ -113,8 +123,42 @@
</div>
@endif
@if($errors->any())
<div class="mx-6 mt-4 p-4 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg">
<p class="text-red-800 dark:text-red-200">
<i class="fas fa-exclamation-circle mr-2"></i>{{ $errors->first() }}
</p>
</div>
@endif
<div id="validationError" class="mx-6 mt-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg hidden">
<p class="text-yellow-800 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-2"></i><span id="validationErrorMessage"></span>
</p>
</div>
<!-- User Table -->
@if(count($userlist) > 0)
<form method="post" action="{{ route('admin.user-list.bulk') }}" id="bulkUserActionForm" class="px-6 py-4 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
@csrf
<div class="flex flex-col md:flex-row md:items-center gap-3">
<label for="bulkUserAction" class="text-sm font-medium text-gray-700 dark:text-gray-300">Bulk Actions:</label>
<select name="action" id="bulkUserAction" class="w-full md:w-auto px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:ring-blue-500 focus:border-blue-500">
<option value="">Select Action</option>
<option value="verify">Mark Selected Verified</option>
<option value="delete">Soft Delete Selected</option>
</select>
<button type="submit"
id="bulkUserActionBtn"
class="w-full md:w-auto px-4 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded-md hover:bg-blue-700 dark:hover:bg-blue-800">
<i class="fas fa-check mr-2"></i>Apply
</button>
<p class="text-xs text-gray-500 dark:text-gray-400">
Select non-admin active users below. Deleted users are managed from the Deleted Users page.
</p>
</div>
</form>
<!-- Top Scrollbar -->
<div class="overflow-x-auto border-b border-gray-200 dark:border-gray-700" id="topScroll">
<div style="height: 1px;" id="topScrollContent"></div>
@@ -124,6 +168,9 @@
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th class="px-6 py-3 text-left">
<input type="checkbox" id="selectAllUserList" class="h-4 w-4 text-blue-600 dark:text-blue-400 focus:ring-blue-500 border-gray-300 dark:border-gray-600 rounded" title="Select all active users on this page">
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<a href="{{ url('admin/user-list?' . http_build_query(array_merge(request()->except('ob'), ['ob' => request('ob') === 'username_asc' ? 'username_desc' : 'username_asc']))) }}" class="group inline-flex items-center hover:text-gray-700 dark:hover:text-gray-200">
@@ -192,7 +239,21 @@
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
@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
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<td class="px-6 py-4 whitespace-nowrap">
@if(!$user->deleted_at && !$isAdminUser)
<input type="checkbox" name="user_ids[]" value="{{ $user->id }}" form="bulkUserActionForm" class="user-list-checkbox h-4 w-4 text-blue-600 dark:text-blue-400 focus:ring-blue-500 border-gray-300 dark:border-gray-600 rounded">
@elseif($isAdminUser)
<input type="checkbox" disabled class="h-4 w-4 text-gray-300 dark:text-gray-600 border-gray-300 dark:border-gray-600 rounded cursor-not-allowed" title="Admin users cannot be selected for bulk actions">
@else
<input type="checkbox" disabled class="h-4 w-4 text-gray-300 dark:text-gray-600 border-gray-300 dark:border-gray-600 rounded cursor-not-allowed" title="Deleted users cannot be selected here">
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{{ $user->id }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ $user->username }}</div>
@@ -270,8 +331,9 @@
N/A
@endif
</td>
@php($isVerified = $user->hasVerifiedEmail())
<td class="px-6 py-4 whitespace-nowrap">
@if($user->verified)
@if($isVerified)
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200">
<i class="fas fa-check mr-1"></i>Yes
</span>
@@ -319,7 +381,7 @@
title="Role History">
<i class="fas fa-history"></i>
</a>
@if(!$user->verified)
@if(!$isVerified)
<form method="POST" action="{{ route('admin.verify') }}" class="inline verify-user-form">
@csrf
<input type="hidden" name="id" value="{{ $user->id }}">
+1
View File
@@ -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');
@@ -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();