Files
ldap-auth-service/public/admin.html
2026-08-28 10:45:49 +05:00

1039 lines
52 KiB
HTML
Raw Permalink 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 lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Администрирование пользователей LDAP</title>
<link rel="stylesheet" href="/css/app.css">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
</head>
<body>
<div id="loginView" class="page-wrap">
<div class="card card-narrow">
<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" autocomplete="username" required>
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" id="loginPassword" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary">Войти</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="btn btn-secondary" onclick="location.href='/gosuslugi'">Госуслуги</button>
<button class="btn btn-secondary" onclick="location.href='/zup'">ЗУП</button>
<button class="btn btn-secondary" onclick="location.href='/help'">Справка</button>
<button class="btn btn-secondary" id="refreshBtn">Обновить</button>
<button class="btn btn-success" id="createBtn">Создать пользователя</button>
<button class="btn btn-danger" id="logoutBtn">Выход</button>
</div>
<div class="content">
<div class="msg" id="adminMsg"></div>
<div class="section">
<h2>Настройки</h2>
<div class="settings-row">
<div class="settings-label">
Разрешить вход (<code>/auth</code>)
<div class="hint">Когда выключено — страница входа недоступна, а переход с <code>/</code> ведёт на <code>/register</code></div>
</div>
<label class="switch">
<input type="checkbox" id="reqAuthEnabled">
<span class="track"></span>
</label>
</div>
<div class="settings-row">
<div class="settings-label">
Телефон обязателен при регистрации
<div class="hint">Поле «Телефон» становится обязательным в форме регистрации</div>
</div>
<label class="switch">
<input type="checkbox" id="reqPhoneRequired">
<span class="track"></span>
</label>
</div>
<div class="settings-row">
<div class="settings-label">
API-ключ для каталога
<div class="hint">Доступ к <code>/api/directory/users</code> · <a href="/help">инструкция</a></div>
</div>
<input type="text" id="apiKeyField" class="api-key-input" readonly value="">
<button type="button" class="btn btn-secondary" id="apiKeyCopyBtn">Копировать</button>
<button type="button" class="btn btn-primary" id="apiKeyRegenBtn">Новый ключ</button>
</div>
<div class="settings-row">
<div class="settings-label">
Сценарий входа (Logon Script)
<div class="hint">Применяется к создаваемым пользователям в AD (атрибут <code>scriptPath</code>)</div>
</div>
<input type="text" id="cfgLogonScript" class="api-key-input" placeholder="PST.bat">
</div>
<div class="settings-row">
<div class="settings-label">
Домашний диск
<div class="hint">Буква домашнего диска (атрибут <code>homeDrive</code>), по умолчанию <code>Z:</code></div>
</div>
<input type="text" id="cfgHomeDrive" class="api-key-input" placeholder="Z:">
</div>
<div class="settings-row">
<div class="settings-label">
Базовый путь домашней папки
<div class="hint">К пути добавится <code>\логин</code>, по умолчанию <code>\\dc\Teachers</code></div>
</div>
<input type="text" id="cfgHomeBasePath" class="api-key-input" placeholder="\\dc\Teachers">
</div>
<div class="settings-row">
<div class="settings-label">
Адрес СРМ (задачник)
<div class="hint">Отображается в письме об одобрении регистрации</div>
</div>
<input type="text" id="cfgCrmUrl" class="api-key-input" placeholder="https://...">
</div>
<div class="settings-row">
<div class="settings-label">
Сервис быстрых ссылок
<div class="hint">Отображается в письме об одобрении регистрации</div>
</div>
<input type="text" id="cfgQuickLinksUrl" class="api-key-input" placeholder="https://...">
</div>
</div>
<div class="toolbar">
<input type="text" id="searchInput" placeholder="Поиск по ФИО, логину, почте, телефону...">
<button class="btn btn-secondary" id="clearSearchBtn">Сброс</button>
</div>
<div id="requestsWrap" class="section">
<h2>Заявки</h2>
<div class="req-tabs">
<button class="btn btn-secondary" id="reqTypeRegistration">Регистрация</button>
<button class="btn btn-secondary" id="reqTypePassword">Смена пароля</button>
</div>
<div class="req-tabs">
<button class="btn btn-secondary" id="reqTabPending">Ожидающие (<span id="reqPendingCount">0</span>)</button>
<button class="btn btn-secondary" id="reqTabHistory">История</button>
</div>
<div id="requestsList"><div class="hint">Загрузка заявок...</div></div>
</div>
<div id="journalWrap" class="section">
<h2>Журнал и безопасность</h2>
<div class="req-tabs">
<button class="btn btn-secondary" id="journalTabAdmin">Вход в админку</button>
<button class="btn btn-secondary" id="journalTabAuth">Вход пользователей</button>
<button class="btn btn-secondary" id="journalTabBlocks">Блокировки IP</button>
<button class="btn btn-secondary" id="journalTabWhitelist">Белый список IP</button>
</div>
<div id="journalFilters" style="display:none">
<select id="jf_success"><option value="">Все</option><option value="1">Успех</option><option value="0">Неудача</option></select>
<input type="datetime-local" id="jf_from" title="С даты">
<input type="datetime-local" id="jf_to" title="По дату">
<input type="text" id="jf_ip" placeholder="IP (подстрока)">
<input type="text" id="jf_username" placeholder="Логин (подстрока)">
<input type="number" id="jf_limit" value="200" min="1" max="1000" style="width:90px" title="Лимит записей">
<button class="btn btn-secondary" id="journalApplyBtn">Применить</button>
</div>
<div id="journalContent"><div class="hint">Выберите раздел журнала</div></div>
</div>
<div id="usersTableWrap" class="section">
<h2>Пользователи</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ФИО</th>
<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="8">Загрузка...</td></tr>
</tbody>
</table>
</div>
</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="row">
<div class="form-group">
<label>Электронная почта</label>
<input type="email" id="f_email">
</div>
<div class="form-group">
<label>Должность</label>
<input type="text" id="f_position" maxlength="120">
</div>
</div>
<div class="form-group">
<label>Группы</label>
<div class="groups-toolbar" id="f_groupsToolbar">
<button type="button" class="btn btn-secondary" id="f_groupsDefault">Группы по умолчанию</button>
<button type="button" class="btn btn-secondary" id="f_groupsRemember">Запомнить выбор</button>
</div>
<div class="groups-list" id="f_groups"><div class="hint">Загрузка групп...</div></div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="modalCancel">Отмена</button>
<button type="submit" class="btn" id="modalSave">Создать</button>
</div>
</form>
</div>
</div>
<div id="reqModalOverlay" class="modal-overlay hidden">
<div class="modal">
<h3 id="reqModalTitle">Рассмотрение заявки</h3>
<div id="reqForm">
<div class="row">
<div class="form-group">
<label>Фамилия</label>
<input type="text" id="r_surname">
</div>
<div class="form-group">
<label>Имя</label>
<input type="text" id="r_name">
</div>
</div>
<div class="form-group">
<label>Отчество</label>
<input type="text" id="r_patronymic">
</div>
<div class="form-group">
<label>Логин (формируется автоматически, можно изменить вручную)</label>
<input type="text" id="r_login">
</div>
<div class="row">
<div class="form-group">
<label>Электронная почта</label>
<input type="email" id="r_email">
</div>
<div class="form-group">
<label>Телефон</label>
<input type="tel" id="r_phone" placeholder="+7 999 123-45-67">
</div>
</div>
<div class="form-group">
<label>Должность</label>
<input type="text" id="r_position" maxlength="120">
</div>
<div class="form-group">
<label>Новый пароль</label>
<input type="password" id="r_password" autocomplete="new-password">
<div class="hint">Пусто — оставить прежний</div>
</div>
<div class="form-group" id="r_groupsWrap">
<label>Группы</label>
<div class="groups-list" id="r_groups"><div class="hint">Загрузка групп...</div></div>
</div>
<div class="form-group" id="r_currentGroupsWrap" style="display:none">
<label>Текущие группы пользователя</label>
<div class="groups-list" id="r_currentGroups"></div>
</div>
<div class="form-group" id="r_currentDataWrap" style="display:none">
<label>Текущие данные пользователя (из AD)</label>
<div class="current-data" id="r_currentData"><div class="hint">Загрузка...</div></div>
</div>
<div class="form-group">
<label>Причина отказа</label>
<textarea id="r_reason" rows="2" placeholder="Заполняется при отказе в регистрации"></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-danger" id="reqDenyBtn">Отказать</button>
<button type="button" class="btn btn-secondary" id="reqCancelBtn">Отмена</button>
<button type="button" class="btn btn-success" id="reqApproveBtn">Одобрить</button>
</div>
</div>
</div>
</div>
<script>
const TOKEN_KEY = 'ldap_admin_token';
const USER_KEY = 'ldap_admin_user';
let adminSettings = { ouDn: '', defaultGroups: [], phoneRequired: false, authEnabled: true, apiKey: '', logonScript: 'PST.bat', homeDrive: 'Z:', homeBasePath: '\\\\dc\\Teachers', crmUrl: '', quickLinksUrl: '' };
let currentMemberGroups = [];
let requestsCache = { pending: [], history: [] };
let reqTab = 'pending';
let reqType = 'registration';
let reqModalId = null;
let reqModalType = 'registration';
let reqModalOriginalLogin = '';
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 clearLocalSession() {
token = '';
currentUser = null;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
$('loginView').classList.remove('hidden');
$('adminView').classList.add('hidden');
$('loginPassword').value = '';
}
function logout() {
if (token) api('/api/admin/logout', { method: 'POST' }).catch(() => {});
clearLocalSession();
}
$('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 loadSettings();
await Promise.all([loadUsers(), loadGroups(), loadOus(), loadRequests()]);
} catch (err) {
showMsg($('loginMsg'), err.message, 'error');
}
});
$('logoutBtn').addEventListener('click', logout);
async function loadUsers() {
try {
$('usersBody').innerHTML = '<tr><td class="loading-row" colspan="8">Загрузка...</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="8" 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 loadSettings() {
try {
const data = await api('/api/admin/settings');
adminSettings = { ouDn: '', defaultGroups: [], phoneRequired: false, authEnabled: true, apiKey: '', logonScript: 'PST.bat', homeDrive: 'Z:', homeBasePath: '\\\\dc\\Teachers', crmUrl: '', quickLinksUrl: '', ...(data.settings || {}) };
$('reqPhoneRequired').checked = !!adminSettings.phoneRequired;
$('reqAuthEnabled').checked = adminSettings.authEnabled !== false;
$('apiKeyField').value = adminSettings.apiKey || '';
$('cfgLogonScript').value = adminSettings.logonScript || '';
$('cfgHomeDrive').value = adminSettings.homeDrive || '';
$('cfgHomeBasePath').value = adminSettings.homeBasePath || '';
$('cfgCrmUrl').value = adminSettings.crmUrl || '';
$('cfgQuickLinksUrl').value = adminSettings.quickLinksUrl || '';
} catch (err) {
adminSettings = { ouDn: '', defaultGroups: [], phoneRequired: false, authEnabled: true, apiKey: '', logonScript: 'PST.bat', homeDrive: 'Z:', homeBasePath: '\\\\dc\\Teachers', crmUrl: '', quickLinksUrl: '' };
}
}
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('');
if (adminSettings.ouDn && ousCache.some(o => o.dn === adminSettings.ouDn)) sel.value = adminSettings.ouDn;
} 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="8" class="empty">Пользователи не найдены</td></tr>';
return;
}
$('usersBody').innerHTML = filtered.map(u => `
<tr>
<td data-label="ФИО">${escapeHtml(u.full_name || '—')}</td>
<td data-label="Логин">${escapeHtml(u.login)}</td>
<td data-label="Почта">${escapeHtml(u.email || '—')}</td>
<td data-label="Телефон">${escapeHtml(u.phone || '—')}</td>
<td data-label="Должность">${escapeHtml(u.position || '—')}</td>
<td class="groups-cell" data-label="Группы">${(u.groups || []).length
? u.groups.map(g => `<span>${escapeHtml(g)}</span>`).join('')
: '—'}</td>
<td data-label="Статус">${u.disabled
? '<span class="badge disabled">Заблокирован</span>'
: '<span class="badge active">Активен</span>'}</td>
<td class="actions" data-label="Действия">
<button class="btn btn-secondary" onclick="openEdit('${escapeHtml(u.login)}')">Изменить</button>
${u.disabled
? `<button class="btn btn-success" onclick="toggleUser('${escapeHtml(u.login)}', true)">Разблокировать</button>`
: `<button class="btn btn-block" onclick="toggleUser('${escapeHtml(u.login)}', false)">Блокировать</button>`}
</td>
</tr>
`).join('');
}
$('refreshBtn').addEventListener('click', () => Promise.all([loadUsers(), loadGroups(), loadOus(), loadRequests()]));
$('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_groupsToolbar').classList.toggle('hidden', isEdit);
$('f_login').disabled = isEdit;
$('modalSave').textContent = isEdit ? 'Сохранить' : 'Создать';
$('modalCancel').textContent = 'Отмена';
$('modalOverlay').classList.remove('hidden');
}
function renderGroupCheckboxes(selected, member, containerEl) {
const container = containerEl || $('f_groups');
if (!groupsCache.length) {
container.innerHTML = '<div class="hint">Группы не загружены</div>';
return;
}
const sel = selected || [];
const mem = (member || []).map(cn => String(cn).toLowerCase());
const added = [], adding = [], available = [];
for (const g of groupsCache) {
const isMember = mem.includes(String(g.cn).toLowerCase());
if (isMember) added.push(g);
else if (sel.includes(g.cn)) adding.push(g);
else available.push(g);
}
const renderSection = (title, list) => list.length
? `<div class="groups-section-label">${title}</div>` + list.map(g => `
<label>
<input type="checkbox" value="${escapeHtml(g.cn)}" ${sel.includes(g.cn) ? 'checked' : ''}>
${escapeHtml(g.cn)} <span class="hint">(${escapeHtml(g.dn)})</span>
</label>
`).join('')
: '';
container.innerHTML =
renderSection('Добавлены', added) +
renderSection('Добавляемые', adding) +
renderSection('Можно добавить', available);
}
$('f_groups').addEventListener('change', () => {
renderGroupCheckboxes(collectGroups(), currentMemberGroups);
});
$('r_groups').addEventListener('change', () => {
renderGroupCheckboxes(collectGroups($('r_groups')), [], $('r_groups'));
});
function collectGroups(containerEl) {
const container = containerEl || $('f_groups');
return Array.from(container.querySelectorAll('input[type="checkbox"]:checked')).map(i => i.value);
}
// ================= Registration requests =================
async function loadRequests() {
try {
const data = await api('/api/admin/requests');
requestsCache = { pending: data.pending || [], history: data.history || [] };
renderRequests();
} catch (err) {
if (err.status === 401) { logout(); return; }
}
}
function renderRequests() {
const list = $('requestsList');
const byPhase = reqTab === 'pending' ? requestsCache.pending : requestsCache.history;
const items = byPhase.filter(r => (r.type || 'registration') === reqType);
$('reqPendingCount').textContent = requestsCache.pending.filter(r => (r.type || 'registration') === reqType).length;
if (!items.length) {
list.innerHTML = `<div class="hint">${reqTab === 'pending' ? 'Нет ожидающих заявок' : 'История пуста'}</div>`;
return;
}
list.innerHTML = items.map(r => {
const badge = r.phase === 'approved' ? '<span class="badge approved">Одобрена</span>'
: r.phase === 'denied' ? '<span class="badge denied">Отклонена</span>'
: '<span class="badge pending">Ожидает</span>';
const typeBadge = (r.type || 'registration') === 'password-change'
? '<span class="badge pwd">Смена пароля</span>' : '';
const groups = reqType === 'password-change'
? (r.currentGroups || []).length
? `<div class="req-meta">Текущие группы:</div><div class="req-groups">${r.currentGroups.map(g => `<span>${escapeHtml(g)}</span>`).join('')}</div>`
: ''
: (r.groups || []).length
? `<div class="req-groups">${r.groups.map(g => `<span>${escapeHtml(g)}</span>`).join('')}</div>`
: '';
const reason = r.phase === 'denied' && r.reason ? `<div class="req-meta">Причина: ${escapeHtml(r.reason)}</div>` : '';
const date = new Date(r.createdAt || Date.now()).toLocaleString('ru-RU');
const resend = (r.phase === 'approved' || r.phase === 'denied') && !r.emailSent
? `<button class="btn btn-secondary" onclick="resendEmail('${r.id}')">Письмо не отправлено — повторить</button>` : '';
const resendApproved = reqTab === 'history' && r.type !== 'password-change' && r.phase === 'approved'
? `<button class="btn btn-secondary" onclick="resendApprovedEmail('${r.id}')">Отправить письмо повторно</button>` : '';
return `<div class="req-card">
<div class="req-head">${badge}${typeBadge}<span class="req-fio">${escapeHtml(r.fio || '')}</span><span class="req-date">${date}</span></div>
<div class="req-meta">Логин: ${escapeHtml(r.login || '—')} · Почта: ${escapeHtml(r.email || '—')}${r.phone ? ' · Телефон: ' + escapeHtml(r.phone) : ''}${r.position ? ' · Должность: ' + escapeHtml(r.position) : ''}</div>
${groups}${reason}
<div class="actions" style="margin-top:8px">${reqTab === 'pending' ? `<button class="btn btn-secondary" onclick="openRequestModal('${r.id}')">Рассмотреть</button>` : ''}${resend}${resendApproved}</div>
</div>`;
}).join('');
}
$('reqTabPending').addEventListener('click', () => { reqTab = 'pending'; renderRequests(); });
$('reqTabHistory').addEventListener('click', () => { reqTab = 'history'; renderRequests(); });
$('reqTypeRegistration').addEventListener('click', () => { reqType = 'registration'; renderRequests(); });
$('reqTypePassword').addEventListener('click', () => { reqType = 'password-change'; renderRequests(); });
function closeReqModal() {
$('reqModalOverlay').classList.add('hidden');
reqModalId = null;
}
async function openRequestModal(id) {
try {
const data = await api(`/api/admin/requests/${id}`);
const r = data.request;
reqModalId = id;
const isPwd = (r.type || 'registration') === 'password-change';
reqModalType = isPwd ? 'password-change' : 'registration';
$('reqModalTitle').textContent = (isPwd ? `Рассмотрение заявки на смену пароля: ${r.login}` : `Рассмотрение заявки: ${r.login}`);
$('r_surname').value = r.surname || '';
$('r_name').value = r.name || '';
$('r_patronymic').value = r.patronymic || '';
$('r_login').value = r.login || '';
$('r_email').value = r.email || '';
$('r_phone').value = r.phone || '';
$('r_position').value = r.position || '';
$('r_password').value = '';
$('r_reason').value = '';
reqModalOriginalLogin = r.login || '';
$('r_login').disabled = isPwd;
$('r_groupsWrap').style.display = isPwd ? 'none' : '';
$('r_currentGroupsWrap').style.display = isPwd ? '' : 'none';
$('r_currentGroups').innerHTML = (r.currentGroups || []).length
? r.currentGroups.map(g => `<span>${escapeHtml(g)}</span>`).join('')
: '<div class="hint">Группы не определены</div>';
$('r_currentDataWrap').style.display = isPwd ? '' : 'none';
$('r_currentData').innerHTML = '<div class="hint">Загрузка...</div>';
if (isPwd) loadCurrentUserData(r.login);
currentMemberGroups = [];
if (!isPwd) renderGroupCheckboxes(r.groups || [], [], $('r_groups'));
$('reqModalOverlay').classList.remove('hidden');
} catch (err) {
showMsg($('adminMsg'), err.message, 'error');
}
}
async function loadCurrentUserData(login) {
const box = $('r_currentData');
try {
const data = await api(`/api/admin/users/${encodeURIComponent(login)}`);
const u = data.user || {};
const rows = [
['ФИО', u.full_name],
['Должность', u.position],
['Телефон', u.phone],
['Почта', u.email]
];
box.innerHTML = rows.map(([k, v]) =>
`<div class="current-data-row"><span>${k}:</span><b>${escapeHtml(v || '—')}</b></div>`).join('');
} catch (err) {
box.innerHTML = `<div class="hint">Не удалось получить текущие данные: ${escapeHtml(err.message)}</div>`;
}
}
async function saveReqEdits() {
const body = {
surname: $('r_surname').value.trim(),
name: $('r_name').value.trim(),
patronymic: $('r_patronymic').value.trim(),
email: $('r_email').value.trim(),
phone: $('r_phone').value.trim(),
position: $('r_position').value.trim()
};
if (reqModalType !== 'password-change') {
body.groupCNs = collectGroups($('r_groups'));
const currentLogin = $('r_login').value.trim();
if (currentLogin !== reqModalOriginalLogin) body.login = currentLogin;
}
if ($('r_password').value) body.password = $('r_password').value;
const data = await api(`/api/admin/requests/${reqModalId}`, { method: 'PUT', body: JSON.stringify(body) });
$('r_login').value = (data.request && data.request.login) || $('r_login').value;
return data.request;
}
$('reqApproveBtn').addEventListener('click', async () => {
if (!reqModalId) return;
const isPwd = reqModalType === 'password-change';
if (!confirm(isPwd
? 'Одобрить смену пароля? Пароль и данные пользователя (ФИО, телефон, почта, должность) будут обновлены в AD, на почту придёт письмо.'
: 'Одобрить регистрацию? Пользователь будет создан в AD, на почту придёт письмо с логином и паролем.')) return;
try {
await saveReqEdits();
const res = await api(`/api/admin/requests/${reqModalId}/approve`, { method: 'POST' });
closeReqModal();
showMsg($('adminMsg'), isPwd
? `Смена пароля одобрена, данные пользователя «${res.login || ''}» обновлены${res.warning ? ' — ' + res.warning : ''}`
: `Заявка одобрена, пользователь создан (логин: ${res.login || ''})${res.warning ? ' — ' + res.warning : ''}`, 'success');
await Promise.all([loadUsers(), loadRequests()]);
} catch (err) {
showMsg($('adminMsg'), err.message, 'error');
}
});
$('reqDenyBtn').addEventListener('click', async () => {
if (!reqModalId) return;
const isPwd = reqModalType === 'password-change';
const reason = $('r_reason').value.trim();
if (!reason) { showMsg($('adminMsg'), 'Укажите причину отказа', 'error'); return; }
if (!confirm('Отказать' + (isPwd ? ' в смене пароля' : ' в регистрации') + '? Пользователю будет отправлено письмо об отказе.')) return;
try {
await saveReqEdits();
const res = await api(`/api/admin/requests/${reqModalId}/deny`, { method: 'POST', body: JSON.stringify({ reason }) });
closeReqModal();
showMsg($('adminMsg'), 'Заявка отклонена' + (res.warning ? ' — ' + res.warning : ''), 'success');
await loadRequests();
} catch (err) {
showMsg($('adminMsg'), err.message, 'error');
}
});
$('reqCancelBtn').addEventListener('click', closeReqModal);
$('reqModalOverlay').addEventListener('click', (e) => { if (e.target === $('reqModalOverlay')) closeReqModal(); });
async function resendEmail(id) {
try {
await api(`/api/admin/requests/${id}/resend`, { method: 'POST' });
showMsg($('adminMsg'), 'Письмо отправлено повторно', 'success');
await loadRequests();
} catch (err) {
showMsg($('adminMsg'), err.message, 'error');
}
}
async function resendApprovedEmail(id) {
if (!confirm('Отправить письмо с данными об одобрении ещё раз?')) return;
try {
await api(`/api/admin/requests/${id}/resend`, { method: 'POST' });
showMsg($('adminMsg'), 'Письмо отправлено повторно', 'success');
await loadRequests();
} catch (err) {
showMsg($('adminMsg'), err.message, 'error');
}
}
// ================= Security journal / whitelist =================
let journalType = 'admin';
let journalCache = { rows: [], active: [], history: [], whitelist: [] };
function fmtDate(ts) {
return new Date(ts).toLocaleString('ru-RU');
}
function journalFilterQuery() {
const parts = [];
const success = $('jf_success').value;
if (success) parts.push('success=' + success);
const from = $('jf_from').value;
if (from) parts.push('from=' + encodeURIComponent(from));
const to = $('jf_to').value;
if (to) parts.push('to=' + encodeURIComponent(to));
const ip = $('jf_ip').value.trim();
if (ip) parts.push('ip=' + encodeURIComponent(ip));
const username = $('jf_username').value.trim();
if (username) parts.push('username=' + encodeURIComponent(username));
const limit = parseInt($('jf_limit').value, 10) || 200;
parts.push('limit=' + Math.min(Math.max(limit, 1), 1000));
return parts.join('&');
}
async function loadJournal() {
try {
$('journalContent').innerHTML = '<div class="hint">Загрузка...</div>';
if (journalType === 'blocks') {
const data = await api('/api/admin/blocks');
journalCache.active = data.active || [];
journalCache.history = data.history || [];
} else if (journalType === 'whitelist') {
const data = await api('/api/admin/whitelist');
journalCache.whitelist = data.whitelist || [];
} else {
const data = await api(`/api/admin/journal/${journalType}?${journalFilterQuery()}`);
journalCache.rows = data.rows || [];
}
renderJournal();
} catch (err) {
if (err.status === 401) { logout(); return; }
$('journalContent').innerHTML = `<div class="hint">Ошибка: ${escapeHtml(err.message)}</div>`;
}
}
function renderJournal() {
const el = $('journalContent');
$('journalFilters').style.display = journalType === 'whitelist' ? 'none' : '';
if (journalType === 'whitelist') { renderWhitelist(); return; }
if (journalType === 'blocks') { renderBlocks(); return; }
const rows = journalCache.rows;
if (!rows.length) { el.innerHTML = '<div class="hint">Нет записей</div>'; return; }
el.innerHTML = '<div class="table-wrap"><table><thead><tr><th>Когда</th><th>IP</th><th>Логин</th><th>Результат</th><th>Причина</th></tr></thead><tbody>' +
rows.map(r => '<tr><td data-label="Когда">' + escapeHtml(fmtDate(r.ts)) + '</td><td data-label="IP">' + escapeHtml(r.ip) + '</td><td data-label="Логин">' + escapeHtml(r.username) + '</td>' +
'<td data-label="Результат">' + (r.success ? '<span class="badge approved">Успех</span>' : '<span class="badge denied">Неудача</span>') + '</td>' +
'<td data-label="Причина">' + escapeHtml(r.reason || '') + '</td></tr>').join('') + '</tbody></table></div>';
}
function renderBlocks() {
const el = $('journalContent');
const active = journalCache.active || [];
const history = journalCache.history || [];
let html = '<h3 class="hint" style="margin:8px 0;">Активные блокировки (' + active.length + ')</h3>';
html += active.length
? '<div class="table-wrap"><table><thead><tr><th>IP</th><th>Заблокирован до</th><th>Осталось</th><th>Попыток</th></tr></thead><tbody>' +
active.map(b => '<tr><td data-label="IP">' + escapeHtml(b.ip) + '</td><td data-label="Заблокирован до">' + escapeHtml(fmtDate(b.blockedUntil)) + '</td><td data-label="Осталось">' + Math.ceil(b.remainingMs / 60000) + ' мин</td><td data-label="Попыток">' + b.attempts + '</td></tr>').join('') + '</tbody></table></div>'
: '<div class="hint">Нет активных блокировок</div>';
html += '<h3 class="hint" style="margin:16px 0 8px;">История блокировок (' + history.length + ')</h3>';
if (history.length) {
html += '<div class="table-wrap"><table><thead><tr><th>Когда</th><th>IP</th><th>До</th><th>Попыток</th></tr></thead><tbody>' +
history.map(r => '<tr><td data-label="Когда">' + escapeHtml(fmtDate(r.ts)) + '</td><td data-label="IP">' + escapeHtml(r.ip) + '</td><td data-label="До">' + escapeHtml(fmtDate(r.blockedUntil)) + '</td><td data-label="Попыток">' + r.attempts + '</td></tr>').join('') + '</tbody></table></div>';
}
el.innerHTML = html;
}
function renderWhitelist() {
const el = $('journalContent');
const items = journalCache.whitelist || [];
let html = '<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px"><input type="text" id="wl_pattern" placeholder="IP или маска, напр. 192.168.1.0/24" style="width:280px"> ' +
'<input type="text" id="wl_comment" placeholder="Комментарий" style="width:220px"> ' +
'<button class="btn btn-success" id="wlAddBtn">Добавить</button></div>';
html += items.length
? '<div class="table-wrap"><table><thead><tr><th>IP / маска</th><th>Комментарий</th><th></th></tr></thead><tbody>' +
items.map(w => '<tr><td data-label="IP / маска">' + escapeHtml(w.pattern) + '</td><td data-label="Комментарий">' + escapeHtml(w.comment) + '</td><td class="actions" data-label=""><button class="btn btn-danger" onclick="removeWhitelist(' + w.id + ')">Удалить</button></td></tr>').join('') + '</tbody></table></div>'
: '<div class="hint">Список пуст — все IP проходят проверку лимитов</div>';
el.innerHTML = html;
const addBtn = $('wlAddBtn');
if (addBtn) addBtn.addEventListener('click', async () => {
try {
await api('/api/admin/whitelist', {
method: 'POST',
body: JSON.stringify({ pattern: $('wl_pattern').value.trim(), comment: $('wl_comment').value.trim() })
});
showMsg($('adminMsg'), 'IP добавлен в белый список', 'success');
await loadJournal();
} catch (err) {
showMsg($('adminMsg'), err.message, 'error');
}
});
}
async function removeWhitelist(id) {
try {
await api(`/api/admin/whitelist/${id}`, { method: 'DELETE' });
await loadJournal();
} catch (err) {
showMsg($('adminMsg'), err.message, 'error');
}
}
function setJournalTab(type) {
journalType = type;
loadJournal();
}
$('journalTabAdmin').addEventListener('click', () => setJournalTab('admin'));
$('journalTabAuth').addEventListener('click', () => setJournalTab('auth'));
$('journalTabBlocks').addEventListener('click', () => setJournalTab('blocks'));
$('journalTabWhitelist').addEventListener('click', () => setJournalTab('whitelist'));
$('journalApplyBtn').addEventListener('click', loadJournal);
$('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_position').value = '';
$('f_passwordLabel').textContent = 'Пароль *';
$('f_passwordHint').textContent = 'Обязателен при создании';
$('f_password').required = true;
if (ousCache.length === 0) loadOus();
currentMemberGroups = [];
renderGroupCheckboxes([], []);
openModal('Создание пользователя', false);
});
$('f_groupsDefault').addEventListener('click', () => {
if (!adminSettings.defaultGroups.length) {
showMsg($('adminMsg'), 'Группы по умолчанию не заданы', 'error');
return;
}
renderGroupCheckboxes(adminSettings.defaultGroups, currentMemberGroups);
showMsg($('adminMsg'), 'Применены группы по умолчанию', 'success');
});
$('f_groupsRemember').addEventListener('click', async () => {
const defaultGroups = collectGroups();
adminSettings.defaultGroups = defaultGroups;
try {
await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ defaultGroups }) });
showMsg($('adminMsg'), 'Выбор сохранён как группы по умолчанию', 'success');
} catch (err) {
showMsg($('adminMsg'), err.message, 'error');
}
});
$('f_ou').addEventListener('change', async () => {
adminSettings.ouDn = $('f_ou').value;
try {
await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ ouDn: adminSettings.ouDn }) });
} catch (err) {
// некритично: настройка сохранится при следующем изменении
}
});
$('reqPhoneRequired').addEventListener('change', async () => {
adminSettings.phoneRequired = $('reqPhoneRequired').checked;
try {
await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ phoneRequired: adminSettings.phoneRequired }) });
} catch (err) {
showMsg($('adminMsg'), 'Не удалось сохранить настройку: ' + err.message, 'error');
}
});
$('reqAuthEnabled').addEventListener('change', async () => {
adminSettings.authEnabled = $('reqAuthEnabled').checked;
try {
await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ authEnabled: adminSettings.authEnabled }) });
showMsg($('adminMsg'), adminSettings.authEnabled ? 'Вход (/auth) включён' : 'Вход (/auth) выключен — переход с / идёт на /register', 'success');
} catch (err) {
showMsg($('adminMsg'), 'Не удалось сохранить настройку: ' + err.message, 'error');
}
});
function bindSettingField(id, key) {
$(id).addEventListener('change', async () => {
adminSettings[key] = $(id).value.trim();
try {
await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ [key]: adminSettings[key] }) });
showMsg($('adminMsg'), 'Настройка сохранена', 'success');
} catch (err) {
showMsg($('adminMsg'), 'Не удалось сохранить настройку: ' + err.message, 'error');
}
});
}
bindSettingField('cfgLogonScript', 'logonScript');
bindSettingField('cfgHomeDrive', 'homeDrive');
bindSettingField('cfgHomeBasePath', 'homeBasePath');
bindSettingField('cfgCrmUrl', 'crmUrl');
bindSettingField('cfgQuickLinksUrl', 'quickLinksUrl');
$('apiKeyCopyBtn').addEventListener('click', async () => {
const val = $('apiKeyField').value;
if (!val) { showMsg($('adminMsg'), 'Ключ ещё не сгенерирован', 'error'); return; }
try {
await navigator.clipboard.writeText(val);
showMsg($('adminMsg'), 'API-ключ скопирован', 'success');
} catch (err) {
$('apiKeyField').select();
document.execCommand('copy');
showMsg($('adminMsg'), 'API-ключ скопирован', 'success');
}
});
$('apiKeyRegenBtn').addEventListener('click', async () => {
if (!confirm('Сгенерировать новый API-ключ? Старый ключ перестанет работать.')) return;
try {
const data = await api('/api/admin/api-key', { method: 'POST' });
adminSettings.apiKey = data.apiKey || '';
$('apiKeyField').value = adminSettings.apiKey;
showMsg($('adminMsg'), 'Новый API-ключ сгенерирован', 'success');
} catch (err) {
showMsg($('adminMsg'), err.message, 'error');
}
});
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_position').value = u.position || '';
$('f_passwordLabel').textContent = 'Новый пароль';
$('f_passwordHint').textContent = 'Оставьте пустым, чтобы не менять';
$('f_password').required = false;
currentMemberGroups = u.groups || [];
renderGroupCheckboxes(u.groups || [], 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(),
position: $('f_position').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('Логин обязателен');
if (body.ouDn) adminSettings.ouDn = body.ouDn;
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(() => loadSettings().then(() => Promise.all([loadUsers(), loadGroups(), loadOus(), loadRequests()])))
.catch(() => clearLocalSession());
return;
} catch (e) { /* ignore */ }
}
logout();
})();
</script>
</body>
</html>