/**
* JS раздела «Пользователи». Drawer-форма (создание/правка), toggle активности,
* удаление — через общий Adminx.Ajax по контракту success/error. Подключается
* точечно из Users\Controller (ТЗ §4).
*/
(function (window, document) {
'use strict';
var Adminx = window.Adminx || (window.Adminx = {});
Adminx.Users = {
form: null,
currentId: '',
t: function (key, fallback) {
return typeof Adminx.t === 'function' ? Adminx.t(key, fallback) : fallback;
},
init: function () {
this.form = document.getElementById('userForm');
if (!this.form) { return; }
var self = this;
document.addEventListener('click', function (e) {
if (e.target.closest('[data-user-new]')) { self.fillNew(); }
var editBtn = e.target.closest('[data-user-edit]');
if (editBtn) { self.fillEdit(editBtn.closest('tr')); }
var delBtn = e.target.closest('[data-user-delete]');
if (delBtn) { self.remove(delBtn.closest('tr')); }
var pwBtn = e.target.closest('[data-pw-toggle]');
if (pwBtn) { self.togglePw(pwBtn); }
if (e.target.closest('[data-pw-generate]')) { self.generatePassword(); }
if (e.target.closest('[data-pw-copy]')) { self.copyPassword(); }
var tab = e.target.closest('[data-user-tab]');
if (tab) { self.activateTab(tab.getAttribute('data-user-tab')); }
var revoke = e.target.closest('[data-session-revoke]');
if (revoke) { self.revokeSession(revoke); }
if (e.target.closest('[data-user-revoke-others]')) { self.revokeOthers(); }
});
document.addEventListener('change', function (e) {
var t = e.target.closest('[data-user-toggle]');
if (t) { self.toggle(t); }
});
this.form.addEventListener('input', function (e) {
var pw = e.target.closest('[data-pw-input]');
if (pw) { self.strength(pw.value || ''); }
if (e.target.matches('[name="name"], [name="email"]')) { self.updatePreview(); }
});
this.form.addEventListener('change', function (e) {
if (e.target.matches('[name="role"]')) { self.updatePreview(); }
});
this.form.addEventListener('submit', function (e) {
e.preventDefault();
self.submit();
});
},
base: function () { return this.form.getAttribute('data-base') || Adminx.base(); },
clearErrors: function () {
this.form.querySelectorAll('[data-error]').forEach(function (s) { s.textContent = ''; });
this.form.querySelectorAll('.input, .select').forEach(function (i) { i.classList.remove('is-invalid'); });
},
setField: function (name, value) {
var el = this.form.querySelector('[name="' + name + '"]');
if (el) { el.value = value; }
},
fillNew: function () {
this.clearErrors();
this.form.reset();
this.setField('id', '');
this.currentId = '';
this.form.querySelector('[name="is_active"]').checked = true;
this.form.querySelector('[name="must_change_password"]').checked = true;
this.form.querySelector('[data-user-tab="security"]').hidden = true;
this.activateTab('account');
this.setSelfLock(false);
this.resetPassword();
document.getElementById('userDrawerTitle').textContent = 'Новый пользователь';
var hint = this.form.querySelector('[data-pass-hint]');
if (hint) { hint.textContent = 'Пароль обязателен для новой учётной записи.'; }
var dates = this.form.querySelector('[data-user-dates]');
if (dates) { dates.hidden = true; }
this.updatePreview();
},
fillEdit: function (row) {
if (!row) { return; }
this.clearErrors();
this.setField('id', row.dataset.id);
this.currentId = row.dataset.id;
this.setField('name', row.dataset.name);
this.setField('email', row.dataset.email);
this.setField('login', row.dataset.login || '');
this.setField('phone', row.dataset.phone || '');
this.setField('role', row.dataset.role);
this.setField('password', '');
this.resetPassword();
this.form.querySelector('[name="is_active"]').checked = row.dataset.active === '1';
this.form.querySelector('[name="must_change_password"]').checked = row.dataset.mustChangePassword === '1';
this.form.querySelector('[data-user-tab="security"]').hidden = false;
this.activateTab('account');
this.setSelfLock(row.hasAttribute('data-self'));
document.getElementById('userDrawerTitle').textContent = 'Изменение: ' + row.dataset.name;
var hint = this.form.querySelector('[data-pass-hint]');
if (hint) { hint.textContent = 'Оставьте пароль пустым, если его не нужно менять.'; }
var dates = this.form.querySelector('[data-user-dates]');
if (dates) { dates.hidden = false; }
var created = this.form.querySelector('[data-user-created]');
var updated = this.form.querySelector('[data-user-updated]');
if (created) { created.textContent = this.formatDate(row.dataset.created); }
if (updated) { updated.textContent = this.formatDate(row.dataset.updated); }
this.updatePreview(row.dataset.roleLabel || row.dataset.role);
this.loadSecurity();
},
activateTab: function (name) {
name = name === 'security' && this.currentId ? 'security' : 'account';
this.form.querySelectorAll('[data-user-tab]').forEach(function (button) {
var active = button.getAttribute('data-user-tab') === name;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
});
this.form.querySelectorAll('[data-user-panel]').forEach(function (panel) {
panel.hidden = panel.getAttribute('data-user-panel') !== name;
});
var save = this.form.querySelector('[data-user-save]');
if (save) { save.hidden = name !== 'account'; }
},
loadSecurity: function () {
if (!this.currentId) { return; }
var list = this.form.querySelector('[data-user-sessions]');
if (list) { list.innerHTML = '
' + this.t('users_sessions_loading', 'Загружаем сессии') + '
'; }
var self = this;
Adminx.Ajax.request(this.base() + '/users/' + this.currentId + '/security').then(function (payload) {
var response = payload.data || {};
if (!response.success) {
if (list) { list.innerHTML = '' + self.t('users_sessions_load_failed', 'Не удалось загрузить сессии') + '
'; }
return;
}
self.renderSecurity(response.data || {});
});
},
renderSecurity: function (data) {
var failed = data.failed || {};
var set = function (selector, value) {
var node = Adminx.Users.form.querySelector(selector);
if (node) { node.textContent = value; }
};
set('[data-user-failed-count]', String(failed.count_24h || 0));
set('[data-user-failed-last]', failed.last_at ? this.formatDate(failed.last_at) : this.t('users_never', 'Не было'));
set('[data-user-failed-ip]', failed.last_ip ? 'IP ' + failed.last_ip : '');
set('[data-user-password-changed]', data.password_changed_at ? this.formatDate(data.password_changed_at) : this.t('users_no_data', 'Нет данных'));
set('[data-user-password-state]', data.must_change_password
? this.t('users_password_change_pending', 'При следующем входе потребуется новый пароль')
: this.t('users_password_change_clear', 'Обязательная смена не назначена'));
set('[data-user-revoke-label]', data.is_self
? this.t('users_revoke_others', 'Завершить остальные')
: this.t('users_revoke_all', 'Завершить все'));
var list = this.form.querySelector('[data-user-sessions]');
if (!list) { return; }
list.innerHTML = '';
var sessions = Array.isArray(data.sessions) ? data.sessions : [];
if (!sessions.length) {
list.innerHTML = ''
+ this.t('users_sessions_empty', 'Активных сессий нет') + ''
+ this.t('users_sessions_empty_hint', 'Пользователь войдёт снова с действующим паролем.') + '
';
return;
}
var self = this;
sessions.forEach(function (session) {
var item = document.createElement('article');
item.className = 'users-session' + (session.is_current ? ' is-current' : '');
var icon = document.createElement('span');
icon.className = 'icon-tile';
icon.innerHTML = '';
var content = document.createElement('div');
content.className = 'users-session-main';
var title = document.createElement('div');
var strong = document.createElement('b');
strong.textContent = (session.browser || self.t('users_browser', 'Браузер')) + ' · '
+ (session.platform || self.t('users_device', 'Устройство'));
title.appendChild(strong);
if (session.is_current) {
var badge = document.createElement('span'); badge.className = 'badge badge-green'; badge.textContent = self.t('users_session_current', 'Текущая'); title.appendChild(badge);
}
var meta = document.createElement('span');
meta.textContent = 'IP ' + (session.ip || '—') + ' · ' + self.t('users_session_login', 'вход') + ' '
+ self.formatDate(session.created_at) + ' · ' + self.t('users_session_activity', 'активность') + ' '
+ self.formatDate(session.last_active);
content.appendChild(title); content.appendChild(meta);
var button = document.createElement('button');
button.type = 'button'; button.className = 'btn btn-ghost btn-icon ax-act ax-act-danger';
button.setAttribute('data-session-revoke', String(session.id)); button.setAttribute('data-tooltip', self.t('users_revoke_session', 'Завершить сессию'));
button.setAttribute('aria-label', self.t('users_revoke_session', 'Завершить сессию')); button.innerHTML = '';
item.appendChild(icon); item.appendChild(content); item.appendChild(button); list.appendChild(item);
});
},
revokeSession: function (button) {
if (!this.currentId) { return; }
var self = this;
var sessionId = button.getAttribute('data-session-revoke');
Adminx.Ajax.post(this.base() + '/users/' + this.currentId + '/sessions/' + sessionId + '/revoke').then(function (payload) {
var data = payload.data || {};
if (data.success) { Adminx.Ajax.handle(payload); if (!data.redirect) { self.loadSecurity(); } }
else { Adminx.Toast.show(data.message || self.t('users_revoke_failed', 'Не удалось завершить сессию'), 'error'); }
});
},
revokeOthers: function () {
if (!this.currentId) { return; }
var self = this;
Adminx.Ajax.post(this.base() + '/users/' + this.currentId + '/sessions/revoke-others').then(function (payload) {
var data = payload.data || {};
if (data.success) { Adminx.Toast.show(data.message, 'success'); self.loadSecurity(); }
else { Adminx.Toast.show(data.message || self.t('users_revoke_all_failed', 'Не удалось завершить сессии'), 'error'); }
});
},
setSelfLock: function (locked) {
var role = this.form.querySelector('[name="role"]');
var active = this.form.querySelector('[name="is_active"]');
var activeCard = active ? active.closest('.users-active-card') : null;
var roleHint = this.form.querySelector('[data-user-self-role-hint]');
var activeHint = this.form.querySelector('[data-user-active-hint]');
if (role) { role.disabled = !!locked; }
if (active) { active.disabled = !!locked; }
if (activeCard) { activeCard.classList.toggle('is-locked', !!locked); }
if (roleHint) { roleHint.hidden = !locked; }
if (activeHint) { activeHint.textContent = locked ? 'Собственную учётную запись отключить нельзя.' : 'Отключённый пользователь не сможет войти.'; }
},
formatDate: function (value) {
if (!value) { return '—'; }
return String(value).replace(/^([0-9]{4})-([0-9]{2})-([0-9]{2})/, '$3.$2.$1').slice(0, 16);
},
updatePreview: function (roleLabel) {
var name = this.form.querySelector('[name="name"]').value.trim() || 'Новый пользователь';
var email = this.form.querySelector('[name="email"]').value.trim() || 'Учётная запись ещё не сохранена';
var role = this.form.querySelector('[name="role"]');
var roleText = roleLabel || (role && role.options[role.selectedIndex] ? role.options[role.selectedIndex].text : 'Пользователь');
var avatar = this.form.querySelector('[data-user-preview-avatar]');
var nameNode = this.form.querySelector('[data-user-preview-name]');
var meta = this.form.querySelector('[data-user-preview-meta]');
var roleNode = this.form.querySelector('[data-user-preview-role]');
if (avatar) { avatar.textContent = name.slice(0, 2).toUpperCase(); }
if (nameNode) { nameNode.textContent = name; }
if (meta) { meta.textContent = email; }
if (roleNode) { roleNode.textContent = roleText; }
},
// --- Пароль: показ/скрытие + индикатор надёжности (компоненты AdminKit) ---
PW_HINT: 'Минимум 6 символов, желательно буквы разных регистров и цифры',
togglePw: function (btn) {
var wrap = btn.closest('.input-wrap');
var input = wrap ? wrap.querySelector('input') : null;
if (!input) { return; }
var show = input.type === 'password';
input.type = show ? 'text' : 'password';
var icon = btn.querySelector('i');
if (icon) { icon.className = show ? 'ti ti-eye-off' : 'ti ti-eye'; }
},
strength: function (value) {
var meter = this.form.querySelector('.pw-strength');
var hint = this.form.querySelector('[data-pw-hint]');
if (!meter) { return; }
if (value === '') {
meter.dataset.level = '';
if (hint) { hint.textContent = this.PW_HINT; }
return;
}
var s = 0;
if (value.length >= 8) { s++; }
if (value.length >= 10) { s++; }
if (/[a-z]/.test(value) && /[A-Z]/.test(value)) { s++; }
if (/\d/.test(value) && /[^A-Za-z0-9]/.test(value)) { s++; }
if (s < 1) { s = 1; }
if (s > 4) { s = 4; }
meter.dataset.level = String(s);
var labels = { 1: 'слабый', 2: 'средний', 3: 'хороший', 4: 'отличный' };
if (hint) { hint.textContent = 'Надёжность: ' + labels[s]; }
},
randomPassword: function (len) {
var lower = 'abcdefghijkmnpqrstuvwxyz';
var upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
var digit = '23456789';
var sym = '!@#$%^&*-_=+';
var all = lower + upper + digit + sym;
var rnd = function (max) {
var a = new Uint32Array(1);
(window.crypto || window.msCrypto).getRandomValues(a);
return a[0] % max;
};
var pick = function (set) { return set.charAt(rnd(set.length)); };
var out = [pick(lower), pick(upper), pick(digit), pick(sym)];
for (var i = out.length; i < len; i++) { out.push(pick(all)); }
for (var j = out.length - 1; j > 0; j--) { // перемешать
var k = rnd(j + 1);
var t = out[j]; out[j] = out[k]; out[k] = t;
}
return out.join('');
},
generatePassword: function () {
var input = this.form.querySelector('[data-pw-input]');
if (!input) { return; }
input.value = this.randomPassword(14);
input.type = 'text'; // показываем, чтобы можно было скопировать
var icon = this.form.querySelector('[data-pw-toggle] i');
if (icon) { icon.className = 'ti ti-eye-off'; }
this.strength(input.value);
Adminx.Toast.show('Пароль сгенерирован', 'success');
},
copyPassword: function () {
var input = this.form.querySelector('[data-pw-input]');
var val = input ? input.value : '';
if (!val) {
Adminx.Toast.show('Сначала введите или сгенерируйте пароль', 'error');
return;
}
var ok = function () { Adminx.Toast.show('Пароль скопирован в буфер', 'success'); };
var fail = function () { Adminx.Toast.show('Не удалось скопировать', 'error'); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(val).then(ok).catch(function () {
if (!Adminx.Users.legacyCopy(input)) { fail(); } else { ok(); }
});
} else if (Adminx.Users.legacyCopy(input)) {
ok();
} else {
fail();
}
},
legacyCopy: function (input) {
try {
var wasPassword = input.type === 'password';
if (wasPassword) { input.type = 'text'; }
input.focus();
input.select();
var done = document.execCommand('copy');
if (wasPassword) { input.type = 'password'; }
return done;
} catch (e) {
return false;
}
},
resetPassword: function () {
var meter = this.form.querySelector('.pw-strength');
if (meter) { meter.dataset.level = ''; }
var hint = this.form.querySelector('[data-pw-hint]');
if (hint) { hint.textContent = this.PW_HINT; }
var input = this.form.querySelector('[data-pw-input]');
if (input) { input.type = 'password'; }
var icon = this.form.querySelector('[data-pw-toggle] i');
if (icon) { icon.className = 'ti ti-eye'; }
},
submit: function () {
this.clearErrors();
var id = (this.form.querySelector('[name="id"]').value || '').trim();
var url = this.base() + '/users' + (id ? '/' + id : '');
var fd = new FormData(this.form);
var self = this;
Adminx.Loader.show();
Adminx.Ajax.post(url, fd).then(function (payload) {
Adminx.Loader.hide();
var d = payload.data || {};
if (d.success) {
Adminx.Ajax.handle(payload);
if (!d.redirect) { window.location.reload(); }
return;
}
// ошибки валидации по полям
var errors = d.errors || {};
Object.keys(errors).forEach(function (field) {
var span = self.form.querySelector('[data-error="' + field + '"]');
if (span) { span.textContent = errors[field]; }
var input = self.form.querySelector('[name="' + field + '"]');
if (input) { input.classList.add('is-invalid'); }
});
if (d.message) { Adminx.Toast.show(d.message, 'error'); }
}).catch(function () {
Adminx.Loader.hide();
Adminx.Toast.show('Ошибка сети', 'error');
});
},
toggle: function (checkbox) {
var row = checkbox.closest('tr');
var id = row.dataset.id;
//-- Нельзя отключать собственную учётную запись (дублирует запрет в контроллере).
if (row.hasAttribute('data-self')) {
checkbox.checked = true;
Adminx.Toast.show('Нельзя отключить свою учётную запись', 'error');
return;
}
Adminx.Ajax.post(this.base() + '/users/' + id + '/toggle').then(function (payload) {
var d = payload.data || {};
if (d.success) {
row.dataset.active = String((d.data && d.data.is_active) || 0);
Adminx.Toast.show(d.message, 'success');
} else {
checkbox.checked = !checkbox.checked;
Adminx.Toast.show(d.message || 'Не удалось', 'error');
}
}).catch(function () {
checkbox.checked = !checkbox.checked;
Adminx.Toast.show('Ошибка сети', 'error');
});
},
remove: function (row) {
var id = row.dataset.id;
var base = this.base();
Adminx.Confirm.open({
kind: 'error',
title: 'Удалить пользователя?',
message: '«' + row.dataset.name + '» (' + row.dataset.email + ') будет удалён без возможности восстановления.',
confirmLabel: 'Удалить',
confirmClass: 'btn-danger',
onConfirm: function () {
Adminx.Loader.show();
Adminx.Ajax.post(base + '/users/' + id + '/delete').then(function (payload) {
Adminx.Loader.hide();
var d = payload.data || {};
if (d.success) {
row.remove();
Adminx.Toast.show(d.message, 'success');
} else {
Adminx.Toast.show(d.message || 'Не удалось удалить', 'error');
}
}).catch(function () {
Adminx.Loader.hide();
Adminx.Toast.show('Ошибка сети', 'error');
});
}
});
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { Adminx.Users.init(); });
} else {
Adminx.Users.init();
}
})(window, document);