v0.5.166: /export→/v2/export редирект, экспорт только админам
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ui/api-client.js — HTTP-клиент к /api/v1/*.
|
||||
*
|
||||
* UI-роуты не ходят напрямую в БД — только сюда.
|
||||
* Токен берётся из req.session.token (установлен на /login).
|
||||
*
|
||||
* Все ошибки пробрасываются как Error с понятным message.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const API_BASE = process.env.API_BASE || 'http://localhost:' + (process.env.PORT || 3000);
|
||||
|
||||
/**
|
||||
* Выполнить запрос к API.
|
||||
* @param {string} method — GET, POST, PATCH, DELETE
|
||||
* @param {string} path — /api/v1/entries
|
||||
* @param {string} token — Bearer token
|
||||
* @param {object} [body] — JSON body (для POST/PATCH)
|
||||
* @returns {Promise<{status: number, data: any}>}
|
||||
*/
|
||||
function apiRequest(method, path, token, body, impHeaders) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(API_BASE + path);
|
||||
const lib = url.protocol === 'https:' ? https : http;
|
||||
const bodyStr = body ? JSON.stringify(body) : null;
|
||||
|
||||
const opts = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
};
|
||||
// Проброс контекста имперсонации из UI в API-слой
|
||||
if (impHeaders) {
|
||||
if (impHeaders.impEmail) opts.headers['X-Imp-Email'] = impHeaders.impEmail;
|
||||
if (impHeaders.impOriginalEmail) opts.headers['X-Imp-OriginalEmail'] = impHeaders.impOriginalEmail;
|
||||
if (impHeaders.impClientId) opts.headers['X-Imp-ClientId'] = impHeaders.impClientId;
|
||||
}
|
||||
if (bodyStr) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.headers['Content-Length'] = Buffer.byteLength(bodyStr);
|
||||
}
|
||||
|
||||
const req = lib.request(opts, (res) => {
|
||||
let raw = '';
|
||||
res.on('data', c => { raw += c; });
|
||||
res.on('end', () => {
|
||||
const ct = res.headers['content-type'] || '';
|
||||
try {
|
||||
const data = ct.includes('application/json') ? JSON.parse(raw) : raw;
|
||||
resolve({ status: res.statusCode, data });
|
||||
} catch (e) {
|
||||
reject(new Error('Invalid JSON from API: ' + e.message));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.setTimeout(15000, () => { req.destroy(); reject(new Error('API timeout')); });
|
||||
req.on('error', reject);
|
||||
if (bodyStr) req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Хелперы — один вызов на метод
|
||||
const api = {
|
||||
get: (path, token, imp) => apiRequest('GET', path, token, null, imp),
|
||||
post: (path, token, body, imp) => apiRequest('POST', path, token, body, imp),
|
||||
patch: (path, token, body, imp) => apiRequest('PATCH', path, token, body, imp),
|
||||
delete: (path, token, imp) => apiRequest('DELETE', path, token, null, imp),
|
||||
|
||||
// Достать Bearer из сессии
|
||||
token: (req) => req.session && req.session.token,
|
||||
|
||||
// Построить заголовки имперсонации из req.user (для проброса в API-слой)
|
||||
impHeaders: (req) => {
|
||||
if (!req.user || !req.user.isImpersonated) return null;
|
||||
return {
|
||||
impEmail: req.user.email,
|
||||
impOriginalEmail: req.user.originalUserEmail,
|
||||
impClientId: req.user.activeClientId || req.user.clientId,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,121 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ui/index.js — UI-слой: SSR через EJS, данные из /api/v1/*.
|
||||
*
|
||||
* createUiRouter({ auth, MOCK_USERS, authLimiter }) → Express Router.
|
||||
*
|
||||
* Монтируется в server.js ПОСЛЕ /api/v1/.
|
||||
*
|
||||
* Middleware:
|
||||
* 1. requireToken — если нет session.token → redirect /login
|
||||
* 2. resolveUser — декодирует payload из токена → req.user
|
||||
* (без проверки подписи; API валидирует при каждом запросе)
|
||||
*
|
||||
* Если API возвращает 401 → сессия уничтожается → redirect /login.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { Router } = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { createRouter: createAuthRouter } = require('./routes/auth');
|
||||
const { createRouter: createEntriesRouter } = require('./routes/entries');
|
||||
const { createRouter: createAdminRouter } = require('./routes/admin');
|
||||
const { createRouter: createExportRouter } = require('./routes/export');
|
||||
const api = require('./api-client');
|
||||
|
||||
const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112';
|
||||
|
||||
function createUiRouter({ auth, MOCK_USERS, authLimiter }) {
|
||||
const router = Router();
|
||||
|
||||
// ── Публичные маршруты: /login, /login-token, /logout ────────────────────
|
||||
router.use('/', createAuthRouter({ auth, MOCK_USERS, authLimiter }));
|
||||
|
||||
// ── requireToken: все маршруты ниже требуют session.token ────────────────
|
||||
router.use((req, res, next) => {
|
||||
const token = api.token(req);
|
||||
if (!token) {
|
||||
return res.redirect('/login?returnTo=' + encodeURIComponent(req.originalUrl));
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// ── resolveUser: payload → req.user ──────────────────────────────────────
|
||||
// Подпись не проверяем здесь — API проверяет при каждом запросе.
|
||||
router.use((req, res, next) => {
|
||||
if (req.user) return next();
|
||||
try {
|
||||
const payload = jwt.decode(api.token(req));
|
||||
if (!payload) throw new Error('empty payload');
|
||||
const rawClientId = payload.ClientID || payload.clientId || payload.sub || '';
|
||||
const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
|
||||
// activeClientId из сессии (переключатель компании) или первая из токена
|
||||
const sessionActive = req.session && req.session.user && req.session.user.activeClientId;
|
||||
const activeClientId = (sessionActive && allClientIds.includes(sessionActive))
|
||||
? sessionActive
|
||||
: (allClientIds[0] || rawClientId);
|
||||
|
||||
// 🔮 isAdmin через IAM, ВРЕМЕННО: мой email = админ
|
||||
const iamAdmin = req.session && req.session.user && req.session.user.isAdmin;
|
||||
const isNail = payload.email === 'ntazetdinov@nubes.ru' || payload.login === 'ntazetdinov@nubes.ru';
|
||||
const canAdminMode = !!(iamAdmin && activeClientId === ADMIN_CLIENT_ID)
|
||||
|| (isNail && activeClientId === ADMIN_CLIENT_ID);
|
||||
const sessionIsImpersonated = req.session && req.session.user && req.session.user.isImpersonated;
|
||||
|
||||
// ── TEST IMPERSONATION (env vars) — закомментировать для прода ─────
|
||||
const impOriginal = process.env.IMPERSONATION_ORIGINAL || '';
|
||||
const impTarget = process.env.IMPERSONATION_TARGET || '';
|
||||
const impCompany = process.env.IMPERSONATION_COMPANY || '';
|
||||
const email = req.session?.user?.email || payload.email || payload.login || activeClientId + '@unknown';
|
||||
const isTestImp = impOriginal && email === impOriginal;
|
||||
const finalActiveClientId = (isTestImp && impCompany) ? impCompany : activeClientId;
|
||||
// ── /TEST IMPERSONATION ─────────────────────────────────────────────
|
||||
|
||||
req.user = {
|
||||
clientId: rawClientId,
|
||||
allClientIds,
|
||||
activeClientId: finalActiveClientId,
|
||||
companyId: payload.company_id || payload.companyId || null,
|
||||
companyName: payload.company_name || payload.companyName || finalActiveClientId,
|
||||
email: (isTestImp && impTarget) ? impTarget : email,
|
||||
isAdmin: iamAdmin || isNail,
|
||||
isImpersonated: !!sessionIsImpersonated || isTestImp,
|
||||
originalUserEmail: req.session?.user?.originalUserEmail || (isTestImp ? impOriginal : ''),
|
||||
originalUserFullName: req.session?.user?.originalUserFullName || '',
|
||||
canAdminMode,
|
||||
adminMode: !!(req.session && req.session.adminMode),
|
||||
};
|
||||
} catch (e) {
|
||||
req.session.destroy(() =>
|
||||
res.redirect('/login?error=' + encodeURIComponent('Токен недействителен, войдите заново'))
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// ── Основные маршруты (все за requireToken + resolveUser) ─────────────────
|
||||
router.use('/', createEntriesRouter());
|
||||
router.use('/', createExportRouter());
|
||||
router.use('/', createAdminRouter());
|
||||
|
||||
// ── 404 ───────────────────────────────────────────────────────────────────
|
||||
router.use((req, res) => {
|
||||
res.status(404).render('error', { status: 404, message: 'Страница не найдена' });
|
||||
});
|
||||
|
||||
// ── Общий обработчик ошибок ───────────────────────────────────────────────
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
router.use((err, req, res, next) => {
|
||||
const msg = err instanceof Error ? (err.message || err.stack || String(err)) : JSON.stringify(err);
|
||||
console.error('[UI] Error:', msg);
|
||||
res.status(500).render('error', { status: 500, message: 'Ошибка: ' + msg.slice(0, 500) });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createUiRouter };
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ui/routes/admin.js — /admin, /audit (только для администратора).
|
||||
* /export — доступен всем аутентифицированным (смотри ui/routes/export.js).
|
||||
*
|
||||
* Данные из:
|
||||
* GET /api/v1/companies
|
||||
* PATCH /api/v1/companies/:id/limit
|
||||
* GET /api/v1/audit
|
||||
*/
|
||||
|
||||
const { Router } = require('express');
|
||||
const api = require('../api-client');
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.user || !req.user.adminMode) {
|
||||
return res.status(403).render('error', {
|
||||
status: 403,
|
||||
message: 'Доступ запрещён — требуются права администратора.',
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function createRouter() {
|
||||
const router = Router();
|
||||
router.use(requireAdmin);
|
||||
|
||||
// GET /admin — список компаний с лимитами
|
||||
router.get('/admin', async (req, res) => {
|
||||
const token = api.token(req);
|
||||
try {
|
||||
const r = await api.get('/api/v1/companies', token);
|
||||
const companies = r.data.companies || [];
|
||||
const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15;
|
||||
res.render('admin', {
|
||||
user: req.user,
|
||||
companies,
|
||||
defaultLimit,
|
||||
error: req.query.error || null,
|
||||
success: req.query.success || null,
|
||||
csrfToken: '',
|
||||
});
|
||||
} catch (e) {
|
||||
const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15;
|
||||
res.render('admin', {
|
||||
user: req.user, companies: [], defaultLimit,
|
||||
error: 'Ошибка загрузки: ' + e.message, success: null, csrfToken: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /admin/limit/:id — установить лимит компании
|
||||
router.post('/admin/limit/:id', async (req, res) => {
|
||||
const token = api.token(req);
|
||||
const rawLimit = req.body.limit;
|
||||
const limit = (rawLimit === '' || rawLimit == null) ? null : parseInt(rawLimit, 10);
|
||||
try {
|
||||
const r = await api.patch('/api/v1/companies/' + req.params.id + '/limit', token, { limit });
|
||||
if (r.status === 200) return res.redirect('/admin?success=' + encodeURIComponent('Лимит обновлён'));
|
||||
res.redirect('/admin?error=' + encodeURIComponent(r.data.error || 'Ошибка'));
|
||||
} catch (e) {
|
||||
res.redirect('/admin?error=' + encodeURIComponent(e.message));
|
||||
}
|
||||
});
|
||||
|
||||
// GET /audit — журнал действий
|
||||
router.get('/audit', async (req, res) => {
|
||||
const token = api.token(req);
|
||||
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
|
||||
try {
|
||||
const r = await api.get('/api/v1/audit' + (companyId ? '?company=' + companyId : ''), token);
|
||||
res.render('audit', {
|
||||
user: req.user,
|
||||
rows: r.data.rows || [],
|
||||
companies: r.data.companies || [],
|
||||
selectedCompanyId: companyId,
|
||||
error: null,
|
||||
});
|
||||
} catch (e) {
|
||||
res.render('audit', {
|
||||
user: req.user, rows: [], companies: [],
|
||||
selectedCompanyId: null, error: 'Ошибка загрузки: ' + e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createRouter };
|
||||
@@ -0,0 +1,191 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ui/routes/auth.js — /login, /logout для UI-слоя.
|
||||
*
|
||||
* Два режима:
|
||||
* Mock (DEV_MODE=true) — страница с пресетами MOCK_USERS.
|
||||
* Выбранный пользователь → генерируем mock Bearer token → session.token.
|
||||
*
|
||||
* Token (всегда доступен) — форма вставки реального Bearer токена от auth-api/KC.
|
||||
* Вставленный токен → session.token (без проверки подписи здесь,
|
||||
* API сам проверит при первом запросе).
|
||||
*
|
||||
* /logout — уничтожает сессию.
|
||||
*/
|
||||
|
||||
const { Router } = require('express');
|
||||
const { safeReturn } = require('../../src/auth');
|
||||
|
||||
function createRouter({ auth, MOCK_USERS, authLimiter }) {
|
||||
const router = Router();
|
||||
const devMode = () => process.env.DEV_MODE === 'true';
|
||||
|
||||
// GET /login
|
||||
router.get('/login', (req, res) => {
|
||||
if (req.session && req.session.token) return res.redirect('/');
|
||||
res.render('ui-login', {
|
||||
devMode: devMode(),
|
||||
MOCK_USERS: devMode() ? MOCK_USERS : [],
|
||||
error: req.query.error || null,
|
||||
returnTo: req.query.returnTo || '/',
|
||||
appVersion: require('../../package.json').version,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /login — mock user
|
||||
router.post('/login', authLimiter, (req, res) => {
|
||||
const { clientId, returnTo } = req.body;
|
||||
if (!clientId) return res.redirect('/login?error=Выберите+пользователя');
|
||||
|
||||
const user = MOCK_USERS.find(u => u.clientId === clientId);
|
||||
if (!user) return res.redirect('/login?error=Пользователь+не+найден');
|
||||
|
||||
// Разбор clientId с запятой (мульти-компания)
|
||||
const allClientIds = user.clientId.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const activeClientId = allClientIds[0] || user.clientId;
|
||||
|
||||
const token = auth.issueMockToken({
|
||||
clientId: user.clientId,
|
||||
companyId: user.companyId || '00000000-0000-0000-0000-000000000001',
|
||||
companyName: user.companyName || user.clientId,
|
||||
email: user.email || user.clientId + '@mock.local',
|
||||
});
|
||||
|
||||
req.session.token = token;
|
||||
req.session.user = {
|
||||
clientId: user.clientId,
|
||||
allClientIds,
|
||||
activeClientId,
|
||||
email: user.email || user.clientId + '@mock.local',
|
||||
companyId: user.companyId || '00000000-0000-0000-0000-000000000001',
|
||||
companyName: user.companyName || user.clientId,
|
||||
isAdmin: user.isAdmin || user.role === 'admin',
|
||||
};
|
||||
res.redirect(safeReturn(returnTo) || '/');
|
||||
});
|
||||
|
||||
// GET /login-token — редирект на /login (форма вставки токена там же)
|
||||
router.get('/login-token', (req, res) => res.redirect('/login'));
|
||||
|
||||
// POST /login-token — вход по Bearer JWT токену (реальному) или clientId (отладка)
|
||||
router.post('/login-token', authLimiter, async (req, res) => {
|
||||
const { token, clientId, returnTo } = req.body;
|
||||
|
||||
// ── Вход по реальному JWT токену ──────────────────────────────────────
|
||||
if (token && token.trim()) {
|
||||
const raw = token.trim().replace(/^Bearer\s+/i, '');
|
||||
// Минимальная проверка: это JWT (3 части, разделённые точками)
|
||||
const parts = raw.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return res.redirect('/login?error=Невалидный+JWT+токен');
|
||||
}
|
||||
// Декодируем payload (без проверки подписи — API проверит)
|
||||
let payload;
|
||||
try {
|
||||
const buf = Buffer.from(parts[1], 'base64url');
|
||||
payload = JSON.parse(buf.toString());
|
||||
if (!payload || typeof payload !== 'object') throw new Error('empty');
|
||||
} catch (_) {
|
||||
return res.redirect('/login?error=Невалидный+payload+токена');
|
||||
}
|
||||
|
||||
// Извлекаем данные пользователя из payload
|
||||
const rawClientId = payload.ClientID || payload.client_id || payload.preferred_username || payload.sub || '';
|
||||
const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const activeClientId = allClientIds[0] || rawClientId;
|
||||
|
||||
req.session.token = raw;
|
||||
|
||||
// Пытаемся обогатить через IAM API (только в OIDC-режиме)
|
||||
if (auth.isOidc) {
|
||||
try {
|
||||
const iamData = await auth.fetchIamUser(raw);
|
||||
req.session.user = {
|
||||
email: iamData.email,
|
||||
clientId: iamData.clientId,
|
||||
allClientIds: iamData.allClientIds,
|
||||
activeClientId: iamData.clientId,
|
||||
activeProfileId: iamData.activeProfileId,
|
||||
companyId: iamData.companyId,
|
||||
companyName: iamData.companyName,
|
||||
isAdmin: iamData.isAdmin,
|
||||
fio: iamData.fio,
|
||||
profiles: iamData.profiles,
|
||||
};
|
||||
console.log('[login-token] IAM enrichment OK:', iamData.email, iamData.clientId);
|
||||
} catch (iamErr) {
|
||||
// IAM недоступен — fallback из JWT
|
||||
console.warn('[login-token] IAM enrichment failed, using JWT fallback:', iamErr.message);
|
||||
}
|
||||
}
|
||||
// Fallback (всегда): базовый user из JWT
|
||||
if (!req.session.user) {
|
||||
req.session.user = {
|
||||
clientId: rawClientId,
|
||||
allClientIds,
|
||||
activeClientId,
|
||||
email: payload.email || payload.login || activeClientId + '@unknown',
|
||||
companyId: payload.company_id || payload.companyId || null,
|
||||
companyName: payload.company_name || payload.companyName || activeClientId,
|
||||
isAdmin: activeClientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112'),
|
||||
};
|
||||
}
|
||||
return res.redirect(safeReturn(returnTo) || '/');
|
||||
}
|
||||
|
||||
// ── Вход по clientId (отладка, mock) ───────────────────────────────────
|
||||
if (!clientId || !clientId.trim()) return res.redirect('/login?error=Введите+clientId');
|
||||
|
||||
// В OIDC-режиме mock-токен бессмысленен — редиректим на KC
|
||||
if (auth.isOidc) {
|
||||
return res.redirect('/login?error=Вход+по+clientId+недоступен+в+SSO-режиме.+Войдите+через+Keycloak.');
|
||||
}
|
||||
|
||||
const trimmed = clientId.trim();
|
||||
const allClientIds = trimmed.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const activeClientId = allClientIds[0];
|
||||
|
||||
const mockToken = auth.issueMockToken({
|
||||
clientId: trimmed,
|
||||
companyId: '00000000-0000-0000-0000-000000000001',
|
||||
companyName: activeClientId,
|
||||
email: activeClientId + '@debug.local',
|
||||
});
|
||||
|
||||
req.session.token = mockToken;
|
||||
req.session.user = {
|
||||
clientId: trimmed,
|
||||
allClientIds,
|
||||
activeClientId,
|
||||
email: activeClientId + '@debug.local',
|
||||
companyId: '00000000-0000-0000-0000-000000000001',
|
||||
companyName: activeClientId,
|
||||
isAdmin: activeClientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112'),
|
||||
};
|
||||
res.redirect(safeReturn(returnTo) || '/');
|
||||
});
|
||||
|
||||
// ── Переключатель режима администратора ──────────────────────────────
|
||||
// 🔮 isAdmin будет из IAM, временно: isAdmin || clientId === WZ01112
|
||||
router.post('/toggle-admin', (req, res) => {
|
||||
const canAdmin = req.session.user && (
|
||||
req.session.user.isAdmin ||
|
||||
req.session.user.clientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112')
|
||||
);
|
||||
if (!canAdmin) return res.status(403).send('Forbidden');
|
||||
|
||||
req.session.adminMode = !req.session.adminMode;
|
||||
// Редирект туда же откуда пришли, или на главную
|
||||
res.redirect(req.get('Referer') || '/');
|
||||
});
|
||||
|
||||
// GET /logout
|
||||
router.get('/logout', (req, res) => {
|
||||
req.session.destroy(() => res.redirect('/login'));
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createRouter };
|
||||
@@ -0,0 +1,220 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ui/routes/entries.js — главная страница: список записей + CRUD.
|
||||
*
|
||||
* Данные из /api/v1/entries (Bearer token из сессии).
|
||||
* Admin: ?company=<id> (числовой PK) для управления конкретной компанией.
|
||||
* - Без параметра: ищем компанию admin в списке по clientId.
|
||||
* Если не найдена (первый вход) — один GET /api/v1/entries создаёт её,
|
||||
* потом редирект на числовой ID.
|
||||
* - С параметром: грузим записи этой компании через admin-ветку API.
|
||||
* User: без параметра, API сам определяет компанию по clientId из токена.
|
||||
*/
|
||||
|
||||
const { Router } = require('express');
|
||||
const api = require('../api-client');
|
||||
|
||||
function createRouter() {
|
||||
const router = Router();
|
||||
|
||||
// URL для возврата после CRUD — всегда с числовым company ID
|
||||
function backUrl(req) {
|
||||
const id = req.query.company || (req.body && req.body.company_id);
|
||||
return id ? '/?company=' + id : '/';
|
||||
}
|
||||
|
||||
// Суффикс ?company=<id> для API — только числовой ID или пусто
|
||||
function companyQuery(req) {
|
||||
const id = req.query.company || (req.body && req.body.company_id);
|
||||
if (id) return '?company=' + id;
|
||||
// Мульти-компания: передаём активный client_id
|
||||
if (!req.user.isAdmin && req.user.allClientIds && req.user.allClientIds.length > 1) {
|
||||
return '?client_id=' + encodeURIComponent(req.user.activeClientId || req.user.clientId);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// GET / — список записей
|
||||
router.get('/', async (req, res) => {
|
||||
const token = api.token(req);
|
||||
const imp = api.impHeaders(req);
|
||||
|
||||
// ── Переключатель компании через IAM ────────────────────────────────
|
||||
const switchTo = (req.query.switchTo || '').trim();
|
||||
if (!req.user.isAdmin && switchTo && req.user.profiles && req.user.profiles.length > 1) {
|
||||
const targetProfile = req.user.profiles.find(p => p.client_id === switchTo);
|
||||
if (targetProfile) {
|
||||
try {
|
||||
const { switchProfile } = require('../../src/auth');
|
||||
await switchProfile(token, targetProfile.id);
|
||||
// Обновляем сессию: новый активный профиль
|
||||
req.session.user.activeClientId = switchTo;
|
||||
req.session.user.clientId = switchTo;
|
||||
req.session.user.companyName = targetProfile.company_name;
|
||||
req.session.user.companyId = targetProfile.company_id;
|
||||
req.session.user.activeProfileId = targetProfile.id;
|
||||
// Обновляем is_active_profile в массиве
|
||||
req.session.user.profiles.forEach(p => {
|
||||
p.is_active_profile = (p.client_id === switchTo);
|
||||
});
|
||||
req.user.activeClientId = switchTo;
|
||||
req.user.clientId = switchTo;
|
||||
} catch (e) {
|
||||
console.warn('[entries] switchProfile failed:', e.message);
|
||||
// Локальный fallback без IAM
|
||||
req.session.user.activeClientId = switchTo;
|
||||
if (req.session.user.clientId) req.session.user.clientId = switchTo;
|
||||
req.user.activeClientId = switchTo;
|
||||
}
|
||||
}
|
||||
} else if (!req.user.isAdmin && switchTo && req.user.allClientIds && req.user.allClientIds.includes(switchTo)) {
|
||||
// Fallback: без profiles (mock-режим или IAM не ответил)
|
||||
req.session.user.activeClientId = switchTo;
|
||||
if (req.session.user.clientId) req.session.user.clientId = switchTo;
|
||||
req.user.activeClientId = switchTo;
|
||||
}
|
||||
|
||||
try {
|
||||
let entries = [], limit = 15, selectedCompany = null, companies = [];
|
||||
|
||||
if (req.user.adminMode) {
|
||||
const cr = await api.get('/api/v1/companies', token, imp);
|
||||
companies = cr.data.companies || [];
|
||||
|
||||
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
|
||||
|
||||
if (companyId) {
|
||||
selectedCompany = companies.find(c => c.id === companyId) || null;
|
||||
}
|
||||
// Если компания не выбрана — показываем список компаний, без записей
|
||||
// (админ выбирает компанию из выпадающего списка)
|
||||
|
||||
if (selectedCompany) {
|
||||
const includeDeleted = req.query.includeDeleted === 'true' ? '&includeDeleted=true' : '';
|
||||
const er = await api.get('/api/v1/entries?company=' + selectedCompany.id + includeDeleted, token, imp);
|
||||
entries = er.data.entries || [];
|
||||
limit = er.data.limit || 15;
|
||||
}
|
||||
} else {
|
||||
// Обычный пользователь: запрашиваем записи для активной компании
|
||||
const activeClientId = req.user.activeClientId || req.user.clientId;
|
||||
const clientIdParam = req.user.allClientIds && req.user.allClientIds.length > 1
|
||||
? '?client_id=' + encodeURIComponent(activeClientId)
|
||||
: '';
|
||||
const er = await api.get('/api/v1/entries' + clientIdParam, token, imp);
|
||||
entries = er.data.entries || [];
|
||||
limit = er.data.limit || 15;
|
||||
}
|
||||
|
||||
const hasMultiple = !req.user.isAdmin && req.user.allClientIds && req.user.allClientIds.length > 1;
|
||||
const profiles = req.user.profiles || [];
|
||||
|
||||
res.render('index', {
|
||||
entries,
|
||||
limit,
|
||||
used: entries.length,
|
||||
user: req.user,
|
||||
isAdmin: req.user.isAdmin,
|
||||
adminMode: req.user.adminMode,
|
||||
companies,
|
||||
selectedCompany,
|
||||
canAdminMode: req.user.canAdminMode,
|
||||
isImpersonated: req.user.isImpersonated,
|
||||
originalUserEmail: req.user.originalUserEmail,
|
||||
allClientIds: hasMultiple ? req.user.allClientIds : null,
|
||||
activeClientId: req.user.activeClientId || req.user.clientId,
|
||||
profiles, // из IAM — для UI переключателя
|
||||
error: req.query.error || null,
|
||||
success: req.query.success || null,
|
||||
lastValue: req.query.lastValue || '',
|
||||
lastComment: req.query.lastComment || '',
|
||||
includeDeleted: req.query.includeDeleted === 'true',
|
||||
csrfToken: '',
|
||||
});
|
||||
} catch (e) {
|
||||
res.render('index', {
|
||||
entries: [], limit: 15, used: 0,
|
||||
user: req.user, isAdmin: req.user.isAdmin, adminMode: req.user.adminMode,
|
||||
canAdminMode: req.user.canAdminMode,
|
||||
isImpersonated: req.user.isImpersonated,
|
||||
originalUserEmail: req.user.originalUserEmail,
|
||||
companies: [], selectedCompany: null,
|
||||
allClientIds: null, activeClientId: null,
|
||||
error: 'Ошибка загрузки: ' + e.message,
|
||||
success: null, lastValue: '', lastComment: '',
|
||||
includeDeleted: false, csrfToken: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /add — создать запись
|
||||
router.post('/add', async (req, res) => {
|
||||
const token = api.token(req);
|
||||
const imp = api.impHeaders(req);
|
||||
const { value, comment } = req.body;
|
||||
const cq = companyQuery(req);
|
||||
const back = backUrl(req);
|
||||
try {
|
||||
const r = await api.post('/api/v1/entries' + cq, token, { value, comment }, imp);
|
||||
if (r.status === 201) {
|
||||
const msg = r.data.wasNormalized
|
||||
? 'Запись добавлена (CIDR нормализован до ' + r.data.entry.value_cidr + ')'
|
||||
: 'Запись добавлена';
|
||||
return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent(msg));
|
||||
}
|
||||
// Ошибка — возвращаем введённые значения чтобы не перепечатывать
|
||||
const err = (r.data && r.data.error) || 'Ошибка';
|
||||
res.redirect(back + (back.includes('?') ? '&' : '?') +
|
||||
'error=' + encodeURIComponent(err) +
|
||||
'&lastValue=' + encodeURIComponent(value || '') +
|
||||
'&lastComment=' + encodeURIComponent(comment || ''));
|
||||
} catch (e) {
|
||||
res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message));
|
||||
}
|
||||
});
|
||||
|
||||
// POST /edit/:id — обновить запись
|
||||
router.post('/edit/:id', async (req, res) => {
|
||||
const token = api.token(req);
|
||||
const imp = api.impHeaders(req);
|
||||
const { value, comment } = req.body;
|
||||
const cq = companyQuery(req);
|
||||
const back = backUrl(req);
|
||||
try {
|
||||
const r = await api.patch('/api/v1/entries/' + req.params.id + cq, token, { value, comment }, imp);
|
||||
if (r.status === 200) {
|
||||
return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent('Запись обновлена'));
|
||||
}
|
||||
// Ошибка — возвращаем введённые значения в модалку
|
||||
const err = (r.data && r.data.error) || 'Ошибка';
|
||||
res.redirect(back + (back.includes('?') ? '&' : '?') +
|
||||
'error=' + encodeURIComponent(err) +
|
||||
'&lastValue=' + encodeURIComponent(value || '') +
|
||||
'&lastComment=' + encodeURIComponent(comment || ''));
|
||||
} catch (e) {
|
||||
res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message));
|
||||
}
|
||||
});
|
||||
|
||||
// POST /delete/:id — удалить запись
|
||||
router.post('/delete/:id', async (req, res) => {
|
||||
const token = api.token(req);
|
||||
const imp = api.impHeaders(req);
|
||||
const cq = companyQuery(req);
|
||||
const back = backUrl(req);
|
||||
try {
|
||||
const r = await api.delete('/api/v1/entries/' + req.params.id + cq, token, imp);
|
||||
if (r.status === 204) {
|
||||
return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent('Запись удалена'));
|
||||
}
|
||||
res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent((r.data && r.data.error) || 'Ошибка'));
|
||||
} catch (e) {
|
||||
res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message));
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createRouter };
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ui/routes/export.js — GET /export.
|
||||
* Только для администратора (adminMode).
|
||||
* Проксирует GET /api/v1/entries/export (text/plain).
|
||||
*/
|
||||
|
||||
const { Router } = require('express');
|
||||
const api = require('../api-client');
|
||||
|
||||
function createRouter() {
|
||||
const router = Router();
|
||||
|
||||
router.get('/export', async (req, res) => {
|
||||
if (!req.user || !req.user.adminMode) {
|
||||
return res.status(403).send('Только для администратора');
|
||||
}
|
||||
const token = api.token(req);
|
||||
// Прокидываем query-параметры в API
|
||||
const params = new URLSearchParams();
|
||||
if (req.query.company) params.set('company', req.query.company);
|
||||
if (req.query.client_id) params.set('client_id', req.query.client_id);
|
||||
const qs = params.toString();
|
||||
const companyQuery = qs ? '?' + qs : '';
|
||||
try {
|
||||
const r = await api.get('/api/v1/entries/export' + companyQuery, token);
|
||||
res.set('Content-Type', 'text/plain; charset=utf-8');
|
||||
// ?view=1 → inline (просмотр), иначе → attachment (скачивание)
|
||||
// ?filename=X → своё имя, по умолчанию white-list.txt
|
||||
const fname = (req.query.filename || 'white-list.txt').replace(/[^\w\-_. ]/g, '_');
|
||||
const disp = req.query.view === '1' ? 'inline' : 'attachment';
|
||||
res.set('Content-Disposition', disp + '; filename="' + fname + '"');
|
||||
res.send(r.data);
|
||||
} catch (e) {
|
||||
res.status(500).send('Ошибка экспорта: ' + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createRouter };
|
||||
Reference in New Issue
Block a user