Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e6e22fadc | ||
|
|
7049b5aaa9 | ||
|
|
f709959478 | ||
|
|
d2c621cf38 | ||
|
|
400908985e | ||
|
|
2d164fb9ee | ||
|
|
c823ae8128 | ||
|
|
b1d670a6bf | ||
|
|
d72bb84d25 | ||
|
|
fb17c83e60 | ||
|
|
f60713f127 | ||
|
|
74f805682f | ||
|
|
060b20002c | ||
|
|
24fb8cdc56 | ||
|
|
710dcfc7c9 | ||
|
|
c50ef814e2 | ||
|
|
30001062d4 | ||
|
|
92f0314a20 | ||
|
|
fa75f8317d | ||
|
|
90b6c509c0 | ||
|
|
102f4279dc | ||
|
|
b7e62f0468 | ||
|
|
7f8aae0d62 | ||
|
|
6899bc40d3 | ||
|
|
9f44281f9f | ||
|
|
e68e1a483c | ||
|
|
4cd1ab8c8e | ||
|
|
10b890437d | ||
|
|
b24cd75a9e | ||
|
|
df0dd73451 | ||
|
|
8f9fc4e3ee | ||
|
|
9e3ba1c97c | ||
|
|
eba1b4974e | ||
|
|
8deea87996 | ||
|
|
a6d64e1e6c | ||
|
|
795a41496f | ||
|
|
08abab6e36 | ||
|
|
0e1f34c913 | ||
|
|
24e0e39782 | ||
|
|
c4d592dcbe | ||
|
|
86a39c7881 | ||
|
|
f0743c6bfe | ||
|
|
c5cd577561 | ||
|
|
5941899c9c | ||
|
|
ecf6638683 | ||
|
|
e440700a72 | ||
|
|
b41af5649f | ||
|
|
bf72cb49fc | ||
|
|
235da375c4 | ||
|
|
6660cb3c77 | ||
|
|
ee0cf68fe3 | ||
|
|
3493d45065 | ||
|
|
e2aaf3f425 | ||
|
|
f215fa8e32 | ||
|
|
6b994d0938 | ||
|
|
08e20c10ac | ||
|
|
1b043713a9 | ||
|
|
799406df78 | ||
|
|
bde68c00a1 | ||
|
|
02abab93d7 | ||
|
|
2233d3f94d | ||
|
|
cb1e4fac22 | ||
|
|
dc1f629560 | ||
|
|
b640e9e8e7 | ||
|
|
afcfc1420e | ||
|
|
07356acadc | ||
|
|
b04ef35b13 | ||
|
|
394c778f46 | ||
|
|
f5ee0a36de | ||
|
|
8fe89f3a0a | ||
|
|
6bff095a67 | ||
|
|
9ca0d087de | ||
|
|
11c3817376 | ||
|
|
ebcdb1ccb1 | ||
|
|
eb3c17cd68 | ||
|
|
eb7788235f | ||
|
|
8ab0bb10ac | ||
|
|
2545bc97f1 | ||
|
|
f00b7f3e87 | ||
|
|
a31b3e61f2 | ||
|
|
2f151f4cdf | ||
|
|
2e89a71afa | ||
|
|
9eba8859cf | ||
|
|
7fa6b43562 |
+15
@@ -0,0 +1,15 @@
|
||||
Доверенные адреса – защита вашего трафика во время DDoS-атак
|
||||
Когда на инфраструктуру провайдера идёт DDoS-атака, системы защиты автоматически фильтруют подозрительный трафик. Иногда под фильтрацию попадает и легитимный трафик, например, запросы из вашего офиса или от ваших партнёров.
|
||||
Чтобы этого не происходило, вы можете заранее указать адреса, которые провайдер гарантированно не заблокирует. Это называется списком доверенных адресов.
|
||||
Как пользоваться
|
||||
<тут можно вставить ссылку на сервис>
|
||||
Зайти в сервис необходимо с учетной записью keycloak.
|
||||
Добавить адрес. Нажмите «Добавить», введите IPv4-адрес или подсеть в формате CIDR и при желании — комментарий, чтобы не забыть, что это за адрес. Например: 203.0.113.10 или 203.0.113.0/24. Нажмите «Сохранить». Если вы вводите адрес хоста с маской подсети (например 203.0.113.5/24 вместо 203.0.113.0/24), сервис автоматически нормализует его до адреса подсети и сообщит вам об этом.
|
||||
Добавляйте комментарии к записям — через несколько месяцев вы скажете себе спасибо: будет сразу понятно, чей это адрес и зачем он добавлен.
|
||||
Редактировать или удалить. Рядом с каждой записью есть кнопки редактирования и удаления. Удалённые записи перестают действовать через 5 минут.
|
||||
Ограничения
|
||||
• Только публичные IPv4-адреса. Серые адреса (192.168.x.x, 10.x.x.x, 172.16–31.x.x и другие служебные диапазоны) добавить нельзя — они не маршрутизируются в интернете и смысла в их добавлении нет.
|
||||
• Только IPv4. IPv6-адреса и доменные имена не поддерживаются.
|
||||
• Максимальный размер подсети — /22. Добавить префикс шире /22 (то есть /21, /20 и т.д.) нельзя.
|
||||
• До 15 записей на компанию. Если вам нужно больше — обратитесь в поддержку.
|
||||
• Нет дублей и пересечений. Если вы попытаетесь добавить адрес, который уже покрывается существующей записью (или наоборот), сервис предупредит об этом.
|
||||
@@ -0,0 +1,65 @@
|
||||
# IAM — переменные окружения
|
||||
|
||||
> Дата: 2026-06-17
|
||||
> Путь: `src/config.js`, `v2/src/config/index.js`
|
||||
|
||||
## IAM_API_URL (основная, приоритет выше)
|
||||
|
||||
**Где используется:** `src/config.js` → `src/auth.js` (fetchIamUser, switchProfile)
|
||||
|
||||
**Формат:** `https://auth-api.ngcloud.ru` (без /api/v1)
|
||||
|
||||
Код добавляет `/api/v1/auth/user` автоматически.
|
||||
|
||||
**Назначение:** получение профилей пользователя, переключение компании.
|
||||
|
||||
**Дефолт:** `https://auth-api.ngcloud.ru`
|
||||
|
||||
**Пример:**
|
||||
- IAM_API_URL=https://auth-api.ngcloud.ru
|
||||
- Фактический путь: https://auth-api.ngcloud.ru/api/v1/auth/user
|
||||
|
||||
## IAM_API_BASE (запасная, приоритет ниже)
|
||||
|
||||
**Где используется:** `v2/src/config/index.js` → `v2/server.js` (тестовые страницы)
|
||||
|
||||
**Формат:** `https://lk-api-gateway.ngcloud.ru/api/v1/iam`
|
||||
|
||||
Код НЕ добавляет /api/v1, путь должен быть полный.
|
||||
|
||||
**Назначение:** тестовые страницы (/v2/iam-gateway-test), заглушка пока IAM не починят.
|
||||
|
||||
**Дефолт:** берётся из IAM_API_URL
|
||||
|
||||
**Пример:**
|
||||
- IAM_API_BASE=https://lk-api-gateway.ngcloud.ru/api/v1/iam
|
||||
- Фактический путь: https://lk-api-gateway.ngcloud.ru/api/v1/iam/auth/user
|
||||
|
||||
## Приоритет в src/config.js
|
||||
|
||||
```
|
||||
process.env.IAM_API_URL || process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru'
|
||||
```
|
||||
|
||||
1. IAM_API_URL — явно задан (auth-api)
|
||||
2. IAM_API_BASE — запасной (lk-api-gateway)
|
||||
3. Дефолт — auth-api.ngcloud.ru
|
||||
|
||||
## Когда что использовать
|
||||
|
||||
| Ситуация | Какая переменная | Какой API |
|
||||
|----------|-----------------|-----------|
|
||||
| Обычная работа (логин, CRUD, компании) | IAM_API_URL | auth-api.ngcloud.ru |
|
||||
| Имперсонация (админ работает от имени юзера) | IAM_API_BASE | lk-api-gateway.ngcloud.ru |
|
||||
| Когда IAM починят | IAM_API_URL | auth-api.ngcloud.ru (везде) |
|
||||
|
||||
## Текущий статус
|
||||
|
||||
- auth-api.ngcloud.ru — не отдаёт profiles таргета при имперсонации (баг разработчиков IAM)
|
||||
- lk-api-gateway.ngcloud.ru — работает, отдаёт profiles таргета, НО не принимает токен от Keycloak whitelist (только от ЛК)
|
||||
- Когда IAM починят: удалить IAM_API_BASE из кода и Nubes UI, оставить только IAM_API_URL
|
||||
|
||||
## Nubes UI (текущие переменные)
|
||||
|
||||
- IAM_API_URL=https://auth-api.ngcloud.ru
|
||||
- IAM_API_BASE=https://lk-api-gateway.ngcloud.ru/api/v1/iam
|
||||
@@ -0,0 +1,83 @@
|
||||
# IAM Impersonation API
|
||||
|
||||
> Источник: Swagger `https://auth-api-dev.ngcloud.ru/api/v1/documentation/`
|
||||
> Дата: 2026-06-16
|
||||
|
||||
## Все эндпоинты имперсонации
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|-------|------|----------|
|
||||
| `POST` | `/api/v1/impersonation/start` | Начать имперсонацию |
|
||||
| `POST` | `/api/v1/impersonation/end` | Завершить имперсонацию |
|
||||
| `POST` | `/api/v1/impersonation/extend` | Продлить сессию имперсонации |
|
||||
| `GET` | `/api/v1/impersonation/status` | Статус текущей имперсонации |
|
||||
| `GET` | `/api/v1/impersonation/history` | История своих имперсонаций |
|
||||
| `GET` | `/api/v1/impersonation/history/all` | История имперсонаций всех админов |
|
||||
|
||||
## Формат ImpersonationStartRequest
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "user",
|
||||
"entity_id": "UUID контакта (contactId из userInfo)",
|
||||
"reason": "описание причины"
|
||||
}
|
||||
```
|
||||
|
||||
- `entity_id` — **UUID таргета** (кого имперсонируем), не свой
|
||||
- `type` — всегда `"user"`
|
||||
|
||||
## curl-команды
|
||||
|
||||
### Начать имперсонацию
|
||||
|
||||
```bash
|
||||
curl --http2 -X POST \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
"https://auth-api.ngcloud.ru/api/v1/impersonation/start" \
|
||||
-d '{"type":"user","entity_id":"UUID_таргета","reason":"тест"}'
|
||||
```
|
||||
|
||||
### Статус
|
||||
|
||||
```bash
|
||||
curl --http2 \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: application/json" \
|
||||
"https://auth-api.ngcloud.ru/api/v1/impersonation/status"
|
||||
```
|
||||
|
||||
### История
|
||||
|
||||
```bash
|
||||
curl --http2 \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: application/json" \
|
||||
"https://auth-api.ngcloud.ru/api/v1/impersonation/history"
|
||||
```
|
||||
|
||||
### Продлить
|
||||
|
||||
```bash
|
||||
curl --http2 -X POST \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: application/json" \
|
||||
"https://auth-api.ngcloud.ru/api/v1/impersonation/extend"
|
||||
```
|
||||
|
||||
### Завершить
|
||||
|
||||
```bash
|
||||
curl --http2 -X POST \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: application/json" \
|
||||
"https://auth-api.ngcloud.ru/api/v1/impersonation/end"
|
||||
```
|
||||
|
||||
## Примечания
|
||||
|
||||
- Требуется роль IAM-админа или право на объект `impersonation`
|
||||
- `--http2` обязателен для обхода ddos-guard
|
||||
- Стенды: `auth-api.ngcloud.ru` (prod), `auth-api-test.ngcloud.ru` (test), `auth-api-dev.ngcloud.ru` (dev)
|
||||
@@ -0,0 +1,76 @@
|
||||
# Баг: переключение компании — таблица IP не обновляется
|
||||
|
||||
## Симптом
|
||||
GET /v2/app?switchTo=WZ30001 → таблица показывает записи старой компании.
|
||||
|
||||
## Найденная причина №1 (основная)
|
||||
Порядок кода в `v2/src/user/index.js`:
|
||||
|
||||
```js
|
||||
// 1. clId = req.clientId ← resolveContext выставил СТАРЫЙ activeClientId
|
||||
const clId = req.clientId;
|
||||
|
||||
// 2. crud.list() по СТАРОМУ clId — ещё до switchTo
|
||||
const { entries, used, limit } = await crud.list(clId, includeDeleted);
|
||||
|
||||
// 3. Потом switchTo обновляет сессию и делает redirect
|
||||
if (req.query.switchTo) {
|
||||
req.session.user.activeClientId = targetId;
|
||||
return req.session.save(() => res.redirect('/v2/app'));
|
||||
}
|
||||
```
|
||||
|
||||
Итог: первый запрос с ?switchTo загружает записи СТАРОЙ компании.
|
||||
Второй запрос (редирект /v2/app) должен уже использовать новый activeClientId — если ничто его не затирает.
|
||||
|
||||
## Найденная причина №2 (если ENV заданы)
|
||||
`v2/src/impersonation/index.js` — при тестовом режиме (IMPERSONATION_TARGET задан):
|
||||
|
||||
```js
|
||||
u.activeClientId = MAIN_COMPANY; // ← затирает то что записал switchTo
|
||||
```
|
||||
|
||||
Middleware запускается при КАЖДОМ запросе (включая редирект после switchTo)
|
||||
и сбрасывает activeClientId обратно в IMPERSONATION_COMPANY.
|
||||
|
||||
## Что проверить
|
||||
```bash
|
||||
echo $IMPERSONATION_COMPANY
|
||||
echo $IMPERSONATION_TARGET
|
||||
echo $IMPERSONATION_ORIGINAL
|
||||
```
|
||||
|
||||
Если заданы → причина №2 (middleware затирает сессию).
|
||||
Если не заданы → причина №1 (но тогда второй запрос после редиректа должен работать).
|
||||
|
||||
## Фикс причины №1
|
||||
В `v2/src/user/index.js` — переставить проверку switchTo ПЕРЕД crud.list():
|
||||
|
||||
```js
|
||||
// Сначала switchTo
|
||||
if (req.query.switchTo) {
|
||||
const targetId = req.query.switchTo;
|
||||
const allowed = isAdmin || (req.profiles || []).find(p => p.client_id === targetId);
|
||||
if (allowed) {
|
||||
req.session.user.activeClientId = targetId;
|
||||
return req.session.save(err => {
|
||||
if (err) console.error('[switchTo] save error:', err.message);
|
||||
res.redirect('/v2/app');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Потом загружать записи
|
||||
const { entries, used, limit } = await crud.list(clId, includeDeleted);
|
||||
```
|
||||
|
||||
## Фикс причины №2
|
||||
В `v2/src/impersonation/index.js` — не перезаписывать activeClientId если он уже есть:
|
||||
|
||||
```js
|
||||
// Вместо:
|
||||
u.activeClientId = MAIN_COMPANY;
|
||||
|
||||
// Сделать:
|
||||
u.activeClientId = u.activeClientId || MAIN_COMPANY;
|
||||
```
|
||||
@@ -195,6 +195,10 @@ IAM (Identity & Access Management) — сервис авторизации эк
|
||||
| 04.06.2026 | **IAM API — источник компаний.** `GET /auth/user` → `profiles[]`. `isAdmin` из `userInfo.isAdmin`. Переключение через `POST /switch-profile`. |
|
||||
| 04.06.2026 | ddos-guard: curl требует `--http2`, Node.js работает |
|
||||
|
||||
| 16.06.2026 | **UI: кнопка Показать/Скрыть удалённые.** Вместо чекбокса — кнопка-переключатель. |
|
||||
| 16.06.2026 | **UI: восстановление удалённых записей.** Кнопка ↺ у удалённых, `POST /restore/:id`. Лимит проверяется. Аудит: RESTORE. |
|
||||
| 16.06.2026 | **UI: время МСК.** `toLocaleString('ru', {timeZone:'Europe/Moscow'})` + метка «МСК». |
|
||||
|
||||
---
|
||||
|
||||
## Легенда статусов
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ipwhitelist",
|
||||
"version": "0.6.20",
|
||||
"version": "0.5.163",
|
||||
"description": "IP WhiteList microservice for cloud provider",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -136,11 +136,12 @@ async function start() {
|
||||
// ── UI-слой: SSR через EJS, данные из /api/v1/* ───────────────────────────
|
||||
// Монтируется ПОСЛЕ /api/v1/ — не перехватывает API-запросы.
|
||||
// ── V2 — тестовый роутер (Keycloak → IAM → вывод) ─────────────────
|
||||
// Редирект корня на V2
|
||||
app.get('/', (req, res) => res.redirect('/v2/app'));
|
||||
const { createV2Router } = require('./v2/server');
|
||||
app.use('/', createV2Router());
|
||||
app.use('/v2', createV2Router());
|
||||
// Хранит Bearer token в сессии, рендерит те же views/*.ejs.
|
||||
// ── Старый UI (заменён на v2) ────────────────────────────────────────
|
||||
// app.use('/', createUiRouter({ auth, MOCK_USERS, authLimiter }));
|
||||
app.use('/', createUiRouter({ auth, MOCK_USERS, authLimiter }));
|
||||
|
||||
// ── Общий обработчик ошибок ───────────────────────────────────────────────
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
|
||||
+4
-3
@@ -25,7 +25,8 @@ const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { IAM_API_URL } = require('./config');
|
||||
const { iamApiBase } = require('../v2/src/config');
|
||||
const IAM_API_URL = iamApiBase;
|
||||
|
||||
// ── Конфигурация из окружения ────────────────────────────────────────────────
|
||||
const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api';
|
||||
@@ -286,7 +287,7 @@ function verifyAnyToken(token, devMode) {
|
||||
*/
|
||||
async function fetchIamUser(token) {
|
||||
const raw = await new Promise((resolve, reject) => {
|
||||
const url = new URL(IAM_API_URL + '/api/v1/auth/user');
|
||||
const url = new URL(IAM_API_URL + '/auth/user');
|
||||
const lib = url.protocol === 'https:' ? https : http;
|
||||
const req = lib.request({
|
||||
hostname: url.hostname,
|
||||
@@ -343,7 +344,7 @@ async function fetchIamUser(token) {
|
||||
async function switchProfile(token, profileId) {
|
||||
const body = JSON.stringify({ profile_id: profileId });
|
||||
const raw = await new Promise((resolve, reject) => {
|
||||
const url = new URL(IAM_API_URL + '/api/v1/user/switch-profile');
|
||||
const url = new URL(IAM_API_URL + '/user/switch-profile');
|
||||
const lib = url.protocol === 'https:' ? https : http;
|
||||
const req = lib.request({
|
||||
hostname: url.hostname,
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@
|
||||
// ── IAM API ───────────────────────────────────────────────────────────────────
|
||||
// URL IAM-сервиса для получения профилей пользователя (GET /api/v1/auth/user)
|
||||
// и переключения активной компании (POST /api/v1/user/switch-profile).
|
||||
const IAM_API_URL = (process.env.IAM_API_URL || 'https://auth-api-dev.ngcloud.ru').replace(/\/$/, '');
|
||||
const IAM_API_URL = (process.env.IAM_API_URL || process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru').replace(/\/$/, '');
|
||||
|
||||
// ── Мок-пользователи (только dev/staging) ─────────────────────────────────────
|
||||
// В продакшене этот список не используется — вход через Keycloak.
|
||||
@@ -94,4 +94,4 @@ function backUrl(isAdmin, companyId, extra = {}) {
|
||||
// Передаётся в шаблоны через app.locals чтобы отображаться в UI.
|
||||
const { version: APP_VERSION } = require('../package.json');
|
||||
|
||||
module.exports = { MOCK_USERS, backUrl, APP_VERSION, IAM_API_URL };
|
||||
module.exports = { MOCK_USERS, backUrl, APP_VERSION, IAM_API_URL: (process.env.IAM_API_URL || process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru').replace(/\/$/, '') };
|
||||
|
||||
@@ -26,6 +26,7 @@ const CSP_DIRECTIVES = {
|
||||
defaultSrc: ["'self'"],
|
||||
// 'unsafe-inline' нужен пока стили и скрипты inline в EJS.
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrcAttr: ["'unsafe-inline'"], // для onchange/onclick в EJS
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:'], // data: для SVG-иконок
|
||||
fontSrc: ["'self'"],
|
||||
|
||||
+2
-1
@@ -74,6 +74,7 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, authLimit
|
||||
isAdmin: iamData.isAdmin,
|
||||
isImpersonated: iamData.isImpersonated,
|
||||
impersonationType: iamData.impersonationType,
|
||||
impersonatedCompanyId: iamData.impersonatedCompanyId,
|
||||
originalUserEmail: iamData.originalUserEmail,
|
||||
originalUserFullName: iamData.originalUserFullName,
|
||||
originalUserCompany: iamData.originalUserCompany,
|
||||
@@ -99,7 +100,7 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, authLimit
|
||||
};
|
||||
}
|
||||
|
||||
const returnTo = safeLocal(req.session.oidcReturnTo) || '/';
|
||||
const returnTo = '/v2/app';
|
||||
delete req.session.oidcReturnTo;
|
||||
res.redirect(returnTo);
|
||||
} catch (e) {
|
||||
|
||||
@@ -21,7 +21,7 @@ const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const IAM_API_URL = (process.env.IAM_API_URL || 'https://auth-api.ngcloud.ru').replace(/\/$/, '');
|
||||
const IAM_API_URL = (process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru').replace(/\/$/, '');
|
||||
const APP_URL = (process.env.APP_URL || 'https://italo.kube5s.ru').replace(/\/$/, '');
|
||||
|
||||
// ── Парсинг файла с токенами ────────────────────────────────────────────────
|
||||
|
||||
@@ -832,173 +832,3 @@ express HTTP
|
||||
| 1 | Интеграция в основной код | ✅ 0.5.98 |
|
||||
| 2 | Auth (KC callback) | ⏸ заблокирован облаком |
|
||||
| 3 | Убрать старые v2_* таблицы из БД | ⏸ после деплоя |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Интеграция v2 в основной проект (0.6.0–0.6.8, 2026-06-15)
|
||||
|
||||
### Что сделано
|
||||
|
||||
| Версия | Что |
|
||||
|--------|-----|
|
||||
| 0.5.98 | Таблицы `v2_*` → `companies`, `whitelist_entries`, `audit_log` |
|
||||
| 0.5.98 | `impersonation/` модуль — добавление компаний через ENV |
|
||||
| 0.5.99 | V2 как основной UI: `app.use('/', createV2Router())`, старый UI закомментирован |
|
||||
| 0.6.0 | Дизайн Nubes: лого, header, стили из `views/index.ejs` |
|
||||
| 0.6.1 | Фикс: email в header |
|
||||
| 0.6.2 | `displayEmail` справа от статистики |
|
||||
| 0.6.3 | Жёлтый баннер имперсонации вместо метки `(имперсонация)` |
|
||||
| 0.6.4 | Авто-имперсонация: `IMPERSONATION_ORIGINAL → TARGET` |
|
||||
| 0.6.4 | `ADMIN_EMAIL` в `resolveContext` |
|
||||
| 0.6.5 | `loginEmail` из `originalUserEmail` |
|
||||
| 0.6.6 | `req.displayEmail` — разделение экран/аудит |
|
||||
| 0.6.7 | **Перепутаны местами**: `req.email` и `req.impersonatedBy` |
|
||||
| 0.6.8 | «Выйти» — только реальный KC-юзер |
|
||||
|
||||
### Ключевая ошибка: перепутаны `req.email` и `req.impersonatedBy`
|
||||
|
||||
**Что было неправильно (0.6.6 и ранее):**
|
||||
|
||||
```js
|
||||
req.email = реальный → created_by = ntazetdinov@nubes.ru ❌
|
||||
req.impersonatedBy = имперсонированный → impersonated_by = tazet@narod.ru ❌
|
||||
```
|
||||
|
||||
**Почему ошибался:** AI думал что `req.email` должно хранить реального юзера «для аудита»,
|
||||
а имперсонированного — для показа. Это неверно. Везде должен использоваться ТЕКУЩИЙ
|
||||
юзер (имперсонированный если есть имперсонация), а реальный — ТОЛЬКО в колонке
|
||||
`impersonated_by` таблицы аудита.
|
||||
|
||||
**Как правильно (0.6.7+):**
|
||||
|
||||
```js
|
||||
req.email = ТЕКУЩИЙ → created_by = tazet@narod.ru ✅
|
||||
req.impersonatedBy = РЕАЛЬНЫЙ → impersonated_by = ntazetdinov@... ✅
|
||||
```
|
||||
|
||||
**Урок:** `req.email` — это ВСЕГДА текущий юзер. Имперсонация не меняет его смысл —
|
||||
она меняет значение `session.user.email`, и `req.email` просто берёт его.
|
||||
|
||||
### Правильная логика (0.6.8)
|
||||
|
||||
```
|
||||
KC логин: ntazetdinov@nubes.ru
|
||||
↓
|
||||
session.user.email = ntazetdinov@nubes.ru
|
||||
↓
|
||||
enhanceImpersonation: ORIGINAL совпал → авто-имперсонация
|
||||
session.user.originalUserEmail = ntazetdinov@nubes.ru (реальный)
|
||||
session.user.email = tazet@narod.ru (текущий)
|
||||
↓
|
||||
resolveContext:
|
||||
req.email = u.email = tazet@narod.ru (текущий — created_by, экран)
|
||||
req.impersonatedBy = u.originalUserEmail (реальный — только аудит)
|
||||
req.isImpersonated = true
|
||||
↓
|
||||
user/index.js → шаблон:
|
||||
loginEmail = ntazetdinov@nubes.ru → «Выйти»
|
||||
email = tazet@narod.ru → контент, статистика
|
||||
```
|
||||
|
||||
### Переменные ENV для авто-имперсонации
|
||||
|
||||
| Переменная | Значение | Роль |
|
||||
|-----------|---------|------|
|
||||
| `IMPERSONATION_ORIGINAL` | `ntazetdinov@nubes.ru` | Условие: чей email сравнить |
|
||||
| `IMPERSONATION_TARGET` | `tazet@narod.ru` | Цель: в кого имперсонировать |
|
||||
| `IMPERSONATION_COMPANY` | `WZ01325` | Компания: какая компания |
|
||||
| `IMPERSONATION_EXTRA_COMPANIES` | `WZ01112` | Доп. компании |
|
||||
| `ADMIN_EMAIL` | `tazet@narod.ru` | Псевдо-админ |
|
||||
|
||||
Все три (`ORIGINAL`, `TARGET`, `COMPANY`) должны быть заданы — иначе авто-имперсонация не включается.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## IAM API — документация (2026-06-15)
|
||||
|
||||
### Источник: https://auth-api-dev.ngcloud.ru/api/v1/documentation/
|
||||
|
||||
### GET /api/v1/auth/user — ответ (AuthUserResult)
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": "uuid",
|
||||
"owner": false,
|
||||
"isPortal": false,
|
||||
"sessionId": "uuid",
|
||||
"privileges": ["system"],
|
||||
"needChangePassword": false,
|
||||
"impersonation": {
|
||||
"is_impersonated": false, // ВСЕГДА есть
|
||||
"type": "user" | "company", // если активна
|
||||
"originalUserEmail": "admin@...", // кто имперсонирует
|
||||
"impersonatedCompanyId": "uuid", // если type=company
|
||||
"impersonatedUserId": "uuid", // если type=user
|
||||
"sessionId": 42,
|
||||
"session_expires_at": "RFC3339"
|
||||
},
|
||||
"permissions": {
|
||||
"read_only_mode_enabled": false,
|
||||
"can_write": true,
|
||||
"is_impersonating": false,
|
||||
"has_write_permissions": true
|
||||
},
|
||||
"userInfo": {
|
||||
"email": "user@example.com", // required
|
||||
"login": "login",
|
||||
"clientID": "WZ01325", // required (может быть "")
|
||||
"company": "ООО Пример",
|
||||
"companyId": "uuid",
|
||||
"companyNumericId": 1,
|
||||
"isAdmin": false, // required
|
||||
"fio": { "fullName": "...", "name": "...", "surname": "...", "secondName": "..." },
|
||||
"profiles": [ // массив, может быть пустым
|
||||
{
|
||||
"id": 123, // required
|
||||
"company_id": "uuid", // required
|
||||
"company_name": "ООО Пример", // required
|
||||
"client_id": "WZ01325", // НЕ required — может отсутствовать!
|
||||
"is_active_profile": true // required
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ключевые факты
|
||||
|
||||
| Поле | Обязательное? | Может быть пустым? |
|
||||
|------|-------------|-------------------|
|
||||
| `userInfo.email` | ✅ required | — |
|
||||
| `userInfo.clientID` | ✅ required | ⚠️ может быть `""` |
|
||||
| `userInfo.isAdmin` | ✅ required | — |
|
||||
| `userInfo.profiles` | массив | может быть `[]` |
|
||||
| `profiles[].client_id` | ❌ не required | может отсутствовать |
|
||||
| `profiles[].company_name` | ✅ required | — |
|
||||
| `impersonation.is_impersonated` | ✅ required | — |
|
||||
|
||||
### Выводы для нашего кода
|
||||
|
||||
1. **`clientID` required, но может быть `""`** — для сотрудников Nubes без компании-клиента.
|
||||
Именно это происходит с `ntazetdinov@nubes.ru`.
|
||||
2. **`profiles` может быть пустым `[]`** — нет компаний → нет client_id.
|
||||
3. **IAM имеет нативную имперсонацию** — `POST /api/v1/impersonation/start`.
|
||||
Но доступно только админам IAM. `ntazetdinov@nubes.ru` — не админ IAM.
|
||||
4. **Наш `enhanceImpersonation`** — нужен как обходной путь: ENV-переменные вместо IAM-админки.
|
||||
|
||||
### Что должно происходить
|
||||
|
||||
```
|
||||
ntazetdinov@nubes.ru логинится через KC
|
||||
→ IAM: { email: "ntazetdinov@nubes.ru", clientID: "", profiles: [], isAdmin: false }
|
||||
→ fetchIamUser: clientId = ""
|
||||
→ сессия без clientId
|
||||
→ enhanceImpersonation: ORIGINAL совпал → авто-имперсонация
|
||||
email = "tazet@narod.ru"
|
||||
clientId = "WZ01325"
|
||||
allClientIds = ["WZ01325", "WZ01112"]
|
||||
→ resolveContext: req.clientId = "WZ01325" ✅
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# V2 Unified Interface — v0.5.102 (2026-06-15)
|
||||
|
||||
**Что сделано:** объединение юзерского и админского интерфейсов в один `/v2/app`.
|
||||
|
||||
## Изменённые файлы
|
||||
|
||||
### `v2/src/user/index.js` — единый роутер
|
||||
- GET `/` — для юзеров и админов
|
||||
- Админ: dropdown = все компании из БД (`getAllCompanies()`), по умолчанию своя
|
||||
- Юзер: dropdown = свои профили из IAM, по умолчанию активный
|
||||
- `?switchTo=W***` — смена компании: админ → любая, юзер → только свои
|
||||
- CRUD без изменений (add/edit/delete через `req.clientId`)
|
||||
|
||||
### `views/v2/user.ejs` — единый шаблон
|
||||
- Дизайн из оригинала: лого Nubes, CSS-переменные, card-раскладка
|
||||
- Хедер: лого | "IP WhiteList v{VERSION}" | email (админ) | Выйти (реальный юзер)
|
||||
- Dropdown компаний + таблица IP с inline CRUD
|
||||
- Статистика, форма добавления, поддержка удалённых записей
|
||||
|
||||
### `v2/server.js`
|
||||
- `/v2/admin` → 302 на `/v2/app`
|
||||
- `toggle-admin` → всегда редирект на `/v2/app`
|
||||
- Убран импорт `createAdminRouter`
|
||||
|
||||
### Версии
|
||||
- `v2/src/config/index.js`: 0.5.98 → 0.5.99
|
||||
- `package.json`: 0.5.101 → 0.5.102
|
||||
|
||||
## Синтаксис
|
||||
`node -c` — OK на всех изменённых файлах.
|
||||
@@ -0,0 +1,53 @@
|
||||
# V2 Unified Architecture — v0.5.105 (2026-06-15)
|
||||
|
||||
## Как устроено
|
||||
|
||||
```
|
||||
/v2/app — единый интерфейс
|
||||
│
|
||||
├── Юзер (isAdmin=false)
|
||||
│ └── dropdown: свои компании из IAM (profiles[])
|
||||
│
|
||||
├── Админ (WZ01112, isAdmin=true)
|
||||
│ ├── dropdown: все компании из БД
|
||||
│ ├── свои помечены (личное)
|
||||
│ └── email помечен (админ)
|
||||
│
|
||||
└── CRUD — одинаковый для обоих
|
||||
└── crud.list/add/edit/remove(clientId)
|
||||
```
|
||||
|
||||
## Слои
|
||||
|
||||
| Слой | Файл | Что |
|
||||
|------|------|-----|
|
||||
| CRUD API | `v2/src/crud/index.js` | Чистые функции, clientId → БД |
|
||||
| SQL | `v2/src/db/queries.js` | Все запросы, транзакции |
|
||||
| Роутер | `v2/src/user/index.js` | Express, сессия → crud |
|
||||
| Шаблон | `views/v2/user.ejs` | EJS, единый для всех |
|
||||
|
||||
## Компании
|
||||
|
||||
- `clientId` (W-номер) → `company_id` (внутренний PK) через `getOrCreateCompany`
|
||||
- По умолчанию: активный профиль из IAM (`is_active_profile=true`)
|
||||
- Админ может переключиться на любую компанию (`?switchTo=W***`)
|
||||
- Юзер — только на свои
|
||||
|
||||
## История версий (новые сверху)
|
||||
|
||||
- **0.5.139** — ширина столбцов: CIDR 125px, комментарий широкий, Правка вместо Редактирование
|
||||
- **0.5.138** — CSP script-src-attr 'unsafe-inline' → onchange заработал
|
||||
- **0.5.137** — onchange + console.log для проверки
|
||||
- **0.5.136** — ↺ зелёная, deleted сохраняется после restore/delete
|
||||
- **0.5.135** — UI таблицы: разделение столбцов, 💾, размеры
|
||||
- **0.5.134** — восстановление записей (restore), кнопка удалённых, время МСК
|
||||
- **0.5.133** — type=submit на кнопке удаления
|
||||
- **0.5.132** — ADMIN_EMAIL в resolveContext для всех
|
||||
- **0.5.131** — isAdmin при имперсонации по ADMIN_EMAIL
|
||||
- **0.5.130** — имперсонация не сохраняет isAdmin от оригинала
|
||||
- **0.5.129** — кнопка Выбрать, убран debug
|
||||
- **0.5.127** — clId обновляется после switchTo (фикс бага)
|
||||
- **0.5.105** — убран toggle Администратор
|
||||
- **0.5.104** — (личное) у своих компаний в dropdown
|
||||
- **0.5.103** — WZ01112: adminMode auto-on
|
||||
- **0.5.102** — единый интерфейс /v2/app (юзер+админ)
|
||||
+213
-14
@@ -5,7 +5,7 @@
|
||||
// ЗАЧЕМ: единственная точка входа v2 в основное приложение.
|
||||
//
|
||||
// МОНТАЖ В ОСНОВНОМ server.js:
|
||||
// const { createV2Router } = require('./server');
|
||||
// const { createV2Router } = require('./v2/server');
|
||||
// app.use('/v2', createV2Router());
|
||||
//
|
||||
// ПОРЯДОК МАРШРУТОВ (важен!):
|
||||
@@ -38,44 +38,184 @@ function createV2Router() {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ── GET /login-v2 — редирект на основной вход (KC OIDC) ─────────────
|
||||
// ── GET /v2/login — редирект на основной вход (KC OIDC) ─────────────
|
||||
router.get('/login', (req, res) => {
|
||||
res.redirect('/login?returnTo=' + encodeURIComponent('/app'));
|
||||
res.redirect('/login?returnTo=' + encodeURIComponent('/v2/app'));
|
||||
});
|
||||
|
||||
// ── GET /iam — отладка: показать IAM-данные из сессии ────────────
|
||||
// ── GET /v2/iam — отладка: показать IAM-данные из сессии ────────────
|
||||
router.get('/iam', (req, res) => {
|
||||
const u = req.session && req.session.user;
|
||||
if (!u) return res.send('<h2>Нет данных</h2><p><a href="/login-v2">Войти</a></p>');
|
||||
if (!u) return res.redirect('/v2/login');
|
||||
res.send(debugPage(u, config.version));
|
||||
});
|
||||
|
||||
// ── /app — пользовательский CRUD (только с сессией) ──────────────
|
||||
// ── GET /v2/iam_response — сырой ответ IAM ─────────────────────────
|
||||
router.get('/iam_response', async (req, res) => {
|
||||
const token = req.session && req.session.token;
|
||||
if (!token) return res.redirect('/v2/login');
|
||||
try {
|
||||
const mainAuth = require('../src/auth');
|
||||
const iamData = await mainAuth.fetchIamUser(token);
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(rawPage('IAM PROD', iamData.raw));
|
||||
} catch (e) {
|
||||
res.send('<h2>Ошибка IAM</h2><pre>' + e.message + '</pre>');
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /v2/iam_response_test — IAM test стенд ──────────────────────
|
||||
router.get('/iam_response_test', async (req, res) => {
|
||||
const token = req.session && req.session.token;
|
||||
if (!token) return res.redirect('/v2/login');
|
||||
try {
|
||||
const raw = await iamFetch(token, config.iamApiBase + '/auth/user');
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(rawPage('IAM TEST', raw));
|
||||
} catch (e) {
|
||||
res.send('<h2>Ошибка IAM Test</h2><pre>' + e.message + '</pre>');
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /v2/iam_response_dev — IAM dev стенд ────────────────────────
|
||||
router.get('/iam_response_dev', async (req, res) => {
|
||||
const token = req.session && req.session.token;
|
||||
if (!token) return res.redirect('/v2/login');
|
||||
try {
|
||||
const raw = await iamFetch(token, config.iamApiBase + '/auth/user');
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(rawPage('IAM DEV', raw));
|
||||
} catch (e) {
|
||||
res.send('<h2>Ошибка IAM Dev</h2><pre>' + e.message + '</pre>');
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /v2/iam-gateway-test — сравнение auth-api vs lk-api-gateway ──
|
||||
router.get('/iam-gateway-test', async (req, res) => {
|
||||
const token = req.session && req.session.token;
|
||||
if (!token) return res.redirect('/v2/login');
|
||||
|
||||
const base = config.iamApiBase;
|
||||
const tests = [
|
||||
{
|
||||
name: 'auth-api /auth/user',
|
||||
url: base + '/auth/user',
|
||||
},
|
||||
{
|
||||
name: 'lk-api-gateway /iam/auth/user',
|
||||
url: 'https://lk-api-gateway.ngcloud.ru/api/v1/iam/auth/user',
|
||||
},
|
||||
{
|
||||
name: 'auth-api /impersonation/status',
|
||||
url: base + '/impersonation/status',
|
||||
},
|
||||
];
|
||||
|
||||
const results = {};
|
||||
for (const t of tests) {
|
||||
try {
|
||||
const data = await iamFetch(token, t.url);
|
||||
results[t.name] = { ok: true, data };
|
||||
} catch (e) {
|
||||
results[t.name] = { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(iamComparePage(results));
|
||||
});
|
||||
|
||||
// ── /v2/app — пользовательский CRUD (только с сессией) ──────────────
|
||||
router.use('/app', enhanceImpersonation, resolveContext, createUserRouter());
|
||||
|
||||
// ── /admin — админка (только для isAdmin=true) ────────────────────
|
||||
// ── /v2/admin — админка: лимиты + аудит ──────────────────────────
|
||||
router.use('/admin', enhanceImpersonation, resolveContext, createAdminRouter());
|
||||
|
||||
// ── GET / — редирект на /app ──────────────────────────────────
|
||||
router.get('/', (req, res) => res.redirect('/app'));
|
||||
// ── GET /v2/ — редирект на /v2/app ──────────────────────────────────
|
||||
router.get('/', (req, res) => res.redirect('/v2/app'));
|
||||
|
||||
// ── /test — тестовый API (отключить: ENABLE_TEST_API=false) ──────
|
||||
// ── /v2/test — тестовый API (отключить: ENABLE_TEST_API=false) ──────
|
||||
// Код остаётся, роутер не монтируется при ENABLE_TEST_API=false.
|
||||
if (process.env.ENABLE_TEST_API !== 'false') {
|
||||
const { createTestRouter } = require('./src/test');
|
||||
router.use('/test', createTestRouter());
|
||||
}
|
||||
|
||||
// ── /export — выгрузка CIDR (публичный, без авторизации) ──────
|
||||
// ── /v2/export — выгрузка CIDR (публичный, без авторизации) ──────
|
||||
const { createExportRouter } = require('./src/export');
|
||||
router.use('/export', createExportRouter());
|
||||
|
||||
// ── GET /logout — редирект на основной выход ─────────────────────
|
||||
router.get('/logout', (req, res) => res.redirect('/logout'));
|
||||
// ── GET /v2/logout — выход только из whitelist (без KC SSO) ─────────
|
||||
router.get('/logout', (req, res) => {
|
||||
const ua = (req.headers['user-agent'] || '').toLowerCase();
|
||||
const isBrowser = ua.includes('mozilla') || ua.includes('chrome') || ua.includes('safari');
|
||||
if (!isBrowser) {
|
||||
req.session.destroy(() => res.redirect('/v2/login'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.query.confirm === 'yes') {
|
||||
req.session.destroy(() => {
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send('<!DOCTYPE html><html><head><meta charset="utf-8"><title>Выход</title>'
|
||||
+ '<style>body{font-family:sans-serif;background:#111;color:#eee;padding:60px;text-align:center}'
|
||||
+ 'h2{color:#4fc3f7}p{font-size:18px;margin:20px 0}a{color:#80cbc4;font-size:16px}'
|
||||
+ '</style></head><body>'
|
||||
+ '<h2>Вы вышли из сервиса</h2>'
|
||||
+ '<p>Сессия whitelist завершена.<br>Сессия в других сервисах Nubes сохранена.</p>'
|
||||
+ '<a href="/v2/app">Войти снова</a>'
|
||||
+ '</body></html>');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send('<!DOCTYPE html><html><head><meta charset="utf-8"><title>Выход</title>'
|
||||
+ '<style>body{font-family:sans-serif;background:#111;color:#eee;padding:40px;text-align:center}'
|
||||
+ 'h2{color:#4fc3f7}p{font-size:16px;margin:20px 0}'
|
||||
+ '.btn{display:inline-block;padding:12px 24px;margin:0 10px;border-radius:6px;text-decoration:none;font-size:16px}'
|
||||
+ '.btn-yes{background:#ef5350;color:#fff}.btn-no{background:#333;color:#eee}'
|
||||
+ '</style></head><body>'
|
||||
+ '<h2>Выход из сервиса</h2>'
|
||||
+ '<p>Вы будете выведены только из whitelist.<br>Сессия в других сервисах Nubes сохранится.</p>'
|
||||
+ '<a href="/v2/logout?confirm=yes" class="btn btn-yes">Выйти</a>'
|
||||
+ '<a href="/v2/app" class="btn btn-no">Отмена</a>'
|
||||
+ '</body></html>');
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// ── Хелпер: HTTP-запрос к IAM ────────────────────────────────────────────────
|
||||
async function iamFetch(token, url) {
|
||||
const https = require('https');
|
||||
const u = new URL(url);
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get({
|
||||
hostname: u.hostname, port: 443, path: u.pathname,
|
||||
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
|
||||
}, res => {
|
||||
let data = '';
|
||||
res.on('data', c => data += c);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) return reject(new Error(res.statusCode + ': ' + data.slice(0, 200)));
|
||||
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function rawPage(title, data) {
|
||||
return '<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>' + title + '</title>'
|
||||
+ '<style>body{font-family:monospace;background:#111;color:#eee;padding:20px}'
|
||||
+ 'pre{background:#1a1a2e;padding:15px;border-radius:8px;overflow-x:auto;font-size:13px}'
|
||||
+ 'h2{color:#4fc3f7}a{color:#80cbc4}</style></head><body>'
|
||||
+ '<h2>Сырой ответ ' + title + '</h2>'
|
||||
+ '<pre>' + JSON.stringify(data, null, 2).replace(/</g, '<') + '</pre>'
|
||||
+ '<p><a href="/v2/iam">← Сессия</a> | <a href="/v2/app">CRUD</a></p>'
|
||||
+ '</body></html>';
|
||||
}
|
||||
|
||||
function debugPage(u, version) {
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>V2 — IAM</title>
|
||||
@@ -88,8 +228,67 @@ function debugPage(u, version) {
|
||||
<p><span class="label">ФИО:</span> ${(u.fio && u.fio.fullName) || u.fio || '—'}</p>
|
||||
<p><span class="label">Компаний:</span> ${u.allClientIds ? u.allClientIds.length : 0} — ${(u.allClientIds || []).join(', ')}</p>
|
||||
<hr><pre>${JSON.stringify(u, null, 2)}</pre>
|
||||
<p><a href="/logout">Выйти</a> | <a href="/app">CRUD</a> | <a href="/admin">Админка</a></p>
|
||||
<p><a href="/v2/logout">Выйти</a> | <a href="/v2/app">CRUD</a> | <a href="/v2/admin">Админка</a></p>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function iamComparePage(results) {
|
||||
function fmt(data, depth) {
|
||||
if (data === null || data === undefined) return '<span style="color:#888">null</span>';
|
||||
if (typeof data === 'string') return '<span style="color:#ce93d8">"' + data.replace(/</g, '<') + '"</span>';
|
||||
if (typeof data === 'number' || typeof data === 'boolean') return '<span style="color:#81c784">' + data + '</span>';
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length === 0) return '<span style="color:#888">[]</span>';
|
||||
let html = '<span style="color:#888">[</span><div style="padding-left:20px">';
|
||||
for (const item of data) html += '<div>' + fmt(item, depth + 1) + ',</div>';
|
||||
html += '</div><span style="color:#888">]</span>';
|
||||
return html;
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
let html = '<span style="color:#888">{</span><div style="padding-left:20px">';
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
html += '<div><span style="color:#82aaff">"' + k + '"</span>: ' + fmt(v, depth + 1) + '</div>';
|
||||
}
|
||||
html += '</div><span style="color:#888">}</span>';
|
||||
return html;
|
||||
}
|
||||
return String(data).replace(/</g, '<');
|
||||
}
|
||||
|
||||
let html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>IAM Impersonation</title>';
|
||||
html += '<style>body{font-family:monospace;background:#111;color:#eee;padding:20px;font-size:14px}';
|
||||
html += 'h2{color:#4fc3f7}.ok{color:#81c784}.err{color:#ef5350}';
|
||||
html += '.card{background:#1a1a2e;border-radius:8px;padding:15px;margin-bottom:15px;overflow-x:auto}';
|
||||
html += '.card-title{color:#b39ddb;font-size:18px;font-weight:bold;margin-bottom:10px}';
|
||||
html += 'a{color:#80cbc4;text-decoration:none}a:hover{text-decoration:underline}';
|
||||
html += '.nav{display:flex;gap:15px;margin-bottom:20px;flex-wrap:wrap}';
|
||||
html += '.nav a{background:#1a1a2e;padding:8px 16px;border-radius:6px;font-size:14px}';
|
||||
html += '</style></head><body>';
|
||||
html += '<h2>🧪 IAM Impersonation — GET эндпоинты</h2>';
|
||||
html += '<div class="nav">';
|
||||
html += '<a href="/v2/iam">← Сессия</a>';
|
||||
html += '<a href="/v2/app">CRUD</a>';
|
||||
html += '<a href="/v2/admin">Админка</a>';
|
||||
html += '<a href="/v2/iam_response">IAM PROD</a>';
|
||||
html += '<a href="/v2/iam_response_test">IAM TEST</a>';
|
||||
html += '<a href="/v2/iam_response_dev">IAM DEV</a>';
|
||||
html += '</div>';
|
||||
|
||||
for (const [name, r] of Object.entries(results)) {
|
||||
html += '<div class="card">';
|
||||
html += '<div class="card-title">GET /impersonation/' + name + '</div>';
|
||||
if (r.ok) {
|
||||
html += '<div style="font-family:monospace;white-space:pre-wrap;tab-size:2">';
|
||||
html += fmt(r.data, 0);
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div class="err">❌ ' + r.error.replace(/</g, '<') + '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</body></html>';
|
||||
return html;
|
||||
}
|
||||
|
||||
module.exports = { createV2Router };
|
||||
|
||||
+15
-26
@@ -1,33 +1,36 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — Админка (Express + EJS)
|
||||
// V2 — Админка: лимиты и аудит (Express + EJS)
|
||||
//
|
||||
// ЭТО: Express-роутер для админа, рендерит EJS-шаблоны.
|
||||
// НЕ: доступно обычным юзерам (middleware: req.isAdmin).
|
||||
// ЭТО: Express-роутер для админа.
|
||||
// НЕ: доступно обычным юзерам (middleware: req.canAdmin).
|
||||
//
|
||||
// ШАБЛОНЫ:
|
||||
// views/admin.ejs — дашборд (companies, user, version)
|
||||
// views/admin-audit.ejs — аудит (audit, company, version)
|
||||
// views/admin-entries.ejs — записи (entries, company, used, limit, ...)
|
||||
// views/v2/admin.ejs — компании + лимиты
|
||||
// views/v2/admin-audit.ejs — аудит
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const express = require('express');
|
||||
const q = require('../db/queries');
|
||||
const crud = require('../crud');
|
||||
const config = require('../config');
|
||||
|
||||
function createAdminRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
router.use((req, res, next) => {
|
||||
if (!req.isAdmin) return res.redirect('/app');
|
||||
if (!req.canAdmin) return res.redirect('/v2/app');
|
||||
// user для шаблонов
|
||||
req.adminUser = {
|
||||
email: req.email,
|
||||
fio: (req.session && req.session.user && req.session.user.fio) || null,
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
// ── GET / — дашборд ─────────────────────────────────────────────────
|
||||
// ── GET / — дашборд: компании + лимиты ─────────────────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const companies = await q.getAllCompanies();
|
||||
res.render('v2/admin', { companies, user: req.user || {}, version: config.version });
|
||||
res.render('v2/admin', { companies, user: req.adminUser, version: config.version });
|
||||
} catch (e) {
|
||||
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre>');
|
||||
}
|
||||
@@ -52,23 +55,9 @@ function createAdminRouter() {
|
||||
const newLimit = parseInt(req.body.limit);
|
||||
if (!companyId || !newLimit || newLimit < 1) throw new Error('Неверные параметры');
|
||||
await q.setLimit(companyId, newLimit);
|
||||
res.redirect('/admin?msg=' + encodeURIComponent('Лимит установлен: ' + newLimit));
|
||||
res.redirect('/v2/admin?msg=' + encodeURIComponent('Лимит установлен: ' + newLimit));
|
||||
} catch (e) {
|
||||
res.redirect('/admin?error=' + encodeURIComponent(e.message));
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /entries ────────────────────────────────────────────────────
|
||||
router.get('/entries', async (req, res) => {
|
||||
try {
|
||||
const companyId = parseInt(req.query.companyId);
|
||||
const company = companyId ? await q.getCompanyById(companyId) : null;
|
||||
if (!company) return res.redirect('/admin');
|
||||
const includeDeleted = req.query.deleted === '1';
|
||||
const { entries, used, limit } = await crud.list(company.client_id, includeDeleted);
|
||||
res.render('v2/admin-entries', { entries, company, used, limit, includeDeleted, version: config.version });
|
||||
} catch (e) {
|
||||
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre>');
|
||||
res.redirect('/v2/admin?error=' + encodeURIComponent(e.message));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
// ЭТО: все настройки в одном месте.
|
||||
// ЗАЧЕМ: всё что может поменяться между средами — здесь, не размазано по коду.
|
||||
//
|
||||
// ПРИОРИТЕТ: process.env (V2_*) → хардкод-умолчания.
|
||||
// Умолчания — для тестовой среды (VM KC на italo.kube5s.ru:8080).
|
||||
// ПРИОРИТЕТ: process.env → хардкод-умолчания.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
module.exports = {
|
||||
version: '0.6.20',
|
||||
version: '0.5.163',
|
||||
|
||||
// ── IAM ──────────────────────────────────────────────────────────────────
|
||||
iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user',
|
||||
iamApiBase: process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru/api/v1',
|
||||
|
||||
// ── OIDC (Keycloak) ──────────────────────────────────────────────────────
|
||||
kcBaseUrl: process.env.V2_KC_BASE_URL || 'https://italo.kube5s.ru:8080/realms/ipwhitelist',
|
||||
kcBaseUrl: process.env.V2_KC_BASE_URL || 'https://login.ngcloud.ru/realms/ipwhitelist',
|
||||
kcClientId: process.env.V2_KC_CLIENT_ID || 'ipwhitelist-app',
|
||||
kcClientSecret: process.env.V2_KC_CLIENT_SECRET || '',
|
||||
appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru',
|
||||
|
||||
@@ -31,7 +31,6 @@ const { validate } = require('../validators');
|
||||
// Возвращает { id, client_id, name, custom_limit }.
|
||||
// Вызывается из list/add/edit/remove перед каждой операцией.
|
||||
async function resolve(clientId) {
|
||||
if (!clientId || !clientId.trim()) throw new Error('Ошибка: не определён clientId (W-номер компании). Обратитесь к администратору.');
|
||||
return q.getOrCreateCompany(clientId, clientId);
|
||||
}
|
||||
|
||||
@@ -75,4 +74,11 @@ async function remove(entryId, clientId, email, impBy) {
|
||||
return q.deleteEntry(entryId, co.id, email, impBy);
|
||||
}
|
||||
|
||||
module.exports = { list, add, edit, remove };
|
||||
// ── restore(entryId, clientId, email, impBy) → void ─────────────────────────
|
||||
// Восстановление удалённой записи.
|
||||
async function restore(entryId, clientId, email, impBy) {
|
||||
const co = await resolve(clientId);
|
||||
return q.restoreEntry(entryId, co.id, email, impBy);
|
||||
}
|
||||
|
||||
module.exports = { list, add, edit, remove, restore };
|
||||
|
||||
+39
-17
@@ -30,7 +30,6 @@ const { overlaps } = require('../validators');
|
||||
* Атомарный upsert — исключает дубли при параллельных запросах.
|
||||
*/
|
||||
async function getOrCreateCompany(clientId, companyName) {
|
||||
if (!clientId || !String(clientId).trim()) throw new Error('DB: clientId не может быть пустым');
|
||||
const res = await pool.query(
|
||||
`INSERT INTO companies (client_id, name) VALUES ($1, $2)
|
||||
ON CONFLICT (client_id) DO UPDATE SET name = COALESCE(companies.name, EXCLUDED.name)
|
||||
@@ -67,9 +66,6 @@ async function listEntries(companyId, includeDeleted = false) {
|
||||
* Создать запись (с валидацией, проверкой лимита и пересечений).
|
||||
*/
|
||||
async function createEntry(companyId, cidr, comment, userEmail, impersonatedBy, wasNormalized = false) {
|
||||
if (!companyId) throw new Error('DB: companyId обязателен');
|
||||
if (!cidr) throw new Error('DB: cidr обязателен');
|
||||
if (!userEmail) throw new Error('DB: userEmail обязателен');
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
@@ -116,10 +112,6 @@ async function createEntry(companyId, cidr, comment, userEmail, impersonatedBy,
|
||||
* Обновить запись.
|
||||
*/
|
||||
async function updateEntry(entryId, companyId, cidr, comment, userEmail, impersonatedBy, wasNormalized = false) {
|
||||
if (!entryId) throw new Error('DB: entryId обязателен');
|
||||
if (!companyId) throw new Error('DB: companyId обязателен');
|
||||
if (!cidr) throw new Error('DB: cidr обязателен');
|
||||
if (!userEmail) throw new Error('DB: userEmail обязателен');
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
@@ -161,9 +153,6 @@ async function updateEntry(entryId, companyId, cidr, comment, userEmail, imperso
|
||||
* Удалить запись (soft delete).
|
||||
*/
|
||||
async function deleteEntry(entryId, companyId, userEmail, impersonatedBy) {
|
||||
if (!entryId) throw new Error('DB: entryId обязателен');
|
||||
if (!companyId) throw new Error('DB: companyId обязателен');
|
||||
if (!userEmail) throw new Error('DB: userEmail обязателен');
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
@@ -188,6 +177,44 @@ async function deleteEntry(entryId, companyId, userEmail, impersonatedBy) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстановить удалённую запись (убрать deleted_at).
|
||||
*/
|
||||
async function restoreEntry(entryId, companyId, userEmail, impersonatedBy) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const old = (await client.query(
|
||||
'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NOT NULL',
|
||||
[entryId, companyId]
|
||||
)).rows[0];
|
||||
if (!old) throw new Error('Удалённая запись не найдена');
|
||||
|
||||
await client.query(
|
||||
'UPDATE whitelist_entries SET deleted_by = NULL, deleted_at = NULL WHERE id = $1 AND company_id = $2',
|
||||
[entryId, companyId]
|
||||
);
|
||||
await logAudit(userEmail, companyId, 'RESTORE', old.value_cidr, old.value_cidr, entryId, impersonatedBy, client);
|
||||
|
||||
// Проверить лимит
|
||||
const company = (await client.query('SELECT * FROM companies WHERE id = $1', [companyId])).rows[0];
|
||||
const limit = company.custom_limit != null ? company.custom_limit : (parseInt(process.env.DEFAULT_LIMIT, 10) || 15);
|
||||
const cnt = (await client.query(
|
||||
'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL',
|
||||
[companyId]
|
||||
)).rows[0].c;
|
||||
if (cnt > limit) throw new Error('Восстановление превысит лимит (' + limit + ')');
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Админ ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getCompanyById(id) {
|
||||
@@ -207,8 +234,6 @@ async function getAllCompanies() {
|
||||
}
|
||||
|
||||
async function setLimit(companyId, newLimit) {
|
||||
if (!companyId) throw new Error('DB: companyId обязателен');
|
||||
if (newLimit == null || newLimit < 0) throw new Error('DB: newLimit должен быть >= 0');
|
||||
const result = await pool.query(
|
||||
'UPDATE companies SET custom_limit = $1, updated_at = NOW() WHERE id = $2',
|
||||
[newLimit, companyId]
|
||||
@@ -233,9 +258,6 @@ async function getExportCIDRs(companyId = null) {
|
||||
// ── Аудит ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function logAudit(userEmail, companyId, action, oldValue, newValue, entryId, impersonatedBy, db = pool) {
|
||||
if (!userEmail) throw new Error('DB audit: userEmail обязателен');
|
||||
if (!companyId) throw new Error('DB audit: companyId обязателен');
|
||||
if (!action) throw new Error('DB audit: action обязателен');
|
||||
await db.query(
|
||||
`INSERT INTO audit_log (user_email, company_id, action, old_value, new_value, entry_id, impersonated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
@@ -257,7 +279,7 @@ async function getAudit(companyId = null) {
|
||||
|
||||
module.exports = {
|
||||
getOrCreateCompany, getLimit,
|
||||
listEntries, createEntry, updateEntry, deleteEntry,
|
||||
listEntries, createEntry, updateEntry, deleteEntry, restoreEntry,
|
||||
getExportCIDRs, getAudit,
|
||||
getCompanyById, getAllCompanies, setLimit,
|
||||
};
|
||||
|
||||
+17
-1
@@ -46,7 +46,7 @@ async function ensureSchema(pool) {
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
company_id INTEGER NOT NULL,
|
||||
action VARCHAR(32) NOT NULL CHECK (action IN ('CREATE','UPDATE','DELETE')),
|
||||
action VARCHAR(32) NOT NULL CHECK (action IN ('CREATE','UPDATE','DELETE','RESTORE')),
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
entry_id INTEGER,
|
||||
@@ -56,6 +56,22 @@ async function ensureSchema(pool) {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_company_time
|
||||
ON audit_log(company_id, created_at DESC);
|
||||
|
||||
-- Миграция: добавить RESTORE в CHECK (если ещё нет)
|
||||
DO $$
|
||||
DECLARE
|
||||
cn text;
|
||||
BEGIN
|
||||
SELECT con.conname INTO cn
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||
WHERE rel.relname = 'audit_log' AND con.contype = 'c';
|
||||
IF cn IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE audit_log DROP CONSTRAINT ' || cn;
|
||||
END IF;
|
||||
EXECUTE 'ALTER TABLE audit_log ADD CONSTRAINT audit_log_action_check CHECK (action IN (''CREATE'',''UPDATE'',''DELETE'',''RESTORE''))';
|
||||
END;
|
||||
$$;
|
||||
`);
|
||||
|
||||
console.log('[db] Schema ensured');
|
||||
|
||||
@@ -1,51 +1,58 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — Авто-имперсонация (отдельный модуль)
|
||||
// V2 — Имперсонация (IAM + URL-режим)
|
||||
//
|
||||
// УСЛОВИЯ (все три должны быть заданы):
|
||||
// IMPERSONATION_ORIGINAL = email юзера при входе (ntazetdinov@nubes.ru)
|
||||
// IMPERSONATION_TARGET = в кого имперсонировать (tazet@narod.ru)
|
||||
// IMPERSONATION_COMPANY = компания цели (WZ01325)
|
||||
// ЭТО: middleware — подмена сессии при имперсонации.
|
||||
// НЕ: жёсткая логика в resolveContext или user/index.js.
|
||||
//
|
||||
// ОПЦИОНАЛЬНО:
|
||||
// IMPERSONATION_EXTRA_COMPANIES = доп. компании через запятую (WZ01112)
|
||||
// IAM-имперсонация: если сессия уже содержит isImpersonated=true → не трогаем.
|
||||
//
|
||||
// ЧТО ДЕЛАЕТ:
|
||||
// Когда юзер с email === ORIGINAL входит → сессия подменяется:
|
||||
// email → TARGET
|
||||
// clientId → COMPANY
|
||||
// allClientIds → [COMPANY, EXTRA_COMPANIES]
|
||||
// isImpersonated → true
|
||||
// originalUserEmail → ORIGINAL
|
||||
// URL-имперсонация: для разрешённых пользователей (IMPERSONATION_ALLOWED_USERS).
|
||||
// /v2/app?impersonate=email&company=W***
|
||||
// Полная подмена сессии: email, clientId, profiles — всё от таргета.
|
||||
//
|
||||
// ENV:
|
||||
// IMP_ALLOWED — emails через запятую (кому можно)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const ORIGINAL = process.env.IMPERSONATION_ORIGINAL || '';
|
||||
const TARGET = process.env.IMPERSONATION_TARGET || '';
|
||||
const COMPANY = process.env.IMPERSONATION_COMPANY || '';
|
||||
const EXTRA = (process.env.IMPERSONATION_EXTRA_COMPANIES || '')
|
||||
.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
// Все три обязательны
|
||||
const ENABLED = ORIGINAL && TARGET && COMPANY;
|
||||
const ALLOWED_USERS = new Set(
|
||||
(process.env.IMP_ALLOWED || '')
|
||||
.split(/[\s,/]+/).map(s => s.trim()).filter(Boolean)
|
||||
);
|
||||
|
||||
function enhanceImpersonation(req, res, next) {
|
||||
if (!ENABLED) return next();
|
||||
|
||||
const u = req.session && req.session.user;
|
||||
if (!u || u.email !== ORIGINAL) return next();
|
||||
if (!u) return next();
|
||||
|
||||
// Подмена сессии — авто-имперсонация
|
||||
const allCids = [COMPANY, ...EXTRA];
|
||||
u.originalUserEmail = ORIGINAL; // кто реально вошёл (KC)
|
||||
u.email = TARGET; // в кого имперсонируем
|
||||
u.clientId = COMPANY;
|
||||
u.activeClientId = COMPANY;
|
||||
u.allClientIds = allCids;
|
||||
// ── IAM-имперсонация — уже в сессии, не трогаем ────────────────────
|
||||
if (u.isImpersonated) return next();
|
||||
|
||||
// ── URL-имперсонация ───────────────────────────────────────────────
|
||||
const targetEmail = req.query.impersonate;
|
||||
const targetCompany = req.query.company;
|
||||
|
||||
if (targetEmail && targetCompany && ALLOWED_USERS.has(u.email)) {
|
||||
// Сохраняем оригинал
|
||||
const origEmail = u.email;
|
||||
const origFio = u.fio;
|
||||
|
||||
// Подменяем ВСЕ данные на таргета (как делает IAM)
|
||||
u.email = targetEmail;
|
||||
u.clientId = targetCompany;
|
||||
u.activeClientId = targetCompany;
|
||||
u.allClientIds = [targetCompany];
|
||||
u.companyName = targetCompany;
|
||||
u.isImpersonated = true;
|
||||
u.impersonatedCompanyId = COMPANY; // для resolveContext
|
||||
u.isAdmin = true; // псевдо-админ
|
||||
u.adminMode = true; // включён
|
||||
u.companyName = COMPANY;
|
||||
u.profiles = allCids.map(cid => ({ client_id: cid, company_name: cid }));
|
||||
u.originalUserEmail = origEmail;
|
||||
u.originalUserFullName = origFio ? (origFio.fullName || '') : '';
|
||||
u.impersonatedCompanyId = targetCompany;
|
||||
u.fio = null;
|
||||
|
||||
u.profiles = [{
|
||||
client_id: targetCompany,
|
||||
company_name: targetCompany,
|
||||
is_active_profile: true,
|
||||
}];
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
+108
-76
@@ -1,84 +1,116 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — resolveContext middleware
|
||||
//
|
||||
// ЭТО: Express middleware — извлекает контекст из сессии.
|
||||
// НЕ: БД, бизнес-логика.
|
||||
//
|
||||
// ЗАЧЕМ:
|
||||
// 1. Единственное место где сессия превращается в req.* поля.
|
||||
// 2. user/ и admin/ не парсят сессию сами — получают готовые req.email, req.clientId.
|
||||
// 3. Поддержка имперсонации (админ действует от имени компании).
|
||||
//
|
||||
// ПОРЯДОК:
|
||||
// 1. Нет сессии → 302 /v2/login
|
||||
// 2. Обычный юзер → req.email = session.user.email, req.clientId = activeClientId
|
||||
// 3. Админ → req.isAdmin = true (если adminMode в сессии)
|
||||
// 4. Имперсонация → req.email = originalUserEmail, req.impersonatedBy = админ
|
||||
//
|
||||
// ВЫХОД (поля на req):
|
||||
// req.email — почта (при имперсонации — originalUserEmail)
|
||||
// req.clientId — W-номер текущей компании
|
||||
// req.companyName — название компании
|
||||
// req.isAdmin — флаг админа
|
||||
// req.isImpersonated — флаг имперсонации
|
||||
// req.impersonatedBy — почта админа (null если нет имперсонации)
|
||||
// req.allClientIds — все W-номера юзера
|
||||
// req.profiles — профили из IAM
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
const https = require('https');
|
||||
const config = require('../config');
|
||||
|
||||
function resolveContext(req, res, next) {
|
||||
/**
|
||||
* Express middleware — извлекает контекст из сессии.
|
||||
* При 401 от IAM — чистит сессию и редиректит на логин (SSO).
|
||||
*/
|
||||
async function resolveContext(req, res, next) {
|
||||
const u = req.session && req.session.user;
|
||||
if (!u) return res.redirect('/v2/login');
|
||||
|
||||
// Нет сессии — нет доступа
|
||||
if (!u) return res.redirect('/login');
|
||||
|
||||
// ═══ САНИТАЙЗЕР IAM — всё что пришло может быть null/undefined/говном ═══
|
||||
const s = (v) => (v == null || v === 'null' || v === 'undefined') ? '' : String(v).trim();
|
||||
const arr = (v) => (Array.isArray(v) ? v : []);
|
||||
|
||||
// Нормализуем ВСЕ поля из сессии
|
||||
const email = s(u.email);
|
||||
const clientId = s(u.clientId);
|
||||
const activeClientId = s(u.activeClientId);
|
||||
const originalUserEmail = s(u.originalUserEmail);
|
||||
const impersonatedCompanyId = s(u.impersonatedCompanyId);
|
||||
const companyName = s(u.companyName);
|
||||
const allClientIds = arr(u.allClientIds).map(s).filter(Boolean);
|
||||
const profiles = arr(u.profiles);
|
||||
const isAdmin = !!u.isAdmin;
|
||||
const adminMode = !!u.adminMode;
|
||||
const isImpersonatedFlag = !!u.isImpersonated;
|
||||
|
||||
const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim();
|
||||
const isImpersonated = (!!originalUserEmail && !!impersonatedCompanyId) || isImpersonatedFlag;
|
||||
|
||||
// req.email = ТЕКУЩИЙ юзер (имперсонированный или реальный)
|
||||
req.email = email;
|
||||
req.clientId = activeClientId || clientId;
|
||||
if (!req.clientId) return res.status(400).send(
|
||||
'<h2>Ошибка</h2><p>Не удалось определить W-номер компании.</p>' +
|
||||
'<p><b>req.clientId:</b> «' + String(req.clientId) + '» (type: ' + typeof req.clientId + ', len: ' + String(req.clientId).length + ')</p>' +
|
||||
'<p><b>email (local):</b> ' + email + '</p>' +
|
||||
'<p><b>clientId (local):</b> ' + clientId + '</p>' +
|
||||
'<p><b>activeClientId (local):</b> ' + activeClientId + '</p>' +
|
||||
'<p><b>isImpersonated:</b> ' + isImpersonated + '</p>' +
|
||||
'<p><b>impersonatedCompanyId (local):</b> ' + impersonatedCompanyId + '</p>' +
|
||||
'<p><b>IMPERSONATION_ORIGINAL env:</b> ' + (process.env.IMPERSONATION_ORIGINAL || 'не задан') + '</p>' +
|
||||
'<p><b>IMPERSONATION_TARGET env:</b> ' + (process.env.IMPERSONATION_TARGET || 'не задан') + '</p>' +
|
||||
'<p><b>IMPERSONATION_COMPANY env:</b> ' + (process.env.IMPERSONATION_COMPANY || 'не задан') + '</p>' +
|
||||
'<p><b>ADMIN_EMAIL env:</b> ' + (process.env.ADMIN_EMAIL || 'не задан') + '</p>' +
|
||||
'<p><b>IMPERSONATION_EXTRA_COMPANIES env:</b> ' + (process.env.IMPERSONATION_EXTRA_COMPANIES || 'не задан') + '</p>' +
|
||||
'<p><b>isAdmin:</b> ' + req.isAdmin + '</p>' +
|
||||
'<p><a href="/logout">Выйти</a></p>'
|
||||
);
|
||||
req.companyName = companyName || req.clientId;
|
||||
req.isAdmin = (isAdmin && adminMode) || (!!ADMIN_EMAIL && email === ADMIN_EMAIL);
|
||||
req.isImpersonated = isImpersonated;
|
||||
req.impersonatedBy = isImpersonated ? originalUserEmail : null;
|
||||
req.allClientIds = allClientIds.length ? allClientIds : [req.clientId];
|
||||
req.profiles = profiles;
|
||||
const token = req.session && req.session.token;
|
||||
if (token) {
|
||||
try {
|
||||
await checkImpersonation(token, req, u);
|
||||
} catch (e) {
|
||||
const msg = (e.message || '');
|
||||
// 401 — токен протух, чистим сессию → SSO даст свежий
|
||||
if (msg.startsWith('401')) {
|
||||
req.session.destroy(() => res.redirect('/v2/login'));
|
||||
return;
|
||||
}
|
||||
// Другие ошибки IAM — игнорируем, используем старые данные
|
||||
}
|
||||
}
|
||||
|
||||
applyContext(req, u);
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Запрашивает IAM и обновляет сессию если имперсонация изменилась.
|
||||
* Бросает ошибку при 401 — вызывающий обработает.
|
||||
*/
|
||||
async function checkImpersonation(token, req, u) {
|
||||
const url = config.iamApiBase + '/auth/user';
|
||||
const uObj = new URL(url);
|
||||
const data = await new Promise((resolve, reject) => {
|
||||
https.get({
|
||||
hostname: uObj.hostname, port: 443, path: uObj.pathname,
|
||||
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
|
||||
}, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) return reject(new Error(res.statusCode + ': ' + d.slice(0, 200)));
|
||||
try { resolve(JSON.parse(d)); } catch (e) { reject(e); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
|
||||
const imp = data.impersonation || {};
|
||||
const newIsImp = !!(imp.is_impersonated);
|
||||
const oldIsImp = !!(u.originalUserEmail && u.impersonatedCompanyId);
|
||||
|
||||
// Статус не изменился — ничего не делаем
|
||||
if (newIsImp === oldIsImp) return;
|
||||
|
||||
if (newIsImp) {
|
||||
// Имперсонация началась — обновляем сессию данными таргета
|
||||
const profiles = data.profiles || [];
|
||||
const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {};
|
||||
const ui = data.userInfo || {};
|
||||
|
||||
req.session.user = {
|
||||
...u,
|
||||
email: ui.email || '',
|
||||
clientId: ui.clientID || activeProfile.client_id || '',
|
||||
allClientIds: profiles.map(p => p.client_id).filter(Boolean),
|
||||
activeClientId: ui.clientID || activeProfile.client_id || '',
|
||||
activeProfileId: activeProfile.id || null,
|
||||
companyId: ui.companyId || '',
|
||||
companyName: ui.company || activeProfile.company_name || '',
|
||||
isAdmin: !!ui.isAdmin,
|
||||
isImpersonated: true,
|
||||
impersonationType: imp.type || null,
|
||||
impersonatedCompanyId: imp.impersonatedCompanyId || '',
|
||||
originalUserEmail: imp.originalUserEmail || u.originalUserEmail || '',
|
||||
originalUserFullName: imp.originalUserFullName || u.originalUserFullName || '',
|
||||
originalUserCompany: imp.originalUserCompany || u.originalUserCompany || '',
|
||||
fio: ui.fio || null,
|
||||
profiles,
|
||||
};
|
||||
} else {
|
||||
// Имперсонация завершилась — убираем флаги, восстанавливаем оригинальные данные
|
||||
req.session.user = {
|
||||
...u,
|
||||
isImpersonated: false,
|
||||
impersonationType: null,
|
||||
impersonatedCompanyId: null,
|
||||
originalUserEmail: null,
|
||||
originalUserFullName: null,
|
||||
originalUserCompany: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function applyContext(req, u) {
|
||||
const isImpersonated = !!(u.originalUserEmail && u.impersonatedCompanyId);
|
||||
|
||||
req.email = u.email || '';
|
||||
req.clientId = isImpersonated
|
||||
? (u.activeClientId || u.impersonatedCompanyId || u.clientId || '')
|
||||
: (u.activeClientId || u.clientId || '');
|
||||
req.companyName = u.companyName || req.clientId;
|
||||
const allCids = u.allClientIds || [u.clientId || ''];
|
||||
req.canAdmin = allCids.includes('WZ01112') || u.clientId === 'WZ01112' || !!u.isAdmin || u.email === (process.env.ADMIN_EMAIL || '');
|
||||
req.adminMode = req.canAdmin;
|
||||
req.isAdmin = req.canAdmin;
|
||||
req.isImpersonated = isImpersonated;
|
||||
req.impersonatedBy = isImpersonated ? u.originalUserEmail : null;
|
||||
req.allClientIds = allCids;
|
||||
req.profiles = u.profiles || [];
|
||||
}
|
||||
|
||||
module.exports = { resolveContext };
|
||||
|
||||
+79
-28
@@ -1,15 +1,22 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — Фронтенд пользователя (Express + EJS)
|
||||
// V2 — Единый фронтенд (Express + EJS) — юзеры и админы
|
||||
//
|
||||
// ЭТО: Express-роутер, рендерит EJS-шаблоны.
|
||||
// ЭТО: Express-роутер, рендерит EJS-шаблон views/v2/user.ejs.
|
||||
// НЕ: работа с БД (только через crud/).
|
||||
//
|
||||
// ШАБЛОН: views/v2/user.ejs — данные: entries, limit, used, companies,
|
||||
// activeClientId, user, includeDeleted, version.
|
||||
// ОДИН ИНТЕРФЕЙС для юзеров и админов:
|
||||
// - Юзер: dropdown = свои профили из IAM, CRUD внутри выбранной компании.
|
||||
// - Админ: dropdown = все компании из БД, CRUD внутри выбранной.
|
||||
// - По умолчанию: активный профиль (is_active_profile=true).
|
||||
//
|
||||
// ПЕРЕКЛЮЧЕНИЕ КОМПАНИИ: ?switchTo=W*** → обновляет activeClientId в сессии.
|
||||
// - Юзер: только свои профили.
|
||||
// - Админ: любая компания.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const express = require('express');
|
||||
const crud = require('../crud');
|
||||
const q = require('../db/queries');
|
||||
const config = require('../config');
|
||||
|
||||
function createUserRouter() {
|
||||
@@ -18,36 +25,68 @@ function createUserRouter() {
|
||||
// ── GET / — список записей + форма ──────────────────────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const clId = req.clientId;
|
||||
const allCompanies = req.profiles.length > 0
|
||||
? req.profiles
|
||||
: [{ client_id: clId, company_name: req.companyName || clId }];
|
||||
const isAdmin = req.canAdmin;
|
||||
let clId = req.clientId;
|
||||
|
||||
// ── Компании для dropdown ───────────────────────────────────────
|
||||
let companies;
|
||||
if (isAdmin) {
|
||||
const all = await q.getAllCompanies();
|
||||
const ownIds = new Set(req.allClientIds || []);
|
||||
companies = all.map(c => ({
|
||||
client_id: c.client_id,
|
||||
name: c.name || c.client_id,
|
||||
active_count: c.active_count,
|
||||
isPersonal: ownIds.has(c.client_id),
|
||||
}));
|
||||
} else {
|
||||
companies = (req.profiles && req.profiles.length > 0)
|
||||
? req.profiles.map(p => ({
|
||||
client_id: p.client_id,
|
||||
name: p.company_name || p.client_id,
|
||||
is_active: p.is_active_profile,
|
||||
}))
|
||||
: [{ client_id: clId, name: req.companyName || clId, is_active: true }];
|
||||
}
|
||||
|
||||
// ── Переключение компании (до crud.list — не грузить зря) ────
|
||||
let switchDebug = null;
|
||||
if (req.query.switchTo) {
|
||||
const targetId = req.query.switchTo;
|
||||
const allowed = isAdmin || (req.profiles || []).find(p => p.client_id === targetId);
|
||||
if (allowed) {
|
||||
req.session.user.activeClientId = targetId;
|
||||
clId = targetId;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Записи ──────────────────────────────────────────────────────
|
||||
const includeDeleted = req.query.deleted === '1';
|
||||
const { entries, used, limit } = await crud.list(clId, includeDeleted);
|
||||
|
||||
// Переключение компании
|
||||
if (req.query.switchTo && allCompanies.find(c => c.client_id === req.query.switchTo)) {
|
||||
req.session.user.activeClientId = req.query.switchTo;
|
||||
return res.redirect('/app');
|
||||
}
|
||||
// ── Данные пользователя для шаблона ────────────────────────────
|
||||
const sessionUser = req.session && req.session.user ? req.session.user : {};
|
||||
const templateUser = {
|
||||
email: req.email,
|
||||
fio: sessionUser.fio || null,
|
||||
isAdmin: req.canAdmin || false,
|
||||
adminMode: isAdmin,
|
||||
isImpersonated: req.isImpersonated || false,
|
||||
originalUserEmail: sessionUser.originalUserEmail || '',
|
||||
originalUserFullName: sessionUser.originalUserFullName || '',
|
||||
originalUserCompany: sessionUser.originalUserCompany || '',
|
||||
};
|
||||
|
||||
res.render('v2/user', {
|
||||
entries, limit, used,
|
||||
companies: allCompanies,
|
||||
companies,
|
||||
activeClientId: clId,
|
||||
user: (req.session && req.session.user) || req.user || {},
|
||||
user: templateUser,
|
||||
includeDeleted,
|
||||
version: config.version,
|
||||
isImpersonated: req.isImpersonated || false,
|
||||
originalUserEmail: req.impersonatedBy ? req.email : null,
|
||||
email: req.email || (req.user && req.user.email) || (req.session && req.session.user && req.session.user.email) || '',
|
||||
loginEmail: (req.session && req.session.user && req.session.user.originalUserEmail) || (req.session && req.session.user && req.session.user.email) || '',
|
||||
email: req.email || '',
|
||||
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre><a href="/app">Назад</a>');
|
||||
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre><a href="/v2/app">Назад</a>');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -56,9 +95,9 @@ function createUserRouter() {
|
||||
try {
|
||||
const result = await crud.add(req.clientId, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy);
|
||||
const msg = result.wasNormalized ? 'Добавлено (адрес нормализован)' : 'Добавлено';
|
||||
res.redirect('/app?msg=' + encodeURIComponent(msg));
|
||||
res.redirect('/v2/app?msg=' + encodeURIComponent(msg));
|
||||
} catch (e) {
|
||||
res.redirect('/app?error=' + encodeURIComponent(e.message));
|
||||
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -67,19 +106,31 @@ function createUserRouter() {
|
||||
try {
|
||||
const result = await crud.edit(parseInt(req.params.id), req.clientId, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy);
|
||||
const msg = result.wasNormalized ? 'Изменено (адрес нормализован)' : 'Изменено';
|
||||
res.redirect('/app?msg=' + encodeURIComponent(msg));
|
||||
res.redirect('/v2/app?msg=' + encodeURIComponent(msg));
|
||||
} catch (e) {
|
||||
res.redirect('/app?error=' + encodeURIComponent(e.message));
|
||||
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /delete/:id ────────────────────────────────────────────────
|
||||
router.post('/delete/:id', async (req, res) => {
|
||||
const qs = (req.body && req.body.deleted === '1') ? '&deleted=1' : '';
|
||||
try {
|
||||
await crud.remove(parseInt(req.params.id), req.clientId, req.email, req.impersonatedBy);
|
||||
res.redirect('/app?msg=Удалено');
|
||||
res.redirect('/v2/app?msg=Удалено' + qs);
|
||||
} catch (e) {
|
||||
res.redirect('/app?error=' + encodeURIComponent(e.message));
|
||||
res.redirect('/v2/app?error=' + encodeURIComponent(e.message) + qs);
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /restore/:id ──────────────────────────────────────────────
|
||||
router.post('/restore/:id', async (req, res) => {
|
||||
const qs = (req.body && req.body.deleted === '1') ? '&deleted=1' : '';
|
||||
try {
|
||||
await crud.restore(parseInt(req.params.id), req.clientId, req.email, req.impersonatedBy);
|
||||
res.redirect('/v2/app?msg=Восстановлено' + qs);
|
||||
} catch (e) {
|
||||
res.redirect('/v2/app?error=' + encodeURIComponent(e.message) + qs);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+22
-10
@@ -100,23 +100,35 @@ function cidrToRange(cidr) {
|
||||
// Схлопывание списка CIDR в минимальный набор (для экспорта).
|
||||
// Пример: 10.0.0.0/24 + 10.0.1.0/24 → 10.0.0.0/23
|
||||
function aggregateCIDRs(cidrs) {
|
||||
// 1. Сортируем по start, мержим смежные
|
||||
const ranges = cidrs.map(c => cidrToRange(c)).sort((a, b) => a.start - b.start);
|
||||
const merged = [];
|
||||
for (const r of ranges) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && r.start <= last.end + 1) last.end = Math.max(last.end, r.end);
|
||||
else merged.push({ ...r });
|
||||
else merged.push({ start: r.start, end: r.end });
|
||||
}
|
||||
return merged.map(r => {
|
||||
let start = r.start, end = r.end, cidrs = [];
|
||||
while (start <= end) {
|
||||
let maxMask = 32;
|
||||
while (maxMask > 0 && ((start & ((1 << (32 - maxMask)) - 1)) !== 0 || (start + (1 << (32 - maxMask)) - 1) > end)) maxMask--;
|
||||
cidrs.push(numToIP(start) + '/' + maxMask);
|
||||
start += (1 << (32 - maxMask));
|
||||
// 2. Каждый merged range → минимальный набор CIDR
|
||||
const result = [];
|
||||
for (const r of merged) {
|
||||
let start = r.start;
|
||||
while (start <= r.end) {
|
||||
// Ищем минимальную маску (самый широкий блок), подходящую под start и end.
|
||||
// Идём от 1 до 32, запоминаем минимальную (самую широкую) подходящую.
|
||||
let bestM = 32;
|
||||
for (let m = 1; m <= 32; m++) {
|
||||
if ((1 << (32 - m)) <= 0) continue;
|
||||
const size = 1 << (32 - m);
|
||||
if ((start & (size - 1)) !== 0) continue; // не выровнен
|
||||
if (start + size - 1 > r.end) continue; // выходит за range
|
||||
bestM = m;
|
||||
break; // нашли самую широкую
|
||||
}
|
||||
return cidrs;
|
||||
}).flat();
|
||||
result.push(numToIP(start) + '/' + bestM);
|
||||
start += (1 << (32 - bestM));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function numToIP(n) {
|
||||
|
||||
+100
-20
@@ -1,20 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Аудит — <%= company ? company.client_id : 'Все' %> — v<%= version %></title>
|
||||
<style>
|
||||
body{font-family:sans-serif;max-width:1200px;margin:20px auto;color:#222}
|
||||
table{width:100%;border-collapse:collapse;margin:10px 0}
|
||||
th,td{border:1px solid #ccc;padding:6px;text-align:left;font-size:13px}
|
||||
th{background:#263238;color:#fff}
|
||||
.bar{display:flex;justify-content:space-between;align-items:center;background:#263238;color:#fff;padding:10px;margin-bottom:15px}
|
||||
.bar a{color:#80cbc4}
|
||||
</style></head><body>
|
||||
<div class="bar">
|
||||
<span><strong>📋 Аудит</strong> <%= company ? company.client_id : 'Все компании' %></span>
|
||||
<span><a href="/admin">← Админка</a> | <a href="/app">Пользовательский</a></span>
|
||||
</div>
|
||||
<table>
|
||||
<tr><th>Дата</th><th>Действие</th><th>Кто</th><th>От имени</th><th>Компания</th><th>Значения</th></tr>
|
||||
<% if (audit.length === 0) { %><tr><td colspan="6">Нет записей</td></tr><% } %>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Аудит — <%= company ? company.client_id : 'Все' %> — IP WhiteList v<%= version %></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);
|
||||
}
|
||||
.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; }
|
||||
.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: .4rem .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; }
|
||||
.empty { text-align: center; color: var(--muted); padding: 2rem; }
|
||||
</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="0 0 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);">Аудит: <%= company ? company.client_id : 'Все компании' %></span>
|
||||
<span style="margin-left:auto;">
|
||||
<a href="/v2/admin" class="btn btn-sm">← Лимиты</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
<div class="card-header">Журнал операций <span style="font-weight:400;font-size:.82rem;color:var(--muted);">(макс 500)</span></div>
|
||||
<% if (audit.length === 0) { %>
|
||||
<div class="empty">Нет записей</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Действие</th>
|
||||
<th>Кто</th>
|
||||
<th>От имени</th>
|
||||
<th>Компания</th>
|
||||
<th>Значения</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% audit.forEach(function(a) { %>
|
||||
<tr>
|
||||
<td><%= new Date(a.created_at).toLocaleString('ru') %></td>
|
||||
@@ -22,9 +97,14 @@
|
||||
<td><%= a.user_email %></td>
|
||||
<td><%= a.impersonated_by || '—' %></td>
|
||||
<td><%= a.company_name || '—' %></td>
|
||||
<td><%= a.old_value || '—' %> → <%= a.new_value || '—' %></td>
|
||||
<td><% if (a.old_value) { %><%= a.old_value %><% } else { %>—<% } %> → <% if (a.new_value) { %><%= a.new_value %><% } else { %>—<% } %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</table>
|
||||
<p>Показано: <%= audit.length %> (макс 500)</p>
|
||||
</body></html>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</style></head><body>
|
||||
<div class="bar">
|
||||
<span><strong>📋 Записи</strong> <%= company.client_id %> — <%= company.name || company.client_id %></span>
|
||||
<span><a href="/admin">← Админка</a></span>
|
||||
<span><a href="/v2/admin">← Админка</a></span>
|
||||
</div>
|
||||
<p>Записей: <strong><%= used %></strong> из <%= limit %> <%= includeDeleted ? '(с удалёнными: ' + entries.length + ')' : '' %></p>
|
||||
<table>
|
||||
|
||||
+125
-33
@@ -1,47 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Admin — IP WhiteList v<%= version %></title>
|
||||
<style>
|
||||
body{font-family:sans-serif;max-width:1100px;margin:20px auto;color:#222}
|
||||
table{width:100%;border-collapse:collapse;margin:10px 0}
|
||||
th,td{border:1px solid #ccc;padding:6px;text-align:left}
|
||||
th{background:#263238;color:#fff}
|
||||
.bar{display:flex;justify-content:space-between;align-items:center;background:#263238;color:#fff;padding:10px;margin-bottom:15px}
|
||||
.bar a{color:#80cbc4}
|
||||
button,input,select{padding:4px 8px}
|
||||
.msg{background:#4caf50;color:#fff;padding:8px;margin-bottom:10px}
|
||||
.err{background:#f44336;color:#fff;padding:8px;margin-bottom:10px}
|
||||
</style></head><body>
|
||||
<div class="bar">
|
||||
<span><strong>🛡️ ADMIN</strong> v<%= version %></span>
|
||||
<span><%= user.email || '—' %> | <a href="/app">← Пользовательский</a> | <a href="/logout">Выйти</a></span>
|
||||
</div>
|
||||
<script>
|
||||
const p=new URLSearchParams(location.search);
|
||||
if(p.get('msg')){document.write('<div class="msg">'+p.get('msg')+'</div>');history.replaceState(null,'','/admin');}
|
||||
if(p.get('error')){document.write('<div class="err">'+p.get('error')+'</div>');history.replaceState(null,'','/admin');}
|
||||
</script>
|
||||
<h2>Компании (<%= companies.length %>)</h2>
|
||||
<table>
|
||||
<tr><th>Компания</th><th>Client ID</th><th>Записей</th><th>Лимит</th><th></th><th>Новый лимит</th></tr>
|
||||
<% if (companies.length === 0) { %><tr><td colspan="6">Нет компаний</td></tr><% } %>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Лимиты и Аудит — IP WhiteList v<%= version %></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: 1100px; 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; }
|
||||
code { background: #f1f5f9; padding: .15rem .4rem; border-radius: 3px; font-size: .9em; }
|
||||
.empty { text-align: center; color: var(--muted); padding: 2rem; }
|
||||
.limit-input {
|
||||
width: 60px; 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); }
|
||||
</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="0 0 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;align-items:center;">
|
||||
<span style="font-weight:500;color:var(--text);"><%= user.email %></span>
|
||||
<a href="/v2/app" class="btn btn-sm">← Назад</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<script>
|
||||
var p=new URLSearchParams(location.search);
|
||||
if(p.get('msg')){document.write('<div class="alert alert-ok">'+p.get('msg')+'</div>');history.replaceState(null,'','/v2/admin');}
|
||||
if(p.get('error')){document.write('<div class="alert alert-err">'+p.get('error')+'</div>');history.replaceState(null,'','/v2/admin');}
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">Компании и лимиты <span style="font-weight:400;font-size:.82rem;color:var(--muted);">(дефолт: 15)</span></div>
|
||||
<% if (companies.length === 0) { %>
|
||||
<div class="empty">Нет компаний в системе</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Компания</th>
|
||||
<th>Client ID</th>
|
||||
<th>Записей</th>
|
||||
<th>Лимит</th>
|
||||
<th></th>
|
||||
<th>Новый лимит</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% companies.forEach(function(c) { %>
|
||||
<tr>
|
||||
<td><%= c.name || c.client_id %></td>
|
||||
<td><strong><%= c.name || c.client_id %></strong></td>
|
||||
<td><code><%= c.client_id %></code></td>
|
||||
<td><strong><%= c.active_count %></strong> / <%= c.custom_limit != null ? c.custom_limit : 15 %></td>
|
||||
<td><%= c.custom_limit != null ? c.custom_limit : '—' %></td>
|
||||
<td>
|
||||
<a href="/admin/audit?companyId=<%= c.id %>">аудит</a> |
|
||||
<a href="/admin/entries?companyId=<%= c.id %>">записи</a>
|
||||
<a href="/v2/admin/audit?companyId=<%= c.id %>" class="btn btn-sm">аудит</a>
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/limit" style="display:inline">
|
||||
<form method="POST" action="/v2/admin/limit" style="display:inline-flex;gap:.4rem;align-items:center;">
|
||||
<input type="hidden" name="companyId" value="<%= c.id %>">
|
||||
<input name="limit" value="<%= c.custom_limit || 15 %>" size="3">
|
||||
<button>установить</button>
|
||||
<input class="limit-input" name="limit" type="number" min="1" value="<%= c.custom_limit || 15 %>">
|
||||
<button class="btn btn-sm btn-primary">установить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</table>
|
||||
</body></html>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+211
-84
@@ -2,169 +2,296 @@
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>IP WhiteList v<%= version %> — Белые списки IP</title>
|
||||
<title>IP WhiteList v<%= version %></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; --red-h: #b91c1c; --green: #16a34a; --amber: #d97706; }
|
||||
:root {
|
||||
--bg: #f5f5f5; --card: #ffffff; --text: #1a1a1a; --muted: #6b7280;
|
||||
--border: #d1d5db; --grey-light: #f3f4f6; --blue: #2563eb; --blue-h: #1d4ed8;
|
||||
--red: #dc2626; --red-h: #b91c1c; --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; }
|
||||
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: 1100px; 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); }
|
||||
|
||||
/* Cards */
|
||||
.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; }
|
||||
|
||||
/* Alerts */
|
||||
.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; }
|
||||
.alert-warn { background: #fef3c7; color: #92400e; border: 1px solid #fde68a; }
|
||||
.alert-warn{ background: #fef3c7; color: #92400e; border: 1px solid #fde68a; }
|
||||
|
||||
/* Stats row */
|
||||
.stats { display: flex; gap: 2rem; }
|
||||
.stat { text-align: center; }
|
||||
.stat .num { font-size: 1.6rem; font-weight: 700; }
|
||||
.stat .lbl { font-size: .8rem; color: var(--muted); }
|
||||
.stat.full .num { color: var(--red); }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr auto; gap: .75rem 1rem; align-items: end; }
|
||||
|
||||
/* Form */
|
||||
.form-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr auto; gap: .75rem 1rem; align-items: end;
|
||||
}
|
||||
.field { display: flex; flex-direction: column; gap: .25rem; }
|
||||
.field label { font-size: .8rem; font-weight: 500; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||
.field input { padding: .5rem .75rem; border: 1px solid var(--border); border-radius: 6px; font-size: .9rem; outline: none; background: #fff; }
|
||||
.field label {
|
||||
font-size: .8rem; font-weight: 500; color: var(--muted);
|
||||
text-transform: uppercase; letter-spacing: .5px;
|
||||
}
|
||||
.field input {
|
||||
padding: .5rem .75rem; border: 1px solid var(--border);
|
||||
border-radius: 6px; font-size: .9rem; outline: none; transition: border .15s; background: #fff;
|
||||
}
|
||||
.field input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
|
||||
.btn { display: inline-flex; align-items: center; gap: .35rem; padding: .5rem 1rem; border: 1px solid var(--border); border-radius: 6px; font-size: .85rem; font-weight: 500; background: #fff; cursor: pointer; white-space: nowrap; text-decoration: none; color: var(--text); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: .35rem;
|
||||
padding: .5rem 1rem; border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: .85rem; font-weight: 500; background: #fff; cursor: pointer;
|
||||
transition: background .15s; white-space: nowrap; text-decoration: none; color: var(--text);
|
||||
}
|
||||
.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); }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; border-color: #93c5fd; }
|
||||
.btn-danger { color: var(--red); border-color: var(--red); }
|
||||
.btn-danger:hover { background: #fecaca; }
|
||||
|
||||
/* Table */
|
||||
.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: .8rem; text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid var(--border); }
|
||||
td { padding: .5rem .75rem; border-bottom: 1px solid var(--border); }
|
||||
th {
|
||||
text-align: left; padding: .5rem .75rem;
|
||||
background: var(--grey-light); color: var(--muted);
|
||||
font-weight: 500; font-size: .8rem; 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:nth-child(even) td { background: #f1f5f9; }
|
||||
tr:nth-child(even):hover td { background: #e2e8f0; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
tr.deleted td { text-decoration: line-through; opacity: 0.5; }
|
||||
code { background: #f1f5f9; padding: .15rem .4rem; border-radius: 3px; font-size: .9em; }
|
||||
.empty { text-align: center; color: var(--muted); padding: 2rem; }
|
||||
.inline-form { display: inline-flex; gap: .5rem; align-items: center; }
|
||||
|
||||
/* Help */
|
||||
.help p, .help ul { margin-top: .5rem; }
|
||||
.help ul li { margin-top: .15rem; }
|
||||
|
||||
/* Select */
|
||||
select {
|
||||
padding: .4rem .75rem; border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: .85rem; background: #fff; outline: none; cursor: pointer;
|
||||
}
|
||||
select:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(37,99,235,.1); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header Nubes -->
|
||||
<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="0 0 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"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<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);">Белые списки IP v<%= version %></span>
|
||||
<span style="font-weight:500;color:var(--text);">IP WhiteList v<%= version %></span>
|
||||
|
||||
<span style="margin-left:auto;display:flex;gap:.5rem;align-items:center;">
|
||||
<% if (user.isAdmin) { %>
|
||||
<a href="/admin" class="btn" style="font-size:.8rem;">Администратор</a>
|
||||
<% } %>
|
||||
<span class="btn" style="font-size:.8rem;cursor:default;">
|
||||
<select onchange="location.href='/app?switchTo='+this.value" style="border:none;background:transparent;font-size:.8rem;color:var(--text);cursor:pointer;outline:none;">
|
||||
<% companies.forEach(function(c) { %>
|
||||
<option value="<%= c.client_id %>" <%= c.client_id === activeClientId ? 'selected' : '' %>><%= c.company_name || c.client_id %> (<%= c.client_id %>)</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</span>
|
||||
<a href="/logout" class="btn" style="font-size:.8rem;">Выйти (<%= loginEmail %>)</a>
|
||||
<span style="font-weight:500;color:var(--text);"><%= user.email %></span>
|
||||
<a href="/v2/logout" class="btn btn-sm">Выйти<% if (user.isImpersonated && user.originalUserEmail) { %> (<%= user.originalUserEmail %>)<% } %></a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- Имперсонация -->
|
||||
<% if (typeof isImpersonated !== 'undefined' && isImpersonated) { %>
|
||||
<% if (isImpersonated) { %>
|
||||
<script>
|
||||
var p=new URLSearchParams(location.search);
|
||||
if(p.get('msg')){document.write('<div class="alert alert-ok">'+p.get('msg')+'</div>');history.replaceState(null,'','/v2/app');}
|
||||
if(p.get('error')){document.write('<div class="alert alert-err">'+p.get('error')+'</div>');history.replaceState(null,'','/v2/app');}
|
||||
</script>
|
||||
|
||||
<% if (user.isImpersonated) { %>
|
||||
<div class="alert alert-warn">
|
||||
⚠️ Режим имперсонации — данные сохраняются от имени <strong><%= email %></strong>
|
||||
(вы: <%= loginEmail %>)
|
||||
⚠️ Режим имперсонации — данные сохраняются от имени <strong><%= activeClientId %></strong>
|
||||
<% if (user.originalUserEmail) { %> (вы: <%= user.originalUserEmail %>)<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
<% } %>
|
||||
|
||||
<!-- Сообщения -->
|
||||
<div id="msgs"></div>
|
||||
<script>
|
||||
const p=new URLSearchParams(location.search);
|
||||
if(p.get('msg')){document.getElementById('msgs').innerHTML='<div class="alert alert-ok">'+p.get('msg')+'</div>';history.replaceState(null,'','/app')}
|
||||
if(p.get('error')){document.getElementById('msgs').innerHTML='<div class="alert alert-err">'+p.get('error')+'</div>';history.replaceState(null,'','/app')}
|
||||
</script>
|
||||
<!-- Выбор компании -->
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:.75rem 1rem;">
|
||||
<form method="GET" action="/v2/app" 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="switchTo" onchange="console.log('onchange fired', this.value); this.form.submit()">
|
||||
<% companies.forEach(function(c) { %>
|
||||
<option value="<%= c.client_id %>" <%= c.client_id === activeClientId ? 'selected' : '' %>>
|
||||
<%= c.name %> (<%= c.client_id %>)<% if (c.isPersonal) { %> (личное)<% } %><% if (c.active_count != null) { %> — <%= c.active_count %> зап.<% } %>
|
||||
</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<a href="?deleted=<%= includeDeleted ? '0' : '1' %>" class="btn btn-sm"><%= includeDeleted ? 'Скрыть удалённые' : 'Показать удалённые' %></a>
|
||||
<% if (user.isAdmin) { %>
|
||||
<span style="margin-left:auto;font-size:.8rem;color:var(--amber);">Режим: Администратор</span>
|
||||
<a href="/v2/admin" class="btn btn-sm">Лимиты и Аудит</a>
|
||||
<% } %>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Статистика -->
|
||||
<div class="card">
|
||||
<div class="card-header">Белый список</div>
|
||||
<!-- Статистика -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="stats" style="margin-bottom:1rem;">
|
||||
<div class="stat <%= used >= limit ? 'full' : '' %>"><div class="num"><%= used %></div><div class="lbl">из <%= limit %></div></div>
|
||||
<div style="margin-left:auto;display:flex;align-items:center;font-weight:600;font-size:1.1rem;color:var(--text);">
|
||||
<span style="font-weight:600;font-size:1.1rem;color:var(--muted);margin-right:.75rem;"><%= activeClientId %></span>
|
||||
<span style="font-weight:600;font-size:1.1rem;"><%= email %></span>
|
||||
<div class="stats">
|
||||
<div class="stat<%= used >= limit ? ' full' : '' %>">
|
||||
<div class="num"><%= used %> / <%= limit %></div>
|
||||
<div class="lbl">записей</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="num"><%= activeClientId %></div>
|
||||
<div class="lbl">компания</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="num"><%= user.email %></div>
|
||||
<div class="lbl">пользователь</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма добавления -->
|
||||
<form method="POST" action="/app/add">
|
||||
<div class="card">
|
||||
<div class="card-header">Добавить адрес</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/v2/app/add">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>IPv4 адрес или подсеть CIDR</label>
|
||||
<input name="cidr" placeholder="x.x.x.x/xx" required>
|
||||
<input name="cidr" placeholder="Например: 203.0.113.10 или 203.0.113.0/24" maxlength="18" required <%= used >= limit ? 'disabled' : '' %>>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Комментарий</label>
|
||||
<input name="comment" placeholder="для чего">
|
||||
<input name="comment" placeholder="Необязательно" maxlength="255">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Добавить</button>
|
||||
<button class="btn btn-primary" type="submit" <%= used >= limit ? 'disabled' : '' %> style="align-self:end">
|
||||
<%= used >= limit ? 'Лимит исчерпан' : 'Добавить' %>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Таблица -->
|
||||
<div class="table-wrap">
|
||||
<!-- Таблица -->
|
||||
<div class="card">
|
||||
<div class="card-header">Доверенные адреса</div>
|
||||
<% if (entries.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>
|
||||
<tr>
|
||||
<th style="width:1%;white-space:nowrap;">Адрес</th>
|
||||
<th>Комментарий</th>
|
||||
<th>Добавил</th>
|
||||
<th style="font-size:.78rem;width:1%;">Добавлена</th>
|
||||
<th style="text-align:center;white-space:nowrap;">Правка</th>
|
||||
<th style="width:1%;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (entries.length === 0) { %>
|
||||
<tr><td colspan="5" class="empty">Нет записей</td></tr>
|
||||
<% } %>
|
||||
<% entries.forEach(function(e) { %>
|
||||
<tr class="<%= e.deleted_at ? 'deleted' : '' %>">
|
||||
<td><code><%= e.value_cidr %></code></td>
|
||||
<td><%= e.comment || '' %></td>
|
||||
<td><%= e.created_by %></td>
|
||||
<td><%= new Date(e.created_at).toLocaleString('ru') %></td>
|
||||
<td>
|
||||
<tr style="<%= e.deleted_at ? 'opacity:.5;text-decoration:line-through;' : '' %>">
|
||||
<td style="white-space:nowrap;"><code><%= e.value_cidr %></code><% if (e.deleted_at) { %> <span style="font-size:.7rem;color:var(--red);">уд.</span><% } %></td>
|
||||
<td><%= e.comment || '—' %></td>
|
||||
<td style="white-space:nowrap;"><%= e.created_by %></td>
|
||||
<td style="font-size:.72rem;line-height:1.2;"><%= new Date(e.created_at).toLocaleString('ru', {timeZone:'Europe/Moscow'}) %> МСК</td>
|
||||
<td style="text-align:center;white-space:nowrap;">
|
||||
<% if (!e.deleted_at) { %>
|
||||
<div class="inline-form">
|
||||
<form method="POST" action="/app/edit/<%= e.id %>" class="inline-form">
|
||||
<input name="cidr" value="<%= e.value_cidr %>" size="18" style="padding:.3rem .5rem;border:1px solid var(--border);border-radius:4px;font-size:.8rem;">
|
||||
<input name="comment" value="<%= e.comment || '' %>" size="16" style="padding:.3rem .5rem;border:1px solid var(--border);border-radius:4px;font-size:.8rem;">
|
||||
<button class="btn" style="font-size:.75rem;padding:.3rem .6rem;">изменить</button>
|
||||
<form method="POST" action="/v2/app/edit/<%= e.id %>" style="display:inline;">
|
||||
<input name="cidr" value="<%= e.value_cidr %>" size="18" style="width:125px;padding:.2rem .3rem;border:1px solid var(--border);border-radius:4px;font-size:.78rem;">
|
||||
<input name="comment" value="<%= e.comment || '' %>" size="30" style="width:200px;padding:.2rem .3rem;border:1px solid var(--border);border-radius:4px;font-size:.78rem;">
|
||||
<button class="btn btn-sm" title="Сохранить">💾</button>
|
||||
</form>
|
||||
<form method="POST" action="/app/delete/<%= e.id %>" style="display:inline;">
|
||||
<button class="btn btn-danger" style="font-size:.75rem;padding:.3rem .6rem;" onclick="return confirm('Удалить?')">удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<span style="color:var(--muted);font-size:.8rem;">удалено</span>
|
||||
<span style="color:var(--muted);font-size:.78rem;">удалена</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<% if (!e.deleted_at) { %>
|
||||
<form method="POST" action="/v2/app/delete/<%= e.id %>" style="display:inline;">
|
||||
<input type="hidden" name="deleted" value="<%= includeDeleted ? '1' : '0' %>">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Удалить?')" title="Удалить">🗑️</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<form method="POST" action="/v2/app/restore/<%= e.id %>" style="display:inline;">
|
||||
<input type="hidden" name="deleted" value="<%= includeDeleted ? '1' : '0' %>">
|
||||
<button type="submit" class="btn btn-sm" title="Восстановить" style="color:var(--green);border-color:var(--green);">↺</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<p style="margin-top:.5rem;text-align:center;">
|
||||
<a href="/app?deleted=<%= includeDeleted ? '0' : '1' %>" style="font-size:.85rem;color:var(--muted);"><%= includeDeleted ? 'Скрыть удалённые' : 'Показать удалённые' %></a>
|
||||
</p>
|
||||
<!-- Справка -->
|
||||
<details class="help" style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1rem;margin-bottom:1rem;">
|
||||
<summary style="cursor:pointer;font-weight:600;font-size:.95rem;color:var(--blue);">Доверенные адреса — защита вашего трафика во время DDoS-атак</summary>
|
||||
<div style="margin-top:.75rem;font-size:.85rem;line-height:1.6;color:var(--text);">
|
||||
<p>Когда на инфраструктуру провайдера идёт DDoS-атака, системы защиты автоматически фильтруют подозрительный трафик. Иногда под фильтрацию попадает и легитимный трафик, например, запросы из вашего офиса или от ваших партнёров.</p>
|
||||
<p>Чтобы этого не происходило, вы можете заранее указать адреса, которые провайдер гарантированно не заблокирует. Это называется списком доверенных адресов.</p>
|
||||
|
||||
<p style="font-weight:600;margin-top:.75rem;">Как пользоваться</p>
|
||||
<p><a href="https://whitelist.nodejsk8s.services.ngcloud.ru/" target="_blank">https://whitelist.nodejsk8s.services.ngcloud.ru/</a></p>
|
||||
<p>Зайти в сервис необходимо с учетной записью keycloak.</p>
|
||||
<p><strong>Добавить адрес.</strong> Нажмите «Добавить», введите IPv4-адрес или подсеть в формате CIDR и при желании — комментарий, чтобы не забыть, что это за адрес. Например: 203.0.113.10 или 203.0.113.0/24. Нажмите «Сохранить». Если вы вводите адрес хоста с маской подсети (например 203.0.113.5/24 вместо 203.0.113.0/24), сервис автоматически нормализует его до адреса подсети и сообщит вам об этом.</p>
|
||||
<p>Добавляйте комментарии к записям — через несколько месяцев вы скажете себе спасибо: будет сразу понятно, чей это адрес и зачем он добавлен.</p>
|
||||
<p><strong>Редактировать или удалить.</strong> Рядом с каждой записью есть кнопки редактирования и удаления. Удалённые записи перестают действовать через 5 минут.</p>
|
||||
|
||||
<p style="font-weight:600;margin-top:.75rem;">Ограничения</p>
|
||||
<ul style="padding-left:1.2rem;margin:.25rem 0;">
|
||||
<li>Только публичные IPv4-адреса. Серые адреса (192.168.x.x, 10.x.x.x, 172.16–31.x.x и другие служебные диапазоны) добавить нельзя — они не маршрутизируются в интернете и смысла в их добавлении нет.</li>
|
||||
<li>Только IPv4. IPv6-адреса и доменные имена не поддерживаются.</li>
|
||||
<li>Максимальный размер подсети — /22. Добавить префикс шире /22 (то есть /21, /20 и т.д.) нельзя.</li>
|
||||
<li>До 15 записей на компанию. Если вам нужно больше — обратитесь в поддержку.</li>
|
||||
<li>Нет дублей и пересечений. Если вы попытаетесь добавить адрес, который уже покрывается существующей записью (или наоборот), сервис предупредит об этом.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
|
||||
<footer style="display:flex;justify-content:space-between;align-items:center;padding:.5rem 1.5rem;font-size:.72rem;color:var(--muted);">
|
||||
<span><% if (user.isAdmin) { %>Для вывода списка: <a href="/v2/export?view=1" style="color:var(--muted);"><%= 'https://whitelist.nodejsk8s.services.ngcloud.ru/export' %></a><% } %></span>
|
||||
<span>v<%= version %></span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user