Files
ldap-auth-service/public/admin.html
2026-08-20 11:50:00 +05:00

493 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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>