mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-08-06 02:35:45 +00:00
468 lines
21 KiB
JavaScript
468 lines
21 KiB
JavaScript
/**
|
|
* 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 = '<div class="empty-state"><i class="ti ti-loader-2"></i><b>' + this.t('users_sessions_loading', 'Загружаем сессии') + '</b></div>'; }
|
|
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 = '<div class="empty-state"><i class="ti ti-alert-triangle"></i><b>' + self.t('users_sessions_load_failed', 'Не удалось загрузить сессии') + '</b></div>'; }
|
|
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 = '<div class="empty-state"><i class="ti ti-devices-off"></i><b>'
|
|
+ this.t('users_sessions_empty', 'Активных сессий нет') + '</b><span>'
|
|
+ this.t('users_sessions_empty_hint', 'Пользователь войдёт снова с действующим паролем.') + '</span></div>';
|
|
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 = '<i class="ti ti-' + (session.device === 'mobile' ? 'device-mobile' : 'device-desktop') + '"></i>';
|
|
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 = '<i class="ti ti-logout"></i>';
|
|
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);
|