mirror of
https://github.com/plexguide/Huntarr.io.git
synced 2026-06-04 21:56:57 -04:00
949 lines
48 KiB
JavaScript
949 lines
48 KiB
JavaScript
/**
|
|
* Requestarr User Management Module
|
|
* Handles user list, create/edit/delete, Plex import, permissions, and category assignment.
|
|
*/
|
|
|
|
window.RequestarrUsers = {
|
|
users: [],
|
|
dropdownOptions: null, // cached { movie_options, tv_options }
|
|
permissionLabels: {
|
|
request_movies: 'Request Movies',
|
|
request_tv: 'Request TV',
|
|
auto_approve: 'Auto Approve All',
|
|
auto_approve_movies: 'Auto Approve Movies',
|
|
auto_approve_tv: 'Auto Approve TV',
|
|
manage_requests: 'Manage Requests',
|
|
manage_users: 'Manage Users',
|
|
view_requests: 'View All Requests',
|
|
hide_media_global: 'Hide Media (Global)',
|
|
},
|
|
|
|
selectedUserIds: new Set(),
|
|
|
|
async init() {
|
|
await this._loadDropdownOptions();
|
|
await this.loadUsers();
|
|
},
|
|
|
|
async _loadDropdownOptions() {
|
|
try {
|
|
const resp = await fetch('./api/requestarr/bundles/dropdown', { cache: 'no-store' });
|
|
if (resp.ok) {
|
|
this.dropdownOptions = await resp.json();
|
|
}
|
|
} catch (e) {
|
|
console.warn('[RequestarrUsers] Could not load dropdown options:', e);
|
|
}
|
|
},
|
|
|
|
_maskEmail(email) {
|
|
if (!email) return '';
|
|
const atIdx = email.indexOf('@');
|
|
if (atIdx <= 0) return email;
|
|
return '*'.repeat(atIdx) + email.substring(atIdx);
|
|
},
|
|
|
|
_getCategoryLabel(value) {
|
|
if (!value) return '';
|
|
if (!this.dropdownOptions) return value;
|
|
const allOptions = [
|
|
...(this.dropdownOptions.movie_options || []),
|
|
...(this.dropdownOptions.tv_options || []),
|
|
];
|
|
const match = allOptions.find(o => o.value === value);
|
|
// If the value is set but not found in known options, it's defunct
|
|
return match ? match.label : null;
|
|
},
|
|
|
|
_isCategoryValid(value) {
|
|
if (!value) return true; // empty = no instance, always valid
|
|
if (!this.dropdownOptions) return true; // can't validate without options
|
|
const allOptions = [
|
|
...(this.dropdownOptions.movie_options || []),
|
|
...(this.dropdownOptions.tv_options || []),
|
|
];
|
|
return allOptions.some(o => o.value === value);
|
|
},
|
|
|
|
async loadUsers() {
|
|
const container = document.getElementById('requestarr-users-view');
|
|
if (!container) return;
|
|
try {
|
|
const resp = await fetch('./api/requestarr/users', { cache: 'no-store' });
|
|
if (!resp.ok) throw new Error('Failed to load users');
|
|
const data = await resp.json();
|
|
this.users = data.users || [];
|
|
this.render();
|
|
} catch (e) {
|
|
console.error('[RequestarrUsers] Error loading users:', e);
|
|
this.renderError();
|
|
}
|
|
},
|
|
|
|
render() {
|
|
const container = document.getElementById('requsers-content');
|
|
if (!container) return;
|
|
|
|
const nonOwnerUsers = this.users.filter(u => u.role !== 'owner');
|
|
|
|
const rows = this.users.map(u => {
|
|
const initials = (u.username || '?').substring(0, 2).toUpperCase();
|
|
const avatarHtml = u.avatar_url
|
|
? `<img src="${u.avatar_url}" alt="" onerror="this.style.display='none';this.parentElement.textContent='${initials}'">`
|
|
: initials;
|
|
const roleClass = `requsers-role-${u.role || 'user'}`;
|
|
const joined = u.created_at ? new Date(u.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '—';
|
|
const isOwner = u.role === 'owner';
|
|
const isChecked = this.selectedUserIds.has(u.id) ? 'checked' : '';
|
|
const maskedEmail = this._maskEmail(u.email);
|
|
|
|
const movieCatLabel = this._getCategoryLabel(u.movie_category);
|
|
const tvCatLabel = this._getCategoryLabel(u.tv_category);
|
|
let movieCatHtml, tvCatHtml;
|
|
if (isOwner) {
|
|
movieCatHtml = '<span class="requsers-cat-none" title="Owner sees all instances">—</span>';
|
|
tvCatHtml = '<span class="requsers-cat-none" title="Owner sees all instances">—</span>';
|
|
} else {
|
|
// movieCatLabel is null if defunct, '' if no value, or a string label
|
|
if (u.movie_category && movieCatLabel === null) {
|
|
// Defunct category - treat as no instance
|
|
movieCatHtml = '<span class="requsers-cat-badge requsers-cat-none-badge" title="Category no longer exists">No Instance</span>';
|
|
} else if (movieCatLabel) {
|
|
movieCatHtml = `<span class="requsers-cat-badge requsers-cat-movie"><i class="fas fa-film"></i> ${this._esc(movieCatLabel)}</span>`;
|
|
} else {
|
|
movieCatHtml = '<span class="requsers-cat-badge requsers-cat-none-badge">No Instance</span>';
|
|
}
|
|
if (u.tv_category && tvCatLabel === null) {
|
|
tvCatHtml = '<span class="requsers-cat-badge requsers-cat-none-badge" title="Category no longer exists">No Instance</span>';
|
|
} else if (tvCatLabel) {
|
|
tvCatHtml = `<span class="requsers-cat-badge requsers-cat-tv"><i class="fas fa-tv"></i> ${this._esc(tvCatLabel)}</span>`;
|
|
} else {
|
|
tvCatHtml = '<span class="requsers-cat-badge requsers-cat-none-badge">No Instance</span>';
|
|
}
|
|
}
|
|
|
|
return `<tr data-user-id="${u.id}">
|
|
<td class="requsers-checkbox-cell">
|
|
${!isOwner ? `<input type="checkbox" class="requsers-row-cb" data-user-id="${u.id}" ${isChecked} onchange="RequestarrUsers.onRowCheckChange()">` : ''}
|
|
</td>
|
|
<td>
|
|
<div class="requsers-user-cell">
|
|
<div class="requsers-avatar">${avatarHtml}</div>
|
|
<div class="requsers-user-info">
|
|
<span class="requsers-user-name">${this._esc(u.username)}</span>
|
|
${maskedEmail ? `<span class="requsers-user-email">${this._esc(maskedEmail)}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>${u.request_count || 0}</td>
|
|
<td><span class="requsers-role-badge ${roleClass}">${u.role || 'user'}</span></td>
|
|
<td class="requsers-cat-cell">${movieCatHtml}</td>
|
|
<td class="requsers-cat-cell">${tvCatHtml}</td>
|
|
<td>${joined}</td>
|
|
<td>
|
|
<div class="requsers-actions">
|
|
<button class="requsers-btn requsers-btn-primary requsers-btn-sm" onclick="RequestarrUsers.openEditModal(${u.id})">Edit</button>
|
|
${!isOwner ? `<button class="requsers-btn requsers-btn-danger requsers-btn-sm" onclick="RequestarrUsers.confirmDelete(${u.id}, '${this._esc(u.username)}')">Delete</button>` : ''}
|
|
</div>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
const allChecked = nonOwnerUsers.length > 0 && nonOwnerUsers.every(u => this.selectedUserIds.has(u.id));
|
|
const someChecked = nonOwnerUsers.some(u => this.selectedUserIds.has(u.id));
|
|
|
|
container.innerHTML = `
|
|
<div class="requsers-table-wrap">
|
|
<table class="requsers-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="requsers-checkbox-cell">
|
|
<input type="checkbox" id="requsers-select-all-cb" ${allChecked ? 'checked' : ''} onchange="RequestarrUsers.onSelectAllChange(this.checked)">
|
|
</th>
|
|
<th>User</th>
|
|
<th>Requests</th>
|
|
<th>Role</th>
|
|
<th>Movie Cat.</th>
|
|
<th>TV Cat.</th>
|
|
<th>Joined</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${rows || '<tr><td colspan="8" style="text-align:center;padding:24px;color:var(--text-muted);">No users found</td></tr>'}</tbody>
|
|
</table>
|
|
<div class="requsers-pagination">
|
|
<span>Showing ${this.users.length} user${this.users.length !== 1 ? 's' : ''}</span>
|
|
</div>
|
|
</div>`;
|
|
|
|
// Update select-all indeterminate state
|
|
const selectAllCb = document.getElementById('requsers-select-all-cb');
|
|
if (selectAllCb) {
|
|
selectAllCb.indeterminate = someChecked && !allChecked;
|
|
}
|
|
|
|
// Show/hide bulk edit button
|
|
this._updateBulkEditButton();
|
|
},
|
|
|
|
// ── Selection / Bulk ─────────────────────────────────────
|
|
|
|
onRowCheckChange() {
|
|
this.selectedUserIds.clear();
|
|
document.querySelectorAll('.requsers-row-cb:checked').forEach(cb => {
|
|
this.selectedUserIds.add(parseInt(cb.dataset.userId));
|
|
});
|
|
const nonOwnerUsers = this.users.filter(u => u.role !== 'owner');
|
|
const allChecked = nonOwnerUsers.length > 0 && nonOwnerUsers.every(u => this.selectedUserIds.has(u.id));
|
|
const someChecked = nonOwnerUsers.some(u => this.selectedUserIds.has(u.id));
|
|
const selectAllCb = document.getElementById('requsers-select-all-cb');
|
|
if (selectAllCb) {
|
|
selectAllCb.checked = allChecked;
|
|
selectAllCb.indeterminate = someChecked && !allChecked;
|
|
}
|
|
this._updateBulkEditButton();
|
|
},
|
|
|
|
onSelectAllChange(checked) {
|
|
const nonOwnerUsers = this.users.filter(u => u.role !== 'owner');
|
|
this.selectedUserIds.clear();
|
|
if (checked) {
|
|
nonOwnerUsers.forEach(u => this.selectedUserIds.add(u.id));
|
|
}
|
|
document.querySelectorAll('.requsers-row-cb').forEach(cb => {
|
|
cb.checked = checked;
|
|
});
|
|
this._updateBulkEditButton();
|
|
},
|
|
|
|
_updateBulkEditButton() {
|
|
const btn = document.getElementById('requsers-bulk-edit-btn');
|
|
if (!btn) return;
|
|
if (this.selectedUserIds.size > 0) {
|
|
btn.style.display = 'inline-flex';
|
|
btn.innerHTML = `<i class="fas fa-pen"></i> Bulk Edit (${this.selectedUserIds.size})`;
|
|
} else {
|
|
btn.style.display = 'none';
|
|
}
|
|
},
|
|
|
|
// ── Bulk Edit Modal ──────────────────────────────────────
|
|
|
|
openBulkEditModal() {
|
|
if (this.selectedUserIds.size === 0) return;
|
|
|
|
const selectedNames = this.users
|
|
.filter(u => this.selectedUserIds.has(u.id))
|
|
.map(u => u.username);
|
|
|
|
const permsHtml = Object.entries(this.permissionLabels).map(([key, label]) => {
|
|
return `<label class="requsers-perm-item requsers-bulk-perm-item">
|
|
<select name="bulk_perm_${key}" class="requsers-bulk-perm-select">
|
|
<option value="unchanged" selected>— No Change —</option>
|
|
<option value="true">Enable</option>
|
|
<option value="false">Disable</option>
|
|
</select>
|
|
<span>${label}</span>
|
|
</label>`;
|
|
}).join('');
|
|
|
|
const movieOpts = this._buildCategoryOptions('movies', '');
|
|
const tvOpts = this._buildCategoryOptions('tv', '');
|
|
|
|
const html = `<div class="requsers-modal-overlay" id="requsers-modal-overlay" onclick="if(event.target===this)RequestarrUsers.closeModal()">
|
|
<div class="requsers-modal">
|
|
<div class="requsers-modal-header">
|
|
<h3 class="requsers-modal-title"><i class="fas fa-pen" style="color:var(--accent-color);margin-right:6px;"></i> Bulk Edit</h3>
|
|
<button class="requsers-modal-close" onclick="RequestarrUsers.closeModal()"><i class="fas fa-times"></i></button>
|
|
</div>
|
|
<div class="requsers-modal-body">
|
|
<p style="color:var(--text-muted);font-size:0.85rem;margin-bottom:14px;">
|
|
Editing <strong style="color:var(--text-primary);">${selectedNames.length}</strong> user${selectedNames.length !== 1 ? 's' : ''}:
|
|
<span style="color:var(--text-secondary);">${selectedNames.map(n => this._esc(n)).join(', ')}</span>
|
|
</p>
|
|
<div class="requsers-field">
|
|
<label>Movie Category</label>
|
|
<select id="requsers-bulk-movie-cat" class="requsers-bulk-perm-select" style="width:100%;margin-bottom:6px;">
|
|
<option value="unchanged" selected>— No Change —</option>
|
|
<option value="">No Instance</option>
|
|
${movieOpts}
|
|
</select>
|
|
</div>
|
|
<div class="requsers-field">
|
|
<label>TV Category</label>
|
|
<select id="requsers-bulk-tv-cat" class="requsers-bulk-perm-select" style="width:100%;margin-bottom:6px;">
|
|
<option value="unchanged" selected>— No Change —</option>
|
|
<option value="">No Instance</option>
|
|
${tvOpts}
|
|
</select>
|
|
</div>
|
|
<div class="requsers-field">
|
|
<label>Permissions</label>
|
|
<p style="color:var(--text-dim);font-size:0.78rem;margin-bottom:10px;">Leave as "No Change" to keep current value.</p>
|
|
<div class="requsers-perms-grid requsers-bulk-perms-grid" id="requsers-bulk-perms-grid">${permsHtml}</div>
|
|
</div>
|
|
</div>
|
|
<div class="requsers-modal-footer">
|
|
<button class="requsers-btn" style="background:var(--bg-tertiary);color:var(--text-secondary);" onclick="RequestarrUsers.closeModal()">Cancel</button>
|
|
<button class="requsers-btn requsers-btn-primary" id="requsers-bulk-save-btn" onclick="RequestarrUsers.saveBulkEdit()">Apply to ${selectedNames.length} User${selectedNames.length !== 1 ? 's' : ''}</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
this.closeModal();
|
|
document.body.insertAdjacentHTML('beforeend', html);
|
|
},
|
|
|
|
async saveBulkEdit() {
|
|
const grid = document.getElementById('requsers-bulk-perms-grid');
|
|
if (!grid) return;
|
|
|
|
// Collect permission changes
|
|
const permChanges = {};
|
|
grid.querySelectorAll('.requsers-bulk-perm-select').forEach(sel => {
|
|
if (sel.value !== 'unchanged') {
|
|
const key = sel.name.replace('bulk_perm_', '');
|
|
permChanges[key] = sel.value === 'true';
|
|
}
|
|
});
|
|
|
|
// Collect category changes
|
|
const movieCatSel = document.getElementById('requsers-bulk-movie-cat');
|
|
const tvCatSel = document.getElementById('requsers-bulk-tv-cat');
|
|
const movieCatChanged = movieCatSel && movieCatSel.value !== 'unchanged';
|
|
const tvCatChanged = tvCatSel && tvCatSel.value !== 'unchanged';
|
|
|
|
if (Object.keys(permChanges).length === 0 && !movieCatChanged && !tvCatChanged) {
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('No changes selected', 'info');
|
|
return;
|
|
}
|
|
|
|
const saveBtn = document.getElementById('requsers-bulk-save-btn');
|
|
if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Applying...'; }
|
|
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
|
|
for (const userId of this.selectedUserIds) {
|
|
try {
|
|
const user = this.users.find(u => u.id === userId);
|
|
if (!user || user.role === 'owner') continue;
|
|
|
|
const body = {};
|
|
|
|
// Merge permissions
|
|
if (Object.keys(permChanges).length > 0) {
|
|
const currentPerms = (typeof user.permissions === 'object') ? { ...user.permissions } : {};
|
|
body.permissions = { ...currentPerms, ...permChanges };
|
|
}
|
|
|
|
if (movieCatChanged) body.movie_category = movieCatSel.value;
|
|
if (tvCatChanged) body.tv_category = tvCatSel.value;
|
|
|
|
const resp = await fetch(`./api/requestarr/users/${userId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success) successCount++;
|
|
else failCount++;
|
|
} catch (e) {
|
|
console.error(`[RequestarrUsers] Bulk edit error for user ${userId}:`, e);
|
|
failCount++;
|
|
}
|
|
}
|
|
|
|
this.closeModal();
|
|
this.selectedUserIds.clear();
|
|
|
|
if (successCount > 0) {
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification(`Updated ${successCount} user${successCount !== 1 ? 's' : ''}${failCount > 0 ? `, ${failCount} failed` : ''}`, 'success');
|
|
} else {
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('Failed to update users', 'error');
|
|
}
|
|
|
|
await this.loadUsers();
|
|
},
|
|
|
|
// ── Helpers ──────────────────────────────────────────────
|
|
|
|
_buildCategoryOptions(type, currentValue) {
|
|
if (!this.dropdownOptions) return '';
|
|
const options = type === 'movies' ? (this.dropdownOptions.movie_options || []) : (this.dropdownOptions.tv_options || []);
|
|
return options.map(o => {
|
|
const selected = o.value === currentValue ? 'selected' : '';
|
|
return `<option value="${this._esc(o.value)}" ${selected}>${this._esc(o.label)}</option>`;
|
|
}).join('');
|
|
},
|
|
|
|
renderError() {
|
|
const container = document.getElementById('requsers-content');
|
|
if (container) {
|
|
container.innerHTML = '<p style="color:var(--error-color);padding:20px;">Failed to load users. Check your connection.</p>';
|
|
}
|
|
},
|
|
|
|
// ── Create / Edit User Modal ─────────────────────────────
|
|
|
|
openCreateModal() {
|
|
this._openModal('Create Local User', null);
|
|
},
|
|
|
|
openEditModal(userId) {
|
|
const user = this.users.find(u => u.id === userId);
|
|
if (!user) return;
|
|
this._openModal('Edit User', user);
|
|
},
|
|
|
|
_openModal(title, user) {
|
|
const isEdit = !!user;
|
|
const isOwner = isEdit && user.role === 'owner';
|
|
const perms = (user && typeof user.permissions === 'object') ? user.permissions : {};
|
|
|
|
const permsHtml = Object.entries(this.permissionLabels).map(([key, label]) => {
|
|
const checked = perms[key] ? 'checked' : '';
|
|
const disabled = isOwner ? 'disabled' : '';
|
|
return `<label class="requsers-perm-item">
|
|
<input type="checkbox" name="perm_${key}" ${checked} ${disabled}>
|
|
<span>${label}</span>
|
|
</label>`;
|
|
}).join('');
|
|
|
|
// Hide password field for owner
|
|
const passwordFieldHtml = isOwner ? '' : `
|
|
<div class="requsers-field">
|
|
<label>${isEdit ? 'New Password (leave blank to keep)' : 'Password'}</label>
|
|
<input type="password" id="requsers-modal-password" placeholder="${isEdit ? '••••••••' : 'Min 8 characters'}" minlength="8" autocomplete="new-password">
|
|
<div class="requsers-field-hint"><a href="#" onclick="RequestarrUsers.fillGeneratedPassword();return false;">Generate random password</a></div>
|
|
</div>`;
|
|
|
|
const movieCatValue = (isEdit && user.movie_category) ? user.movie_category : '';
|
|
const tvCatValue = (isEdit && user.tv_category) ? user.tv_category : '';
|
|
|
|
const categoriesHtml = isOwner ? '' : `
|
|
<div class="requsers-field">
|
|
<label>Movie Category</label>
|
|
<select id="requsers-modal-movie-cat">
|
|
<option value="">No Instance</option>
|
|
${this._buildCategoryOptions('movies', movieCatValue)}
|
|
</select>
|
|
</div>
|
|
<div class="requsers-field">
|
|
<label>TV Category</label>
|
|
<select id="requsers-modal-tv-cat">
|
|
<option value="">No Instance</option>
|
|
${this._buildCategoryOptions('tv', tvCatValue)}
|
|
</select>
|
|
</div>`;
|
|
|
|
const html = `<div class="requsers-modal-overlay" id="requsers-modal-overlay" onclick="if(event.target===this)RequestarrUsers.closeModal()">
|
|
<div class="requsers-modal">
|
|
<div class="requsers-modal-header">
|
|
<h3 class="requsers-modal-title">${title}</h3>
|
|
<button class="requsers-modal-close" onclick="RequestarrUsers.closeModal()"><i class="fas fa-times"></i></button>
|
|
</div>
|
|
<div class="requsers-modal-body">
|
|
<div class="requsers-field">
|
|
<label>Username</label>
|
|
<input type="text" id="requsers-modal-username" value="${isEdit ? this._esc(user.username) : ''}" ${isOwner ? 'disabled' : ''} placeholder="Enter username" minlength="3">
|
|
</div>
|
|
<div class="requsers-field">
|
|
<label>Email (optional)</label>
|
|
<input type="email" id="requsers-modal-email" value="${isEdit ? this._esc(user.email || '') : ''}" placeholder="user@example.com">
|
|
</div>${passwordFieldHtml}
|
|
<div class="requsers-field">
|
|
<label>Role</label>
|
|
<select id="requsers-modal-role" ${isOwner ? 'disabled' : ''} onchange="RequestarrUsers.onRoleChange()">
|
|
<option value="user" ${(!isEdit || user.role === 'user') ? 'selected' : ''}>User</option>
|
|
${isOwner ? '<option value="owner" selected>Owner</option>' : ''}
|
|
</select>
|
|
</div>${categoriesHtml}
|
|
<div class="requsers-field">
|
|
<label>Permissions</label>
|
|
<div class="requsers-perms-grid" id="requsers-perms-grid">${permsHtml}</div>
|
|
</div>
|
|
</div>
|
|
<div class="requsers-modal-footer">
|
|
<button class="requsers-btn" style="background:var(--bg-tertiary);color:var(--text-secondary);" onclick="RequestarrUsers.closeModal()">Cancel</button>
|
|
<button class="requsers-btn requsers-btn-primary" id="requsers-modal-save" onclick="RequestarrUsers.saveUser(${isEdit ? user.id : 'null'})">${isEdit ? 'Save Changes' : 'Create User'}</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
this.closeModal();
|
|
document.body.insertAdjacentHTML('beforeend', html);
|
|
},
|
|
|
|
closeModal() {
|
|
const overlay = document.getElementById('requsers-modal-overlay');
|
|
if (overlay) overlay.remove();
|
|
const plexOverlay = document.getElementById('requsers-plex-modal-overlay');
|
|
if (plexOverlay) plexOverlay.remove();
|
|
},
|
|
|
|
async fillGeneratedPassword() {
|
|
try {
|
|
const resp = await fetch('./api/requestarr/users/generate-password');
|
|
const data = await resp.json();
|
|
const input = document.getElementById('requsers-modal-password');
|
|
if (input && data.password) {
|
|
input.type = 'text';
|
|
input.value = data.password;
|
|
try { await navigator.clipboard.writeText(data.password); } catch (_) { }
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('Password generated and copied to clipboard', 'success');
|
|
}
|
|
} catch (e) {
|
|
console.error('[RequestarrUsers] Error generating password:', e);
|
|
}
|
|
},
|
|
|
|
async onRoleChange() {
|
|
try {
|
|
const resp = await fetch('./api/requestarr/users/permissions-template');
|
|
const templates = await resp.json();
|
|
const role = document.getElementById('requsers-modal-role').value;
|
|
const perms = templates[role] || {};
|
|
const grid = document.getElementById('requsers-perms-grid');
|
|
if (!grid) return;
|
|
grid.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
|
const key = cb.name.replace('perm_', '');
|
|
cb.checked = !!perms[key];
|
|
});
|
|
} catch (_) { }
|
|
},
|
|
|
|
async saveUser(userId) {
|
|
const username = (document.getElementById('requsers-modal-username').value || '').trim();
|
|
const email = (document.getElementById('requsers-modal-email').value || '').trim();
|
|
const passwordEl = document.getElementById('requsers-modal-password');
|
|
const password = passwordEl ? passwordEl.value : '';
|
|
const role = document.getElementById('requsers-modal-role').value;
|
|
|
|
// Collect permissions
|
|
const permissions = {};
|
|
const grid = document.getElementById('requsers-perms-grid');
|
|
if (grid) {
|
|
grid.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
|
const key = cb.name.replace('perm_', '');
|
|
permissions[key] = cb.checked;
|
|
});
|
|
}
|
|
|
|
if (!username || username.length < 3) {
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('Username must be at least 3 characters', 'error');
|
|
return;
|
|
}
|
|
|
|
const body = { username, email, role, permissions };
|
|
if (password) body.password = password;
|
|
|
|
// Category assignment
|
|
const movieCatEl = document.getElementById('requsers-modal-movie-cat');
|
|
const tvCatEl = document.getElementById('requsers-modal-tv-cat');
|
|
if (movieCatEl) body.movie_category = movieCatEl.value;
|
|
if (tvCatEl) body.tv_category = tvCatEl.value;
|
|
|
|
const isEdit = userId !== null;
|
|
if (!isEdit && (!password || password.length < 8)) {
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('Password must be at least 8 characters', 'error');
|
|
return;
|
|
}
|
|
|
|
const saveBtn = document.getElementById('requsers-modal-save');
|
|
if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving...'; }
|
|
|
|
try {
|
|
const url = isEdit ? `./api/requestarr/users/${userId}` : './api/requestarr/users';
|
|
const method = isEdit ? 'PUT' : 'POST';
|
|
const resp = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
this.closeModal();
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification(isEdit ? 'User updated' : 'User created', 'success');
|
|
await this.loadUsers();
|
|
} else {
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification(data.error || 'Failed to save user', 'error');
|
|
}
|
|
} catch (e) {
|
|
console.error('[RequestarrUsers] Error saving user:', e);
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('Failed to save user', 'error');
|
|
} finally {
|
|
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = isEdit ? 'Save Changes' : 'Create User'; }
|
|
}
|
|
},
|
|
|
|
confirmDelete(userId, username) {
|
|
if (window.HuntarrConfirmModal && typeof window.HuntarrConfirmModal.show === 'function') {
|
|
window.HuntarrConfirmModal.show({
|
|
title: 'Delete User',
|
|
message: `Are you sure you want to delete <strong>${this._esc(username)}</strong>? This cannot be undone.`,
|
|
confirmText: 'Delete',
|
|
confirmClass: 'danger',
|
|
onConfirm: () => this.deleteUser(userId),
|
|
});
|
|
} else {
|
|
if (confirm(`Delete user "${username}"? This cannot be undone.`)) {
|
|
this.deleteUser(userId);
|
|
}
|
|
}
|
|
},
|
|
|
|
async deleteUser(userId) {
|
|
try {
|
|
const resp = await fetch(`./api/requestarr/users/${userId}`, { method: 'DELETE' });
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('User deleted', 'success');
|
|
await this.loadUsers();
|
|
} else {
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification(data.error || 'Failed to delete user', 'error');
|
|
}
|
|
} catch (e) {
|
|
console.error('[RequestarrUsers] Error deleting user:', e);
|
|
}
|
|
},
|
|
|
|
// ── Plex Import ──────────────────────────────────────────
|
|
|
|
async openPlexImportModal() {
|
|
try {
|
|
const resp = await fetch('./api/requestarr/users/plex/friends');
|
|
const data = await resp.json();
|
|
if (data.error) {
|
|
if (window.HuntarrConfirm && window.HuntarrConfirm.show) {
|
|
window.HuntarrConfirm.show({
|
|
title: 'Plex Account Not Linked',
|
|
message: 'No Plex account is linked. Would you like to link your Plex account now? Once linked, you can import your Plex users.',
|
|
confirmLabel: 'Link Plex',
|
|
onConfirm: () => { this._startPlexLinkFromUsers(); }
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
const allUsers = data.friends || [];
|
|
const importable = allUsers.filter(f => !f.already_imported);
|
|
const alreadyImported = allUsers.filter(f => f.already_imported);
|
|
|
|
if (!allUsers.length) {
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('No Plex users found with server access', 'info');
|
|
return;
|
|
}
|
|
|
|
const renderUserRow = (f, disabled) => {
|
|
const initial = (f.username || '?').charAt(0).toUpperCase();
|
|
const avatarHtml = f.thumb
|
|
? `<img class="requsers-plex-thumb" src="${f.thumb}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><div class="requsers-plex-avatar-fallback" style="display:none;">${initial}</div>`
|
|
: `<div class="requsers-plex-avatar-fallback">${initial}</div>`;
|
|
const disabledAttr = disabled ? 'disabled' : '';
|
|
const dimClass = disabled ? 'requsers-plex-item-disabled' : '';
|
|
return `<label class="requsers-plex-item ${dimClass}">
|
|
<input type="checkbox" value="${f.id}" data-username="${this._esc(f.username)}" ${disabledAttr}>
|
|
<div class="requsers-plex-avatar-wrap">${avatarHtml}</div>
|
|
<div class="requsers-user-info">
|
|
<span class="requsers-user-name">${this._esc(f.username)}</span>
|
|
${f.email ? `<span class="requsers-user-email">${this._esc(this._maskEmail(f.email))}</span>` : ''}
|
|
</div>
|
|
${disabled ? '<span class="requsers-plex-imported-badge">Imported</span>' : ''}
|
|
</label>`;
|
|
};
|
|
|
|
const importableHtml = importable.map(f => renderUserRow(f, false)).join('');
|
|
const alreadyHtml = alreadyImported.map(f => renderUserRow(f, true)).join('');
|
|
const selectAllDisabled = importable.length === 0 ? 'disabled' : '';
|
|
|
|
const html = `<div class="requsers-modal-overlay" id="requsers-plex-modal-overlay" onclick="if(event.target===this)RequestarrUsers.closeModal()">
|
|
<div class="requsers-modal" style="max-width:500px;">
|
|
<div class="requsers-modal-header">
|
|
<h3 class="requsers-modal-title"><i class="fas fa-download" style="color:#e5a00d;margin-right:6px;"></i> Import Plex Users</h3>
|
|
<button class="requsers-modal-close" onclick="RequestarrUsers.closeModal()"><i class="fas fa-times"></i></button>
|
|
</div>
|
|
<div class="requsers-modal-body">
|
|
<p style="color:var(--text-muted);font-size:0.85rem;margin-bottom:12px;">Select Plex users with server access to import with the "User" role.</p>
|
|
<div class="requsers-plex-select-all">
|
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
|
|
<input type="checkbox" id="requsers-plex-select-all-cb" ${selectAllDisabled} onchange="RequestarrUsers.toggleSelectAll(this.checked)">
|
|
<span style="font-weight:600;font-size:0.85rem;color:var(--text-secondary);">USER</span>
|
|
</label>
|
|
<span style="font-size:0.78rem;color:var(--text-muted);">${importable.length} available${alreadyImported.length ? `, ${alreadyImported.length} already imported` : ''}</span>
|
|
</div>
|
|
<div class="requsers-plex-list">${importableHtml}${alreadyHtml}</div>
|
|
</div>
|
|
<div class="requsers-modal-footer">
|
|
<button class="requsers-btn" style="background:var(--bg-tertiary);color:var(--text-secondary);" onclick="RequestarrUsers.closeModal()">Cancel</button>
|
|
<button class="requsers-btn requsers-btn-plex" id="requsers-plex-import-btn" onclick="RequestarrUsers.doPlexImport()"><i class="fas fa-download"></i> Import</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
this.closeModal();
|
|
document.body.insertAdjacentHTML('beforeend', html);
|
|
const plexOverlay = document.getElementById('requsers-plex-modal-overlay');
|
|
if (plexOverlay) {
|
|
plexOverlay.querySelectorAll('.requsers-plex-list input[type="checkbox"]:not(:disabled)').forEach(cb => {
|
|
cb.addEventListener('change', () => this._updatePlexSelectAllState());
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error('[RequestarrUsers] Error opening Plex import:', e);
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('Failed to load Plex users', 'error');
|
|
}
|
|
},
|
|
|
|
toggleSelectAll(checked) {
|
|
const overlay = document.getElementById('requsers-plex-modal-overlay');
|
|
if (!overlay) return;
|
|
overlay.querySelectorAll('.requsers-plex-list input[type="checkbox"]:not(:disabled)').forEach(cb => {
|
|
cb.checked = checked;
|
|
});
|
|
},
|
|
|
|
_updatePlexSelectAllState() {
|
|
const overlay = document.getElementById('requsers-plex-modal-overlay');
|
|
if (!overlay) return;
|
|
const allCbs = overlay.querySelectorAll('.requsers-plex-list input[type="checkbox"]:not(:disabled)');
|
|
const checkedCbs = overlay.querySelectorAll('.requsers-plex-list input[type="checkbox"]:not(:disabled):checked');
|
|
const selectAllCb = document.getElementById('requsers-plex-select-all-cb');
|
|
if (selectAllCb && allCbs.length > 0) {
|
|
selectAllCb.checked = checkedCbs.length === allCbs.length;
|
|
selectAllCb.indeterminate = checkedCbs.length > 0 && checkedCbs.length < allCbs.length;
|
|
}
|
|
},
|
|
|
|
async doPlexImport() {
|
|
const overlay = document.getElementById('requsers-plex-modal-overlay');
|
|
if (!overlay) return;
|
|
const checked = overlay.querySelectorAll('.requsers-plex-list input[type="checkbox"]:checked:not(:disabled)');
|
|
const friendIds = Array.from(checked).map(cb => parseInt(cb.value)).filter(v => !isNaN(v));
|
|
if (!friendIds.length) {
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('Select at least one user to import', 'warning');
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById('requsers-plex-import-btn');
|
|
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Importing...'; }
|
|
|
|
try {
|
|
const resp = await fetch('./api/requestarr/users/plex/import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ friend_ids: friendIds }),
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
const msg = `Imported ${data.imported.length} user${data.imported.length !== 1 ? 's' : ''}${data.skipped.length ? `, ${data.skipped.length} skipped` : ''}`;
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification(msg, 'success');
|
|
this.closeModal();
|
|
await this.loadUsers();
|
|
} else {
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification(data.error || 'Import failed', 'error');
|
|
}
|
|
} catch (e) {
|
|
console.error('[RequestarrUsers] Plex import error:', e);
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('Import failed', 'error');
|
|
} finally {
|
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-download"></i> Import Selected'; }
|
|
}
|
|
},
|
|
|
|
_esc(str) {
|
|
if (!str) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
},
|
|
|
|
/**
|
|
* Start Plex account linking directly from the Users page via popup flow.
|
|
* On success, automatically opens the Plex import modal.
|
|
*/
|
|
_startPlexLinkFromUsers() {
|
|
const overlay = document.createElement('div');
|
|
overlay.id = 'requsers-plex-link-overlay';
|
|
overlay.style.cssText = 'position:fixed;z-index:1000;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.7);backdrop-filter:blur(5px);display:flex;align-items:center;justify-content:center;';
|
|
overlay.innerHTML = `
|
|
<div style="background:linear-gradient(180deg,rgba(22,26,34,0.98),rgba(18,22,30,0.95));border-radius:15px;padding:30px;width:400px;max-width:90%;box-shadow:0 8px 30px rgba(0,0,0,0.5);border:1px solid rgba(90,109,137,0.15);color:#f8f9fa;text-align:center;">
|
|
<div style="font-size:40px;color:#e69500;margin-bottom:10px;"><i class="fas fa-tv"></i></div>
|
|
<h2 style="margin:0 0 15px;">Link Plex Account</h2>
|
|
<div id="requsers-plex-link-status" class="plex-status waiting" style="margin:15px 0;padding:10px;border-radius:8px;background:rgba(255,193,7,0.2);border:1px solid rgba(255,193,7,0.3);color:#ffc107;">
|
|
<i class="fas fa-spinner fa-spin"></i> Preparing Plex authentication...
|
|
</div>
|
|
<button id="requsers-plex-link-cancel" class="action-button secondary-button" style="margin-top:10px;">Cancel</button>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
|
|
const statusEl = document.getElementById('requsers-plex-link-status');
|
|
const cancelBtn = document.getElementById('requsers-plex-link-cancel');
|
|
let plexPopup = null;
|
|
let pollInterval = null;
|
|
let pinId = null;
|
|
|
|
const cleanup = () => {
|
|
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
|
|
if (plexPopup && !plexPopup.closed) plexPopup.close();
|
|
plexPopup = null;
|
|
const el = document.getElementById('requsers-plex-link-overlay');
|
|
if (el) el.remove();
|
|
};
|
|
|
|
cancelBtn.addEventListener('click', cleanup);
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(); });
|
|
|
|
fetch('./api/auth/plex/pin', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ user_mode: true, popup_mode: true })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (!data.success) {
|
|
statusEl.className = 'plex-status error';
|
|
statusEl.innerHTML = '<i class="fas fa-exclamation-triangle"></i> ' + (data.error || 'Failed to create PIN');
|
|
return;
|
|
}
|
|
pinId = data.pin_id;
|
|
statusEl.innerHTML = '<i class="fas fa-external-link-alt"></i> A Plex window has opened. Please sign in there.';
|
|
|
|
const w = 600, h = 700;
|
|
const left = Math.max(0, Math.round(window.screenX + (window.outerWidth - w) / 2));
|
|
const top = Math.max(0, Math.round(window.screenY + (window.outerHeight - h) / 2));
|
|
plexPopup = window.open(data.auth_url, 'PlexAuth', `width=${w},height=${h},left=${left},top=${top},toolbar=no,menubar=no,scrollbars=yes`);
|
|
|
|
pollInterval = setInterval(() => {
|
|
fetch(`./api/auth/plex/check/${pinId}`)
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
if (d.success && d.claimed) {
|
|
clearInterval(pollInterval); pollInterval = null;
|
|
if (plexPopup && !plexPopup.closed) plexPopup.close();
|
|
statusEl.className = 'plex-status success';
|
|
statusEl.innerHTML = '<i class="fas fa-check"></i> Plex authenticated! Linking account...';
|
|
fetch('./api/auth/plex/link', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ token: d.token, setup_mode: true })
|
|
})
|
|
.then(r => r.json())
|
|
.then(linkResult => {
|
|
if (linkResult.success) {
|
|
statusEl.innerHTML = '<i class="fas fa-check-circle"></i> Plex linked! Loading friends...';
|
|
setTimeout(() => {
|
|
cleanup();
|
|
this.openPlexImportModal();
|
|
}, 1000);
|
|
} else {
|
|
statusEl.className = 'plex-status error';
|
|
statusEl.innerHTML = '<i class="fas fa-exclamation-triangle"></i> ' + (linkResult.error || 'Linking failed');
|
|
}
|
|
})
|
|
.catch(() => {
|
|
statusEl.className = 'plex-status error';
|
|
statusEl.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Network error linking account';
|
|
});
|
|
}
|
|
})
|
|
.catch(() => { });
|
|
}, 2000);
|
|
|
|
setTimeout(() => {
|
|
if (pollInterval) {
|
|
cleanup();
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('Plex authentication timed out', 'error');
|
|
}
|
|
}, 600000);
|
|
})
|
|
.catch(() => {
|
|
statusEl.className = 'plex-status error';
|
|
statusEl.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Network error creating PIN';
|
|
});
|
|
},
|
|
|
|
// ── Default Categories Modal ─────────────────────────────
|
|
|
|
async openDefaultCategoriesModal() {
|
|
// Load current defaults
|
|
let currentDefaults = { default_movie_category: '', default_tv_category: '' };
|
|
try {
|
|
const resp = await fetch('./api/requestarr/users/default-categories', { cache: 'no-store' });
|
|
if (resp.ok) currentDefaults = await resp.json();
|
|
} catch (e) {
|
|
console.warn('[RequestarrUsers] Could not load default categories:', e);
|
|
}
|
|
|
|
const movieOpts = this._buildCategoryOptions('movies', currentDefaults.default_movie_category || '');
|
|
const tvOpts = this._buildCategoryOptions('tv', currentDefaults.default_tv_category || '');
|
|
|
|
const html = `<div class="requsers-modal-overlay" id="requsers-modal-overlay" onclick="if(event.target===this)RequestarrUsers.closeModal()">
|
|
<div class="requsers-modal" style="max-width:480px;">
|
|
<div class="requsers-modal-header">
|
|
<h3 class="requsers-modal-title"><i class="fas fa-cog" style="color:#2dd4bf;margin-right:6px;"></i> Default Categories</h3>
|
|
<button class="requsers-modal-close" onclick="RequestarrUsers.closeModal()"><i class="fas fa-times"></i></button>
|
|
</div>
|
|
<div class="requsers-modal-body">
|
|
<p style="color:var(--text-muted);font-size:0.85rem;margin-bottom:16px;">
|
|
Set the default Movie and TV categories assigned to <strong style="color:var(--text-primary);">new users</strong> when they are created or imported.
|
|
</p>
|
|
<div class="requsers-field">
|
|
<label>Default Movie Category</label>
|
|
<select id="requsers-default-movie-cat">
|
|
<option value="">No Instance</option>
|
|
${movieOpts}
|
|
</select>
|
|
</div>
|
|
<div class="requsers-field">
|
|
<label>Default TV Category</label>
|
|
<select id="requsers-default-tv-cat">
|
|
<option value="">No Instance</option>
|
|
${tvOpts}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="requsers-modal-footer">
|
|
<button class="requsers-btn" style="background:var(--bg-tertiary);color:var(--text-secondary);" onclick="RequestarrUsers.closeModal()">Cancel</button>
|
|
<button class="requsers-btn requsers-btn-primary" id="requsers-default-save-btn" onclick="RequestarrUsers.saveDefaultCategories()">Save Defaults</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
this.closeModal();
|
|
document.body.insertAdjacentHTML('beforeend', html);
|
|
},
|
|
|
|
async saveDefaultCategories() {
|
|
const movieCat = document.getElementById('requsers-default-movie-cat');
|
|
const tvCat = document.getElementById('requsers-default-tv-cat');
|
|
if (!movieCat || !tvCat) return;
|
|
|
|
const saveBtn = document.getElementById('requsers-default-save-btn');
|
|
if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving...'; }
|
|
|
|
try {
|
|
const resp = await fetch('./api/requestarr/users/default-categories', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
default_movie_category: movieCat.value,
|
|
default_tv_category: tvCat.value,
|
|
}),
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
this.closeModal();
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('Default categories saved', 'success');
|
|
} else {
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification(data.error || 'Failed to save', 'error');
|
|
}
|
|
} catch (e) {
|
|
console.error('[RequestarrUsers] Error saving default categories:', e);
|
|
if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('Failed to save', 'error');
|
|
} finally {
|
|
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save Defaults'; }
|
|
}
|
|
},
|
|
};
|