feat: UI — edit modal, admin-панель выбора компании, кнопки Аудит/Лимиты; views/audit.ejs, views/admin.ejs
This commit is contained in:
+204
@@ -0,0 +1,204 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Управление лимитами — IP WhiteList</title>
|
||||
<link rel="icon" href="/favicon.png" type="image/png">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f5f5; --card: #ffffff; --text: #1a1a1a; --muted: #6b7280;
|
||||
--border: #d1d5db; --grey-light: #f3f4f6; --blue: #2563eb; --blue-h: #1d4ed8;
|
||||
--red: #dc2626; --green: #16a34a; --amber: #d97706;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
||||
}
|
||||
.page { max-width: 900px; margin: 1.5rem auto; padding: 0 1rem; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,.04); margin-bottom: 1rem; overflow: hidden; }
|
||||
.card-header { background: var(--grey-light); padding: .75rem 1rem; font-weight: 600; font-size: 1rem; border-bottom: 1px solid var(--border); }
|
||||
.card-body { padding: 1rem; }
|
||||
.alert { padding: .75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: .9rem; }
|
||||
.alert-ok { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
|
||||
.alert-err { background: #fecaca; color: #991b1b; border: 1px solid #fca5a5; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: .35rem;
|
||||
padding: .4rem .85rem; border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: .82rem; font-weight: 500; background: #fff; cursor: pointer;
|
||||
text-decoration: none; color: var(--text); transition: background .15s;
|
||||
}
|
||||
.btn:hover { background: var(--grey-light); }
|
||||
.btn-sm { padding: .3rem .65rem; font-size: .78rem; }
|
||||
.btn-primary { background: var(--blue); color: #fff; border-color: var(--blue); }
|
||||
.btn-primary:hover { background: var(--blue-h); }
|
||||
/* Таблица */
|
||||
.table-wrap { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .85rem; }
|
||||
th {
|
||||
text-align: left; padding: .5rem .75rem;
|
||||
background: var(--grey-light); color: var(--muted);
|
||||
font-weight: 500; font-size: .78rem; text-transform: uppercase; letter-spacing: .5px;
|
||||
border-bottom: 1px solid var(--border); border-right: 1px solid var(--border);
|
||||
}
|
||||
th:last-child { border-right: none; }
|
||||
td { padding: .5rem .75rem; border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); vertical-align: middle; }
|
||||
td:last-child { border-right: none; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
/* Инпут лимита */
|
||||
.limit-input {
|
||||
width: 80px; padding: .3rem .5rem; border: 1px solid var(--border);
|
||||
border-radius: 5px; font-size: .85rem; text-align: center;
|
||||
}
|
||||
.limit-input:focus { border-color: var(--blue); outline: none; box-shadow: 0 0 0 2px rgba(37,99,235,.1); }
|
||||
/* Индикатор использования */
|
||||
.usage-bar { display: flex; align-items: center; gap: .5rem; }
|
||||
.bar { height: 6px; border-radius: 3px; background: var(--border); flex: 1; min-width: 60px; }
|
||||
.bar-fill { height: 100%; border-radius: 3px; background: var(--blue); }
|
||||
.bar-fill.full { background: var(--red); }
|
||||
.bar-fill.warn { background: var(--amber); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header style="background:#fff;border-bottom:1px solid var(--border);padding:0 1.5rem;height:48px;display:flex;align-items:center;gap:.75rem;font-size:.9rem;color:var(--muted);">
|
||||
<svg width="130" height="28" viewBox="330 228 311 69" style="display:block;">
|
||||
<g transform="translate(-330 -228)">
|
||||
<path d="M348.061 246.203 332.42 246.203 330 256.661 336.75 256.661 336.794 295.573 336.794 296.585 336.795 296.585 347.25 296.585 347.25 263.611C347.25 259.78 350.368 256.663 354.2 256.663L376.489 256.793C380.322 256.793 383.44 259.912 383.44 263.743L383.44 296.717 393.896 296.717 393.896 263.743C393.896 254.144 386.087 246.338 376.495 246.338L354.196 246.204 348.061 246.221 348.061 246.203Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M582.095 296.65 626.768 296.65C634.793 296.65 641 290.03 641 282.009 641 273.985 634.473 267.458 626.449 267.458L601.724 267.458C599.446 267.458 597.595 265.607 597.595 263.328L597.595 260.825C597.595 258.548 599.446 256.697 601.724 256.697L628.041 256.697 630.452 246.276 601.724 246.276C593.7 246.276 587.174 252.802 587.174 260.825L587.174 263.328C587.174 271.352 593.7 277.879 601.724 277.879L626.449 277.879C628.726 277.879 630.579 279.732 630.579 282.009 630.579 284.283 628.726 286.4 626.449 286.4L584.126 286.4 582.095 296.65Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M564.549 246.347 537.193 246.347C529.027 246.347 522.385 252.989 522.385 261.156L522.385 279.521C522.385 288.916 530.028 296.557 539.422 296.557L576.846 296.557 579.196 286.401 539.422 286.401C535.775 286.401 532.805 283.172 532.805 279.521L532.748 278.028 567.684 278.028 579.354 277.959 579.354 268.958 579.354 261.156C579.354 252.989 572.712 246.347 564.549 246.347ZM568.935 267.54 532.805 267.54 532.805 261.156C532.805 258.737 534.774 256.767 537.193 256.767L564.549 256.767C566.966 256.767 568.935 258.737 568.935 261.156L568.935 267.54Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M507.156 273.231 507.156 279.597C507.156 283.414 504.066 286.344 500.247 286.344L477.934 286.39C474.41 286.39 471.493 283.744 471.064 280.335L471.064 263.006C471.494 259.597 474.41 256.951 477.934 256.951L500.23 257.083C504.049 257.083 507.156 260.192 507.156 264.009L507.156 265.16 507.156 273.232ZM460.623 260.161 460.589 230.089 471.064 228.058 471.064 247.957C473.172 247.043 475.49 246.53 477.929 246.53L500.235 246.662C509.793 246.662 517.575 254.444 517.575 264.009L517.575 265.159 517.575 273.231 517.575 279.597C517.575 289.161 509.793 296.941 500.235 296.941L477.929 296.809C468.37 296.809 460.589 289.029 460.589 279.464L460.589 272.972 460.589 263.876 460.623 260.161Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M445.037 271.59 445.037 279.317C445.037 283.134 441.93 286.241 438.111 286.241L416.092 286.11C412.273 286.11 409.166 283.002 409.166 279.185L409.166 276.635 409.222 276.644 409.222 244.013 398.748 246.044 398.752 249.955 398.748 249.957 398.748 279.184C398.748 288.749 406.529 296.529 416.087 296.529L438.116 296.661C447.674 296.661 455.456 288.881 455.456 279.316L455.456 276.639 455.482 276.643 455.482 244.013 445.008 246.043 445.037 271.589Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span>|</span>
|
||||
<span style="font-weight:500;color:var(--text);">Управление лимитами</span>
|
||||
<span style="margin-left:auto;display:flex;gap:.5rem;">
|
||||
<a href="/" class="btn btn-sm">← Список адресов</a>
|
||||
<a href="/audit" class="btn btn-sm">Журнал аудита</a>
|
||||
<a href="/logout" class="btn btn-sm">Выйти (<%= user.email %>)</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<% if (message) { %><div class="alert alert-ok"><%= message %></div><% } %>
|
||||
<% if (error) { %><div class="alert alert-err"><%= error %></div><% } %>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Компании и лимиты
|
||||
<span style="margin-left:.5rem;font-weight:400;font-size:.82rem;color:var(--muted);">
|
||||
Глобальный дефолт: <strong><%= defaultLimit %></strong> записей.
|
||||
Пустое поле = сброс на дефолт.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<% if (companies.length === 0) { %>
|
||||
<div style="text-align:center;color:var(--muted);padding:2rem;">Нет компаний в системе</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Компания</th>
|
||||
<th>ClientID</th>
|
||||
<th>Использовано</th>
|
||||
<th>Лимит</th>
|
||||
<th>Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% companies.forEach(c => { %>
|
||||
<%
|
||||
// Вычисляем эффективный лимит (кастомный или дефолтный)
|
||||
const effectiveLimit = c.custom_limit != null ? c.custom_limit : defaultLimit;
|
||||
const pct = effectiveLimit > 0 ? Math.min(c.active_count / effectiveLimit, 1) : 1;
|
||||
const barClass = pct >= 1 ? 'full' : pct >= 0.8 ? 'warn' : '';
|
||||
%>
|
||||
<tr>
|
||||
<td><strong><%= c.name %></strong></td>
|
||||
<td style="color:var(--muted);font-size:.82rem;"><%= c.client_id %></td>
|
||||
<!-- Индикатор использования: полоска + цифры -->
|
||||
<td>
|
||||
<div class="usage-bar">
|
||||
<div class="bar">
|
||||
<div class="bar-fill <%= barClass %>" style="width:<%= Math.round(pct*100) %>%"></div>
|
||||
</div>
|
||||
<span style="white-space:nowrap;font-size:.82rem;">
|
||||
<%= c.active_count %> / <%= effectiveLimit %>
|
||||
<% if (c.custom_limit != null) { %>
|
||||
<span style="color:var(--amber);margin-left:.25rem;" title="Индивидуальный лимит">★</span>
|
||||
<% } %>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Текущий лимит: кастомный или "по умолчанию" -->
|
||||
<td style="color:var(--muted);font-size:.82rem;">
|
||||
<% if (c.custom_limit != null) { %>
|
||||
<span style="color:var(--amber);font-weight:600;"><%= c.custom_limit %></span>
|
||||
<span style="font-size:.75rem;">(индивидуальный)</span>
|
||||
<% } else { %>
|
||||
<span>дефолт (<%= defaultLimit %>)</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<!-- Форма изменения лимита для этой компании -->
|
||||
<td>
|
||||
<form method="POST" action="/admin/limit/<%= c.id %>"
|
||||
style="display:flex;align-items:center;gap:.5rem;"
|
||||
onsubmit="return confirmLimit(this, '<%= c.name %>')">
|
||||
<input
|
||||
class="limit-input"
|
||||
type="number"
|
||||
name="limit"
|
||||
min="0"
|
||||
placeholder="<%= defaultLimit %>"
|
||||
value="<%= c.custom_limit != null ? c.custom_limit : '' %>"
|
||||
title="Введите число ≥ 0, или оставьте пустым для дефолта (<%=defaultLimit%>)"
|
||||
>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Сохранить</button>
|
||||
<% if (c.custom_limit != null) { %>
|
||||
<!-- Кнопка сброса: отправляет пустое значение → null → дефолт -->
|
||||
<button type="button" class="btn btn-sm"
|
||||
onclick="resetLimit(this, <%= c.id %>)"
|
||||
title="Сбросить на дефолт">↺</button>
|
||||
<% } %>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Подтверждение перед сохранением лимита
|
||||
function confirmLimit(form, companyName) {
|
||||
const val = form.querySelector('input[name="limit"]').value;
|
||||
const label = val === '' ? 'сбросить на дефолт' : 'установить лимит ' + val;
|
||||
return confirm('Компания «' + companyName + '»: ' + label + '?');
|
||||
}
|
||||
|
||||
// Сброс лимита на дефолт — отправляет форму с пустым полем
|
||||
function resetLimit(btn, companyId) {
|
||||
if (!confirm('Сбросить лимит на дефолт?')) return;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/admin/limit/' + companyId;
|
||||
// Пустое значение → сервер интерпретирует как null → дефолт
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden';
|
||||
inp.name = 'limit';
|
||||
inp.value = '';
|
||||
form.appendChild(inp);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Журнал аудита — IP WhiteList</title>
|
||||
<link rel="icon" href="/favicon.png" type="image/png">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f5f5; --card: #ffffff; --text: #1a1a1a; --muted: #6b7280;
|
||||
--border: #d1d5db; --grey-light: #f3f4f6; --blue: #2563eb; --blue-h: #1d4ed8;
|
||||
--red: #dc2626; --green: #16a34a; --amber: #d97706;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
||||
}
|
||||
.page { max-width: 1200px; margin: 1.5rem auto; padding: 0 1rem; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,.04); margin-bottom: 1rem; overflow: hidden; }
|
||||
.card-header { background: var(--grey-light); padding: .75rem 1rem; font-weight: 600; font-size: 1rem; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 1rem; }
|
||||
.card-body { padding: 1rem; }
|
||||
.alert { padding: .75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: .9rem; }
|
||||
.alert-err { background: #fecaca; color: #991b1b; border: 1px solid #fca5a5; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: .35rem;
|
||||
padding: .4rem .85rem; border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: .82rem; font-weight: 500; background: #fff; cursor: pointer;
|
||||
text-decoration: none; color: var(--text); transition: background .15s;
|
||||
}
|
||||
.btn:hover { background: var(--grey-light); }
|
||||
.btn-sm { padding: .3rem .65rem; font-size: .78rem; }
|
||||
/* Фильтр */
|
||||
.filter-bar { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||
.filter-bar select { padding: .4rem .75rem; border: 1px solid var(--border); border-radius: 6px; font-size: .85rem; background: #fff; }
|
||||
/* Таблица */
|
||||
.table-wrap { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .82rem; }
|
||||
th {
|
||||
text-align: left; padding: .5rem .75rem;
|
||||
background: var(--grey-light); color: var(--muted);
|
||||
font-weight: 500; font-size: .78rem; text-transform: uppercase; letter-spacing: .5px;
|
||||
border-bottom: 1px solid var(--border); border-right: 1px solid var(--border);
|
||||
}
|
||||
th:last-child { border-right: none; }
|
||||
td { padding: .45rem .75rem; border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); vertical-align: top; }
|
||||
td:last-child { border-right: none; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
code { background: #f1f5f9; padding: .1rem .35rem; border-radius: 3px; font-size: .88em; }
|
||||
.empty { text-align: center; color: var(--muted); padding: 2rem; }
|
||||
/* Badges действий */
|
||||
.badge { display: inline-block; padding: .15rem .5rem; border-radius: 4px; font-size: .72rem; font-weight: 600; text-transform: uppercase; }
|
||||
.badge-create { background: #dcfce7; color: #166534; }
|
||||
.badge-update { background: #dbeafe; color: #1e40af; }
|
||||
.badge-delete { background: #fecaca; color: #991b1b; }
|
||||
/* Стрелка old→new */
|
||||
.arrow { color: var(--muted); margin: 0 .3rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Шапка — такая же как в index.ejs -->
|
||||
<header style="background:#fff;border-bottom:1px solid var(--border);padding:0 1.5rem;height:48px;display:flex;align-items:center;gap:.75rem;font-size:.9rem;color:var(--muted);">
|
||||
<svg width="130" height="28" viewBox="330 228 311 69" style="display:block;">
|
||||
<g transform="translate(-330 -228)">
|
||||
<path d="M348.061 246.203 332.42 246.203 330 256.661 336.75 256.661 336.794 295.573 336.794 296.585 336.795 296.585 347.25 296.585 347.25 263.611C347.25 259.78 350.368 256.663 354.2 256.663L376.489 256.793C380.322 256.793 383.44 259.912 383.44 263.743L383.44 296.717 393.896 296.717 393.896 263.743C393.896 254.144 386.087 246.338 376.495 246.338L354.196 246.204 348.061 246.221 348.061 246.203Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M582.095 296.65 626.768 296.65C634.793 296.65 641 290.03 641 282.009 641 273.985 634.473 267.458 626.449 267.458L601.724 267.458C599.446 267.458 597.595 265.607 597.595 263.328L597.595 260.825C597.595 258.548 599.446 256.697 601.724 256.697L628.041 256.697 630.452 246.276 601.724 246.276C593.7 246.276 587.174 252.802 587.174 260.825L587.174 263.328C587.174 271.352 593.7 277.879 601.724 277.879L626.449 277.879C628.726 277.879 630.579 279.732 630.579 282.009 630.579 284.283 628.726 286.4 626.449 286.4L584.126 286.4 582.095 296.65Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M564.549 246.347 537.193 246.347C529.027 246.347 522.385 252.989 522.385 261.156L522.385 279.521C522.385 288.916 530.028 296.557 539.422 296.557L576.846 296.557 579.196 286.401 539.422 286.401C535.775 286.401 532.805 283.172 532.805 279.521L532.748 278.028 567.684 278.028 579.354 277.959 579.354 268.958 579.354 261.156C579.354 252.989 572.712 246.347 564.549 246.347ZM568.935 267.54 532.805 267.54 532.805 261.156C532.805 258.737 534.774 256.767 537.193 256.767L564.549 256.767C566.966 256.767 568.935 258.737 568.935 261.156L568.935 267.54Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M507.156 273.231 507.156 279.597C507.156 283.414 504.066 286.344 500.247 286.344L477.934 286.39C474.41 286.39 471.493 283.744 471.064 280.335L471.064 263.006C471.494 259.597 474.41 256.951 477.934 256.951L500.23 257.083C504.049 257.083 507.156 260.192 507.156 264.009L507.156 265.16 507.156 273.232ZM460.623 260.161 460.589 230.089 471.064 228.058 471.064 247.957C473.172 247.043 475.49 246.53 477.929 246.53L500.235 246.662C509.793 246.662 517.575 254.444 517.575 264.009L517.575 265.159 517.575 273.231 517.575 279.597C517.575 289.161 509.793 296.941 500.235 296.941L477.929 296.809C468.37 296.809 460.589 289.029 460.589 279.464L460.589 272.972 460.589 263.876 460.623 260.161Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M445.037 271.59 445.037 279.317C445.037 283.134 441.93 286.241 438.111 286.241L416.092 286.11C412.273 286.11 409.166 283.002 409.166 279.185L409.166 276.635 409.222 276.644 409.222 244.013 398.748 246.044 398.752 249.955 398.748 249.957 398.748 279.184C398.748 288.749 406.529 296.529 416.087 296.529L438.116 296.661C447.674 296.661 455.456 288.881 455.456 279.316L455.456 276.639 455.482 276.643 455.482 244.013 445.008 246.043 445.037 271.589Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span>|</span>
|
||||
<span style="font-weight:500;color:var(--text);">Журнал аудита</span>
|
||||
<span style="margin-left:auto;display:flex;gap:.5rem;">
|
||||
<a href="/" class="btn btn-sm">← Список адресов</a>
|
||||
<a href="/admin" class="btn btn-sm">Управление лимитами</a>
|
||||
<a href="/logout" class="btn btn-sm">Выйти (<%= user.email %>)</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<% if (error) { %><div class="alert alert-err"><%= error %></div><% } %>
|
||||
|
||||
<!-- Фильтр по компании -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Фильтр
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="GET" action="/audit" class="filter-bar">
|
||||
<label style="font-size:.85rem;color:var(--muted);">Компания:</label>
|
||||
<select name="company" onchange="this.form.submit()">
|
||||
<option value="">— Все компании —</option>
|
||||
<% companies.forEach(c => { %>
|
||||
<option value="<%= c.id %>" <%= selectedCompanyId === c.id ? 'selected' : '' %>>
|
||||
<%= c.name %> (<%= c.client_id %>)
|
||||
</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<% if (selectedCompanyId) { %>
|
||||
<a href="/audit" class="btn btn-sm">Сбросить</a>
|
||||
<% } %>
|
||||
<span style="margin-left:auto;color:var(--muted);font-size:.82rem;">
|
||||
Показано: <strong><%= rows.length %></strong> записей (макс 500)
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Таблица аудита -->
|
||||
<div class="card">
|
||||
<div class="card-header">Записи журнала</div>
|
||||
<% if (rows.length === 0) { %>
|
||||
<div class="empty">Нет записей в журнале аудита</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Когда</th>
|
||||
<th>Кто</th>
|
||||
<th>Компания</th>
|
||||
<th>Действие</th>
|
||||
<th>Значение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% rows.forEach(r => { %>
|
||||
<tr>
|
||||
<!-- Дата в читаемом виде -->
|
||||
<td style="white-space:nowrap;color:var(--muted);">
|
||||
<%= new Date(r.created_at).toLocaleString('ru', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
}) %>
|
||||
</td>
|
||||
<td><%= r.user_email %></td>
|
||||
<!-- Название компании из JOIN, client_id в скобках -->
|
||||
<td>
|
||||
<%= r.company_name %><br>
|
||||
<span style="color:var(--muted);font-size:.78rem;"><%= r.client_id %></span>
|
||||
</td>
|
||||
<!-- Цветной badge типа действия -->
|
||||
<td>
|
||||
<span class="badge badge-<%= r.action.toLowerCase() %>"><%= r.action %></span>
|
||||
</td>
|
||||
<!-- old_value → new_value: показываем что изменилось -->
|
||||
<td>
|
||||
<% if (r.old_value && r.new_value) { %>
|
||||
<code><%= r.old_value %></code>
|
||||
<span class="arrow">→</span>
|
||||
<code><%= r.new_value %></code>
|
||||
<% } else if (r.new_value) { %>
|
||||
<code><%= r.new_value %></code>
|
||||
<% } else if (r.old_value) { %>
|
||||
<code style="text-decoration:line-through;color:var(--muted);"><%= r.old_value %></code>
|
||||
<% } else { %>
|
||||
<span style="color:var(--muted);">—</span>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+158
-4
@@ -157,6 +157,14 @@
|
||||
</svg>
|
||||
<span>|</span>
|
||||
<span style="font-weight:500;color:var(--text);">Белые списки IP</span>
|
||||
<!-- Кнопки правой части header: admin-ссылки + выход -->
|
||||
<span style="margin-left:auto;display:flex;gap:.5rem;align-items:center;">
|
||||
<% if (isAdmin) { %>
|
||||
<a href="/audit" style="font-size:.8rem;color:var(--muted);text-decoration:none;padding:.2rem .6rem;border:1px solid var(--border);border-radius:5px;background:#fff;">Аудит</a>
|
||||
<a href="/admin" style="font-size:.8rem;color:var(--muted);text-decoration:none;padding:.2rem .6rem;border:1px solid var(--border);border-radius:5px;background:#fff;">Лимиты</a>
|
||||
<% } %>
|
||||
<a href="/logout" style="font-size:.8rem;color:var(--muted);text-decoration:none;padding:.2rem .6rem;border:1px solid var(--border);border-radius:5px;background:#fff;">Выйти (<%= user.email %>)</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
@@ -164,6 +172,40 @@
|
||||
<% if (message) { %><div class="alert <%= wasNormalized ? 'alert-warn' : 'alert-ok' %>"><%= message %></div><% } %>
|
||||
<% if (error) { %><div class="alert alert-err"><%= error %></div><% } %>
|
||||
|
||||
<%
|
||||
// Для admin: selectedCompany — выбранная компания (объект из getAllCompanies).
|
||||
// Для обычного пользователя: selectedCompany — их компания (из getOrCreateCompany).
|
||||
// company_id — числовой PK для передачи в скрытые поля форм.
|
||||
const companyPk = selectedCompany ? selectedCompany.id : null;
|
||||
%>
|
||||
|
||||
<!-- Переключатель компании — только для администратора -->
|
||||
<% if (isAdmin && companies) { %>
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:.75rem 1rem;">
|
||||
<form method="GET" action="/" style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
|
||||
<span style="font-size:.85rem;color:var(--muted);font-weight:500;">Компания:</span>
|
||||
<select name="company" onchange="this.form.submit()"
|
||||
style="padding:.4rem .75rem;border:1px solid var(--border);border-radius:6px;font-size:.85rem;background:#fff;">
|
||||
<% companies.forEach(c => { %>
|
||||
<option value="<%= c.id %>" <%= selectedCompany && c.id === selectedCompany.id ? 'selected' : '' %>>
|
||||
<%= c.name %> (<%= c.client_id %>) — <%= c.active_count %> зап.
|
||||
</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<!-- Быстрый доступ к admin-страницам прямо из главной -->
|
||||
<a href="/audit<%= selectedCompany ? '?company=' + selectedCompany.id : '' %>"
|
||||
style="font-size:.82rem;color:var(--muted);text-decoration:none;">→ Аудит этой компании</a>
|
||||
<a href="/admin"
|
||||
style="font-size:.82rem;color:var(--muted);text-decoration:none;">→ Лимиты</a>
|
||||
<span style="margin-left:auto;font-size:.8rem;color:var(--muted);">
|
||||
Режим: <strong style="color:var(--amber);">Администратор</strong>
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- Статистика -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -173,7 +215,8 @@
|
||||
<div class="lbl">записей</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="num"><%= user.clientId %></div>
|
||||
<!-- Для admin показываем clientId выбранной компании, для user — их собственный -->
|
||||
<div class="num"><%= selectedCompany ? selectedCompany.client_id : user.clientId %></div>
|
||||
<div class="lbl">компания</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
@@ -189,6 +232,13 @@
|
||||
<div class="card-header">Добавить адрес</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/add">
|
||||
<%
|
||||
// Передаём числовой PK компании в скрытом поле.
|
||||
// Для admin: server.js возьмёт его из req.body.company_id.
|
||||
// Для обычного пользователя: server.js игнорирует это поле
|
||||
// и использует clientId из токена — защита от подмены.
|
||||
%>
|
||||
<input type="hidden" name="company_id" value="<%= companyPk || '' %>">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>IPv4 адрес или подсеть CIDR</label>
|
||||
@@ -220,7 +270,8 @@
|
||||
<th>Комментарий</th>
|
||||
<th>Добавил</th>
|
||||
<th>Дата</th>
|
||||
<th class="text-center">Действие</th>
|
||||
<th>Изменён</th>
|
||||
<th class="text-center">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -230,8 +281,33 @@
|
||||
<td><%= e.comment || '—' %></td>
|
||||
<td><%= e.created_by %></td>
|
||||
<td><%= new Date(e.created_at).toLocaleDateString('ru', {day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'}) %></td>
|
||||
<td class="text-center">
|
||||
<form method="POST" action="/delete/<%= e.id %>" style="display:inline" onsubmit="return confirm('Удалить запись?')">
|
||||
<!-- Дата последнего редактирования (null если не менялась) -->
|
||||
<td style="color:var(--muted);font-size:.8rem;">
|
||||
<%= e.updated_at
|
||||
? new Date(e.updated_at).toLocaleDateString('ru', {day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})
|
||||
: '—' %>
|
||||
</td>
|
||||
<td class="text-center" style="white-space:nowrap;">
|
||||
<%
|
||||
// data-* атрибуты для JS-заполнения modal без inline-onclick с параметрами.
|
||||
// Так как комментарий может содержать кавычки — EJS экранирует их в HTML.
|
||||
// Но в data-атрибуте JSON::stringify безопаснее.
|
||||
%>
|
||||
<!-- Кнопка редактирования: открывает modal и заполняет его данными из data-* -->
|
||||
<button class="btn"
|
||||
data-id="<%= e.id %>"
|
||||
data-value="<%= e.value_cidr %>"
|
||||
data-comment="<%= e.comment || '' %>"
|
||||
data-company="<%= companyPk || '' %>"
|
||||
onclick="openEditModal(this)"
|
||||
style="margin-right:.25rem;">
|
||||
Изменить
|
||||
</button>
|
||||
<!-- Форма удаления: company_id нужен для admin-ветки в server.js -->
|
||||
<form method="POST" action="/delete/<%= e.id %>"
|
||||
style="display:inline"
|
||||
onsubmit="return confirm('Удалить запись <%= e.value_cidr %>?')">
|
||||
<input type="hidden" name="company_id" value="<%= companyPk || '' %>">
|
||||
<button class="btn btn-danger">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
@@ -244,5 +320,83 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ══ Modal редактирования записи ══════════════════════════════════════════ -->
|
||||
<!--
|
||||
Модальное окно — фиксированный overlay поверх страницы.
|
||||
Открывается кнопкой "Изменить" в таблице (через JS openEditModal).
|
||||
Закрывается кнопкой "Отмена", кликом по backdrop или Escape.
|
||||
Данные заполняются из data-* атрибутов кнопки — никаких лишних fetch-запросов.
|
||||
-->
|
||||
<div id="edit-modal"
|
||||
style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;
|
||||
background:rgba(0,0,0,.4);z-index:200;align-items:center;justify-content:center;">
|
||||
<div style="background:#fff;border-radius:12px;padding:1.5rem;width:500px;
|
||||
max-width:92vw;box-shadow:0 8px 32px rgba(0,0,0,.18);">
|
||||
<h3 style="font-size:1rem;font-weight:600;margin-bottom:1.25rem;">Редактировать запись</h3>
|
||||
<form method="POST" id="edit-form">
|
||||
<!-- Скрытые поля: action (action='/edit/N') и company_id заполняет openEditModal() -->
|
||||
<input type="hidden" id="edit-company-id" name="company_id" value="">
|
||||
|
||||
<div class="field" style="margin-bottom:.75rem;">
|
||||
<label>IPv4 адрес или подсеть CIDR</label>
|
||||
<input id="edit-value" name="value"
|
||||
pattern="^(\d{1,3}\.){3}\d{1,3}(/\d{1,2})?$"
|
||||
maxlength="18"
|
||||
title="IPv4 адрес или CIDR /22–/32"
|
||||
required
|
||||
style="padding:.5rem .75rem;border:1px solid var(--border);border-radius:6px;font-size:.9rem;">
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:1.25rem;">
|
||||
<label>Комментарий</label>
|
||||
<input id="edit-comment" name="comment"
|
||||
maxlength="255"
|
||||
placeholder="Необязательно"
|
||||
style="padding:.5rem .75rem;border:1px solid var(--border);border-radius:6px;font-size:.9rem;">
|
||||
</div>
|
||||
<div style="display:flex;gap:.75rem;justify-content:flex-end;">
|
||||
<button type="button" class="btn" onclick="closeEditModal()">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Открыть modal редактирования.
|
||||
* Вызывается из onclick кнопки "Изменить" в таблице.
|
||||
* @param {HTMLElement} btn — кнопка с data-id, data-value, data-comment, data-company
|
||||
*/
|
||||
function openEditModal(btn) {
|
||||
// Заполняем поля из data-атрибутов (EJS уже HTML-экранировал значения)
|
||||
document.getElementById('edit-value').value = btn.dataset.value;
|
||||
document.getElementById('edit-comment').value = btn.dataset.comment;
|
||||
document.getElementById('edit-company-id').value = btn.dataset.company;
|
||||
// Устанавливаем action формы на конкретный /edit/:id
|
||||
document.getElementById('edit-form').action = '/edit/' + btn.dataset.id;
|
||||
// Показываем modal (display:flex для центрирования)
|
||||
const modal = document.getElementById('edit-modal');
|
||||
modal.style.display = 'flex';
|
||||
// Фокус на поле ввода для удобства клавиатурных пользователей
|
||||
setTimeout(() => document.getElementById('edit-value').focus(), 50);
|
||||
}
|
||||
|
||||
/** Закрыть modal без сохранения */
|
||||
function closeEditModal() {
|
||||
document.getElementById('edit-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Закрытие кликом по backdrop (за пределами карточки)
|
||||
document.getElementById('edit-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeEditModal();
|
||||
});
|
||||
|
||||
// Закрытие клавишей Escape
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeEditModal();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user