/** * Requestarr User Management Module * Handles user list, create/edit/delete, Plex import, and permissions. */ window.RequestarrUsers = { users: [], 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)', disable_chat: 'Disable Chat', }, async init() { await this.loadUsers(); }, 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 rows = this.users.map(u => { const initials = (u.username || '?').substring(0, 2).toUpperCase(); const avatarHtml = u.avatar_url ? `` : 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'; return `
${avatarHtml}
${this._esc(u.username)} ${u.email ? `${this._esc(u.email)}` : ''}
${u.request_count || 0} ${u.role || 'user'} ${joined}
${!isOwner ? `` : ''}
`; }).join(''); container.innerHTML = `
${rows || ''}
User Requests Role Joined Actions
No users found
Showing ${this.users.length} user${this.users.length !== 1 ? 's' : ''}
`; }, renderError() { const container = document.getElementById('requsers-content'); if (container) { container.innerHTML = '

Failed to load users. Check your connection.

'; } }, // ── Create 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' : ''; // Hide disable_chat for owner — owner can never be chat-disabled if (key === 'disable_chat' && isOwner) return ''; return ``; }).join(''); // Hide password field for owner const passwordFieldHtml = isOwner ? '' : `
Generate random password
`; const html = `

${title}

${passwordFieldHtml}
${permsHtml}
`; // Remove existing modal if any 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; // Copy to clipboard 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() { // Load default permissions for the selected role 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; 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 ${this._esc(username)}? 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) { // No Plex linked — offer to link it right here via popup 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 || []; // Split into importable and already-imported 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 ? `` : `
${initial}
`; const disabledAttr = disabled ? 'disabled' : ''; const dimClass = disabled ? 'requsers-plex-item-disabled' : ''; return ``; }; 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 = `

Import Plex Users

Select Plex users with server access to import with the "User" role.

${importable.length} available${alreadyImported.length ? `, ${alreadyImported.length} already imported` : ''}
${importableHtml}${alreadyHtml}
`; this.closeModal(); document.body.insertAdjacentHTML('beforeend', html); // Attach change listeners to individual checkboxes for select-all sync 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._updateSelectAllState()); }); } } 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; }); }, _updateSelectAllState() { 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 = ' 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 = ' 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() { // Create a status overlay 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 = `

Link Plex Account

`; 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(); }); // Request PIN with popup_mode 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 = ' ' + (data.error || 'Failed to create PIN'); return; } pinId = data.pin_id; statusEl.innerHTML = ' A Plex window has opened. Please sign in there.'; // Open popup 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`); // Poll for claim 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 = ' Plex authenticated! Linking account...'; // Link the 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 = ' Plex linked! Loading friends...'; setTimeout(() => { cleanup(); this.openPlexImportModal(); }, 1000); } else { statusEl.className = 'plex-status error'; statusEl.innerHTML = ' ' + (linkResult.error || 'Linking failed'); } }) .catch(() => { statusEl.className = 'plex-status error'; statusEl.innerHTML = ' Network error linking account'; }); } }) .catch(() => {}); }, 2000); // 10 min timeout setTimeout(() => { if (pollInterval) { cleanup(); if (window.HuntarrNotifications) window.HuntarrNotifications.showNotification('Plex authentication timed out', 'error'); } }, 600000); }) .catch(() => { statusEl.className = 'plex-status error'; statusEl.innerHTML = ' Network error creating PIN'; }); }, };