v0.5.2: multi-company, isAdmin fix, appVersion, docs
This commit is contained in:
@@ -7,6 +7,8 @@ __pycache__/
|
|||||||
.idea/
|
.idea/
|
||||||
Files/nubes_login.har
|
Files/nubes_login.har
|
||||||
|
|
||||||
|
token*.*
|
||||||
|
|
||||||
# Внутренняя аналитика — не для публичного доступа
|
# Внутренняя аналитика — не для публичного доступа
|
||||||
PROJECT-AUDIT.md
|
PROJECT-AUDIT.md
|
||||||
AGENT-DIAGNOSIS.md
|
AGENT-DIAGNOSIS.md
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# Keycloak Auth Reference — консолидированная справка
|
||||||
|
|
||||||
|
> Обновлено: 2026-06-02
|
||||||
|
> Файл-ориентир. Править по мере уточнения у DevOps и продвижения проекта.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Keycloak-эндпоинты
|
||||||
|
|
||||||
|
Realm: **`cloud`** на `keycloak.nubes.ru`
|
||||||
|
|
||||||
|
| Назначение | URL |
|
||||||
|
|---|---|
|
||||||
|
| Authorization | `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/auth` |
|
||||||
|
| Token exchange | `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/token` |
|
||||||
|
| JWKS (сертификаты) | `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/certs` |
|
||||||
|
| Logout | `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/logout` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Зарегистрированное приложение (OAuth client)
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|---|---|
|
||||||
|
| `client_id` | `deck-test.ngcloud.ru` |
|
||||||
|
|
||||||
|
Источник: HAR-файл `Files/nubes_login.har` (в query-параметрах auth request).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Схема аутентификации: Authorization Code Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Браузер → /login (приложение)
|
||||||
|
2. Приложение → 302 → Keycloak /auth?client_id=...&scope=openid&redirect_uri=...&state=...
|
||||||
|
3. Пользователь вводит логин/пароль в Keycloak
|
||||||
|
4. Пользователь вводит OTP (MFA)
|
||||||
|
5. Keycloak → 302 → /callback?code=...&state=...
|
||||||
|
6. Приложение → POST /token (code → access_token + refresh_token + id_token)
|
||||||
|
7. Валидация JWT через JWKS (RS256)
|
||||||
|
8. Извлечение claims → req.user → сессия
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Получение токена через API Gateway (альтернативный путь)
|
||||||
|
|
||||||
|
Из HAR: код обменивается не напрямую в Keycloak, а через **API Gateway ngcloud**:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://lk-api-gateway-test.ngcloud.ru/api/v1/iam/auth/token
|
||||||
|
Content-Type: application/json
|
||||||
|
Body: {"code": "<authorization_code>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "...",
|
||||||
|
"refresh_token": "...",
|
||||||
|
"expires_in": 43200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. JWT-токены
|
||||||
|
|
||||||
|
### 5.1. Токен из `Files/token.txt` (test-окружение)
|
||||||
|
|
||||||
|
| Поле | Значение |
|
||||||
|
|---|---|
|
||||||
|
| `iss` | `https://auth.k8s.ngcloud.ru` |
|
||||||
|
| `aud` | `backend` |
|
||||||
|
| `sub` | `tazetdinovn@gmail.com` |
|
||||||
|
| `name` | Наиль Тазетдинов |
|
||||||
|
| `email` | `tazetdinovn@gmail.com` |
|
||||||
|
| `expires_in` | 86400 (24ч) |
|
||||||
|
| `scope` | `openid` |
|
||||||
|
| `token_type` | `Bearer` |
|
||||||
|
|
||||||
|
Claims (массив):
|
||||||
|
- `""`
|
||||||
|
- `""`
|
||||||
|
- `wz03709-shturval-admin-iot-naeel` — namespace в k8s (Fission)
|
||||||
|
- `wz03709-shturval-admin-naeel-test-3` — ещё один namespace
|
||||||
|
|
||||||
|
> ⚠️ `ClientID` в этом токене **отсутствует** — это другой issuer (`auth.k8s.ngcloud.ru`), не Keycloak.
|
||||||
|
|
||||||
|
### 5.2. Токен из HAR (deck-test)
|
||||||
|
|
||||||
|
| Поле | Значение |
|
||||||
|
|---|---|
|
||||||
|
| `iss` | `auth-api` |
|
||||||
|
| `ClientID` | `WZ01325` |
|
||||||
|
| `company_id` | `3e64aac6-dcfc-4082-88dc-da19c86555a5` |
|
||||||
|
| `company_name` | Тест |
|
||||||
|
| `login` | `tazet@narod.ru` |
|
||||||
|
| `firstname` | Наиль |
|
||||||
|
| `lastname` | Тазетдинов |
|
||||||
|
| `email` | `tazet@narod.ru` |
|
||||||
|
|
||||||
|
### 5.3. Целевой формат токена (из ТЗ)
|
||||||
|
|
||||||
|
| Claim | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `ClientID` | Идентификатор компании (WZ-номер) |
|
||||||
|
| `company_id` | UUID компании |
|
||||||
|
| `company_name` | Название компании |
|
||||||
|
| `email` | Email пользователя (для аудита) |
|
||||||
|
| `groups` | ? (пока null) |
|
||||||
|
| `roles` | ? (пока null) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Роли
|
||||||
|
|
||||||
|
| Роль | ClientID | Права |
|
||||||
|
|---|---|---|
|
||||||
|
| **Администратор** | `WZ01112` | Все записи, просмотр аудита, экспорт |
|
||||||
|
| **Клиент** | любой другой WZ-номер | Только свои записи |
|
||||||
|
|
||||||
|
> Как именно ClientID попадает в токен — через группы Keycloak или маппер — **требует уточнения у DevOps**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Реализация в коде
|
||||||
|
|
||||||
|
### `src/auth.js` — два режима
|
||||||
|
|
||||||
|
| Режим | Условие | Как работает |
|
||||||
|
|---|---|---|
|
||||||
|
| **OIDC** | `KC_CLIENT_ID` + `KC_CLIENT_SECRET` заданы | Authorization Code Flow, JWKS-валидация |
|
||||||
|
| **Mock** | `KC_CLIENT_ID` пусто | Локальная RSA-пара, JWT из `MOCK_USERS` |
|
||||||
|
|
||||||
|
### `src/config.js` — Mock-пользователи
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
MOCK_USERS = [
|
||||||
|
{ clientId: 'WZ01325', companyName: 'Тест', email: 'tazet@narod.ru' },
|
||||||
|
{ clientId: 'WZ01112', companyName: 'Нубес', email: 'admin@nubes.ru' }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Два слоя аутентификации
|
||||||
|
|
||||||
|
| Слой | Механизм | Где |
|
||||||
|
|---|---|---|
|
||||||
|
| **UI (EJS)** | Session cookie + PostgreSQL session store | `/`, `/admin`, `/entries` |
|
||||||
|
| **REST API** | Bearer JWT (всегда проверяется явно) | `/api/v1/*` |
|
||||||
|
|
||||||
|
### Dev-backdoor
|
||||||
|
|
||||||
|
- `DEV_MODE=true` или `DEV_SECRET` → `/dev-login` — выбор любого пользователя в обход OIDC/mock
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Переменные окружения
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Auth
|
||||||
|
DEV_MODE=true
|
||||||
|
JWT_ISSUER=mock-auth-api
|
||||||
|
ADMIN_CLIENT_ID=WZ01112
|
||||||
|
|
||||||
|
# OIDC (production)
|
||||||
|
KC_BASE_URL=https://keycloak.nubes.ru/realms/cloud
|
||||||
|
KC_CLIENT_ID=
|
||||||
|
KC_CLIENT_SECRET=
|
||||||
|
APP_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Session
|
||||||
|
SESSION_SECRET=<обязателен в prod>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Открытые вопросы (к DevOps)
|
||||||
|
|
||||||
|
| # | Вопрос | Статус |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Где брать `client_id` и `client_secret` для регистрации приложения в Keycloak? | 🔴 |
|
||||||
|
| 2 | Какой URL JWKS? `auth-api` или `keycloak.nubes.ru`? | 🔴 |
|
||||||
|
| 3 | Когда заполняются `claims.groups` и `claims.roles`? (пока null) | 🔴 |
|
||||||
|
| 4 | Как выглядит токен, если у пользователя несколько компаний? Массив `ClientID`? | 🔴 |
|
||||||
|
| 5 | Есть ли в Keycloak понятие admin-роли? В каком claim? | 🔴 |
|
||||||
|
| 6 | Redirect URI: `https://white.nodejsk8s.dev.nubes.ru/callback` — OK? | 🔴 |
|
||||||
|
| 7 | Кто потребитель `/export`? Какие IP/подсети для allowlist? | 🔴 |
|
||||||
|
| 8 | Формат `jsonEnv` — пример правильного? | 🔴 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Исходные файлы
|
||||||
|
|
||||||
|
| Файл | Содержание |
|
||||||
|
|---|---|
|
||||||
|
| `docs/ТЗ.md` | Техническое задание |
|
||||||
|
| `docs/ТЗ-реализация.md` | Карта реализации требований |
|
||||||
|
| `docs/questions-devops.md` | Вопросы к DevOps |
|
||||||
|
| `research/auth-flow.md` | Детальное исследование OIDC-flow |
|
||||||
|
| `research/payg-sso/` | CF-эталон (OAuth2 + JWT RS256) |
|
||||||
|
| `Files/token.txt` | Реальный токен из test-окружения |
|
||||||
|
| `Files/nubes_login.har` | HAR трассировка login-сессии deck-test |
|
||||||
|
| `src/auth.js` | Основной модуль аутентификации |
|
||||||
|
| `src/config.js` | Конфигурация и mock-пользователи |
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Тесты: мульти-компания (2026-06-02)
|
||||||
|
|
||||||
|
> Запускать против `https://white.nodejsk8s.dev.nubes.ru`
|
||||||
|
> Куки сохраняются между запросами (использовать `-c jar.txt -b jar.txt`)
|
||||||
|
|
||||||
|
## 1. Логин под мульти-компанией
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Получить CSRF-токен и логин-форму
|
||||||
|
curl -s -c jar.txt https://white.nodejsk8s.dev.nubes.ru/login > /dev/null
|
||||||
|
|
||||||
|
# Извлечь CSRF-токен
|
||||||
|
CSRF=$(curl -s -b jar.txt https://white.nodejsk8s.dev.nubes.ru/login | grep -oP 'name="_csrf" value="\K[^"]+')
|
||||||
|
|
||||||
|
# Залогиниться как "multi"
|
||||||
|
curl -s -b jar.txt -c jar.txt \
|
||||||
|
-X POST https://white.nodejsk8s.dev.nubes.ru/login \
|
||||||
|
-d "user=multi&_csrf=$CSRF" \
|
||||||
|
-D - | head -1
|
||||||
|
# Ожидаем: HTTP/1.1 302 → /
|
||||||
|
|
||||||
|
# Проверить что сессия содержит allClientIds
|
||||||
|
curl -s -b jar.txt https://white.nodejsk8s.dev.nubes.ru/ | grep -c 'WZ01325'
|
||||||
|
# Ожидаем: >0 (активная компания в интерфейсе)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Переключатель компаний в UI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Страница должна содержать переключатель (<select name="switchTo">)
|
||||||
|
curl -s -b jar.txt https://white.nodejsk8s.dev.nubes.ru/ | grep -c 'switchTo'
|
||||||
|
# Ожидаем: >0
|
||||||
|
|
||||||
|
# Должны быть обе компании в options
|
||||||
|
curl -s -b jar.txt https://white.nodejsk8s.dev.nubes.ru/ | grep -o 'WZ0[0-9]*'
|
||||||
|
# Ожидаем: WZ01325 и WZ02001
|
||||||
|
|
||||||
|
# Переключиться на WZ02001
|
||||||
|
curl -s -b jar.txt -c jar.txt \
|
||||||
|
https://white.nodejsk8s.dev.nubes.ru/?switchTo=WZ02001 \
|
||||||
|
> /dev/null
|
||||||
|
|
||||||
|
# Проверить что активная компания — WZ02001
|
||||||
|
curl -s -b jar.txt https://white.nodejsk8s.dev.nubes.ru/ | grep -c 'WZ02001'
|
||||||
|
# Ожидаем: >0
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Добавление записи в активную компанию
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создать запись в WZ02001 (после переключения на неё)
|
||||||
|
CSRF=$(curl -s -b jar.txt https://white.nodejsk8s.dev.nubes.ru/ | grep -oP 'name="_csrf" value="\K[^"]+')
|
||||||
|
|
||||||
|
curl -s -b jar.txt -c jar.txt \
|
||||||
|
-X POST https://white.nodejsk8s.dev.nubes.ru/add \
|
||||||
|
-d "value=8.8.8.8/32&comment=multi-test&_csrf=$CSRF" \
|
||||||
|
-D - | head -1
|
||||||
|
# Ожидаем: HTTP/1.1 302
|
||||||
|
|
||||||
|
# Проверить что запись появилась в WZ02001
|
||||||
|
curl -s -b jar.txt https://white.nodejsk8s.dev.nubes.ru/ | grep -c '8.8.8.8'
|
||||||
|
# Ожидаем: >0
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Проверка изоляции компаний
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Переключиться обратно на WZ01325
|
||||||
|
curl -s -b jar.txt -c jar.txt \
|
||||||
|
https://white.nodejsk8s.dev.nubes.ru/?switchTo=WZ01325 \
|
||||||
|
> /dev/null
|
||||||
|
|
||||||
|
# Запись 8.8.8.8 из WZ02001 НЕ должна быть видна в WZ01325
|
||||||
|
curl -s -b jar.txt https://white.nodejsk8s.dev.nubes.ru/ | grep -c '8.8.8.8'
|
||||||
|
# Ожидаем: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. API Bearer-тест (опционально)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Выпустить mock JWT с clientid через запятую
|
||||||
|
# (требует ручной JWT через /dev-login или issueMockToken)
|
||||||
|
# Затем:
|
||||||
|
curl -s -H "Authorization: Bearer <mock-jwt>" \
|
||||||
|
https://white.nodejsk8s.dev.nubes.ru/api/v1/entries | jq .
|
||||||
|
# Ожидаем: записи для первой компании (WZ01325)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Негативные тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Переключение на несуществующую компанию — игнорируется
|
||||||
|
curl -s -b jar.txt -c jar.txt \
|
||||||
|
https://white.nodejsk8s.dev.nubes.ru/?switchTo=WZ99999 \
|
||||||
|
> /dev/null
|
||||||
|
|
||||||
|
# Должен остаться на WZ01325
|
||||||
|
curl -s -b jar.txt https://white.nodejsk8s.dev.nubes.ru/ | grep -c 'WZ01325'
|
||||||
|
# Ожидаем: >0
|
||||||
|
|
||||||
|
# Одиночная компания (test, не multi) — переключателя быть не должно
|
||||||
|
# Залогиниться как test и проверить:
|
||||||
|
# grep -c 'switchTo' → ожидаем 0
|
||||||
|
```
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
# ТЗ-плюс — уточнения и дополнения от заказчика
|
||||||
|
|
||||||
|
> Основа: `docs/ТЗ.md`
|
||||||
|
> Файл для фиксации уточнений, дополнений и решений по мере обсуждения с заказчиком/DevOps.
|
||||||
|
> Каждая запись = дата + источник + формулировка + статус.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Аутентификация и Keycloak
|
||||||
|
|
||||||
|
### 1.1. client_id приложения в Keycloak
|
||||||
|
- **Дата**: 2026-06-02
|
||||||
|
- **Источник**: HAR `Files/nubes_login.har`
|
||||||
|
- **Факт**: `client_id = deck-test.ngcloud.ru` (в URL auth-запроса)
|
||||||
|
- **Статус**: 🔴 требует подтверждения — этот ли client_id для ipwhitelist, или будет отдельный?
|
||||||
|
|
||||||
|
### 1.2. ClientID в JWT — УТОЧНЕНО
|
||||||
|
- **ТЗ**: claim `ClientID` — идентификатор компании
|
||||||
|
- **Факт (HAR)**: `ClientID: "WZ01325"`, нет массива
|
||||||
|
- **Факт (token.txt)**: `ClientID` отсутствует, вместо него `claims[]` с названиями namespace'ов
|
||||||
|
- **Уточнение заказчика (01.06.2026)**: в claims приходит `clientid`. Формат может быть разный.
|
||||||
|
- Одно значение: `WZ04228`, `1700`, `asokolov-test`
|
||||||
|
- Несколько через запятую: `WZ11125, WZ03816`, `WZ52235, WZ62587, WZ02315`
|
||||||
|
- **Логика**: первое «слово» до запятой определяет общий ЛК.
|
||||||
|
Пользователи с одинаковым первым словом → общий whitelist.
|
||||||
|
- **Что делать в коде**: брать первый `clientid` до запятой как активную компанию.
|
||||||
|
Остальные (если есть) — дополнительные компании пользователя для переключателя.
|
||||||
|
- **Статус**: 🟡 уточнено, требует реализации в коде
|
||||||
|
|
||||||
|
### 1.3. Несколько компаний на пользователя — УТОЧНЕНО
|
||||||
|
- **ТЗ, п.3.1**: «поддерживается сценарий, когда пользователь принадлежит нескольким компаниям»
|
||||||
|
- **Уточнение заказчика (01.06.2026)**: поле `clientid` в claims содержит список через запятую.
|
||||||
|
Примеры: `WZ11125, WZ03816`, `WZ52235, WZ62587, WZ02315`.
|
||||||
|
- **Логика ЛК**: первое значение до запятой — активная компания.
|
||||||
|
Пользователи с одинаковым первым `clientid` видят общий whitelist.
|
||||||
|
Переключатель между компаниями — все значения из списка.
|
||||||
|
- **Открытый вопрос**: какое именно поле в Keycloak за это отвечает? Заказчик уточнит.
|
||||||
|
- **Статус**: 🟡 уточнено, требует реализации в коде
|
||||||
|
|
||||||
|
### 1.4. Admin-роль в Keycloak
|
||||||
|
- **ТЗ**: admin = `ClientID = WZ01112` + отдельный чек-бокс
|
||||||
|
- **Вопрос**: чек-бокс — это claim? Какой? `is_admin`, `realm_access.roles`, `groups`?
|
||||||
|
- **Статус**: 🔴
|
||||||
|
|
||||||
|
### 1.5. JWKS URL
|
||||||
|
- **Вариант A**: `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/certs`
|
||||||
|
- **Вариант B**: `https://auth-api...` (через API Gateway)
|
||||||
|
- **Статус**: 🔴
|
||||||
|
|
||||||
|
### 1.6. Redirect URI
|
||||||
|
- **Предложение**: `https://white.nodejsk8s.dev.nubes.ru/callback`
|
||||||
|
- **Статус**: 🔴 требует подтверждения DevOps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Инфраструктура и деплой
|
||||||
|
|
||||||
|
### 2.1. Платформа
|
||||||
|
- **Статус**: 🔴 уточнить — Node.js на k8s? Какой кластер?
|
||||||
|
|
||||||
|
### 2.2. Деплой
|
||||||
|
- **Статус**: 🔴 уточнить — CI/CD? Как поставлять `jsonEnv`?
|
||||||
|
|
||||||
|
### 2.3. Сетевое ограничение для /export
|
||||||
|
- **Вопрос**: какие IP/подсети будут потребителями внешней выдачи?
|
||||||
|
- **Статус**: 🔴
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Функциональные уточнения
|
||||||
|
|
||||||
|
### 3.1. Мульти-компания в UI
|
||||||
|
- **ТЗ, п.3.1**: «переключатель активной компании»
|
||||||
|
- **Вопрос**: дизайн переключателя? Dropdown в шапке? Отдельная страница?
|
||||||
|
- **Статус**: 🔴
|
||||||
|
|
||||||
|
### 3.2. Soft-delete и фильтр «показать удалённые»
|
||||||
|
- **ТЗ, п.4.1**: «для администратора предусмотрен фильтр»
|
||||||
|
- **Код**: `includeDeleted` всегда `false`
|
||||||
|
- **Статус**: 🟡 не реализовано в коде
|
||||||
|
|
||||||
|
### 3.3. Индивидуальные лимиты компаний
|
||||||
|
- **ТЗ, п.4.5**: админ может задать индивидуальный лимит
|
||||||
|
- **Код**: ?
|
||||||
|
- **Статус**: 🔴 проверить реализацию
|
||||||
|
|
||||||
|
### 3.4. Уведомление о нормализации
|
||||||
|
- **ТЗ, п.5**: «пользователь должен быть уведомлен, что ввел адрес из хостовой части»
|
||||||
|
- **Статус**: 🔴 проверить реализацию в UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Тестовые данные
|
||||||
|
|
||||||
|
### 4.1. Тестовые пользователи
|
||||||
|
|
||||||
|
| Email | ClientID | Компания | Роль |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `tazet@narod.ru` | `WZ01325` | Тест | Клиент |
|
||||||
|
| `admin@nubes.ru` | `WZ01112` | Нубес | Админ |
|
||||||
|
| `tazetdinovn@gmail.com` | ? | ? | ? (из token.txt) |
|
||||||
|
|
||||||
|
### 4.2. HAR-сессия
|
||||||
|
- Файл: `Files/nubes_login.har`
|
||||||
|
- Пользователь: `tazet@narod.ru`
|
||||||
|
- Дата: 2026-05-30
|
||||||
|
- Окружение: deck-test
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Уточнения от заказчика (01.06.2026)
|
||||||
|
|
||||||
|
### 6.1. Формат clientid в токене
|
||||||
|
- Поле: `clientid` в claims JWT
|
||||||
|
- Формат: строка, значения через запятую если несколько
|
||||||
|
- Примеры:
|
||||||
|
- `"WZ04228"` — одна компания
|
||||||
|
- `"1700"` — числовой ID
|
||||||
|
- `"asokolov-test"` — текстовый ID
|
||||||
|
- `"WZ11125, WZ03816"` — две компании
|
||||||
|
- `"WZ52235, WZ62587, WZ02315"` — три компании
|
||||||
|
- **Правило**: первое «слово» до запятой = активная компания (определяет ЛК)
|
||||||
|
- **Общий доступ**: пользователи с одинаковым первым словом имеют общий whitelist
|
||||||
|
|
||||||
|
### 6.2. Экспорт — подтверждение формата
|
||||||
|
- GET-endpoint отдаёт txt файл
|
||||||
|
- Одна строка = один объект (адрес/подсеть)
|
||||||
|
- Без разделителей, без заголовков
|
||||||
|
|
||||||
|
### 6.3. Текущее демо — одобрено
|
||||||
|
- `https://white.nodejsk8s.dev.nubes.ru/login` — ✓
|
||||||
|
- `https://white.nodejsk8s.dev.nubes.ru/exp` — ✓ (вывод без токена для тестирования)
|
||||||
|
|
||||||
|
### 6.4. Что ещё уточняется
|
||||||
|
- Какое именно поле Keycloak маппится в `clientid` claim? (Заказчик уточнит)
|
||||||
|
- Как определяется admin-роль? (отдельный чек-бокс в Keycloak?)
|
||||||
|
- `client_id` и `client_secret` для регистрации приложения
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Решения и договорённости
|
||||||
|
|
||||||
|
> Сюда записывать принятые решения с датой и контекстом.
|
||||||
|
|
||||||
|
(пока пусто)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Легенда статусов
|
||||||
|
|
||||||
|
| Статус | Значение |
|
||||||
|
|---|---|
|
||||||
|
| 🔴 | Требует уточнения |
|
||||||
|
| 🟡 | Уточнено, не реализовано |
|
||||||
|
| 🟢 | Реализовано / подтверждено |
|
||||||
|
| ⚫ | Отменено / не актуально |
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ipwhitelist",
|
"name": "ipwhitelist",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"description": "IP WhiteList microservice for cloud provider",
|
"description": "IP WhiteList microservice for cloud provider",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+12
-6
@@ -243,13 +243,19 @@ function verifyOidcToken(token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function userFromPayload(payload) {
|
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 {
|
return {
|
||||||
email: payload.email || payload.login || 'unknown',
|
email: payload.email || payload.login || 'unknown',
|
||||||
clientId,
|
clientId: activeClientId, // активная компания (первая в списке)
|
||||||
companyId: payload.company_id || payload.sub || null,
|
allClientIds, // все компании пользователя (массив)
|
||||||
companyName: payload.company_name || clientId,
|
activeClientId, // дубль для ясности
|
||||||
isAdmin: clientId === ADMIN_CLIENT_ID,
|
companyId: payload.company_id || payload.sub || null,
|
||||||
|
companyName: payload.company_name || activeClientId,
|
||||||
|
isAdmin: activeClientId === ADMIN_CLIENT_ID,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const MOCK_USERS = [
|
|||||||
id: 'admin',
|
id: 'admin',
|
||||||
label: 'Администратор (WZ01112)',
|
label: 'Администратор (WZ01112)',
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
|
isAdmin: true,
|
||||||
clientId: 'WZ01112',
|
clientId: 'WZ01112',
|
||||||
companyId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
companyId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||||
companyName: 'Nubes Admin',
|
companyName: 'Nubes Admin',
|
||||||
@@ -28,15 +29,27 @@ const MOCK_USERS = [
|
|||||||
id: 'test',
|
id: 'test',
|
||||||
label: 'Тест (WZ01325)',
|
label: 'Тест (WZ01325)',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
|
isAdmin: false,
|
||||||
clientId: 'WZ01325',
|
clientId: 'WZ01325',
|
||||||
companyId: '3e64aac6-dcfc-4082-88dc-da19c86555a5',
|
companyId: '3e64aac6-dcfc-4082-88dc-da19c86555a5',
|
||||||
companyName: 'Тест',
|
companyName: 'Тест',
|
||||||
email: 'tazet@narod.ru',
|
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',
|
id: 'client2',
|
||||||
label: 'Компания 2 (WZ02001)',
|
label: 'Компания 2 (WZ02001)',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
|
isAdmin: false,
|
||||||
clientId: 'WZ02001',
|
clientId: 'WZ02001',
|
||||||
companyId: '11111111-2222-3333-4444-555555555555',
|
companyId: '11111111-2222-3333-4444-555555555555',
|
||||||
companyName: 'Вторая Компания',
|
companyName: 'Вторая Компания',
|
||||||
|
|||||||
+14
-5
@@ -78,6 +78,9 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USER
|
|||||||
companyName: user.companyName,
|
companyName: user.companyName,
|
||||||
isAdmin: user.role === 'admin',
|
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).
|
// session.regenerate() меняет идентификатор сессии — защита от session fixation (A07).
|
||||||
// Данные пишем уже в новую сессию.
|
// Данные пишем уже в новую сессию.
|
||||||
@@ -167,12 +170,15 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USER
|
|||||||
if (!mockUser) {
|
if (!mockUser) {
|
||||||
return res.redirect('/dev-login?error=' + encodeURIComponent('Пользователь не найден'));
|
return res.redirect('/dev-login?error=' + encodeURIComponent('Пользователь не найден'));
|
||||||
}
|
}
|
||||||
|
const allClientIds = mockUser.clientId.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
user = {
|
user = {
|
||||||
email: mockUser.email,
|
email: mockUser.email,
|
||||||
clientId: mockUser.clientId,
|
clientId: mockUser.clientId,
|
||||||
companyId: mockUser.companyId,
|
allClientIds,
|
||||||
companyName: mockUser.companyName,
|
activeClientId: allClientIds[0] || mockUser.clientId,
|
||||||
isAdmin: mockUser.role === 'admin',
|
companyId: mockUser.companyId,
|
||||||
|
companyName: mockUser.companyName,
|
||||||
|
isAdmin: mockUser.role === 'admin',
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Произвольные поля
|
// Произвольные поля
|
||||||
@@ -180,9 +186,12 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USER
|
|||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return res.redirect('/dev-login?error=' + encodeURIComponent('ClientID обязателен'));
|
return res.redirect('/dev-login?error=' + encodeURIComponent('ClientID обязателен'));
|
||||||
}
|
}
|
||||||
|
const allClientIds = clientId.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
user = {
|
user = {
|
||||||
email: (req.body.email || '').trim() || clientId + '@dev.local',
|
email: (req.body.email || '').trim() || clientId + '@dev.local',
|
||||||
clientId,
|
clientId,
|
||||||
|
allClientIds,
|
||||||
|
activeClientId: allClientIds[0] || clientId,
|
||||||
companyId: (req.body.companyId || '').trim() || null,
|
companyId: (req.body.companyId || '').trim() || null,
|
||||||
companyName: (req.body.companyName || '').trim() || clientId,
|
companyName: (req.body.companyName || '').trim() || clientId,
|
||||||
isAdmin: req.body.isAdmin === 'on',
|
isAdmin: req.body.isAdmin === 'on',
|
||||||
|
|||||||
+39
-27
@@ -36,25 +36,32 @@ function createRouter({ q, doubleCsrfProtection, mutationLimiter, generateCsrfTo
|
|||||||
// Admin: видит все компании + переключатель ?company=<числовой PK>.
|
// Admin: видит все компании + переключатель ?company=<числовой PK>.
|
||||||
// User: только своя компания.
|
// User: только своя компания.
|
||||||
router.get('/', async (req, res) => {
|
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);
|
const csrfToken = generateCsrfToken(req, res);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
// ── Ветка admin ───────────────────────────────────────────────────────
|
// ── Ветка admin ───────────────────────────────────────────────────────
|
||||||
// Загружаем все компании (с active_count через LEFT JOIN)
|
|
||||||
const companies = await q.getAllCompanies();
|
const companies = await q.getAllCompanies();
|
||||||
|
|
||||||
// ?company=X — числовой PK выбранной компании.
|
|
||||||
// Если параметра нет — берём первую компанию из списка.
|
|
||||||
const selectedId = req.query.company
|
const selectedId = req.query.company
|
||||||
? parseInt(req.query.company, 10)
|
? parseInt(req.query.company, 10)
|
||||||
: (companies[0] ? companies[0].id : null);
|
: (companies[0] ? companies[0].id : null);
|
||||||
|
|
||||||
// Ищем объект компании по id. Если id не найден — берём первую.
|
|
||||||
const selectedCompany = companies.find(c => c.id === selectedId) || companies[0] || null;
|
const selectedCompany = companies.find(c => c.id === selectedId) || companies[0] || null;
|
||||||
|
|
||||||
const entries = selectedCompany ? await q.listEntries(selectedCompany.id) : [];
|
const entries = selectedCompany ? await q.listEntries(selectedCompany.id) : [];
|
||||||
@@ -63,46 +70,51 @@ function createRouter({ q, doubleCsrfProtection, mutationLimiter, generateCsrfTo
|
|||||||
return res.render('index', {
|
return res.render('index', {
|
||||||
entries,
|
entries,
|
||||||
limit,
|
limit,
|
||||||
used: entries.length,
|
used: entries.length,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
companies, // для dropdown-переключателя
|
companies, // dropdown-переключатель всех компаний в БД
|
||||||
selectedCompany, // текущая выбранная компания
|
selectedCompany,
|
||||||
error: req.query.error || null,
|
allClientIds: null, // admin использует companies (не clientIds)
|
||||||
message: req.query.message || null,
|
activeClientId: null,
|
||||||
wasNormalized: req.query.wasNormalized === '1',
|
error: req.query.error || null,
|
||||||
|
message: req.query.message || null,
|
||||||
|
wasNormalized: req.query.wasNormalized === '1',
|
||||||
csrfToken,
|
csrfToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ветка обычного пользователя ────────────────────────────────────────
|
// ── Ветка обычного пользователя ────────────────────────────────────────
|
||||||
// getOrCreateCompany: создаёт запись в companies если её ещё нет.
|
const company = await q.getOrCreateCompany(effectiveClientId, effectiveCompanyName);
|
||||||
// clientId и companyName берутся из JWT (нельзя подменить через форму).
|
|
||||||
const company = await q.getOrCreateCompany(clientId, companyName);
|
|
||||||
const limit = await q.getLimit(company);
|
const limit = await q.getLimit(company);
|
||||||
const entries = await q.listEntries(company.id);
|
const entries = await q.listEntries(company.id);
|
||||||
|
|
||||||
|
// Если у пользователя несколько компаний — готовим список для переключателя
|
||||||
|
const hasMultiple = allClientIds && allClientIds.length > 1;
|
||||||
|
|
||||||
res.render('index', {
|
res.render('index', {
|
||||||
entries,
|
entries,
|
||||||
limit,
|
limit,
|
||||||
used: entries.length,
|
used: entries.length,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
companies: null, // пользователь не видит список компаний
|
companies: null,
|
||||||
selectedCompany: company, // его единственная компания
|
selectedCompany: company,
|
||||||
error: req.query.error || null,
|
allClientIds: hasMultiple ? allClientIds : null,
|
||||||
message: req.query.message || null,
|
activeClientId: effectiveClientId,
|
||||||
wasNormalized: req.query.wasNormalized === '1',
|
error: req.query.error || null,
|
||||||
|
message: req.query.message || null,
|
||||||
|
wasNormalized: req.query.wasNormalized === '1',
|
||||||
csrfToken,
|
csrfToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('GET / error:', e);
|
console.error('GET / error:', e);
|
||||||
// При ошибке рендерим страницу с сообщением, не падаем с 500
|
|
||||||
res.render('index', {
|
res.render('index', {
|
||||||
entries: [], limit: 15, used: 0,
|
entries: [], limit: 15, used: 0,
|
||||||
user: req.user, isAdmin,
|
user: req.user, isAdmin,
|
||||||
companies: null, selectedCompany: null,
|
companies: null, selectedCompany: null,
|
||||||
|
allClientIds: null, activeClientId: null,
|
||||||
error: 'Ошибка загрузки данных: ' + e.message,
|
error: 'Ошибка загрузки данных: ' + e.message,
|
||||||
message: null,
|
message: null,
|
||||||
wasNormalized: false,
|
wasNormalized: false,
|
||||||
|
|||||||
+11
-6
@@ -50,13 +50,18 @@ function createUiRouter({ auth, MOCK_USERS, authLimiter }) {
|
|||||||
try {
|
try {
|
||||||
const payload = jwt.decode(api.token(req));
|
const payload = jwt.decode(api.token(req));
|
||||||
if (!payload) throw new Error('empty payload');
|
if (!payload) throw new Error('empty payload');
|
||||||
const clientId = payload.ClientID || payload.clientId || payload.sub || '';
|
const rawClientId = payload.ClientID || payload.clientId || payload.sub || '';
|
||||||
|
const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const activeClientId = allClientIds[0] || rawClientId;
|
||||||
|
|
||||||
req.user = {
|
req.user = {
|
||||||
clientId,
|
clientId: rawClientId,
|
||||||
companyId: payload.company_id || payload.companyId || null,
|
allClientIds,
|
||||||
companyName: payload.company_name || payload.companyName || clientId,
|
activeClientId,
|
||||||
email: payload.email || clientId + '@unknown',
|
companyId: payload.company_id || payload.companyId || null,
|
||||||
isAdmin: clientId === ADMIN_CLIENT_ID,
|
companyName: payload.company_name || payload.companyName || activeClientId,
|
||||||
|
email: payload.email || activeClientId + '@unknown',
|
||||||
|
isAdmin: activeClientId === ADMIN_CLIENT_ID,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
req.session.destroy(() =>
|
req.session.destroy(() =>
|
||||||
|
|||||||
+12
-5
@@ -28,6 +28,7 @@ function createRouter({ auth, MOCK_USERS, authLimiter }) {
|
|||||||
MOCK_USERS: devMode() ? MOCK_USERS : [],
|
MOCK_USERS: devMode() ? MOCK_USERS : [],
|
||||||
error: req.query.error || null,
|
error: req.query.error || null,
|
||||||
returnTo: req.query.returnTo || '/',
|
returnTo: req.query.returnTo || '/',
|
||||||
|
appVersion: require('../../package.json').version,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,6 +40,10 @@ function createRouter({ auth, MOCK_USERS, authLimiter }) {
|
|||||||
const user = MOCK_USERS.find(u => u.clientId === clientId);
|
const user = MOCK_USERS.find(u => u.clientId === clientId);
|
||||||
if (!user) return res.redirect('/login?error=Пользователь+не+найден');
|
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({
|
const token = auth.issueMockToken({
|
||||||
clientId: user.clientId,
|
clientId: user.clientId,
|
||||||
companyId: user.companyId || '00000000-0000-0000-0000-000000000001',
|
companyId: user.companyId || '00000000-0000-0000-0000-000000000001',
|
||||||
@@ -48,11 +53,13 @@ function createRouter({ auth, MOCK_USERS, authLimiter }) {
|
|||||||
|
|
||||||
req.session.token = token;
|
req.session.token = token;
|
||||||
req.session.user = {
|
req.session.user = {
|
||||||
clientId: user.clientId,
|
clientId: user.clientId,
|
||||||
email: user.email || user.clientId + '@mock.local',
|
allClientIds,
|
||||||
companyId: user.companyId || '00000000-0000-0000-0000-000000000001',
|
activeClientId,
|
||||||
companyName: user.companyName || user.clientId,
|
email: user.email || user.clientId + '@mock.local',
|
||||||
isAdmin: user.role === 'admin',
|
companyId: user.companyId || '00000000-0000-0000-0000-000000000001',
|
||||||
|
companyName: user.companyName || user.clientId,
|
||||||
|
isAdmin: user.isAdmin || user.role === 'admin',
|
||||||
};
|
};
|
||||||
res.redirect(returnTo || '/');
|
res.redirect(returnTo || '/');
|
||||||
});
|
});
|
||||||
|
|||||||
+23
-10
@@ -33,6 +33,14 @@ function createRouter() {
|
|||||||
// GET / — список записей
|
// GET / — список записей
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const token = api.token(req);
|
const token = api.token(req);
|
||||||
|
|
||||||
|
// ── Переключатель компании для мульти-компании ──────────────────────
|
||||||
|
const switchTo = (req.query.switchTo || '').trim();
|
||||||
|
if (!req.user.isAdmin && switchTo && req.user.allClientIds && req.user.allClientIds.includes(switchTo)) {
|
||||||
|
req.session.user.activeClientId = switchTo;
|
||||||
|
if (req.session.user.clientId) req.session.user.clientId = switchTo;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let entries = [], limit = 15, selectedCompany = null, companies = [];
|
let entries = [], limit = 15, selectedCompany = null, companies = [];
|
||||||
|
|
||||||
@@ -43,10 +51,8 @@ function createRouter() {
|
|||||||
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
|
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
|
||||||
|
|
||||||
if (!companyId) {
|
if (!companyId) {
|
||||||
// Найти свою компанию admin по clientId
|
|
||||||
let ownCo = companies.find(c => c.client_id === req.user.clientId) || null;
|
let ownCo = companies.find(c => c.client_id === req.user.clientId) || null;
|
||||||
if (!ownCo) {
|
if (!ownCo) {
|
||||||
// Компании нет — создаём через первый GET /entries (getOrCreateCompany внутри)
|
|
||||||
await api.get('/api/v1/entries', token);
|
await api.get('/api/v1/entries', token);
|
||||||
const cr2 = await api.get('/api/v1/companies', token);
|
const cr2 = await api.get('/api/v1/companies', token);
|
||||||
ownCo = (cr2.data.companies || []).find(c => c.client_id === req.user.clientId) || null;
|
ownCo = (cr2.data.companies || []).find(c => c.client_id === req.user.clientId) || null;
|
||||||
@@ -61,30 +67,37 @@ function createRouter() {
|
|||||||
limit = er.data.limit || 15;
|
limit = er.data.limit || 15;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Обычный пользователь: запрашиваем записи для активной компании
|
||||||
|
const activeClientId = req.user.activeClientId || req.user.clientId;
|
||||||
const er = await api.get('/api/v1/entries', token);
|
const er = await api.get('/api/v1/entries', token);
|
||||||
entries = er.data.entries || [];
|
entries = er.data.entries || [];
|
||||||
limit = er.data.limit || 15;
|
limit = er.data.limit || 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasMultiple = !req.user.isAdmin && req.user.allClientIds && req.user.allClientIds.length > 1;
|
||||||
|
|
||||||
res.render('index', {
|
res.render('index', {
|
||||||
entries,
|
entries,
|
||||||
limit,
|
limit,
|
||||||
used: entries.length,
|
used: entries.length,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
isAdmin: req.user.isAdmin,
|
isAdmin: req.user.isAdmin,
|
||||||
companies,
|
companies,
|
||||||
selectedCompany,
|
selectedCompany,
|
||||||
error: req.query.error || null,
|
allClientIds: hasMultiple ? req.user.allClientIds : null,
|
||||||
success: req.query.success || null,
|
activeClientId: req.user.activeClientId || req.user.clientId,
|
||||||
lastValue: req.query.lastValue || '',
|
error: req.query.error || null,
|
||||||
lastComment: req.query.lastComment || '',
|
success: req.query.success || null,
|
||||||
csrfToken: '',
|
lastValue: req.query.lastValue || '',
|
||||||
|
lastComment: req.query.lastComment || '',
|
||||||
|
csrfToken: '',
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.render('index', {
|
res.render('index', {
|
||||||
entries: [], limit: 15, used: 0,
|
entries: [], limit: 15, used: 0,
|
||||||
user: req.user, isAdmin: req.user.isAdmin,
|
user: req.user, isAdmin: req.user.isAdmin,
|
||||||
companies: [], selectedCompany: null,
|
companies: [], selectedCompany: null,
|
||||||
|
allClientIds: null, activeClientId: null,
|
||||||
error: 'Ошибка загрузки: ' + e.message,
|
error: 'Ошибка загрузки: ' + e.message,
|
||||||
success: null, lastValue: '', lastComment: '', csrfToken: '',
|
success: null, lastValue: '', lastComment: '', csrfToken: '',
|
||||||
});
|
});
|
||||||
|
|||||||
+24
-1
@@ -179,7 +179,7 @@
|
|||||||
const companyPk = selectedCompany ? selectedCompany.id : null;
|
const companyPk = selectedCompany ? selectedCompany.id : null;
|
||||||
%>
|
%>
|
||||||
|
|
||||||
<!-- Переключатель компании — только для администратора -->
|
<!-- Переключатель компании — для администратора -->
|
||||||
<% if (isAdmin && companies) { %>
|
<% if (isAdmin && companies) { %>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body" style="padding:.75rem 1rem;">
|
<div class="card-body" style="padding:.75rem 1rem;">
|
||||||
@@ -207,6 +207,29 @@
|
|||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Переключатель компании — для обычного пользователя с несколькими компаниями -->
|
||||||
|
<% if (!isAdmin && allClientIds && allClientIds.length > 1) { %>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body" style="padding:.75rem 1rem;">
|
||||||
|
<form method="GET" action="/" style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
|
||||||
|
<span style="font-size:.85rem;color:var(--muted);font-weight:500;">Компания:</span>
|
||||||
|
<select name="switchTo" id="switchto-select"
|
||||||
|
style="padding:.4rem .75rem;border:1px solid var(--border);border-radius:6px;font-size:.85rem;background:#fff;">
|
||||||
|
<% allClientIds.forEach(id => { %>
|
||||||
|
<option value="<%= id %>" <%= id === activeClientId ? 'selected' : '' %>>
|
||||||
|
<%= id %>
|
||||||
|
</option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-sm">Переключить</button>
|
||||||
|
<span style="margin-left:auto;font-size:.8rem;color:var(--muted);">
|
||||||
|
Компаний: <strong><%= allClientIds.length %></strong>
|
||||||
|
</span>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<!-- Статистика -->
|
<!-- Статистика -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
Reference in New Issue
Block a user