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') -
+ {{ $errors->first() }} +
+| + + | 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
|
+ @php($isVerified = $user->hasVerifiedEmail())
- @if($user->verified) + @if($isVerified) Yes @@ -319,7 +381,7 @@ title="Role History"> - @if(!$user->verified) + @if(!$isVerified) |