/** * BetterDesk Console - Users Page * User management for admins */ (function() { 'use strict'; document.addEventListener('DOMContentLoaded', init); // State let users = []; let userGroups = []; let userGroupsLoaded = false; let editingUserId = null; // Cache: userId -> [{ id, org_id, name, org_name, role }] const userOrgsCache = new Map(); // Elements let tableBody, emptyState, userGroupsManager; function init() { tableBody = document.getElementById('users-tbody'); emptyState = document.getElementById('users-empty'); userGroupsManager = document.getElementById('user-groups-manager-list'); loadUserGroups(); loadUsers(); initEventListeners(); focusUserGroupsFromHash(); window.addEventListener('app:refresh', loadUsers); } function focusUserGroupsFromHash() { if (window.location.hash !== '#user-groups') return; window.requestAnimationFrame(() => { document.getElementById('user-groups')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); document.getElementById('add-user-group-btn')?.focus(); }); } function initEventListeners() { // Add user button document.getElementById('add-user-btn')?.addEventListener('click', showAddUserModal); document.getElementById('add-user-group-btn')?.addEventListener('click', () => showUserGroupModal()); } /** * Load users from API */ async function loadUsers() { try { const response = await Utils.api('/api/users'); users = response.users || []; renderUsers(); // Lazy-load organizations for each user (parallel, best-effort) loadUsersOrganizations(); } catch (error) { console.error('Failed to load users:', error); if (error.status === 403) { Notifications.error(_('users.admin_only')); } else { Notifications.error(_('errors.load_users_failed')); } } } async function loadUserGroups() { try { const response = await Utils.api('/api/panel/user-groups'); userGroups = response.groups || []; userGroupsLoaded = true; renderUserGroupsManager(); if (users.length > 0) renderUsers(); } catch (error) { userGroups = []; userGroupsLoaded = true; renderUserGroupsManager(); console.error('Failed to load user groups:', error); } } async function ensureUserGroupsLoaded() { if (!userGroupsLoaded) await loadUserGroups(); } function userGroupName(guid) { const group = userGroups.find(item => item.guid === guid); return group ? group.name : guid; } function renderUserGroupBadges(groupGuids) { if (!Array.isArray(groupGuids) || groupGuids.length === 0) return ''; return `
${groupGuids.map(guid => ` group ${Utils.escapeHtml(userGroupName(guid))} `).join('')}
`; } function renderUserGroupCheckboxes(selectedGuids) { const selected = new Set(Array.isArray(selectedGuids) ? selectedGuids : []); const container = document.getElementById('user-groups-list'); if (!container) return; if (!userGroups.length) { container.innerHTML = `
${_('users.no_user_groups') || 'No user groups'}
`; return; } container.innerHTML = userGroups.map(group => ` `).join(''); } function selectedUserGroupGuids() { return Array.from(document.querySelectorAll('#user-groups-list input:checked')).map(input => input.value); } function renderUserGroupsManager() { if (!userGroupsManager) return; if (!userGroupsLoaded) { userGroupsManager.innerHTML = `
${_('users.loading_user_groups') || 'Loading user groups...'}
`; return; } if (!userGroups.length) { userGroupsManager.innerHTML = `
${_('users.no_user_groups') || 'No user groups available'}
`; return; } userGroupsManager.innerHTML = userGroups.map(group => `
groups
${Utils.escapeHtml(group.name || group.guid)} ${group.note ? `${Utils.escapeHtml(group.note)}` : ''}
${_('users.group_members_count', { count: group.member_count || 0 }) || (group.member_count || 0)}
`).join(''); userGroupsManager.querySelectorAll('[data-action="edit-user-group"]').forEach(btn => { btn.addEventListener('click', () => { const group = userGroups.find(item => item.guid === btn.dataset.guid); if (group) showUserGroupModal(group); }); }); userGroupsManager.querySelectorAll('[data-action="delete-user-group"]').forEach(btn => { btn.addEventListener('click', () => { const group = userGroups.find(item => item.guid === btn.dataset.guid); if (group) deleteUserGroup(group); }); }); } function showUserGroupModal(group = null) { const editing = !!group; Modal.show({ title: editing ? (_('users.edit_user_group') || 'Edit user group') : (_('users.create_user_group') || 'Create user group'), content: `
`, size: 'medium', buttons: [ { label: _('actions.cancel'), class: 'btn-secondary', onClick: () => Modal.close() }, { label: _('actions.save'), class: 'btn-primary', onClick: () => saveUserGroup(group) } ], onOpen: () => document.getElementById('user-group-name')?.focus() }); } async function saveUserGroup(group = null) { const name = document.getElementById('user-group-name')?.value.trim() || ''; const note = document.getElementById('user-group-note')?.value.trim() || ''; if (!name) { Notifications.error(_('users.group_name_required') || 'Group name is required'); return; } try { const url = group ? `/api/panel/user-groups/${encodeURIComponent(group.guid)}` : '/api/panel/user-groups'; await Utils.api(url, { method: group ? 'PATCH' : 'POST', body: { name, note } }); Notifications.success(group ? (_('users.user_group_updated') || 'User group updated') : (_('users.user_group_created') || 'User group created')); Modal.close(); userGroupsLoaded = false; await loadUserGroups(); await loadUsers(); } catch (error) { Notifications.error(error.message || _('errors.server_error')); } } async function deleteUserGroup(group) { const confirmed = await Modal.confirm({ title: _('users.delete_user_group') || 'Delete user group', message: (_('users.delete_user_group_confirm') || 'Delete user group {name}?').replace('{name}', group.name || group.guid), confirmLabel: _('actions.delete'), danger: true }); if (!confirmed) return; try { await Utils.api(`/api/panel/user-groups/${encodeURIComponent(group.guid)}`, { method: 'DELETE' }); Notifications.success(_('users.user_group_deleted') || 'User group deleted'); userGroupsLoaded = false; await loadUserGroups(); await loadUsers(); } catch (error) { Notifications.error(error.message || _('errors.server_error')); } } /** * Lazy-load each user's organization memberships and update the table cells. * Errors per user are silently ignored so the table still renders. */ async function loadUsersOrganizations() { if (!Array.isArray(users) || users.length === 0) return; await Promise.all(users.map(async (user) => { try { const resp = await Utils.api(`/api/users/${user.id}/organizations`); const orgs = (resp.organizations || []).map(normalizeOrgPayload).filter(o => o.id); userOrgsCache.set(Number(user.id), orgs); renderUserOrgsCell(user.id, orgs); } catch (_err) { renderUserOrgsCell(user.id, []); } })); } function normalizeOrgPayload(org) { const id = String(org.org_id || org.id || org.organization_id || ''); return { ...org, id, org_id: id, name: org.name || org.org_name || (id ? 'Org #' + id : ''), org_name: org.org_name || org.name || (id ? 'Org #' + id : ''), role: org.role || '' }; } function renderUserOrgsCell(userId, orgs) { const cell = document.querySelector(`tr[data-id="${userId}"] .user-orgs-cell`); if (!cell) return; if (!orgs || orgs.length === 0) { cell.innerHTML = `${_('users.no_orgs_short')}`; return; } cell.innerHTML = orgs.map(o => ` business ${Utils.escapeHtml(o.org_name)} `).join(''); } /** * Re-fetch a single user's org memberships and refresh the inline cell. * Safe to call after add/remove from the Organizations modal. */ async function refreshUserOrgsCell(userId) { try { const resp = await Utils.api(`/api/users/${userId}/organizations`); const orgs = (resp.organizations || []).map(normalizeOrgPayload).filter(o => o.id); userOrgsCache.set(Number(userId), orgs); renderUserOrgsCell(userId, orgs); } catch (_err) { // Leave cell as-is on failure } } /** * Render users table */ function renderUsers() { if (!tableBody) return; if (users.length === 0) { tableBody.innerHTML = ''; emptyState?.classList.remove('hidden'); return; } emptyState?.classList.add('hidden'); tableBody.innerHTML = users.map(user => { const roleIcons = { super_admin: 'shield_person', admin: 'admin_panel_settings', server_admin: 'dns', global_admin: 'public', operator: 'engineering', viewer: 'visibility', pro: 'star' }; const roleIcon = roleIcons[user.role] || 'person'; const roleLabelKey = 'users.role_' + user.role; const provider = (user.auth_provider || 'local').toLowerCase(); const providerLabel = _('users.provider_' + provider) || provider; const isLocal = provider === 'local'; return `
${roleIcon}
${Utils.escapeHtml(user.username)} ${renderUserGroupBadges(user.user_groups)}
${Utils.escapeHtml(_(roleLabelKey))} ${Utils.escapeHtml(providerLabel)}
${Utils.formatDate(user.created_at)} ${user.last_login ? Utils.formatDate(user.last_login) : '' + _('users.never') + ''}
${isLocal ? `` : ''}
`}).join(''); // Attach event listeners tableBody.querySelectorAll('.action-btn').forEach(btn => { btn.addEventListener('click', () => handleAction(btn.dataset.action, btn.dataset.id, btn.dataset)); }); // Clicking the inline orgs cell opens the same Organizations modal as the action button tableBody.querySelectorAll('.user-orgs-cell').forEach(cell => { cell.addEventListener('click', () => { const id = cell.dataset.userId; const username = cell.dataset.username; if (id && username) showOrganizationsModal(id, username); }); }); } /** * Handle actions */ async function handleAction(action, userId, data) { switch (action) { case 'edit': showEditUserModal(userId); break; case 'reset-password': await resetPassword(userId); break; case 'delete': await deleteUser(userId, data.username); break; case 'organizations': await showOrganizationsModal(userId, data.username); break; } } /** * Show add user modal */ async function showAddUserModal() { await ensureUserGroupsLoaded(); editingUserId = null; const template = document.getElementById('user-form-template'); const content = template.content.cloneNode(true); const formHtml = content.querySelector('form').outerHTML; Modal.show({ title: _('users.add_user'), content: formHtml, size: 'medium', buttons: [ { label: _('actions.cancel'), class: 'btn-secondary', onClick: () => Modal.close() }, { label: _('users.create'), class: 'btn-primary', onClick: () => submitUserForm() } ], onOpen: () => { initFormListeners(); renderUserGroupCheckboxes([]); document.getElementById('user-username')?.focus(); } }); } /** * Show edit user modal */ async function showEditUserModal(userId) { await ensureUserGroupsLoaded(); const user = users.find(u => Number(u.id) === Number(userId)); if (!user) return; editingUserId = user.id; const template = document.getElementById('user-form-template'); const content = template.content.cloneNode(true); const formHtml = content.querySelector('form').outerHTML; Modal.show({ title: _('users.edit_user'), content: formHtml, size: 'medium', buttons: [ { label: _('actions.cancel'), class: 'btn-secondary', onClick: () => Modal.close() }, { label: _('actions.save'), class: 'btn-primary', onClick: () => submitUserForm() } ], onOpen: () => { initFormListeners(); // Fill form with user data const usernameInput = document.getElementById('user-username'); const roleSelect = document.getElementById('user-role'); const passwordInput = document.getElementById('user-password'); if (usernameInput) { usernameInput.value = user.username; usernameInput.readOnly = true; usernameInput.classList.add('readonly'); } if (roleSelect) roleSelect.value = user.role; if (passwordInput) passwordInput.placeholder = _('users.password_leave_empty'); // LDAP/OIDC accounts are managed by the identity provider: // password cannot be set locally and the role is provider-mapped. const provider = (user.auth_provider || 'local').toLowerCase(); if (provider !== 'local') { if (passwordInput) { passwordInput.value = ''; passwordInput.disabled = true; passwordInput.placeholder = _('users.password_managed_by_provider'); } const passwordGroup = passwordInput ? passwordInput.closest('.form-group') : null; if (passwordGroup) { const hint = document.createElement('span'); hint.className = 'form-hint'; hint.textContent = _('users.provider_managed_hint'); passwordGroup.appendChild(hint); } } renderUserGroupCheckboxes(user.user_groups || []); } }); } /** * Initialize form listeners */ function initFormListeners() { // Password visibility toggle document.querySelector('.toggle-password')?.addEventListener('click', function() { const input = document.getElementById('user-password'); const icon = this.querySelector('.material-icons'); if (input.type === 'password') { input.type = 'text'; icon.textContent = 'visibility_off'; } else { input.type = 'password'; icon.textContent = 'visibility'; } }); // Password strength indicator document.getElementById('user-password')?.addEventListener('input', function() { updatePasswordStrength(this.value); }); } /** * Update password strength indicator */ function updatePasswordStrength(password) { const container = document.getElementById('password-strength'); if (!container) return; if (!password) { container.innerHTML = ''; return; } let score = 0; const feedback = []; if (password.length >= 8) score++; else feedback.push(_('settings.req_length')); if (password.length >= 12) score++; if (/[a-z]/.test(password)) score++; else feedback.push(_('settings.req_lowercase')); if (/[A-Z]/.test(password)) score++; else feedback.push(_('settings.req_uppercase')); if (/[0-9]/.test(password)) score++; else feedback.push(_('settings.req_number')); if (/[^a-zA-Z0-9]/.test(password)) score++; const strength = score <= 2 ? 'weak' : score <= 4 ? 'medium' : 'strong'; const labels = { weak: _('users.strength_weak'), medium: _('users.strength_medium'), strong: _('users.strength_strong') }; container.innerHTML = `
${labels[strength]} `; } /** * Submit user form */ async function submitUserForm() { const form = document.getElementById('user-form'); if (!form) return; const username = document.getElementById('user-username')?.value.trim(); const password = document.getElementById('user-password')?.value; const role = document.getElementById('user-role')?.value; const groupGuids = selectedUserGroupGuids(); // Validate if (!editingUserId) { // Creating new user if (!username || !password) { Notifications.error(_('users.fill_required')); return; } if (!/^[a-zA-Z0-9_]{3,32}$/.test(username)) { Notifications.error(_('users.invalid_username')); return; } if (password.length < 8) { Notifications.error(_('users.password_too_short')); return; } } try { if (editingUserId) { // Update existing user const data = { role }; if (password) data.password = password; data.groupGuids = groupGuids; await Utils.api(`/api/users/${editingUserId}`, { method: 'PATCH', body: data }); Notifications.success(_('users.user_updated')); } else { // Create new user await Utils.api('/api/users', { method: 'POST', body: { username, password, role, groupGuids } }); Notifications.success(_('users.user_created')); } Modal.close(); loadUsers(); } catch (error) { Notifications.error(error.message || _('errors.server_error')); } } /** * Reset user password */ async function resetPassword(userId) { const user = users.find(u => Number(u.id) === Number(userId)); if (!user) return; const newPassword = await Modal.prompt({ title: _('users.reset_password'), label: _('users.new_password'), hint: _('users.password_hint'), inputType: 'password' }); if (!newPassword) return; if (newPassword.length < 8) { Notifications.error(_('users.password_too_short')); return; } try { await Utils.api(`/api/users/${userId}/reset-password`, { method: 'POST', body: { newPassword } }); Notifications.success(_('users.password_reset_success')); } catch (error) { Notifications.error(error.message || _('errors.server_error')); } } /** * Delete user */ async function deleteUser(userId, username) { const confirmed = await Modal.confirm({ title: _('users.delete_title'), message: _('users.delete_confirm', { username }), confirmLabel: _('actions.delete'), danger: true }); if (!confirmed) return; try { await Utils.api(`/api/users/${userId}`, { method: 'DELETE' }); Notifications.success(_('users.delete_success')); loadUsers(); } catch (error) { Notifications.error(error.message || _('errors.server_error')); } } /** * Show organizations modal for user */ async function showOrganizationsModal(userId, username) { let userOrgs = []; let allOrgs = []; const normalizeOrg = (org) => { const id = String(org.org_id || org.id || org.organization_id || ''); return { ...org, id, org_id: id, name: org.name || org.org_name || (id ? 'Org #' + id : ''), org_name: org.org_name || org.name || (id ? 'Org #' + id : ''), role: org.role || '' }; }; try { // Fetch user's organizations const orgsResponse = await Utils.api(`/api/users/${userId}/organizations`); userOrgs = (orgsResponse.organizations || []).map(normalizeOrg).filter(o => o.id); // Fetch all organizations for adding const allOrgsResponse = await Utils.api('/api/panel/org'); allOrgs = (allOrgsResponse.organizations || []).map(normalizeOrg).filter(o => o.id); } catch (error) { console.error('Failed to load organizations:', error); Notifications.error(_('errors.load_orgs_failed')); return; } // Filter out orgs user is already in const userOrgIds = new Set(userOrgs.map(o => o.id)); const availableOrgs = allOrgs.filter(o => !userOrgIds.has(String(o.id))); const orgsListHtml = userOrgs.length > 0 ? userOrgs.map(org => `
business ${Utils.escapeHtml(org.org_name)} ${org.role ? `${_('organizations.role_' + org.role)}` : ''}
`).join('') : `
${_('users.no_organizations')}
`; const addOrgHtml = availableOrgs.length > 0 ? `
` : `
${_('users.all_orgs_assigned')}
`; Modal.show({ title: _('users.user_organizations', { username }), content: `

${_('users.org_membership_section')}

${_('users.org_membership_hint')}

${orgsListHtml}
${addOrgHtml}
`, size: 'medium', buttons: [ { label: _('actions.close'), class: 'btn-secondary', onClick: () => Modal.close() } ], onOpen: () => { // Remove org handler document.querySelectorAll('.remove-org-btn').forEach(btn => { btn.addEventListener('click', async () => { const orgId = btn.dataset.orgId; try { await Utils.api(`/api/panel/org/${orgId}/members/${userId}`, { method: 'DELETE' }); Notifications.success(_('users.org_removed')); userOrgsCache.delete(Number(userId)); Modal.close(); refreshUserOrgsCell(userId); showOrganizationsModal(userId, username); // Refresh } catch (error) { Notifications.error(error.message || _('errors.server_error')); } }); }); // Add org handler document.getElementById('add-org-btn')?.addEventListener('click', async () => { const orgId = document.getElementById('add-org-select').value; const role = document.getElementById('add-org-role').value; if (!orgId) { Notifications.error(_('policies.select_org_placeholder')); return; } try { await Utils.api(`/api/users/${userId}/organizations`, { method: 'POST', body: { org_id: orgId, role } }); Notifications.success(_('organizations.user_linked')); userOrgsCache.delete(Number(userId)); Modal.close(); refreshUserOrgsCell(userId); showOrganizationsModal(userId, username); // Refresh } catch (error) { Notifications.error(error.message || _('errors.server_error')); } }); } }); } })();