mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update admin views and controllers
This commit is contained in:
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Admin;
|
||||
use App\Enums\SignupError;
|
||||
use App\Enums\UserRole;
|
||||
use App\Http\Controllers\BasePageController;
|
||||
use App\Http\Requests\Admin\AdminUserListRequest;
|
||||
use App\Models\Invitation;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDownload;
|
||||
@@ -28,43 +29,23 @@ class AdminUserController extends BasePageController
|
||||
/**
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
public function index(AdminUserListRequest $request): View
|
||||
{
|
||||
$this->setAdminPrefs();
|
||||
|
||||
$meta_title = $title = 'User List';
|
||||
|
||||
$roles = Role::pluck('name', 'id')->toArray();
|
||||
$roles = Role::query()->orderBy('name')->pluck('name', 'id')->toArray();
|
||||
|
||||
$ordering = getUserBrowseOrdering();
|
||||
$orderBy = $request->has('ob') && \in_array($request->input('ob'), $ordering, false) ? $request->input('ob') : '';
|
||||
$page = $request->has('page') && is_numeric($request->input('page')) ? $request->input('page') : 1;
|
||||
$offset = ($page - 1) * config('nntmux.items_per_page');
|
||||
$orderBy = $request->orderBy();
|
||||
$variables = $request->filters();
|
||||
|
||||
$variables = [
|
||||
'username' => $request->has('username') ? $request->input('username') : '',
|
||||
'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') : '',
|
||||
];
|
||||
|
||||
$result = User::getRange(
|
||||
$offset,
|
||||
(int) config('nntmux.items_per_page'),
|
||||
$results = User::adminListPaginator(
|
||||
$variables,
|
||||
$orderBy,
|
||||
$variables['username'],
|
||||
$variables['email'],
|
||||
$variables['host'],
|
||||
$variables['role'],
|
||||
$variables['created_from'],
|
||||
$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'], $variables['verified']), config('nntmux.items_per_page'), $page, $request->url(), $request->query());
|
||||
(int) config('nntmux.items_per_page'),
|
||||
)->withQueryString();
|
||||
|
||||
// Build order by URLs
|
||||
$orderByUrls = [];
|
||||
@@ -392,8 +373,9 @@ class AdminUserController extends BasePageController
|
||||
$user->delete();
|
||||
Cache::forget(AdminDashboardSnapshotService::CACHE_KEY);
|
||||
|
||||
// Redirect with username to display in notification
|
||||
return redirect()->to('admin/user-list?deleted=1&username='.urlencode($username));
|
||||
return redirect()
|
||||
->route('admin.user-list')
|
||||
->with('success', 'User "'.$username.'" has been deleted successfully.');
|
||||
}
|
||||
|
||||
if ($request->has('redir')) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AdminUserListRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
return $user instanceof User && $user->hasRole('Admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'username' => ['nullable', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'string', 'max:255'],
|
||||
'host' => ['nullable', 'string', 'max:255'],
|
||||
'role' => ['nullable', 'integer', 'exists:roles,id'],
|
||||
'verified' => ['nullable', 'in:0,1'],
|
||||
'created_from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'created_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:created_from'],
|
||||
'ob' => ['nullable', 'string', 'in:'.implode(',', getUserBrowseOrdering())],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{username: string, email: string, host: string, role: string, verified: string, created_from: string, created_to: string}
|
||||
*/
|
||||
public function filters(): array
|
||||
{
|
||||
return [
|
||||
'username' => $this->scalarInput('username'),
|
||||
'email' => $this->scalarInput('email'),
|
||||
'host' => $this->scalarInput('host'),
|
||||
'role' => $this->scalarInput('role'),
|
||||
'verified' => $this->scalarInput('verified'),
|
||||
'created_from' => $this->scalarInput('created_from'),
|
||||
'created_to' => $this->scalarInput('created_to'),
|
||||
];
|
||||
}
|
||||
|
||||
public function orderBy(): string
|
||||
{
|
||||
$orderBy = $this->scalarInput('ob');
|
||||
|
||||
return \in_array($orderBy, getUserBrowseOrdering(), true) ? $orderBy : '';
|
||||
}
|
||||
|
||||
private function scalarInput(string $key): string
|
||||
{
|
||||
$value = $this->input($key, '');
|
||||
|
||||
return is_scalar($value) ? trim((string) $value) : '';
|
||||
}
|
||||
}
|
||||
@@ -506,6 +506,8 @@ class Release extends Model
|
||||
'video:id,title,tvdb,trakt,tvrage,tvmaze,source',
|
||||
'video.tvInfo:videos_id,summary,image',
|
||||
'episode:id,title,firstaired,se_complete',
|
||||
'releaseGroup:releases_id,groups_id',
|
||||
'releaseGroup.group:id,name',
|
||||
]);
|
||||
|
||||
if (is_array($guid)) {
|
||||
|
||||
+64
-24
@@ -15,6 +15,7 @@ use Carbon\CarbonImmutable;
|
||||
use Illuminate\Auth\MustVerifyEmail as MustVerifyEmailTrait;
|
||||
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
@@ -755,20 +756,15 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha
|
||||
?string $createdTo = '',
|
||||
?string $verified = '',
|
||||
): int {
|
||||
return self::query()
|
||||
->withTrashed()
|
||||
->excludeSharing()
|
||||
->when($role, fn (Builder $q) => $q->where('roles_id', $role))
|
||||
->when($username, fn (Builder $q) => $q->where('username', 'like', "%{$username}%"))
|
||||
->when($host, fn (Builder $q) => $q->where('host', 'like', "%{$host}%"))
|
||||
->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'))
|
||||
return self::adminListBaseQuery([
|
||||
'role' => (string) $role,
|
||||
'username' => (string) $username,
|
||||
'host' => (string) $host,
|
||||
'email' => (string) $email,
|
||||
'created_from' => (string) $createdFrom,
|
||||
'created_to' => (string) $createdTo,
|
||||
'verified' => (string) $verified,
|
||||
])
|
||||
->count();
|
||||
}
|
||||
|
||||
@@ -1355,6 +1351,57 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha
|
||||
): Collection {
|
||||
$order = self::getBrowseOrder($orderBy);
|
||||
|
||||
return self::adminListBaseQuery([
|
||||
'username' => (string) $userName,
|
||||
'email' => (string) $email,
|
||||
'host' => (string) $host,
|
||||
'role' => (string) $role,
|
||||
'created_from' => (string) $createdFrom,
|
||||
'created_to' => (string) $createdTo,
|
||||
'verified' => (string) $verified,
|
||||
])
|
||||
->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()),
|
||||
])
|
||||
->with('roles:id,name')
|
||||
->orderBy($order[0], $order[1])
|
||||
->when($start !== false, fn (Builder $q) => $q->offset($start)->limit($offset))
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{username?: string, email?: string, host?: string, role?: string, verified?: string, created_from?: string, created_to?: string} $filters
|
||||
* @return LengthAwarePaginator<int, User>
|
||||
*/
|
||||
public static function adminListPaginator(array $filters, string $orderBy, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
$order = self::getBrowseOrder($orderBy);
|
||||
|
||||
return self::adminListBaseQuery($filters)
|
||||
->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()),
|
||||
])
|
||||
->with('roles:id,name')
|
||||
->orderBy($order[0], $order[1])
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{username?: string, email?: string, host?: string, role?: string, verified?: string, created_from?: string, created_to?: string} $filters
|
||||
* @return Builder<User>
|
||||
*/
|
||||
private static function adminListBaseQuery(array $filters): Builder
|
||||
{
|
||||
$username = $filters['username'] ?? '';
|
||||
$email = $filters['email'] ?? '';
|
||||
$host = $filters['host'] ?? '';
|
||||
$role = $filters['role'] ?? '';
|
||||
$verified = $filters['verified'] ?? '';
|
||||
$createdFrom = $filters['created_from'] ?? '';
|
||||
$createdTo = $filters['created_to'] ?? '';
|
||||
|
||||
return self::query()
|
||||
->withTrashed()
|
||||
->select('users.*')
|
||||
@@ -1363,7 +1410,7 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha
|
||||
'rolename'
|
||||
)
|
||||
->excludeSharing()
|
||||
->when($userName, fn (Builder $q) => $q->where('username', 'like', "%{$userName}%"))
|
||||
->when($username, fn (Builder $q) => $q->where('username', 'like', "%{$username}%"))
|
||||
->when($email, fn (Builder $q) => $q->where('email', 'like', "%{$email}%"))
|
||||
->when($host, fn (Builder $q) => $q->where('host', 'like', "%{$host}%"))
|
||||
->when($role, fn (Builder $q) => $q->where('roles_id', $role))
|
||||
@@ -1373,15 +1420,7 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha
|
||||
$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()),
|
||||
])
|
||||
->with('roles:id,name')
|
||||
->orderBy($order[0], $order[1])
|
||||
->when($start !== false, fn (Builder $q) => $q->offset($start)->limit($offset))
|
||||
->get();
|
||||
->when($verified === '0', fn (Builder $q) => $q->where('verified', false)->whereNull('email_verified_at'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1400,6 +1439,7 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha
|
||||
'createdat' => 'created_at',
|
||||
'lastlogin' => 'lastlogin',
|
||||
'apiaccess' => 'apiaccess',
|
||||
'apirequests' => 'daily_api_count',
|
||||
'grabs' => 'grabs',
|
||||
'role' => 'rolename',
|
||||
'rolechangedate' => 'rolechangedate',
|
||||
|
||||
@@ -29,6 +29,7 @@ use App\Observers\ReleaseObserver;
|
||||
use App\Observers\RolePromotionObserver;
|
||||
use App\Observers\SteamAppObserver;
|
||||
use App\Observers\VideoObserver;
|
||||
use App\View\Composers\AdminDataComposer;
|
||||
use App\View\Composers\GlobalDataComposer;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
@@ -46,19 +47,22 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
Paginator::useTailwind();
|
||||
|
||||
// Share global data with layouts and all admin views
|
||||
// Admin child views need direct registration because @section blocks
|
||||
// are evaluated before the layout composer runs
|
||||
// Same for search.* so category menu data (e.g. parentcatlist) exists in @section('content')
|
||||
// Share public-site global data with layouts that need menu/category data.
|
||||
// search.* needs direct registration because @section blocks are evaluated
|
||||
// before the layout composer runs.
|
||||
view()->composer([
|
||||
'layouts.main',
|
||||
'layouts.admin',
|
||||
'layouts.guest',
|
||||
'layouts.app',
|
||||
'admin.*',
|
||||
'search.*',
|
||||
], GlobalDataComposer::class);
|
||||
|
||||
// Admin pages use a leaner composer and do not need public-site menu data.
|
||||
view()->composer([
|
||||
'layouts.admin',
|
||||
'admin.*',
|
||||
], AdminDataComposer::class);
|
||||
|
||||
Gate::define('viewPulse', function (User $user) {
|
||||
return $user->hasRole('Admin');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\View\Composers;
|
||||
|
||||
use App\Models\Settings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AdminDataComposer
|
||||
{
|
||||
/**
|
||||
* Cache TTL in seconds (5 minutes).
|
||||
*/
|
||||
private const CACHE_TTL = 300;
|
||||
|
||||
/**
|
||||
* Bind lightweight admin data to the view.
|
||||
*/
|
||||
public function compose(View $view): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
$isNntmuxUser = $user instanceof User;
|
||||
|
||||
$view->with([
|
||||
'serverroot' => url('/'),
|
||||
'site' => $this->rememberWithCacheFallback('site_settings_array', self::CACHE_TTL, function () {
|
||||
return Settings::query()
|
||||
->pluck('value', 'name')
|
||||
->map(fn ($value) => Settings::convertValue($value))
|
||||
->all();
|
||||
}),
|
||||
'userdata' => $user,
|
||||
'loggedin' => $user !== null,
|
||||
'isadmin' => $isNntmuxUser && $user->hasRole('Admin'),
|
||||
'ismod' => $isNntmuxUser && $user->hasRole('Moderator'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(): mixed $callback
|
||||
*/
|
||||
private function rememberWithCacheFallback(string $key, int|\DateInterval $ttl, callable $callback): mixed
|
||||
{
|
||||
try {
|
||||
if (Cache::has($key)) {
|
||||
return Cache::get($key);
|
||||
}
|
||||
|
||||
$value = $callback();
|
||||
Cache::put($key, $value, $ttl);
|
||||
|
||||
return $value;
|
||||
} catch (\Throwable $e) {
|
||||
if (config('app.debug')) {
|
||||
Log::debug('AdminDataComposer cache bypassed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return $callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,149 +216,6 @@ 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) {
|
||||
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 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);
|
||||
},
|
||||
|
||||
_checkedUnverifiedBoxes() {
|
||||
return this._checkedBoxes().filter(cb => cb.dataset.isVerified === '0');
|
||||
},
|
||||
|
||||
_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 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 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: config.title,
|
||||
message: message,
|
||||
type: config.type,
|
||||
confirmText: config.confirmText,
|
||||
onConfirm: function() { if (form) form.submit(); }
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
// Admin deleted users
|
||||
Alpine.data('adminDeletedUsers', () => ({
|
||||
allChecked: false,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Alpine.data('adminUserList') - Admin user list bulk selection and scroll sync.
|
||||
*/
|
||||
import Alpine from '@alpinejs/csp';
|
||||
|
||||
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) {
|
||||
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 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);
|
||||
},
|
||||
|
||||
_checkedUnverifiedBoxes() {
|
||||
return this._checkedBoxes().filter(cb => cb.dataset.isVerified === '0');
|
||||
},
|
||||
|
||||
_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 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 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: config.title,
|
||||
message: message,
|
||||
type: config.type,
|
||||
confirmText: config.confirmText,
|
||||
onConfirm: function() { if (form) form.submit(); },
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -51,7 +51,7 @@ const lazyComponentMap = {
|
||||
'adminGroups': () => import('./components/admin/groups.js'),
|
||||
'adminFeatures': () => import('./components/admin/features.js'),
|
||||
'adminUserEdit': () => import('./components/admin/features.js'), // same file
|
||||
'adminUserList': () => import('./components/admin/features.js'), // same file
|
||||
'adminUserList': () => import('./components/admin/user-list.js'),
|
||||
'adminDeletedUsers': () => import('./components/admin/features.js'), // same file
|
||||
'adminInvitations': () => import('./components/admin/features.js'), // same file
|
||||
'adminRegexForm': () => import('./components/admin/features.js'), // same file
|
||||
|
||||
@@ -3,20 +3,16 @@
|
||||
@section('content')
|
||||
<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">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-800 dark:text-gray-200">
|
||||
<i class="fas fa-users mr-2"></i>{{ $title }}
|
||||
</h1>
|
||||
<x-admin.page-header :title="$title" icon="fas fa-users">
|
||||
<x-slot:actions>
|
||||
<a href="{{ url('admin/user-edit?action=add') }}" class="px-4 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800">
|
||||
<i class="fas fa-plus mr-2"></i>Add New User
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
</x-admin.page-header>
|
||||
|
||||
<!-- Search Filters -->
|
||||
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<x-admin.search-bar>
|
||||
<form method="get" action="{{ url('admin/user-list') }}">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
@@ -95,34 +91,9 @@
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Success/Error Messages -->
|
||||
@if(request()->has('deleted') && request()->input('deleted') == 1)
|
||||
<div class="mx-6 mt-4 p-4 bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-lg">
|
||||
<p class="text-green-800 dark:text-green-200">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
User "{{ request()->input('username') }}" has been deleted successfully.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('success'))
|
||||
<div class="mx-6 mt-4 p-4 bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-lg">
|
||||
<p class="text-green-800 dark:text-green-200">
|
||||
<i class="fas fa-check-circle mr-2"></i>{{ session('success') }}
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<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>{{ session('error') }}
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</x-admin.search-bar>
|
||||
|
||||
<!-- Validation Messages -->
|
||||
@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">
|
||||
@@ -489,4 +460,3 @@
|
||||
@endsection
|
||||
|
||||
{{-- Scripts moved to resources/js/csp-safe.js --}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user