493 lines
21 KiB
HTML
493 lines
21 KiB
HTML
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Администрирование пользователей LDAP</title>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
background: #f0f2f5;
|
||
min-height: 100vh;
|
||
}
|
||
.hidden { display: none !important; }
|
||
|
||
.login-wrap {
|
||
min-height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
}
|
||
.login-card {
|
||
background: white;
|
||
padding: 40px;
|
||
border-radius: 10px;
|
||
box-shadow: 0 15px 35px rgba(0,0,0,0.1);
|
||
max-width: 420px;
|
||
width: 100%;
|
||
}
|
||
.login-card h2 { color: #333; margin-bottom: 8px; text-align: center; }
|
||
.login-card .subtitle { text-align: center; color: #666; margin-bottom: 25px; font-size: 14px; }
|
||
.form-group { margin-bottom: 18px; }
|
||
label { display: block; margin-bottom: 8px; color: #555; font-weight: 500; }
|
||
input[type="text"], input[type="password"], input[type="email"], select {
|
||
width: 100%;
|
||
padding: 11px 14px;
|
||
border: 2px solid #ddd;
|
||
border-radius: 6px;
|
||
font-size: 15px;
|
||
transition: border-color 0.3s;
|
||
background: white;
|
||
}
|
||
input:focus, select:focus { border-color: #667eea; outline: none; }
|
||
button {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
padding: 12px 20px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
transition: opacity 0.2s;
|
||
}
|
||
button:hover { opacity: 0.9; }
|
||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
button.secondary { background: #6c757d; }
|
||
button.danger { background: #dc3545; }
|
||
button.success { background: #28a745; }
|
||
button.block { background: #ffc107; color: #333; }
|
||
|
||
.msg { padding: 12px 16px; border-radius: 6px; margin-bottom: 15px; font-size: 14px; display: none; }
|
||
.msg.error { display: block; background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
||
.msg.success { display: block; background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||
|
||
.topbar {
|
||
background: white;
|
||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||
padding: 14px 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
.topbar h1 { font-size: 18px; color: #333; flex: 1; }
|
||
.topbar .user-name { color: #667eea; font-weight: 600; }
|
||
.topbar button { padding: 8px 14px; font-size: 13px; }
|
||
|
||
.content { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||
.toolbar { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
|
||
.toolbar input[type="text"] { max-width: 300px; }
|
||
|
||
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 6px rgba(0,0,0,0.08); }
|
||
th, td { padding: 12px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: 14px; vertical-align: top; }
|
||
th { background: #f8f9fa; color: #495057; font-weight: 600; white-space: nowrap; }
|
||
tr:hover td { background: #fafbfc; }
|
||
.badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||
.badge.active { background: #d4edda; color: #155724; }
|
||
.badge.disabled { background: #f8d7da; color: #721c24; }
|
||
.groups-cell span { margin: 2px 4px 2px 0; display: inline-block; background: #e9ecef; color: #495057; padding: 2px 8px; border-radius: 10px; font-size: 12px; }
|
||
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.actions button { padding: 6px 12px; font-size: 12px; }
|
||
.empty { text-align: center; color: #888; padding: 40px; }
|
||
|
||
.modal-overlay {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
|
||
display: flex; align-items: flex-start; justify-content: center;
|
||
padding: 40px 16px; overflow-y: auto; z-index: 100;
|
||
}
|
||
.modal {
|
||
background: white; border-radius: 10px; width: 100%; max-width: 620px;
|
||
padding: 28px; box-shadow: 0 20px 50px rgba(0,0,0,0.2);
|
||
}
|
||
.modal h3 { margin-bottom: 20px; color: #333; }
|
||
.modal .form-group { margin-bottom: 14px; }
|
||
.modal .row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||
.modal-actions { display: flex; gap: 10px; margin-top: 22px; justify-content: flex-end; }
|
||
.groups-list { max-height: 160px; overflow-y: auto; border: 1px solid #ddd; border-radius: 6px; padding: 10px; }
|
||
.groups-list label { display: flex; align-items: center; gap: 8px; font-weight: 400; padding: 3px 0; cursor: pointer; }
|
||
.hint { font-size: 12px; color: #888; margin-top: 4px; }
|
||
.loading-row { text-align: center; padding: 40px; color: #667eea; }
|
||
@media (max-width: 720px) { .modal .row { grid-template-columns: 1fr; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="loginView" class="login-wrap">
|
||
<div class="login-card">
|
||
<h2>Панель администратора</h2>
|
||
<p class="subtitle">Вход под учётной записью с правами управления пользователями</p>
|
||
<div class="msg" id="loginMsg"></div>
|
||
<form id="loginForm">
|
||
<div class="form-group">
|
||
<label>Логин</label>
|
||
<input type="text" id="loginUsername" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Пароль</label>
|
||
<input type="password" id="loginPassword" required>
|
||
</div>
|
||
<button type="submit" style="width:100%">Войти</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="adminView" class="hidden">
|
||
<div class="topbar">
|
||
<h1>Управление пользователями Active Directory</h1>
|
||
<span class="user-name" id="adminName"></span>
|
||
<button class="secondary" id="refreshBtn">Обновить</button>
|
||
<button class="success" id="createBtn">Создать пользователя</button>
|
||
<button class="danger" id="logoutBtn">Выход</button>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<div class="msg" id="adminMsg"></div>
|
||
<div class="toolbar">
|
||
<input type="text" id="searchInput" placeholder="Поиск по ФИО, логину, почте, телефону...">
|
||
<button class="secondary" id="clearSearchBtn">Сброс</button>
|
||
</div>
|
||
<div id="usersTableWrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>ФИО</th>
|
||
<th>Логин</th>
|
||
<th>Почта</th>
|
||
<th>Телефон</th>
|
||
<th>Группы</th>
|
||
<th>Статус</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="usersBody">
|
||
<tr><td class="loading-row" colspan="7">Загрузка...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="modalOverlay" class="modal-overlay hidden">
|
||
<div class="modal">
|
||
<h3 id="modalTitle">Создание пользователя</h3>
|
||
<form id="userForm">
|
||
<input type="hidden" id="f_editLogin">
|
||
<div class="form-group hidden" id="f_ouGroup">
|
||
<label>Организационная единица (OU)</label>
|
||
<select id="f_ou"><option value="">Загрузка...</option></select>
|
||
</div>
|
||
<div class="row">
|
||
<div class="form-group">
|
||
<label>ФИО *</label>
|
||
<input type="text" id="f_fio" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Логин (sAMAccountName) *</label>
|
||
<input type="text" id="f_login" required>
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="form-group">
|
||
<label id="f_passwordLabel">Пароль</label>
|
||
<input type="password" id="f_password" autocomplete="new-password">
|
||
<div class="hint" id="f_passwordHint">Оставьте пустым, чтобы не менять</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Телефон</label>
|
||
<input type="text" id="f_phone">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Электронная почта</label>
|
||
<input type="email" id="f_email">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Группы</label>
|
||
<div class="groups-list" id="f_groups"><div class="hint">Загрузка групп...</div></div>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button type="button" class="secondary" id="modalCancel">Отмена</button>
|
||
<button type="submit" id="modalSave">Создать</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const TOKEN_KEY = 'ldap_admin_token';
|
||
const USER_KEY = 'ldap_admin_user';
|
||
let token = localStorage.getItem(TOKEN_KEY) || '';
|
||
let currentUser = null;
|
||
let usersCache = [];
|
||
let groupsCache = [];
|
||
let ousCache = [];
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
function showMsg(el, text, type) {
|
||
el.className = 'msg ' + (type || 'error');
|
||
el.textContent = text;
|
||
if (!text) el.className = 'msg';
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
|
||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||
const res = await fetch(path, { ...options, headers });
|
||
let data = null;
|
||
try { data = await res.json(); } catch (e) { data = {}; }
|
||
if (!res.ok) {
|
||
const err = new Error(data.error || ('Ошибка ' + res.status));
|
||
err.status = res.status;
|
||
throw err;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return String(str == null ? '' : str).replace(/[&<>"']/g, c => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
}[c]));
|
||
}
|
||
|
||
function logout() {
|
||
if (token) api('/api/admin/logout', { method: 'POST' }).catch(() => {});
|
||
token = '';
|
||
currentUser = null;
|
||
localStorage.removeItem(TOKEN_KEY);
|
||
localStorage.removeItem(USER_KEY);
|
||
$('loginView').classList.remove('hidden');
|
||
$('adminView').classList.add('hidden');
|
||
$('loginPassword').value = '';
|
||
}
|
||
|
||
$('loginForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const username = $('loginUsername').value.trim();
|
||
const password = $('loginPassword').value;
|
||
showMsg($('loginMsg'), '', '');
|
||
try {
|
||
const data = await api('/api/admin/login', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ username, password })
|
||
});
|
||
token = data.token;
|
||
currentUser = data.user;
|
||
localStorage.setItem(TOKEN_KEY, token);
|
||
localStorage.setItem(USER_KEY, JSON.stringify(currentUser));
|
||
$('adminName').textContent = currentUser.full_name || currentUser.username;
|
||
$('loginView').classList.add('hidden');
|
||
$('adminView').classList.remove('hidden');
|
||
await Promise.all([loadUsers(), loadGroups(), loadOus()]);
|
||
} catch (err) {
|
||
showMsg($('loginMsg'), err.message, 'error');
|
||
}
|
||
});
|
||
|
||
$('logoutBtn').addEventListener('click', logout);
|
||
|
||
async function loadUsers() {
|
||
try {
|
||
$('usersBody').innerHTML = '<tr><td class="loading-row" colspan="7">Загрузка...</td></tr>';
|
||
const data = await api('/api/admin/users');
|
||
usersCache = data.users || [];
|
||
renderUsers();
|
||
} catch (err) {
|
||
if (err.status === 401) { logout(); return; }
|
||
$('usersBody').innerHTML = `<tr><td colspan="7" class="empty">Ошибка: ${escapeHtml(err.message)}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
async function loadGroups() {
|
||
try {
|
||
const data = await api('/api/admin/groups');
|
||
groupsCache = data.groups || [];
|
||
} catch (err) {
|
||
groupsCache = [];
|
||
}
|
||
}
|
||
|
||
async function loadOus() {
|
||
try {
|
||
const data = await api('/api/admin/ous');
|
||
ousCache = data.ous || [];
|
||
const sel = $('f_ou');
|
||
sel.innerHTML = ousCache.map(o => `<option value="${escapeHtml(o.dn)}">${escapeHtml(o.name)} (${escapeHtml(o.dn)})</option>`).join('');
|
||
} catch (err) {
|
||
ousCache = [];
|
||
}
|
||
}
|
||
|
||
function renderUsers() {
|
||
const q = $('searchInput').value.trim().toLowerCase();
|
||
const filtered = usersCache.filter(u => {
|
||
if (!q) return true;
|
||
return [u.full_name, u.login, u.email, u.phone, (u.groups || []).join(' ')]
|
||
.join(' ').toLowerCase().includes(q);
|
||
});
|
||
|
||
if (filtered.length === 0) {
|
||
$('usersBody').innerHTML = '<tr><td colspan="7" class="empty">Пользователи не найдены</td></tr>';
|
||
return;
|
||
}
|
||
|
||
$('usersBody').innerHTML = filtered.map(u => `
|
||
<tr>
|
||
<td>${escapeHtml(u.full_name || '—')}</td>
|
||
<td>${escapeHtml(u.login)}</td>
|
||
<td>${escapeHtml(u.email || '—')}</td>
|
||
<td>${escapeHtml(u.phone || '—')}</td>
|
||
<td class="groups-cell">${(u.groups || []).length
|
||
? u.groups.map(g => `<span>${escapeHtml(g)}</span>`).join('')
|
||
: '—'}</td>
|
||
<td>${u.disabled
|
||
? '<span class="badge disabled">Заблокирован</span>'
|
||
: '<span class="badge active">Активен</span>'}</td>
|
||
<td class="actions">
|
||
<button class="secondary" onclick="openEdit('${escapeHtml(u.login)}')">Изменить</button>
|
||
${u.disabled
|
||
? `<button class="success" onclick="toggleUser('${escapeHtml(u.login)}', true)">Разблокировать</button>`
|
||
: `<button class="block" onclick="toggleUser('${escapeHtml(u.login)}', false)">Блокировать</button>`}
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
$('refreshBtn').addEventListener('click', () => Promise.all([loadUsers(), loadGroups(), loadOus()]));
|
||
$('searchInput').addEventListener('input', renderUsers);
|
||
$('clearSearchBtn').addEventListener('click', () => { $('searchInput').value = ''; renderUsers(); });
|
||
|
||
async function toggleUser(login, enable) {
|
||
if (!confirm(`Действительно ${enable ? 'разблокировать' : 'заблокировать'} пользователя «${login}»?`)) return;
|
||
try {
|
||
await api(`/api/admin/users/${encodeURIComponent(login)}/${enable ? 'enable' : 'disable'}`, { method: 'POST' });
|
||
showMsg($('adminMsg'), `Пользователь «${login}» ${enable ? 'разблокирован' : 'заблокирован'}`, 'success');
|
||
await loadUsers();
|
||
} catch (err) {
|
||
showMsg($('adminMsg'), err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function openModal(title, isEdit) {
|
||
$('modalTitle').textContent = title;
|
||
$('f_ouGroup').classList.toggle('hidden', isEdit);
|
||
$('f_login').disabled = isEdit;
|
||
$('modalSave').textContent = isEdit ? 'Сохранить' : 'Создать';
|
||
$('modalCancel').textContent = 'Отмена';
|
||
$('modalOverlay').classList.remove('hidden');
|
||
}
|
||
|
||
function renderGroupCheckboxes(selected) {
|
||
const container = $('f_groups');
|
||
if (!groupsCache.length) {
|
||
container.innerHTML = '<div class="hint">Группы не загружены</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = groupsCache.map(g => `
|
||
<label>
|
||
<input type="checkbox" value="${escapeHtml(g.cn)}" ${(selected || []).includes(g.cn) ? 'checked' : ''}>
|
||
${escapeHtml(g.cn)} <span class="hint">(${escapeHtml(g.dn)})</span>
|
||
</label>
|
||
`).join('');
|
||
}
|
||
|
||
function collectGroups() {
|
||
return Array.from($('f_groups').querySelectorAll('input[type="checkbox"]:checked')).map(i => i.value);
|
||
}
|
||
|
||
$('createBtn').addEventListener('click', () => {
|
||
$('f_editLogin').value = '';
|
||
$('f_fio').value = '';
|
||
$('f_login').value = '';
|
||
$('f_login').disabled = false;
|
||
$('f_password').value = '';
|
||
$('f_email').value = '';
|
||
$('f_phone').value = '';
|
||
$('f_passwordLabel').textContent = 'Пароль *';
|
||
$('f_passwordHint').textContent = 'Обязателен при создании';
|
||
$('f_password').required = true;
|
||
if (ousCache.length === 0) loadOus();
|
||
renderGroupCheckboxes([]);
|
||
openModal('Создание пользователя', false);
|
||
});
|
||
|
||
async function openEdit(login) {
|
||
try {
|
||
const data = await api(`/api/admin/users/${encodeURIComponent(login)}`);
|
||
const u = data.user;
|
||
$('f_editLogin').value = u.login;
|
||
$('f_fio').value = u.full_name || '';
|
||
$('f_login').value = u.login;
|
||
$('f_login').disabled = true;
|
||
$('f_password').value = '';
|
||
$('f_email').value = u.email || '';
|
||
$('f_phone').value = u.phone || '';
|
||
$('f_passwordLabel').textContent = 'Новый пароль';
|
||
$('f_passwordHint').textContent = 'Оставьте пустым, чтобы не менять';
|
||
$('f_password').required = false;
|
||
renderGroupCheckboxes(u.groups || []);
|
||
openModal(`Изменение пользователя: ${u.login}`, true);
|
||
} catch (err) {
|
||
showMsg($('adminMsg'), err.message, 'error');
|
||
}
|
||
}
|
||
|
||
$('modalCancel').addEventListener('click', () => $('modalOverlay').classList.add('hidden'));
|
||
$('modalOverlay').addEventListener('click', (e) => { if (e.target === $('modalOverlay')) $('modalOverlay').classList.add('hidden'); });
|
||
|
||
$('userForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const isEdit = !!$('f_editLogin').value;
|
||
const body = {
|
||
fio: $('f_fio').value.trim(),
|
||
email: $('f_email').value.trim(),
|
||
phone: $('f_phone').value.trim(),
|
||
password: $('f_password').value || undefined,
|
||
groupCNs: collectGroups()
|
||
};
|
||
|
||
try {
|
||
if (isEdit) {
|
||
await api(`/api/admin/users/${encodeURIComponent($('f_editLogin').value)}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(body)
|
||
});
|
||
showMsg($('adminMsg'), 'Пользователь обновлён', 'success');
|
||
} else {
|
||
body.login = $('f_login').value.trim();
|
||
body.ouDn = $('f_ou').value;
|
||
if (!body.login) throw new Error('Логин обязателен');
|
||
await api('/api/admin/users', { method: 'POST', body: JSON.stringify(body) });
|
||
showMsg($('adminMsg'), 'Пользователь создан', 'success');
|
||
}
|
||
$('modalOverlay').classList.add('hidden');
|
||
await loadUsers();
|
||
} catch (err) {
|
||
alert('Ошибка: ' + err.message);
|
||
}
|
||
});
|
||
|
||
(function init() {
|
||
const saved = localStorage.getItem(USER_KEY);
|
||
if (token && saved) {
|
||
try {
|
||
currentUser = JSON.parse(saved);
|
||
$('adminName').textContent = currentUser.full_name || currentUser.username;
|
||
$('loginView').classList.add('hidden');
|
||
$('adminView').classList.remove('hidden');
|
||
api('/api/admin/me').then(() => Promise.all([loadUsers(), loadGroups(), loadOus()]))
|
||
.catch(() => logout());
|
||
return;
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
logout();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html> |