v0.5.2: multi-company, isAdmin fix, appVersion, docs

This commit is contained in:
2026-06-02 10:52:12 +03:00
parent 07528b0de3
commit a083f65323
13 changed files with 616 additions and 61 deletions
+12 -6
View File
@@ -243,13 +243,19 @@ function verifyOidcToken(token) {
}
function userFromPayload(payload) {
const clientId = payload.ClientID || payload.client_id || payload.preferred_username || 'UNKNOWN';
// clientid может быть "WZ01325" или "WZ01325, WZ54321" (ТЗ-плюс §6.1)
const rawClientId = payload.ClientID || payload.client_id || payload.preferred_username || 'UNKNOWN';
const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
const activeClientId = allClientIds[0] || 'UNKNOWN';
return {
email: payload.email || payload.login || 'unknown',
clientId,
companyId: payload.company_id || payload.sub || null,
companyName: payload.company_name || clientId,
isAdmin: clientId === ADMIN_CLIENT_ID,
email: payload.email || payload.login || 'unknown',
clientId: activeClientId, // активная компания (первая в списке)
allClientIds, // все компании пользователя (массив)
activeClientId, // дубль для ясности
companyId: payload.company_id || payload.sub || null,
companyName: payload.company_name || activeClientId,
isAdmin: activeClientId === ADMIN_CLIENT_ID,
};
}
+13
View File
@@ -19,6 +19,7 @@ const MOCK_USERS = [
id: 'admin',
label: 'Администратор (WZ01112)',
role: 'admin',
isAdmin: true,
clientId: 'WZ01112',
companyId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
companyName: 'Nubes Admin',
@@ -28,15 +29,27 @@ const MOCK_USERS = [
id: 'test',
label: 'Тест (WZ01325)',
role: 'user',
isAdmin: false,
clientId: 'WZ01325',
companyId: '3e64aac6-dcfc-4082-88dc-da19c86555a5',
companyName: 'Тест',
email: 'tazet@narod.ru',
},
{
id: 'multi',
label: 'Мульти-компания (WZ01325, WZ02001)',
role: 'user',
isAdmin: false,
clientId: 'WZ01325, WZ02001',
companyId: '3e64aac6-dcfc-4082-88dc-da19c86555a5',
companyName: 'Тест',
email: 'multi@example.com',
},
{
id: 'client2',
label: 'Компания 2 (WZ02001)',
role: 'user',
isAdmin: false,
clientId: 'WZ02001',
companyId: '11111111-2222-3333-4444-555555555555',
companyName: 'Вторая Компания',
+14 -5
View File
@@ -78,6 +78,9 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USER
companyName: user.companyName,
isAdmin: user.role === 'admin',
};
// Добавляем allClientIds для поддержки мульти-компании
userData.allClientIds = user.clientId.split(',').map(s => s.trim()).filter(Boolean);
userData.activeClientId = userData.allClientIds[0] || user.clientId;
// session.regenerate() меняет идентификатор сессии — защита от session fixation (A07).
// Данные пишем уже в новую сессию.
@@ -167,12 +170,15 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USER
if (!mockUser) {
return res.redirect('/dev-login?error=' + encodeURIComponent('Пользователь не найден'));
}
const allClientIds = mockUser.clientId.split(',').map(s => s.trim()).filter(Boolean);
user = {
email: mockUser.email,
clientId: mockUser.clientId,
companyId: mockUser.companyId,
companyName: mockUser.companyName,
isAdmin: mockUser.role === 'admin',
email: mockUser.email,
clientId: mockUser.clientId,
allClientIds,
activeClientId: allClientIds[0] || mockUser.clientId,
companyId: mockUser.companyId,
companyName: mockUser.companyName,
isAdmin: mockUser.role === 'admin',
};
} else {
// Произвольные поля
@@ -180,9 +186,12 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USER
if (!clientId) {
return res.redirect('/dev-login?error=' + encodeURIComponent('ClientID обязателен'));
}
const allClientIds = clientId.split(',').map(s => s.trim()).filter(Boolean);
user = {
email: (req.body.email || '').trim() || clientId + '@dev.local',
clientId,
allClientIds,
activeClientId: allClientIds[0] || clientId,
companyId: (req.body.companyId || '').trim() || null,
companyName: (req.body.companyName || '').trim() || clientId,
isAdmin: req.body.isAdmin === 'on',
+39 -27
View File
@@ -36,25 +36,32 @@ function createRouter({ q, doubleCsrfProtection, mutationLimiter, generateCsrfTo
// Admin: видит все компании + переключатель ?company=<числовой PK>.
// User: только своя компания.
router.get('/', async (req, res) => {
const { clientId, companyName, isAdmin } = req.user;
const { clientId, companyName, isAdmin, allClientIds, activeClientId } = req.user;
// ── Переключатель компании для обычного пользователя ─────────────────
// ?switchTo=WZ02001 — меняет активную компанию в сессии (только если есть в allClientIds)
const switchTo = (req.query.switchTo || '').trim();
let effectiveClientId = clientId;
let effectiveCompanyName = companyName;
if (!isAdmin && switchTo && allClientIds && allClientIds.includes(switchTo)) {
effectiveClientId = switchTo;
effectiveCompanyName = switchTo; // companyName обновится из БД через getOrCreateCompany
req.session.user.clientId = switchTo;
req.session.user.activeClientId = switchTo;
}
// Генерируем CSRF-токен здесь, один раз для всей страницы.
// Он попадёт в три формы: /add, /edit (modal), /delete (каждая строка).
const csrfToken = generateCsrfToken(req, res);
try {
if (isAdmin) {
// ── Ветка admin ───────────────────────────────────────────────────────
// Загружаем все компании (с active_count через LEFT JOIN)
const companies = await q.getAllCompanies();
// ?company=X — числовой PK выбранной компании.
// Если параметра нет — берём первую компанию из списка.
const selectedId = req.query.company
? parseInt(req.query.company, 10)
: (companies[0] ? companies[0].id : null);
// Ищем объект компании по id. Если id не найден — берём первую.
const selectedCompany = companies.find(c => c.id === selectedId) || companies[0] || null;
const entries = selectedCompany ? await q.listEntries(selectedCompany.id) : [];
@@ -63,46 +70,51 @@ function createRouter({ q, doubleCsrfProtection, mutationLimiter, generateCsrfTo
return res.render('index', {
entries,
limit,
used: entries.length,
user: req.user,
isAdmin: true,
companies, // для dropdown-переключателя
selectedCompany, // текущая выбранная компания
error: req.query.error || null,
message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1',
used: entries.length,
user: req.user,
isAdmin: true,
companies, // dropdown-переключатель всех компаний в БД
selectedCompany,
allClientIds: null, // admin использует companies (не clientIds)
activeClientId: null,
error: req.query.error || null,
message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1',
csrfToken,
});
}
// ── Ветка обычного пользователя ────────────────────────────────────────
// getOrCreateCompany: создаёт запись в companies если её ещё нет.
// clientId и companyName берутся из JWT (нельзя подменить через форму).
const company = await q.getOrCreateCompany(clientId, companyName);
const company = await q.getOrCreateCompany(effectiveClientId, effectiveCompanyName);
const limit = await q.getLimit(company);
const entries = await q.listEntries(company.id);
// Если у пользователя несколько компаний — готовим список для переключателя
const hasMultiple = allClientIds && allClientIds.length > 1;
res.render('index', {
entries,
limit,
used: entries.length,
user: req.user,
isAdmin: false,
companies: null, // пользователь не видит список компаний
selectedCompany: company, // его единственная компания
error: req.query.error || null,
message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1',
used: entries.length,
user: req.user,
isAdmin: false,
companies: null,
selectedCompany: company,
allClientIds: hasMultiple ? allClientIds : null,
activeClientId: effectiveClientId,
error: req.query.error || null,
message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1',
csrfToken,
});
} catch (e) {
console.error('GET / error:', e);
// При ошибке рендерим страницу с сообщением, не падаем с 500
res.render('index', {
entries: [], limit: 15, used: 0,
user: req.user, isAdmin,
companies: null, selectedCompany: null,
allClientIds: null, activeClientId: null,
error: 'Ошибка загрузки данных: ' + e.message,
message: null,
wasNormalized: false,