/**
* 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'));
}
});
}
});
}
})();