1039 lines
52 KiB
HTML
1039 lines
52 KiB
HTML
<!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 => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
}[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>
|