Вхід за логіном, групи хостів і права доступу

Логін:
- core.users.username замість пошти як ідентифікатор: у мережевій
  інсталяції половина акаунтів технічні (noc, monitoring, oncall) і
  скриньки не мають узагалі. Пошта лишилась необов'язковим полем
- наявним користувачам логін виведено з пошти, збіги розведено
  суфіксом: мовчки злити admin@a.com і admin@b.com в один логін —
  це втрата акаунта, а не міграція
- сервер шукає за логіном і за поштою, тому звичка вводити email
  нікого не відхиляє

Групи (модель Zabbix):
- групи хостів і групи доступу; права read/write/deny на групу хостів
- роль каже, що вільно робити; група — над якими хостами. Інженер над
  філією та інженер над усією мережею мають однакову роль і різний
  доступ
- хто не входить у жодну групу, групами не обмежений — свідомо не
  по-заббіксівськи: там кожна нова інсталяція починається з питання
  «чому порожньо»
- заборона перемагає дозвіл, інакше її обійти додаванням у сусідню групу
- фільтр накладається в самому запиті, а не після вибірки

Хости: додавання, редагування, м'яке видалення, прив'язка до груп,
фільтр за групою в таблиці.

Мапа: створення мапи з транслітерацією slug, інспектор вузла — підпис,
значок, розмір, ширина, колір рамки, закріплення.

Виправлено за скаргами:
- перемикач вилазив на 14px за трек: у ручки не заданий left, а
  статичну позицію зсуває типове text-align: center у <button>
- сторінка правил не оновлювала «активних»: після повернення правила
  алерти піднімаються наступним тіком движка, тобто ПІСЛЯ нашого
  перечитування — бракувало підписки на живі події
- канал із секретом показувався як «секрету немає»

Живий прогін: eng у групі з read на «Доступ» бачить 2 хости замість 3,
обидва без запису, алертів 1 замість 3, редагування чужого хоста 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-24 02:00:04 +03:00
parent 36f29e23ec
commit 96692ec9df
30 changed files with 2792 additions and 217 deletions

View file

@ -897,3 +897,105 @@ curl-ом. Цей етап закриває розрив між готовим
немає), сторінки налаштувань зонда, редагування правила (лише
створення й вимкнення), маршрутів сповіщень і вікон обслуговування
(є в схемі й у движку, у UI — ні), NOC-режиму на телевізор.
---
## 2026-08-15 — Вхід за логіном, групи хостів і права доступу
Три зауваження за одну сесію: перемикачі виглядають зламано, входити треба
логіном, а не поштою, і бракує заббіксівського — груп, прав на них, додавання
хостів і редагування мапи. Плюс два власні спостереження користувача про
сторінку правил і мапу.
### Створено
- `db/migrations/0013_groups_login.sql``core.users.username`,
`core.user_groups`, `core.user_group_members`, `core.group_permissions`,
функції `core.device_access_level` і `core.accessible_devices`, RLS на нові
таблиці.
- `server/internal/store/groups.go` — CRUD груп, хостів і обчислення `Scope`.
- `server/internal/httpapi/groups.go` — 11 ендпоїнтів.
- `web/src/pages/GroupsPage.tsx` — групи хостів і груп доступу на одному екрані.
- `web/src/components/NodeInspector.tsx` — підпис, значок, розмір, ширина,
колір, закріплення вузла.
- Форма хоста (створення й редагування) і фільтр за групами на сторінці хостів.
- Створення мапи з транслітерацією slug.
### Прийняті рішення
**Логін замість пошти.** У мережевій інсталяції половина облікових записів
технічні — `noc`, `monitoring`, `oncall`, — і скриньки не мають узагалі. Пошта
лишилась необов'язковим полем для сповіщень. Сервер шукає за обома, тому людину,
яка за звичкою ввела email, ніхто не відхиляє.
**Наявним користувачам логін вивели з пошти**, а збіги розвели суфіксом за
порядком створення. Мовчки злити `admin@a.com` і `admin@b.com` в один логін
було б не міграцією, а втратою акаунта. Механізм одразу знадобився: на стенді
жили залишки від тестових прогонів, і `admin` дістався саме їм.
**Ролі й групи — два незалежні виміри.** Роль каже, що людині вільно робити;
група доступу — над якими хостами. Інженер над однією філією та інженер над
усією мережею мають однакову роль і різний доступ, і змішати це в один список
прав неможливо без втрати сенсу.
**Хто не входить у жодну групу — не обмежений групами.** Це свідомо не
по-заббіксівськи: там користувач без груп не бачить нічого, і кожна нова
інсталяція починається з питання «чому порожньо». Тут звуження вмикається тоді,
коли його справді налаштували.
**Заборона перемагає дозвіл.** Інакше її можна обійти, додавши хост у будь-яку
іншу групу.
**Фільтр видимості накладається в самому запиті**, а не після вибірки:
відсіювати вже прочитане означало б тягнути з БД чужі рядки й покладатися на те,
що жоден не проскочить у відповідь.
**Правки вузла застосовуються кнопкою, а не на кожну літеру.** Кожне збереження
— це нова ревізія полотна й подія для всіх, хто дивиться на ту саму мапу.
### Знайдено й виправлено
**Перемикач вилазив за межі треку.** Скарга була «вигляд глюкнутий»; вимір
показав причину: у ручки не заданий `left`, тож вона стає на статичну позицію,
а та у `<button>` зсунута типовим `text-align: center`. Обчислений `left`
виходив 18px замість 2px, ручка виступала на 14px і накривала сусідній хрестик.
**Сторінка правил не оновлювала лічильник «активних».** Друга скарга, і причина
не та, що здається. Після вимкнення правила його алерти закриваються одразу, а
після повернення піднімаються лише наступним тіком движка — тобто через секунди
ПІСЛЯ нашого перечитування. Сторінка не була підписана на живі події, тому
показувала нулі до перезавантаження. Той самий недогляд, що раніше знайшовся на
сторінці хостів; тепер обидві користуються спільним хуком.
**Канал із секретом показувався як «секрету немає»** — прапорець ставився
всередині гілки розшифровки, а перелік для UI викликається без ключа навмисно.
### Перевірено наживо
Прогін проти справжнього стенду:
```
вхід за логіном 'admin' 200, роль owner
вхід тим самим, але поштою 200 — сумісність збережена
дві групи хостів 201/201
хости розкладено gateway→Магістраль, snmp-host→Доступ
новий хост через API 201
група доступу для 'eng' лише «Доступ», рівень read
що бачить eng snmp-host і test-host, обидва writable=false;
gateway зник із вибірки
алерти під eng 1 замість 3
eng редагує чужий хост 403
```
У браузері: вхід логіном (у шапці «admin», не пошта); перемикачі вміщаються в
трек в обох станах із відступом 2px; правила оновлюються 2 → 0 → 2 без
перезавантаження; сторінка груп показує обидві половини; інспектор вузла змінив
підпис, значок і ширину до 220px із ревізією 13 → 14; створення мапи дало slug
`kyiv-iadro` з «Київ ядро» і порожнє полотно.
### Чого ще немає
Динамічних груп (`inv.device_groups.kind='dynamic'` у схемі є, правило відбору
не читається), успадкування прав між групами, редагування груп хостів після
створення (лише створення й видалення), масових операцій над хостами.

View file

@ -0,0 +1,219 @@
-- =====================================================================
-- NetPulse :: 0013_groups_login.sql
-- Вхід за логіном; групи користувачів і права на групи пристроїв
-- у моделі, знайомій за Zabbix.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Логін
-- ---------------------------------------------------------------------
-- Email лишається — він потрібен для сповіщень і відновлення доступу, —
-- але входом більше не є. Причина проста: у мережевій інсталяції
-- половина облікових записів технічні (noc, monitoring, oncall) і
-- поштової скриньки не має взагалі.
ALTER TABLE core.users ADD COLUMN IF NOT EXISTS username citext;
-- Наявним користувачам логін виводимо з локальної частини пошти.
-- Збіги неминучі (admin@a.com і admin@b.com), тому додаємо суфікс за
-- порядком створення: мовчки злити два акаунти в один логін не можна.
WITH numbered AS (
SELECT id,
split_part(email::text, '@', 1) AS base,
row_number() OVER (
PARTITION BY split_part(email::text, '@', 1)
ORDER BY created_at, id
) AS n
FROM core.users
WHERE username IS NULL
)
UPDATE core.users u
SET username = CASE WHEN n = 1 THEN numbered.base
ELSE numbered.base || n::text END
FROM numbered
WHERE u.id = numbered.id;
ALTER TABLE core.users ALTER COLUMN username SET NOT NULL;
-- Email більше не обов'язковий: технічний акаунт може жити без нього.
ALTER TABLE core.users ALTER COLUMN email DROP NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'users_username_uniq'
) THEN
ALTER TABLE core.users ADD CONSTRAINT users_username_uniq UNIQUE (username);
END IF;
END $$;
-- Логін бере на себе роль ідентифікатора, тому й обмеження на нього
-- жорсткіші за поштові: жодних пробілів, крапок на краях і регістрових
-- сюрпризів (citext уже прибирає останнє).
ALTER TABLE core.users
ADD CONSTRAINT users_username_format
CHECK (username ~ '^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$');
-- ---------------------------------------------------------------------
-- Групи користувачів
-- ---------------------------------------------------------------------
-- Ролі відповідають на питання «що людині вільно робити», групи — «над
-- якими пристроями». Два незалежні виміри, і змішувати їх в одному
-- списку прав не можна: інженер над однією філією та інженер над усією
-- мережею мають однакову роль і різний доступ.
CREATE TABLE IF NOT EXISTS core.user_groups (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
CREATE TABLE IF NOT EXISTS core.user_group_members (
group_id uuid NOT NULL REFERENCES core.user_groups(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, user_id)
);
CREATE INDEX IF NOT EXISTS user_group_members_user_idx
ON core.user_group_members (user_id);
-- ---------------------------------------------------------------------
-- Права на групи пристроїв
-- ---------------------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'access_level') THEN
CREATE TYPE core.access_level AS ENUM ('deny','read','write');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS core.group_permissions (
user_group_id uuid NOT NULL REFERENCES core.user_groups(id) ON DELETE CASCADE,
device_group_id uuid NOT NULL REFERENCES inv.device_groups(id) ON DELETE CASCADE,
level core.access_level NOT NULL DEFAULT 'read',
PRIMARY KEY (user_group_id, device_group_id)
);
-- ---------------------------------------------------------------------
-- Обчислення доступу
-- ---------------------------------------------------------------------
-- Рівень доступу людини до одного пристрою.
--
-- Три правила, і кожне має причину:
--
-- 1. Хто не входить у жодну групу — не обмежений групами взагалі.
-- Це свідомо не по-заббіксівськи. Там користувач без груп не бачить
-- нічого, і кожна нова інсталяція починається з питання «чому
-- порожньо». Тут звуження вмикається тоді, коли його справді
-- налаштували, а до того доступ визначає роль.
--
-- 2. Заборона перемагає дозвіл. Пристрій у двох групах, де одна дає
-- читання, а друга забороняє, лишається невидимим: інакше заборону
-- можна обійти, додавши об'єкт у будь-яку іншу групу.
--
-- 3. Серед дозволів виграє найширший. Читання в одній групі й запис у
-- другій дають запис — людині надали обидва, і слабший не є
-- обмеженням для сильнішого.
CREATE OR REPLACE FUNCTION core.device_access_level(p_user uuid, p_device uuid)
RETURNS core.access_level
LANGUAGE sql STABLE AS $$
WITH my_groups AS (
SELECT group_id FROM core.user_group_members WHERE user_id = p_user
),
perms AS (
SELECT gp.level
FROM core.group_permissions gp
JOIN my_groups g ON g.group_id = gp.user_group_id
JOIN inv.device_group_members m ON m.group_id = gp.device_group_id
WHERE m.device_id = p_device
)
SELECT CASE
-- Без груп — без звуження.
WHEN NOT EXISTS (SELECT 1 FROM my_groups) THEN 'write'::core.access_level
WHEN EXISTS (SELECT 1 FROM perms WHERE level = 'deny') THEN 'deny'::core.access_level
WHEN EXISTS (SELECT 1 FROM perms WHERE level = 'write') THEN 'write'::core.access_level
WHEN EXISTS (SELECT 1 FROM perms WHERE level = 'read') THEN 'read'::core.access_level
-- У групах є, але цього пристрою в них немає — значить, не його.
ELSE 'deny'::core.access_level
END;
$$;
-- Перелік доступних пристроїв одним набором.
--
-- Окрема функція, а не виклик попередньої в циклі: список пристроїв
-- читається на кожному екрані, і перевірка по одному перетворила б
-- звичайну вибірку на N запитів.
CREATE OR REPLACE FUNCTION core.accessible_devices(p_user uuid, p_tenant uuid, p_need core.access_level)
RETURNS TABLE (device_id uuid)
LANGUAGE sql STABLE AS $$
WITH my_groups AS (
SELECT group_id FROM core.user_group_members WHERE user_id = p_user
),
unrestricted AS (
SELECT NOT EXISTS (SELECT 1 FROM my_groups) AS yes
),
granted AS (
SELECT m.device_id,
bool_or(gp.level = 'deny') AS denied,
bool_or(gp.level = 'write') AS writable
FROM core.group_permissions gp
JOIN my_groups g ON g.group_id = gp.user_group_id
JOIN inv.device_group_members m ON m.group_id = gp.device_group_id
GROUP BY m.device_id
)
SELECT d.id
FROM inv.devices d
WHERE d.tenant_id = p_tenant
AND d.deleted_at IS NULL
AND (
(SELECT yes FROM unrestricted)
OR EXISTS (
SELECT 1 FROM granted
WHERE granted.device_id = d.id
AND NOT granted.denied
AND (p_need <> 'write' OR granted.writable)
)
);
$$;
-- ---------------------------------------------------------------------
-- RLS для нових таблиць
-- ---------------------------------------------------------------------
ALTER TABLE core.user_groups ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.user_groups FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON core.user_groups;
CREATE POLICY tenant_isolation ON core.user_groups
USING (tenant_id = core.current_tenant())
WITH CHECK (tenant_id = core.current_tenant());
-- Членство й права прив'язані до групи, тому їхня ізоляція випливає з
-- ізоляції самої групи. Окремої колонки tenant_id тут немає навмисно:
-- вона могла б розійтися з групою й тихо створити дірку.
ALTER TABLE core.user_group_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.user_group_members FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON core.user_group_members;
CREATE POLICY tenant_isolation ON core.user_group_members
USING (EXISTS (SELECT 1 FROM core.user_groups g
WHERE g.id = group_id AND g.tenant_id = core.current_tenant()))
WITH CHECK (EXISTS (SELECT 1 FROM core.user_groups g
WHERE g.id = group_id AND g.tenant_id = core.current_tenant()));
ALTER TABLE core.group_permissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.group_permissions FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON core.group_permissions;
CREATE POLICY tenant_isolation ON core.group_permissions
USING (EXISTS (SELECT 1 FROM core.user_groups g
WHERE g.id = user_group_id AND g.tenant_id = core.current_tenant()))
WITH CHECK (EXISTS (SELECT 1 FROM core.user_groups g
WHERE g.id = user_group_id AND g.tenant_id = core.current_tenant()));
-- Шлях входу читає користувача до того, як тенант відомий, — так само,
-- як у 0012 для пошуку за поштою.
DROP POLICY IF EXISTS users_auth_lookup ON core.users;
CREATE POLICY users_auth_lookup ON core.users
FOR SELECT USING (core.current_tenant() IS NULL);

View file

@ -24,7 +24,12 @@ Authorization: Bearer <токен>
токен. Це навмисно не залежить від префікса, бо префікс може змінитись, а формат
JWT — ні.
**Люди** входять через `/api/v1/auth/login` і отримують короткий access-токен
**Люди** входять **за логіном** (`core.users.username`), а не за поштою: у
мережевій інсталяції половина облікових записів технічні (`noc`, `monitoring`,
`oncall`) і скриньки не мають узагалі. Пошта лишається прийнятною — сервер шукає
за обома полями, — але вона більше не обов'язкова.
Люди входять через `/api/v1/auth/login` і отримують короткий access-токен
(HS256, 15 хвилин, у пам'яті вкладки) плюс refresh-сесію в `httpOnly`-кукі
`np_refresh` (30 днів, `SameSite=Strict`, `Path=/api/v1/auth`). Access-токен
свідомо не кладеться в `localStorage`: будь-який XSS звідти його забирає, а з
@ -48,7 +53,7 @@ JWT — ні.
через API:
```bash
./netpulse-user -dsn "postgres://..." -tenant acme -email admin@acme.io -role owner
./netpulse-user -dsn "postgres://..." -tenant acme -login admin -role owner
```
`-list` показує учасників, `-reset-password` міняє пароль і відкликає всі сесії.
@ -58,7 +63,7 @@ JWT — ні.
| Метод | Шлях | Призначення |
|-------|------|-------------|
| `GET` | `/healthz` | стан процесу й кількість WebSocket-підписників |
| `POST` | `/api/v1/auth/login` | вхід за email і паролем |
| `POST` | `/api/v1/auth/login` | вхід за логіном і паролем |
| `POST` | `/api/v1/auth/refresh` | обмін refresh-кукі на новий access-токен |
| `POST` | `/api/v1/auth/logout` | відкликати поточну сесію |
| `POST` | `/api/v1/auth/password` | змінити свій пароль |
@ -75,7 +80,17 @@ JWT — ні.
| `DELETE` | `/api/v1/maps/{id}` | м'яко видалити мапу (топологія лишається) |
| `POST` | `/api/v1/maps/{id}/build` | добудувати мапу з виявленої топології |
| `POST` | `/api/v1/maps/{id}/undo` | відкотити останню зміну полотна |
| `GET` | `/api/v1/devices` | інвентар |
| `GET` | `/api/v1/devices` | інвентар (звужений групами доступу) |
| `POST` | `/api/v1/devices` | додати хост (`devices:write`) |
| `PATCH` | `/api/v1/devices/{id}` | змінити хост і його групи |
| `DELETE` | `/api/v1/devices/{id}` | мʼяко видалити хост |
| `GET` | `/api/v1/device-groups` | групи хостів із лічильниками |
| `POST` | `/api/v1/device-groups` | створити групу хостів |
| `DELETE` | `/api/v1/device-groups/{id}` | видалити групу |
| `GET` | `/api/v1/user-groups` | групи доступу з правами |
| `POST` | `/api/v1/user-groups` | створити групу доступу |
| `PATCH` | `/api/v1/user-groups/{id}` | склад і права одним запитом |
| `DELETE` | `/api/v1/user-groups/{id}` | видалити групу доступу |
| `GET` | `/api/v1/agents` | зонди, версії, самометрики |
| `GET` | `/api/v1/alerts` | активні алерти + лічильники |
| `POST` | `/api/v1/alerts/{id}/ack` | підтвердити (`alerts:ack`) |
@ -94,16 +109,19 @@ JWT — ні.
### `POST /api/v1/auth/login` — вхід
```jsonc
{"email": "admin@acme.io", "password": "…", "tenant_id": "…"} // tenant_id — опційно
{"login": "admin", "password": "…", "tenant_id": "…"} // tenant_id — опційно
```
Поле `login` приймає і логін, і пошту. Старе `email` теж лишається робочим:
інтеграції, написані до перейменування, не мають ламатися через назву ключа.
Успіх:
```jsonc
{
"access_token": "eyJ…",
"expires_in": 900,
"user": {"id": "…", "email": "admin@acme.io", "full_name": "Admin"},
"user": {"id": "…", "username": "admin", "email": "admin@acme.io", "full_name": "Admin"},
"tenant": {"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"},
"permissions": ["maps:read", "maps:write", "users:write", …]
}
@ -116,12 +134,12 @@ JWT — ні.
{"tenants": [{"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"}, …]}
```
Помилки навмисно нерозрізненні: невідомий email і невірний пароль дають однаковий
Помилки навмисно нерозрізненні: невідомий логін і невірний пароль дають однаковий
`401 bad_credentials`, і на неіснуючому користувачі сервер спалює стільки ж часу на
фіктивній перевірці argon2id. Інакше час відповіді сам би розказував, які адреси
зареєстровані.
фіктивній перевірці argon2id. Інакше час відповіді сам би розказував, які акаунти
існують.
Після 10 невдач з одного email або IP за 15 хвилин — `429 too_many_attempts`.
Після 10 невдач з одного логіна або IP за 15 хвилин — `429 too_many_attempts`.
Лічильник живе в `core.login_attempts` (гіпертаблиця), тому переживає рестарт
процесу й працює на кількох інстансах одразу.
@ -143,6 +161,11 @@ JWT — ні.
Для машинного токена замість `user_id`/`email` приходить `token_name`.
Формат логіна: 364 символи з латиниці, цифр, крапки, дефіса й підкреслення;
перший і останній символ — літера або цифра. Перевіряється і в БД (CHECK), і в
API — відмова БД виглядає як «violates check constraint», і людині з неї нічого
не зрозуміло.
### `GET|POST|PATCH|DELETE /api/v1/team`
Керування учасниками; усе, крім читання, потребує `users:write`.
@ -401,6 +424,56 @@ Sec-WebSocket-Protocol: netpulse.token.<токен>
знімків в історії: 2
```
## Групи й доступ до хостів
Два незалежні виміри, які не можна змішувати в одному списку прав:
- **Роль** відповідає на питання «що людині вільно робити» — `maps:write`,
`alerts:ack`, `users:write`.
- **Група доступу** відповідає на «над якими хостами». Інженер над однією
філією та інженер над усією мережею мають однакову роль і різний доступ.
Група доступу (`core.user_groups`) містить людей і видає права на групи хостів
(`inv.device_groups`) одним із трьох рівнів: `read`, `write`, `deny`.
### Три правила обчислення
1. **Хто не входить у жодну групу — не обмежений групами взагалі.** Це свідомо
не по-заббіксівськи: там користувач без груп не бачить нічого, і кожна нова
інсталяція починається з питання «чому порожньо». Тут звуження вмикається
тоді, коли його справді налаштували, а до того доступ визначає роль.
2. **Заборона перемагає дозвіл.** Хост у двох групах, де одна дає читання, а
друга забороняє, лишається невидимим — інакше заборону можна обійти,
додавши об'єкт у будь-яку іншу групу.
3. **Серед дозволів виграє найширший.** Читання в одній групі й запис у другій
дають запис.
Обчислення живе в БД (`core.accessible_devices`) і читається раз на запит:
питати про кожен хост окремо означало б перетворити список на N запитів рівно
тоді, коли хостів багато.
Звуження діє на `/api/v1/devices`, `/api/v1/alerts`, лічильники алертів і
операції запису (редагування хоста, заглушення). Алерт **без** пристрою
(наприклад, про сам зонд) видно всім: сховати його від обмеженого користувача
означало б приховати аварію, до якої групи не мають стосунку.
### Живий прогін груп
```
1) вхід за логіном 'admin' 200, роль owner
2) вхід тим самим, але поштою 200 — сумісність збережена
3) створено дві групи хостів 201/201
4) хости розкладено gateway→Магістраль, snmp-host→Доступ
5) новий хост через API 201
6) лічильники груп Доступ=2, Магістраль=1
7) група доступу для 'eng' лише «Доступ», рівень read
8) що бачить eng snmp-host, test-host — обидва writable=false
gateway зник із вибірки
9) алерти під eng 1 замість 3
10) eng редагує чужий хост 403
11) видалення хоста власником 204
```
## Алерти
Движок правил живе всередині `netpulse-api` і обчислюється раз на

View file

@ -33,7 +33,8 @@ func run() error {
var (
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
tenant = flag.String("tenant", "", "slug або uuid тенанта")
email = flag.String("email", "", "email користувача")
login = flag.String("login", "", "логін користувача")
email = flag.String("email", "", "email (необовʼязково — для сповіщень)")
fullName = flag.String("name", "", "повне ім'я")
role = flag.String("role", "owner", "owner|admin|engineer|operator|viewer")
password = flag.String("password", "", "пароль; порожній — спитати інтерактивно")
@ -65,8 +66,8 @@ func run() error {
return listUsers(ctx, conn, tenantID, tenantName)
}
if *email == "" {
return errors.New("не вказано -email")
if *login == "" {
return errors.New("не вказано -login")
}
pw := *password
@ -88,13 +89,13 @@ func run() error {
if *reset {
tag, err := conn.Exec(ctx, `
UPDATE core.users SET password_hash = $2, updated_at = now()
WHERE email = $1 AND deleted_at IS NULL
`, *email, hash)
WHERE username = $1 AND deleted_at IS NULL
`, *login, hash)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("користувача %s не знайдено", *email)
return fmt.Errorf("користувача %s не знайдено", *login)
}
// Зміна пароля адміністратором — це реакція на втрату доступу
// або на компрометацію. В обох випадках старі сесії мають померти.
@ -117,14 +118,15 @@ func run() error {
var userID string
if err := tx.QueryRow(ctx, `
INSERT INTO core.users (email, password_hash, full_name, email_verified_at)
VALUES ($1, $2, NULLIF($3,''), now())
ON CONFLICT (email) DO UPDATE
INSERT INTO core.users (username, email, password_hash, full_name, email_verified_at)
VALUES ($1, NULLIF($2,'')::citext, $3, NULLIF($4,''),
CASE WHEN NULLIF($2,'') IS NULL THEN NULL ELSE now() END)
ON CONFLICT (username) DO UPDATE
SET password_hash = EXCLUDED.password_hash,
full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name),
updated_at = now()
RETURNING id::text
`, *email, hash, *fullName).Scan(&userID); err != nil {
`, *login, *email, hash, *fullName).Scan(&userID); err != nil {
return fmt.Errorf("користувач: %w", err)
}
@ -150,7 +152,7 @@ func run() error {
}
fmt.Printf("користувач %s (%s) доданий до «%s» з роллю %s\n",
*email, userID, tenantName, *role)
*login, userID, tenantName, *role)
return nil
}
@ -167,13 +169,13 @@ func resolveTenant(ctx context.Context, conn *pgx.Conn, ref string) (id, name st
func listUsers(ctx context.Context, conn *pgx.Conn, tenantID, tenantName string) error {
rows, err := conn.Query(ctx, `
SELECT u.email::text, COALESCE(u.full_name,''), r.key::text,
SELECT u.username::text, COALESCE(u.full_name,''), r.key::text,
COALESCE(u.last_login_at::text, '—')
FROM core.memberships m
JOIN core.users u ON u.id = m.user_id
JOIN core.roles r ON r.id = m.role_id
WHERE m.tenant_id = $1 AND u.deleted_at IS NULL
ORDER BY u.email
ORDER BY u.username
`, tenantID)
if err != nil {
return err
@ -183,11 +185,11 @@ func listUsers(ctx context.Context, conn *pgx.Conn, tenantID, tenantName string)
fmt.Printf("Користувачі «%s»:\n", tenantName)
n := 0
for rows.Next() {
var email, name, role, last string
if err := rows.Scan(&email, &name, &role, &last); err != nil {
var login, name, role, last string
if err := rows.Scan(&login, &name, &role, &last); err != nil {
return err
}
fmt.Printf(" %-30s %-12s %-20s вхід: %s\n", email, role, name, last)
fmt.Printf(" %-30s %-12s %-20s вхід: %s\n", login, role, name, last)
n++
}
if n == 0 {

View file

@ -26,6 +26,7 @@ func (s *Server) handleListAlerts(w http.ResponseWriter, r *http.Request, p *Pri
f := store.AlertFilter{
MinSeverity: r.URL.Query().Get("min_severity"),
DeviceID: r.URL.Query().Get("device_id"),
Scope: p.Scope(),
}
if v := r.URL.Query().Get("state"); v != "" {
f.States = strings.Split(v, ",")
@ -44,7 +45,7 @@ func (s *Server) handleListAlerts(w http.ResponseWriter, r *http.Request, p *Pri
alerts = []store.Alert{}
}
counts, err := s.store.CountAlerts(r.Context(), p.TenantID)
counts, err := s.store.CountAlerts(r.Context(), p.TenantID, p.Scope())
if err != nil {
s.log.Error("лічильники алертів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
@ -125,6 +126,11 @@ func (s *Server) handleMuteDevice(w http.ResponseWriter, r *http.Request, p *Pri
req.Minutes = 60
}
if !p.Scope().CanWrite(req.DeviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
until := time.Now().Add(time.Duration(req.Minutes) * time.Minute)
if max := time.Now().Add(maxMute); until.After(max) {
until = max

View file

@ -10,7 +10,7 @@ import (
)
// Гальмування перебору: після 10 невдач за 15 хвилин вхід для цього
// email тимчасово закритий. Рахуємо за email, а не за IP: атака з
// логін тимчасово закритий. Рахуємо за логіном, а не за IP: атака з
// ботнета міняє IP на кожен запит, а ціль лишається та сама.
const (
maxFailedLogins = 10
@ -25,6 +25,9 @@ const (
const refreshCookie = "np_refresh"
type loginRequest struct {
// Login — те, що людина ввела в поле «Логін». Приймається і пошта:
// відмовляти тому, хто за звичкою ввів email, було б причепливістю.
Login string `json:"login"`
Email string `json:"email"`
Password string `json:"password"`
// Необовʼязковий: потрібен лише коли людина працює в кількох тенантах.
@ -45,8 +48,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if !decodeBody(w, r, &in) {
return
}
if in.Email == "" || in.Password == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібні email і пароль")
// Старе поле лишається прийнятним: інтеграції, написані до
// перейменування, не мають ламатися через назву ключа в JSON.
if in.Login == "" {
in.Login = in.Email
}
if in.Login == "" || in.Password == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібні логін і пароль")
return
}
@ -54,20 +62,20 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
ua := r.UserAgent()
if n, err := s.store.FailedLoginsSince(ctx, in.Email, failWindow); err == nil && n >= maxFailedLogins {
s.log.Warn("вхід заблоковано через перебір", "email", in.Email, "спроб", n)
if n, err := s.store.FailedLoginsSince(ctx, in.Login, failWindow); err == nil && n >= maxFailedLogins {
s.log.Warn("вхід заблоковано через перебір", "login", in.Login, "спроб", n)
writeError(w, http.StatusTooManyRequests, "too_many_attempts",
"забагато невдалих спроб, спробуйте за 15 хвилин")
return
}
user, memberships, err := s.store.AuthenticateUser(ctx, in.Email, in.Password, ip, ua)
user, memberships, err := s.store.AuthenticateUser(ctx, in.Login, in.Password, ip, ua)
switch {
case errors.Is(err, store.ErrBadCredentials):
// Однакова відповідь для «немає такого email» і «невірний
// Однакова відповідь для «немає такого логіна» і «невірний
// пароль»: різні тексти перетворюють форму входу на довідник
// зареєстрованих адрес.
writeError(w, http.StatusUnauthorized, "bad_credentials", "невірний email або пароль")
writeError(w, http.StatusUnauthorized, "bad_credentials", "невірний логін або пароль")
return
case errors.Is(err, store.ErrNoMembership):
writeError(w, http.StatusForbidden, "no_membership", "користувача не додано до жодної організації")

View file

@ -3,13 +3,18 @@ package httpapi_test
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
// seedUser заводить людину з паролем і роллю в тенанті фікстури.
//
// Логін виводиться з пошти, щоб тести лишались читабельними: у них
// важлива роль користувача, а не форма його ідентифікатора.
func (f *fixture) seedUser(t *testing.T, email, password, roleKey string) string {
t.Helper()
id, err := f.store.CreateUser(f.ctx, f.tenantID, email, password, "Тест Тестович", roleKey)
login, _, _ := strings.Cut(email, "@")
id, err := f.store.CreateUser(f.ctx, f.tenantID, login, email, password, "Тест Тестович", roleKey)
if err != nil {
t.Fatalf("створення користувача: %v", err)
}
@ -22,6 +27,7 @@ type loginResp struct {
Permissions []string `json:"permissions"`
User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
} `json:"user"`
Tenant *struct {
@ -35,7 +41,8 @@ type loginResp struct {
func (f *fixture) login(t *testing.T, email, password string) (int, loginResp, *http.Cookie) {
t.Helper()
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
login, _, _ := strings.Cut(email, "@")
body, _ := json.Marshal(map[string]string{"login": login, "password": password})
req, _ := http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/login", bytesReader(body))
req.Header.Set("Content-Type", "application/json")

View file

@ -0,0 +1,298 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"github.com/netpulse/netpulse/server/internal/store"
)
// ---------------------------------------------------------------------
// Групи пристроїв
// ---------------------------------------------------------------------
func (s *Server) handleListDeviceGroups(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
groups, err := s.store.ListDeviceGroups(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік груп", err)
return
}
if groups == nil {
groups = []store.DeviceGroup{}
}
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
}
func (s *Server) handleCreateDeviceGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
var in struct {
Name string `json:"name"`
Color string `json:"color"`
}
if !decodeBody(w, r, &in) {
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва групи")
return
}
id, err := s.store.CreateDeviceGroup(r.Context(), p.TenantID, strings.TrimSpace(in.Name), in.Color)
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "група з такою назвою вже є")
return
}
s.writeStoreError(w, "створення групи", err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
func (s *Server) handleDeleteDeviceGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
err := s.store.DeleteDeviceGroup(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrGroupNotFound) {
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "видалення групи", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------
// Хости
// ---------------------------------------------------------------------
var validDeviceKinds = map[string]bool{
"router": true, "switch": true, "firewall": true, "server": true,
"ap": true, "olt": true, "onu": true, "ups": true, "printer": true,
"camera": true, "sensor": true, "vm": true, "container": true, "other": true,
}
func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
in, ok := decodeDevice(w, r)
if !ok {
return
}
id, err := s.store.CreateDevice(r.Context(), p.TenantID, in)
if err != nil {
if isPlanLimit(err) {
writeError(w, http.StatusPaymentRequired, "plan_limit",
"вичерпано ліміт пристроїв тарифу")
return
}
s.writeStoreError(w, "створення хоста", err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
deviceID := r.PathValue("id")
// Право «редагувати пристрої» і право «редагувати ЦЕЙ пристрій» —
// різні речі: друге дають групи.
if !p.Scope().CanWrite(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста")
return
}
in, ok := decodeDevice(w, r)
if !ok {
return
}
if err := s.store.UpdateDevice(r.Context(), p.TenantID, deviceID, in); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
return
}
s.writeStoreError(w, "оновлення хоста", err)
return
}
if err := s.store.SetDeviceGroups(r.Context(), p.TenantID, deviceID, in.GroupIDs); err != nil {
s.writeStoreError(w, "групи хоста", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanWrite(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста")
return
}
if err := s.store.DeleteDevice(r.Context(), p.TenantID, deviceID); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
return
}
s.writeStoreError(w, "видалення хоста", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func decodeDevice(w http.ResponseWriter, r *http.Request) (store.DeviceInput, bool) {
var in struct {
Name string `json:"name"`
Address string `json:"address"`
Kind string `json:"kind"`
Vendor string `json:"vendor"`
AgentID string `json:"agent_id"`
Enabled *bool `json:"enabled"`
GroupIDs []string `json:"group_ids"`
Notes string `json:"notes"`
}
if !decodeBody(w, r, &in) {
return store.DeviceInput{}, false
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва хоста")
return store.DeviceInput{}, false
}
if in.Kind == "" {
in.Kind = "other"
}
if !validDeviceKinds[in.Kind] {
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий тип пристрою")
return store.DeviceInput{}, false
}
return store.DeviceInput{
Name: strings.TrimSpace(in.Name),
Address: strings.TrimSpace(in.Address),
Kind: in.Kind,
Vendor: in.Vendor,
AgentID: in.AgentID,
Enabled: in.Enabled == nil || *in.Enabled,
GroupIDs: in.GroupIDs,
Notes: in.Notes,
}, true
}
func isPlanLimit(err error) bool {
return err != nil && strings.Contains(err.Error(), "ліміт")
}
// ---------------------------------------------------------------------
// Групи користувачів
// ---------------------------------------------------------------------
func (s *Server) handleListUserGroups(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:read") {
return
}
groups, err := s.store.ListUserGroups(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік груп користувачів", err)
return
}
if groups == nil {
groups = []store.UserGroup{}
}
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
}
func (s *Server) handleCreateUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:write") {
return
}
var in struct {
Name string `json:"name"`
Description string `json:"description"`
}
if !decodeBody(w, r, &in) {
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва групи")
return
}
id, err := s.store.CreateUserGroup(r.Context(), p.TenantID, strings.TrimSpace(in.Name), in.Description)
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "група з такою назвою вже є")
return
}
s.writeStoreError(w, "створення групи користувачів", err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
// handlePatchUserGroup міняє склад і права одним запитом.
//
// Разом, а не окремими ендпоїнтами: у формі це один екран і одна
// кнопка «Зберегти», і розбивати її на два запити означало б лишати
// групу в напівзбереженому стані, якщо другий не пройде.
func (s *Server) handlePatchUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:write") {
return
}
var in struct {
MemberIDs *[]string `json:"member_ids"`
Permissions *map[string]string `json:"permissions"`
}
if !decodeBody(w, r, &in) {
return
}
groupID := r.PathValue("id")
if in.MemberIDs != nil {
if err := s.store.SetUserGroupMembers(r.Context(), p.TenantID, groupID, *in.MemberIDs); err != nil {
if errors.Is(err, store.ErrGroupNotFound) {
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
return
}
s.writeStoreError(w, "склад групи", err)
return
}
}
if in.Permissions != nil {
if err := s.store.SetUserGroupPermissions(r.Context(), p.TenantID, groupID, *in.Permissions); err != nil {
if errors.Is(err, store.ErrGroupNotFound) {
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
return
}
s.writeStoreError(w, "права групи", err)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:write") {
return
}
err := s.store.DeleteUserGroup(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrGroupNotFound) {
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "видалення групи", err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -7,6 +7,7 @@ import (
"strings"
"github.com/netpulse/netpulse/server/internal/auth"
"github.com/netpulse/netpulse/server/internal/store"
)
// Principal — хто саме робить запит.
@ -19,6 +20,7 @@ type Principal struct {
TenantID string
// Порожній для машинного токена.
UserID string
Username string
Email string
// Порожній для людини.
TokenID string
@ -28,6 +30,20 @@ type Principal struct {
// Машинний токен без scopes успадковує повний доступ тенанта:
// так поводяться ключі, які власник створює сам для себе.
unrestricted bool
// scope — які саме пристрої видно. Права відповідають на питання
// «що вільно робити», scope — «над чим». Два незалежні виміри:
// інженер над однією філією та інженер над усією мережею мають
// однакову роль і різний доступ.
scope store.Scope
}
// Scope — обмеження видимості для вибірок.
func (p *Principal) Scope() store.Scope {
if p == nil {
return store.Scope{Unrestricted: true}
}
return p.scope
}
func (p *Principal) Can(perm string) bool {
@ -114,6 +130,15 @@ func (s *Server) resolvePrincipal(r *http.Request, raw string) (*Principal, erro
for _, k := range perms {
p.perms[k] = true
}
// Обмеження за групами читається на кожному запиті разом із
// правами — з тієї самої причини: зміна складу групи має діяти
// одразу, а не після протермінування токена.
sc, err := s.store.LoadScope(ctx, claims.TenantID, claims.UserID)
if err != nil {
return nil, err
}
p.scope = sc
return p, nil
}
@ -134,6 +159,9 @@ func (s *Server) resolvePrincipal(r *http.Request, raw string) (*Principal, erro
for _, k := range tok.Scopes {
p.perms[k] = true
}
// Машинний токен групами не обмежений: у нього немає членства, а
// його межі задає власний scopes.
p.scope = store.Scope{Unrestricted: true}
return p, nil
}

View file

@ -87,6 +87,18 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/v1/maps/{id}/build", s.authenticated(s.handleBuildMap))
mux.Handle("POST /api/v1/maps/{id}/undo", s.authenticated(s.handleUndoMap))
mux.Handle("GET /api/v1/devices", s.authenticated(s.handleListDevices))
mux.Handle("POST /api/v1/devices", s.authenticated(s.handleCreateDevice))
mux.Handle("PATCH /api/v1/devices/{id}", s.authenticated(s.handleUpdateDevice))
mux.Handle("DELETE /api/v1/devices/{id}", s.authenticated(s.handleDeleteDevice))
mux.Handle("GET /api/v1/device-groups", s.authenticated(s.handleListDeviceGroups))
mux.Handle("POST /api/v1/device-groups", s.authenticated(s.handleCreateDeviceGroup))
mux.Handle("DELETE /api/v1/device-groups/{id}", s.authenticated(s.handleDeleteDeviceGroup))
mux.Handle("GET /api/v1/user-groups", s.authenticated(s.handleListUserGroups))
mux.Handle("POST /api/v1/user-groups", s.authenticated(s.handleCreateUserGroup))
mux.Handle("PATCH /api/v1/user-groups/{id}", s.authenticated(s.handlePatchUserGroup))
mux.Handle("DELETE /api/v1/user-groups/{id}", s.authenticated(s.handleDeleteUserGroup))
mux.Handle("GET /api/v1/agents", s.authenticated(s.handleListAgents))
mux.Handle("GET /api/v1/alerts", s.authenticated(s.handleListAlerts))
@ -256,7 +268,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request, p *Pr
return
}
devices, err := s.store.ListDevices(r.Context(), p.TenantID)
devices, err := s.store.ListDevices(r.Context(), p.TenantID, p.Scope())
if err != nil {
s.log.Error("перелік пристроїв", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")

View file

@ -3,10 +3,17 @@ package httpapi
import (
"errors"
"net/http"
"regexp"
"strings"
"github.com/netpulse/netpulse/server/internal/store"
)
// usernameRe повторює CHECK із міграції 0013. Дублювання свідоме:
// відмова БД виглядає як «violates check constraint users_username_format»,
// і людині з неї нічого не зрозуміло.
var usernameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$`)
func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:read") {
return
@ -50,6 +57,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, p *Pri
}
var in struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
FullName string `json:"full_name"`
@ -58,8 +66,16 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, p *Pri
if !decodeBody(w, r, &in) {
return
}
if in.Email == "" || in.Password == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібні email і пароль")
in.Username = strings.ToLower(strings.TrimSpace(in.Username))
if in.Username == "" || in.Password == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібні логін і пароль")
return
}
// Формат перевіряє й БД, але помилка звідти виглядає як
// «violates check constraint» — людині з неї нічого не зрозуміло.
if !usernameRe.MatchString(in.Username) {
writeError(w, http.StatusBadRequest, "bad_username",
"логін: 364 символи, лише латиниця, цифри, крапка, дефіс і підкреслення")
return
}
if in.RoleKey == "" {
@ -72,7 +88,8 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, p *Pri
return
}
u, err := s.store.CreateUser(r.Context(), p.TenantID, in.Email, in.Password, in.FullName, in.RoleKey)
u, err := s.store.CreateUser(r.Context(), p.TenantID, in.Username, in.Email,
in.Password, in.FullName, in.RoleKey)
if err != nil {
s.writeStoreError(w, "створення користувача", err)
return

View file

@ -19,6 +19,11 @@ type AlertFilter struct {
MinSeverity string
DeviceID string
Limit int
// Scope звужує вибірку до пристроїв, доступних цій людині. Алерт
// без пристрою (наприклад, про сам зонд) видно всім: сховати його
// від обмеженого користувача означало б приховати аварію, до якої
// групи не мають стосунку.
Scope Scope
}
// severityRank повторює порядок ENUM alr.severity. Тримати його в Go
@ -56,13 +61,15 @@ func (s *Store) ListAlerts(ctx context.Context, tenantID string, f AlertFilter)
WHERE a.tenant_id = $1
AND a.state::text = ANY($2::text[])
AND ($3::uuid IS NULL OR a.device_id = $3::uuid)
AND ($5::boolean OR a.device_id IS NULL OR a.device_id = ANY($6::uuid[]))
ORDER BY
CASE a.severity
WHEN 'disaster' THEN 0 WHEN 'high' THEN 1 WHEN 'average' THEN 2
WHEN 'warning' THEN 3 ELSE 4 END,
a.started_at DESC
LIMIT $4
`, tenantID, states, nullUUID(f.DeviceID), f.Limit)
`, tenantID, states, nullUUID(f.DeviceID), f.Limit,
f.Scope.Unrestricted, nonNilIDs(f.Scope.Readable))
if err != nil {
return err
}
@ -106,7 +113,7 @@ type AlertCounts struct {
High int `json:"high"`
}
func (s *Store) CountAlerts(ctx context.Context, tenantID string) (AlertCounts, error) {
func (s *Store) CountAlerts(ctx context.Context, tenantID string, sc Scope) (AlertCounts, error) {
var c AlertCounts
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
@ -118,7 +125,9 @@ func (s *Store) CountAlerts(ctx context.Context, tenantID string) (AlertCounts,
count(*) FILTER (WHERE severity = 'high' AND state = 'firing')
FROM alr.alerts
WHERE tenant_id = $1 AND state IN ('firing','acknowledged','suppressed')
`, tenantID).Scan(&c.Firing, &c.Acknowledged, &c.Suppressed, &c.Disaster, &c.High)
AND ($2::boolean OR device_id IS NULL OR device_id = ANY($3::uuid[]))
`, tenantID, sc.Unrestricted, nonNilIDs(sc.Readable)).
Scan(&c.Firing, &c.Acknowledged, &c.Suppressed, &c.Disaster, &c.High)
})
return c, err
}

View file

@ -0,0 +1,501 @@
package store
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
var ErrGroupNotFound = errors.New("групу не знайдено")
// ---------------------------------------------------------------------
// Групи пристроїв
// ---------------------------------------------------------------------
type DeviceGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Color string `json:"color,omitempty"`
DeviceCount int `json:"device_count"`
}
func (s *Store) ListDeviceGroups(ctx context.Context, tenantID string) ([]DeviceGroup, error) {
var out []DeviceGroup
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT g.id::text, g.name, g.kind::text, COALESCE(g.color,''),
(SELECT count(*) FROM inv.device_group_members m
JOIN inv.devices d ON d.id = m.device_id AND d.deleted_at IS NULL
WHERE m.group_id = g.id)::int
FROM inv.device_groups g
WHERE g.tenant_id = $1
ORDER BY g.name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var g DeviceGroup
if err := rows.Scan(&g.ID, &g.Name, &g.Kind, &g.Color, &g.DeviceCount); err != nil {
return err
}
out = append(out, g)
}
return rows.Err()
})
return out, err
}
func (s *Store) CreateDeviceGroup(ctx context.Context, tenantID, name, color string) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
INSERT INTO inv.device_groups (tenant_id, name, kind, color)
VALUES ($1, $2, 'static', NULLIF($3,''))
RETURNING id::text
`, tenantID, name, color).Scan(&id)
})
return id, err
}
func (s *Store) DeleteDeviceGroup(ctx context.Context, tenantID, groupID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx,
`DELETE FROM inv.device_groups WHERE tenant_id = $1 AND id = $2`, tenantID, groupID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrGroupNotFound
}
return nil
})
}
// SetDeviceGroups замінює членство пристрою цілком.
//
// Саме заміна, а не додавання: інтерфейс показує повний набір галочок,
// і зняту галочку треба вміти зняти. Часткові операції довелося б
// узгоджувати з тим, що бачить людина, і будь-яке розходження
// закінчувалося б непоміченим доступом.
func (s *Store) SetDeviceGroups(ctx context.Context, tenantID, deviceID string, groupIDs []string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var owned bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM inv.devices
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)
`, deviceID, tenantID).Scan(&owned); err != nil {
return err
}
if !owned {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `
DELETE FROM inv.device_group_members m
USING inv.device_groups g
WHERE m.group_id = g.id AND m.device_id = $1 AND g.tenant_id = $2
`, deviceID, tenantID); err != nil {
return err
}
if len(groupIDs) == 0 {
return nil
}
// Групи звіряються з тенантом усередині запиту: інакше чужий id
// у списку тихо додав би пристрій у групу сусіда.
_, err := tx.Exec(ctx, `
INSERT INTO inv.device_group_members (group_id, device_id)
SELECT g.id, $1 FROM inv.device_groups g
WHERE g.tenant_id = $2 AND g.id = ANY($3::uuid[])
ON CONFLICT DO NOTHING
`, deviceID, tenantID, groupIDs)
return err
})
}
// ---------------------------------------------------------------------
// Групи користувачів і права на групи пристроїв
// ---------------------------------------------------------------------
type UserGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
MemberIDs []string `json:"member_ids"`
Permissions map[string]string `json:"permissions"` // device_group_id → deny|read|write
}
func (s *Store) ListUserGroups(ctx context.Context, tenantID string) ([]UserGroup, error) {
var out []UserGroup
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT g.id::text, g.name, COALESCE(g.description,''),
COALESCE(array_agg(DISTINCT m.user_id::text)
FILTER (WHERE m.user_id IS NOT NULL), '{}')
FROM core.user_groups g
LEFT JOIN core.user_group_members m ON m.group_id = g.id
WHERE g.tenant_id = $1
GROUP BY g.id, g.name, g.description
ORDER BY g.name
`, tenantID)
if err != nil {
return err
}
byID := map[string]int{}
for rows.Next() {
var g UserGroup
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.MemberIDs); err != nil {
rows.Close()
return err
}
g.Permissions = map[string]string{}
byID[g.ID] = len(out)
out = append(out, g)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(out) == 0 {
return nil
}
perms, err := tx.Query(ctx, `
SELECT p.user_group_id::text, p.device_group_id::text, p.level::text
FROM core.group_permissions p
JOIN core.user_groups g ON g.id = p.user_group_id
WHERE g.tenant_id = $1
`, tenantID)
if err != nil {
return err
}
defer perms.Close()
for perms.Next() {
var ug, dg, lvl string
if err := perms.Scan(&ug, &dg, &lvl); err != nil {
return err
}
if i, ok := byID[ug]; ok {
out[i].Permissions[dg] = lvl
}
}
return perms.Err()
})
return out, err
}
func (s *Store) CreateUserGroup(ctx context.Context, tenantID, name, description string) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
INSERT INTO core.user_groups (tenant_id, name, description)
VALUES ($1, $2, NULLIF($3,''))
RETURNING id::text
`, tenantID, name, description).Scan(&id)
})
return id, err
}
func (s *Store) DeleteUserGroup(ctx context.Context, tenantID, groupID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx,
`DELETE FROM core.user_groups WHERE tenant_id = $1 AND id = $2`, tenantID, groupID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrGroupNotFound
}
return nil
})
}
// SetUserGroupMembers і SetUserGroupPermissions — теж повна заміна, з
// тієї самої причини, що й у пристроїв.
func (s *Store) SetUserGroupMembers(ctx context.Context, tenantID, groupID string, userIDs []string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if err := assertUserGroup(ctx, tx, tenantID, groupID); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`DELETE FROM core.user_group_members WHERE group_id = $1`, groupID); err != nil {
return err
}
if len(userIDs) == 0 {
return nil
}
// Лише ті, хто справді є в цьому тенанті: додати чужого
// користувача в свою групу означало б видати йому доступ.
_, err := tx.Exec(ctx, `
INSERT INTO core.user_group_members (group_id, user_id)
SELECT $1, m.user_id
FROM core.memberships m
WHERE m.tenant_id = $2 AND m.user_id = ANY($3::uuid[])
ON CONFLICT DO NOTHING
`, groupID, tenantID, userIDs)
return err
})
}
func (s *Store) SetUserGroupPermissions(ctx context.Context, tenantID, groupID string, perms map[string]string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if err := assertUserGroup(ctx, tx, tenantID, groupID); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`DELETE FROM core.group_permissions WHERE user_group_id = $1`, groupID); err != nil {
return err
}
for dg, lvl := range perms {
switch lvl {
case "deny", "read", "write":
default:
return fmt.Errorf("невідомий рівень доступу %q", lvl)
}
if _, err := tx.Exec(ctx, `
INSERT INTO core.group_permissions (user_group_id, device_group_id, level)
SELECT $1, g.id, $3::core.access_level
FROM inv.device_groups g
WHERE g.id = $2 AND g.tenant_id = $4
`, groupID, dg, lvl, tenantID); err != nil {
return err
}
}
return nil
})
}
func assertUserGroup(ctx context.Context, tx pgx.Tx, tenantID, groupID string) error {
var ok bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM core.user_groups WHERE id = $1 AND tenant_id = $2)
`, groupID, tenantID).Scan(&ok); err != nil {
return err
}
if !ok {
return ErrGroupNotFound
}
return nil
}
// ---------------------------------------------------------------------
// Обмеження видимості
// ---------------------------------------------------------------------
// Scope — що саме цій людині видно.
//
// Обчислюється раз на запит і далі йде параметром у вибірки. Питати
// БД про кожен пристрій окремо означало б перетворити список на N
// запитів рівно тоді, коли пристроїв багато.
type Scope struct {
// Unrestricted — людина в жодній групі користувачів, тож групами не
// обмежена; доступ визначає роль. Див. коментар у міграції 0013.
Unrestricted bool
Readable []string
Writable []string
}
func (s Scope) CanWrite(deviceID string) bool {
if s.Unrestricted {
return true
}
for _, id := range s.Writable {
if id == deviceID {
return true
}
}
return false
}
func (s Scope) CanRead(deviceID string) bool {
if s.Unrestricted {
return true
}
for _, id := range s.Readable {
if id == deviceID {
return true
}
}
return false
}
// LoadScope рахує доступні пристрої одним запитом.
func (s *Store) LoadScope(ctx context.Context, tenantID, userID string) (Scope, error) {
sc := Scope{Unrestricted: true}
if userID == "" {
// Машинний токен групами не обмежений: у нього немає членства,
// а його межі задає власний scopes.
return sc, nil
}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var inGroups bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM core.user_group_members m
JOIN core.user_groups g ON g.id = m.group_id
WHERE m.user_id = $1 AND g.tenant_id = $2)
`, userID, tenantID).Scan(&inGroups); err != nil {
return err
}
if !inGroups {
return nil
}
sc.Unrestricted = false
for _, lvl := range []string{"read", "write"} {
rows, err := tx.Query(ctx,
`SELECT device_id::text FROM core.accessible_devices($1, $2, $3::core.access_level)`,
userID, tenantID, lvl)
if err != nil {
return err
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if lvl == "read" {
sc.Readable = ids
} else {
sc.Writable = ids
}
}
return nil
})
return sc, err
}
// ---------------------------------------------------------------------
// Хости
// ---------------------------------------------------------------------
type DeviceInput struct {
Name string
Address string
Kind string
Vendor string
AgentID string
SiteID string
Enabled bool
GroupIDs []string
Notes string
}
func (s *Store) CreateDevice(ctx context.Context, tenantID string, in DeviceInput) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO inv.devices
(tenant_id, name, address, kind, vendor, agent_id, site_id, enabled, notes, source)
VALUES ($1, $2, NULLIF($3,'')::inet, $4::inv.device_kind, NULLIF($5,''),
$6, $7, $8, NULLIF($9,''), 'manual')
RETURNING id::text
`, tenantID, in.Name, in.Address, in.Kind, in.Vendor,
nullUUID(in.AgentID), nullUUID(in.SiteID), in.Enabled, in.Notes).Scan(&id); err != nil {
return fmt.Errorf("створення хоста: %w", err)
}
if len(in.GroupIDs) == 0 {
return nil
}
_, err := tx.Exec(ctx, `
INSERT INTO inv.device_group_members (group_id, device_id)
SELECT g.id, $1 FROM inv.device_groups g
WHERE g.tenant_id = $2 AND g.id = ANY($3::uuid[])
ON CONFLICT DO NOTHING
`, id, tenantID, in.GroupIDs)
return err
})
return id, err
}
func (s *Store) UpdateDevice(ctx context.Context, tenantID, deviceID string, in DeviceInput) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE inv.devices
SET name = $3, address = NULLIF($4,'')::inet, kind = $5::inv.device_kind,
vendor = NULLIF($6,''), agent_id = $7, enabled = $8,
notes = NULLIF($9,''), updated_at = now()
WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL
`, tenantID, deviceID, in.Name, in.Address, in.Kind, in.Vendor,
nullUUID(in.AgentID), in.Enabled, in.Notes)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// DeleteDevice — м'яке видалення.
//
// Жорстке зняло б за собою всю телеметрію й історію алертів. Пристрій
// зникає з інтерфейсу, але його минуле лишається доступним для розбору.
func (s *Store) DeleteDevice(ctx context.Context, tenantID, deviceID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE inv.devices SET deleted_at = now(), enabled = false
WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL
`, tenantID, deviceID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// DeviceGroupsOf — у яких групах пристрій. Потрібне формі редагування.
func (s *Store) DeviceGroupsOf(ctx context.Context, tenantID string) (map[string][]string, error) {
out := map[string][]string{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT m.device_id::text, m.group_id::text
FROM inv.device_group_members m
JOIN inv.device_groups g ON g.id = m.group_id
WHERE g.tenant_id = $1
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var d, g string
if err := rows.Scan(&d, &g); err != nil {
return err
}
out[d] = append(out[d], g)
}
return rows.Err()
})
return out, err
}
// nonNilIDs віддає порожній масив замість nil.
//
// pgx перетворює nil-слайс на SQL NULL, а `id = ANY(NULL)` — це не
// «жоден», а NULL, тобто рядок не проходить умову, але й помилки немає.
// Такий фільтр мовчки ховав би всі пристрої.
func nonNilIDs(ids []string) []string {
if ids == nil {
return []string{}
}
return ids
}

View file

@ -20,10 +20,19 @@ type DeviceSummary struct {
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
AgentID string `json:"agent_id,omitempty"`
IfaceCount int `json:"interface_count"`
GroupIDs []string `json:"group_ids"`
// Writable каже інтерфейсу, чи показувати дії. Рахується з груп
// користувача, а не з ролі: роль дозволяє редагувати пристрої взагалі,
// групи — саме цей.
Writable bool `json:"writable"`
}
// ListDevices — інвентар для таблиці в UI.
func (s *Store) ListDevices(ctx context.Context, tenantID string) ([]DeviceSummary, error) {
//
// Фільтр видимості накладається в самому запиті, а не після вибірки:
// відсіювати вже прочитане означало б тягнути з БД чужі рядки й
// покладатися на те, що жоден із них не проскочить у відповідь.
func (s *Store) ListDevices(ctx context.Context, tenantID string, sc Scope) ([]DeviceSummary, error) {
var out []DeviceSummary
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
@ -35,8 +44,9 @@ func (s *Store) ListDevices(ctx context.Context, tenantID string) ([]DeviceSumma
FROM inv.devices d
LEFT JOIN inv.sites st ON st.id = d.site_id
WHERE d.tenant_id = $1 AND d.deleted_at IS NULL
AND ($2::boolean OR d.id = ANY($3::uuid[]))
ORDER BY d.name
`, tenantID)
`, tenantID, sc.Unrestricted, nonNilIDs(sc.Readable))
if err != nil {
return err
}
@ -49,12 +59,28 @@ func (s *Store) ListDevices(ctx context.Context, tenantID string) ([]DeviceSumma
&d.IfaceCount); err != nil {
return err
}
d.Writable = sc.CanWrite(d.ID)
d.GroupIDs = []string{}
out = append(out, d)
}
return rows.Err()
})
if err != nil {
return nil, err
}
return out, err
// Групи добираємо одним запитом і розкладаємо в пам'яті: агрегат у
// головному запиті змусив би його групуватись по всіх колонках.
groups, err := s.DeviceGroupsOf(ctx, tenantID)
if err != nil {
return nil, err
}
for i := range out {
if g, ok := groups[out[i].ID]; ok {
out[i].GroupIDs = g
}
}
return out, nil
}
type AgentSummary struct {

View file

@ -20,14 +20,15 @@ import (
const RefreshTTL = 30 * 24 * time.Hour
var (
ErrBadCredentials = errors.New("невірний email або пароль")
ErrBadCredentials = errors.New("невірний логін або пароль")
ErrNoMembership = errors.New("користувач не належить жодному тенанту")
ErrSessionInvalid = errors.New("сесія недійсна")
)
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
FullName string `json:"full_name,omitempty"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
@ -46,7 +47,8 @@ type TenantMembership struct {
// TeamMember — рядок сторінки «Команда».
type TeamMember struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
FullName string `json:"full_name,omitempty"`
RoleID string `json:"role_id"`
RoleKey string `json:"role_key"`
@ -69,45 +71,56 @@ type Role struct {
// Вхід
// ---------------------------------------------------------------------
// AuthenticateUser перевіряє email і пароль.
// AuthenticateUser перевіряє логін і пароль.
//
// Запити йдуть БЕЗ tenant-контексту: у момент входу тенант ще невідомий,
// його називає саме членство. Політики з міграції 0012 дозволяють це
// рівно тоді, коли app.tenant_id порожній.
func (s *Store) AuthenticateUser(ctx context.Context, email, password string, ip net.IP, userAgent string) (*User, []TenantMembership, error) {
// Ідентифікатором є username, а не пошта: у мережевій інсталяції
// половина облікових записів технічні (noc, monitoring, oncall) і
// скриньки не мають узагалі. Пошта лишається прийнятною — люди звикли
// вводити її, і відмовляти їм у вході через це було б причепливістю, —
// але вона більше не єдиний шлях і не обов'язкове поле.
func (s *Store) AuthenticateUser(ctx context.Context, login, password string, ip net.IP, userAgent string) (*User, []TenantMembership, error) {
var (
u User
hash *string
email *string
)
err := s.pool.QueryRow(ctx, `
SELECT id::text, email::text, COALESCE(full_name,''), locale, timezone,
mfa_enabled, password_hash
SELECT id::text, username::text, email::text, COALESCE(full_name,''),
locale, timezone, mfa_enabled, password_hash
FROM core.users
WHERE email = $1 AND deleted_at IS NULL
`, email).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA, &hash)
WHERE (username = $1 OR email = $1) AND deleted_at IS NULL
-- Збіг за логіном виграє: пошта одного може випадково збігтися
-- з логіном іншого, і тоді пріоритет має мати той, кому цей
-- рядок належить як ідентифікатор.
ORDER BY (username = $1) DESC
LIMIT 1
`, login).Scan(&u.ID, &u.Username, &email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA, &hash)
if errors.Is(err, pgx.ErrNoRows) {
// Спалюємо стільки ж часу, скільки справжня перевірка: інакше
// швидка відповідь видає, що такого email немає, і перебір
// перетворюється на збір списку зареєстрованих адрес.
// швидка відповідь видає, що такого логіна немає, і перебір
// перетворюється на збір списку зареєстрованих акаунтів.
auth.DummyVerify(password)
s.recordLogin(ctx, email, ip, userAgent, false, "no_user", "")
s.recordLogin(ctx, login, ip, userAgent, false, "no_user", "")
return nil, nil, ErrBadCredentials
}
if err != nil {
return nil, nil, err
}
if email != nil {
u.Email = *email
}
if hash == nil || *hash == "" {
// Користувач лише через SSO — локального пароля немає.
auth.DummyVerify(password)
s.recordLogin(ctx, email, ip, userAgent, false, "no_password", u.ID)
s.recordLogin(ctx, login, ip, userAgent, false, "no_password", u.ID)
return nil, nil, ErrBadCredentials
}
if err := auth.VerifyPassword(*hash, password); err != nil {
s.recordLogin(ctx, email, ip, userAgent, false, "bad_password", u.ID)
s.recordLogin(ctx, login, ip, userAgent, false, "bad_password", u.ID)
return nil, nil, ErrBadCredentials
}
@ -116,11 +129,11 @@ func (s *Store) AuthenticateUser(ctx context.Context, email, password string, ip
return nil, nil, err
}
if len(memberships) == 0 {
s.recordLogin(ctx, email, ip, userAgent, false, "no_membership", u.ID)
s.recordLogin(ctx, login, ip, userAgent, false, "no_membership", u.ID)
return nil, nil, ErrNoMembership
}
s.recordLogin(ctx, email, ip, userAgent, true, "", u.ID)
s.recordLogin(ctx, login, ip, userAgent, true, "", u.ID)
if _, err := s.pool.Exec(ctx,
`UPDATE core.users SET last_login_at = now() WHERE id = $1`, u.ID); err != nil {
@ -352,14 +365,15 @@ func (s *Store) ListTeam(ctx context.Context, tenantID string) ([]TeamMember, er
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT u.id::text, u.email::text, COALESCE(u.full_name,''),
SELECT u.id::text, u.username::text, COALESCE(u.email::text,''),
COALESCE(u.full_name,''),
r.id::text, r.key::text, r.name,
u.last_login_at, m.accepted_at, m.scope_group_ids::text[]
FROM core.memberships m
JOIN core.users u ON u.id = m.user_id
JOIN core.roles r ON r.id = m.role_id
WHERE m.tenant_id = $1 AND u.deleted_at IS NULL
ORDER BY u.email
ORDER BY u.username
`, tenantID)
if err != nil {
return err
@ -368,7 +382,7 @@ func (s *Store) ListTeam(ctx context.Context, tenantID string) ([]TeamMember, er
for rows.Next() {
var t TeamMember
if err := rows.Scan(&t.UserID, &t.Email, &t.FullName,
if err := rows.Scan(&t.UserID, &t.Username, &t.Email, &t.FullName,
&t.RoleID, &t.RoleKey, &t.RoleName,
&t.LastLogin, &t.AcceptedAt, &t.ScopeGroups); err != nil {
return err
@ -417,7 +431,7 @@ func (s *Store) ListRoles(ctx context.Context, tenantID string) ([]Role, error)
// CreateUser заводить користувача й одразу дає йому членство.
//
// Використовується і CLI-початківцем (перший власник), і запрошеннями.
func (s *Store) CreateUser(ctx context.Context, tenantID, email, password, fullName, roleKey string) (*User, error) {
func (s *Store) CreateUser(ctx context.Context, tenantID, username, email, password, fullName, roleKey string) (*User, error) {
hash, err := auth.HashPassword(password)
if err != nil {
return nil, err
@ -426,17 +440,27 @@ func (s *Store) CreateUser(ctx context.Context, tenantID, email, password, fullN
var u User
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Користувач глобальний: одна людина може працювати в кількох
// тенантах (типово для MSP-провайдера).
// тенантах (типово для MSP-провайдера). Тому конфлікт за логіном
// означає «той самий чоловік», а не помилку — але пароль при
// цьому НЕ перезаписується: інакше адмін одного тенанта міняв би
// пароль людини в чужому.
var mail *string
err := tx.QueryRow(ctx, `
INSERT INTO core.users (email, password_hash, full_name, email_verified_at)
VALUES ($1, $2, NULLIF($3,''), now())
ON CONFLICT (email) DO UPDATE
INSERT INTO core.users (username, email, password_hash, full_name, email_verified_at)
VALUES ($1, NULLIF($2,'')::citext, $3, NULLIF($4,''),
CASE WHEN NULLIF($2,'') IS NULL THEN NULL ELSE now() END)
ON CONFLICT (username) DO UPDATE
SET full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name)
RETURNING id::text, email::text, COALESCE(full_name,''), locale, timezone, mfa_enabled
`, email, hash, fullName).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA)
RETURNING id::text, username::text, email::text,
COALESCE(full_name,''), locale, timezone, mfa_enabled
`, username, email, hash, fullName).
Scan(&u.ID, &u.Username, &mail, &u.FullName, &u.Locale, &u.Timezone, &u.MFA)
if err != nil {
return fmt.Errorf("створення користувача: %w", err)
}
if mail != nil {
u.Email = *mail
}
var roleID string
if err := tx.QueryRow(ctx, `

View file

@ -6,6 +6,7 @@ import { AppShell } from './components/AppShell'
import { LoginPage } from './components/LoginPage'
import { MapPage } from './pages/MapPage'
import { DevicesPage } from './pages/DevicesPage'
import { GroupsPage } from './pages/GroupsPage'
import { AlertsPage } from './pages/AlertsPage'
import { RulesPage } from './pages/RulesPage'
import { ChannelsPage } from './pages/ChannelsPage'
@ -49,6 +50,7 @@ export default function App() {
<Route index element={<Home />} />
<Route path="/map" element={<Guard perm="maps:read" element={<MapPage />} />} />
<Route path="/devices" element={<Guard perm="devices:read" element={<DevicesPage />} />} />
<Route path="/groups" element={<Guard perm="devices:read" element={<GroupsPage />} />} />
<Route path="/alerts" element={<Guard perm="alerts:read" element={<AlertsPage />} />} />
<Route path="/rules" element={<Guard perm="alerts:read" element={<RulesPage />} />} />
<Route path="/channels" element={<Guard perm="alerts:read" element={<ChannelsPage />} />} />

View file

@ -1,11 +1,13 @@
import { meFromLogin, session } from './session'
import type {
AccessLevel,
AgentSummary,
Alert,
AlertCounts,
AlertRule,
BuildResult,
Channel,
DeviceGroup,
DeviceSummary,
LoginResponse,
MapPatch,
@ -14,9 +16,23 @@ import type {
PatchResult,
Role,
TeamMember,
UserGroup,
UndoResult,
} from '../types'
/** Те, що приймає POST/PATCH /api/v1/devices. */
export interface DeviceInput {
name: string
address?: string
kind: string
vendor?: string
agent_id?: string
enabled?: boolean
group_ids?: string[]
notes?: string
}
// Машинний токен зі змінної збірки лишається запасним шляхом для кіосків
// і NOC-екранів, де входити нікому. Жива сесія людини має пріоритет.
const STATIC_TOKEN = import.meta.env.VITE_API_TOKEN ?? ''
@ -118,10 +134,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = {
// --- вхід ---
login: (email: string, password: string, tenantID?: string) =>
login: (login: string, password: string, tenantID?: string) =>
raw<LoginResponse>('/api/v1/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password, tenant_id: tenantID }),
body: JSON.stringify({ login, password, tenant_id: tenantID }),
}),
/** Тихе відновлення сесії після перезавантаження сторінки. */
@ -150,11 +166,13 @@ export const api = {
roles: () => request<{ roles: Role[] }>('/api/v1/roles').then((r) => r.roles ?? []),
addMember: (email: string, password: string, roleKey: string, fullName = '') =>
request<{ id: string }>('/api/v1/team', {
method: 'POST',
body: JSON.stringify({ email, password, role_key: roleKey, full_name: fullName }),
}),
addMember: (u: {
username: string
password: string
role_key: string
email?: string
full_name?: string
}) => request<{ id: string }>('/api/v1/team', { method: 'POST', body: JSON.stringify(u) }),
setRole: (userID: string, roleID: string) =>
request<void>(`/api/v1/team/${userID}`, {
@ -195,6 +213,49 @@ export const api = {
listAgents: () =>
request<{ agents: AgentSummary[] }>('/api/v1/agents').then((r) => r.agents ?? []),
// --- хости ---
createDevice: (d: DeviceInput) =>
request<{ id: string }>('/api/v1/devices', { method: 'POST', body: JSON.stringify(d) }),
updateDevice: (id: string, d: DeviceInput) =>
request<void>(`/api/v1/devices/${id}`, { method: 'PATCH', body: JSON.stringify(d) }),
deleteDevice: (id: string) => request<void>(`/api/v1/devices/${id}`, { method: 'DELETE' }),
// --- групи пристроїв ---
listDeviceGroups: () =>
request<{ groups: DeviceGroup[] }>('/api/v1/device-groups').then((r) => r.groups ?? []),
createDeviceGroup: (name: string, color = '') =>
request<{ id: string }>('/api/v1/device-groups', {
method: 'POST',
body: JSON.stringify({ name, color }),
}),
deleteDeviceGroup: (id: string) =>
request<void>(`/api/v1/device-groups/${id}`, { method: 'DELETE' }),
// --- групи користувачів ---
listUserGroups: () =>
request<{ groups: UserGroup[] }>('/api/v1/user-groups').then((r) => r.groups ?? []),
createUserGroup: (name: string, description = '') =>
request<{ id: string }>('/api/v1/user-groups', {
method: 'POST',
body: JSON.stringify({ name, description }),
}),
updateUserGroup: (
id: string,
patch: { member_ids?: string[]; permissions?: Record<string, AccessLevel> },
) => request<void>(`/api/v1/user-groups/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
deleteUserGroup: (id: string) =>
request<void>(`/api/v1/user-groups/${id}`, { method: 'DELETE' }),
// --- алерти ---
listAlerts: () =>

View file

@ -12,6 +12,7 @@ import type { Permission } from '../types'
export interface Me {
userID?: string
username?: string
email?: string
fullName?: string
tokenName?: string
@ -59,13 +60,14 @@ export const session = {
/** Розбір відповіді входу в єдину форму. */
export function meFromLogin(body: {
user?: { id: string; email: string; full_name?: string }
user?: { id: string; username?: string; email?: string; full_name?: string }
tenant?: { tenant_id: string; tenant_name?: string; role_name?: string } | null
permissions?: string[]
tenant_id?: string
}): Me {
return {
userID: body.user?.id,
username: body.user?.username,
email: body.user?.email,
fullName: body.user?.full_name,
tenantID: body.tenant?.tenant_id ?? body.tenant_id ?? '',

View file

@ -17,7 +17,8 @@ interface NavItem {
const navItems: NavItem[] = [
{ to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
{ to: '/devices', label: 'Пристрої', icon: '🖧', perm: 'devices:read' },
{ to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' },
{ to: '/groups', label: 'Групи', icon: '🗂', perm: 'devices:read' },
{ to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
{ to: '/rules', label: 'Правила', icon: '⚖', perm: 'alerts:read' },
{ to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' },
@ -108,15 +109,15 @@ export function AppShell() {
</NavLink>
)}
{me?.email && (
{(me?.username || me?.email) && (
<div className="flex items-center gap-1">
<NavLink
to="/profile"
className="max-w-[9rem] truncate rounded border border-slate-700 px-2 py-1
text-slate-400 hover:bg-slate-800"
title={me.email}
title={me.email || me.username}
>
{me.email}
{me.username || me.email}
</NavLink>
<button
className="rounded border border-slate-700 px-2 py-1 text-slate-500 hover:bg-slate-800"

View file

@ -8,6 +8,11 @@ export type DeviceNodeData = {
rtt?: number
loss?: number
icon?: string
/** Ширина з map_nodes.width; нижня межа лишається за замовчуванням. */
width?: number
/** Масштаб підпису: 'sm' | 'md' | 'lg'. */
size?: string
color?: string
}
export type DeviceNodeType = Node<DeviceNodeData, 'device'>
@ -34,14 +39,28 @@ const ICONS: Record<string, string> = {
other: '□',
}
const TEXT_SIZE: Record<string, string> = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
}
export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
const s = STATUS[data.status || 'unknown'] ?? STATUS.unknown
const icon = ICONS[data.icon ?? data.kind] ?? ICONS.other
const textSize = TEXT_SIZE[data.size ?? 'md'] ?? TEXT_SIZE.md
return (
<div
className={`min-w-[150px] rounded-lg border-2 bg-slate-900/95 px-3 py-2 shadow-lg backdrop-blur
className={`rounded-lg border-2 bg-slate-900/95 px-3 py-2 shadow-lg backdrop-blur
${s.ring} ${selected ? 'ring-2 ring-sky-400' : ''}`}
// Ширину задає людина в інспекторі; колір рамки — теж, і тоді він
// перекриває статусний. Це свідомо: на схемі, де кольором
// позначені майданчики, статус читають за крапкою.
style={{
minWidth: data.width ?? 150,
...(data.color ? { borderColor: data.color } : {}),
}}
>
<Handle type="target" position={Position.Left} className="!h-2 !w-2 !bg-slate-500" />
<Handle type="source" position={Position.Right} className="!h-2 !w-2 !bg-slate-500" />
@ -49,7 +68,7 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
<span className="text-base leading-none text-slate-400">{icon}</span>
<span className="truncate text-sm font-medium text-slate-100">{data.label}</span>
<span className={`truncate font-medium text-slate-100 ${textSize}`}>{data.label}</span>
</div>
{/* Цифри показуємо лише коли вони є: порожній рядок « мс»

View file

@ -8,11 +8,14 @@ import type { TenantMembership } from '../types'
*
* Свідомо мінімальна: одне поле помилки, жодних підказок на кшталт
* «такого користувача немає». Сервер і так відповідає однаково на
* невідомий email і невірний пароль показувати різне на клієнті
* невідомий логін і невірний пароль показувати різне на клієнті
* означало б звести нанівець цей захист.
*
* Поле приймає і пошту: сервер шукає за обома. Людину, яка за звичкою
* ввела email, відхиляти було б причепливістю.
*/
export function LoginPage({ onDone }: { onDone: () => void }) {
const [email, setEmail] = useState('')
const [login, setLogin] = useState('')
const [password, setPassword] = useState('')
const [tenants, setTenants] = useState<TenantMembership[] | null>(null)
const [busy, setBusy] = useState(false)
@ -22,7 +25,7 @@ export function LoginPage({ onDone }: { onDone: () => void }) {
setBusy(true)
setError(null)
try {
const r = await api.login(email, password, tenantID)
const r = await api.login(login.trim(), password, tenantID)
// Людина працює в кількох організаціях — треба вибрати.
if (!r.access_token && r.tenants && r.tenants.length > 0) {
@ -43,7 +46,7 @@ export function LoginPage({ onDone }: { onDone: () => void }) {
? 'Забагато невдалих спроб. Спробуйте за 15 хвилин.'
: e.status === 403
? 'Користувача не додано до жодної організації'
: 'Невірний email або пароль',
: 'Невірний логін або пароль',
)
} else {
setError(e instanceof Error ? e.message : String(e))
@ -89,16 +92,18 @@ export function LoginPage({ onDone }: { onDone: () => void }) {
}}
>
<label className="block">
<span className="mb-1 block text-xs text-slate-400">Email</span>
<span className="mb-1 block text-xs text-slate-400">Логін</span>
<input
type="email"
type="text"
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
required
autoFocus
className="w-full rounded border border-slate-700 bg-slate-800 px-3 py-2.5 text-sm
text-slate-100 outline-none focus:border-sky-600"
value={email}
onChange={(e) => setEmail(e.target.value)}
value={login}
onChange={(e) => setLogin(e.target.value)}
/>
</label>

View file

@ -44,6 +44,9 @@ export function MapCanvas({ state, onPatch, onSelect, readOnly = false }: Props)
rtt: n.rtt_ms,
loss: n.loss_pct,
icon: n.style?.icon as string | undefined,
width: n.width,
size: n.style?.size as string | undefined,
color: n.style?.color as string | undefined,
},
})),
[state.nodes, readOnly],

View file

@ -0,0 +1,218 @@
import { useEffect, useState } from 'react'
import { Button, Field, inputClass } from './ui'
import type { MapNode, NodeInput } from '../types'
const ICON_CHOICES = [
{ key: '', label: 'за типом' },
{ key: 'switch', label: 'комутатор ⇄' },
{ key: 'router', label: 'маршрутизатор ⇉' },
{ key: 'server', label: 'сервер ▤' },
{ key: 'firewall', label: 'фаєрвол 🛡' },
{ key: 'ap', label: 'точка доступу' },
{ key: 'ups', label: 'ДБЖ ⚡' },
{ key: 'olt', label: 'OLT ⌸' },
{ key: 'cloud', label: 'хмара ☁' },
{ key: 'other', label: 'інше □' },
]
const SIZE_CHOICES = [
{ key: 'sm', label: 'дрібний' },
{ key: 'md', label: 'звичайний' },
{ key: 'lg', label: 'великий' },
]
/**
* Інспектор вузла: підпис, вигляд, розмір, положення.
*
* Правки застосовуються кнопкою, а не на кожне натискання клавіші.
* Кожне збереження це нова ревізія полотна й подія для всіх, хто
* дивиться на ту саму мапу; писати їх по літері означало б засмітити
* історію й змусити сусідні екрани перечитувати мапу десятки разів
* поки хтось перейменовує вузол.
*/
export function NodeInspector({
node,
readOnly,
onPatch,
onDelete,
onClose,
}: {
node: MapNode
readOnly: boolean
onPatch: (patch: NodeInput, comment: string) => Promise<void> | void
onDelete: () => void
onClose: () => void
}) {
const [label, setLabel] = useState(node.label ?? '')
const [icon, setIcon] = useState((node.style?.icon as string) ?? '')
const [size, setSize] = useState((node.style?.size as string) ?? 'md')
const [color, setColor] = useState((node.style?.color as string) ?? '')
const [width, setWidth] = useState(node.width ? String(Math.round(node.width)) : '')
const [locked, setLocked] = useState(node.locked)
const [busy, setBusy] = useState(false)
// Вибір іншого вузла має перезаряджати форму, інакше вона показувала б
// поля попереднього.
useEffect(() => {
setLabel(node.label ?? '')
setIcon((node.style?.icon as string) ?? '')
setSize((node.style?.size as string) ?? 'md')
setColor((node.style?.color as string) ?? '')
setWidth(node.width ? String(Math.round(node.width)) : '')
setLocked(node.locked)
}, [node.id, node.label, node.style, node.width, node.locked])
const dirty =
label !== (node.label ?? '') ||
icon !== ((node.style?.icon as string) ?? '') ||
size !== ((node.style?.size as string) ?? 'md') ||
color !== ((node.style?.color as string) ?? '') ||
width !== (node.width ? String(Math.round(node.width)) : '') ||
locked !== node.locked
async function apply() {
setBusy(true)
try {
// Стиль шлемо цілком: сервер зберігає його як jsonb і зливати
// часткові зміни всередині поля не вміє.
const style: Record<string, unknown> = { ...(node.style ?? {}) }
if (icon) style.icon = icon
else delete style.icon
if (size && size !== 'md') style.size = size
else delete style.size
if (color) style.color = color
else delete style.color
const patch: NodeInput = { id: node.id, label, style, locked }
const w = Number(width)
if (width.trim() !== '' && Number.isFinite(w) && w > 0) patch.width = w
await onPatch(patch, 'зміна вигляду вузла')
} finally {
setBusy(false)
}
}
return (
<div
className="shrink-0 border-t border-slate-800 bg-slate-900 px-3 py-2.5 text-xs
md:absolute md:bottom-3 md:right-3 md:z-10 md:w-72 md:rounded-lg md:border"
>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate font-medium text-slate-100">{node.label || 'Вузол'}</span>
<button className="px-1 text-slate-500 hover:text-slate-300" onClick={onClose}>
</button>
</div>
<div className="mb-2 grid grid-cols-2 gap-x-3 gap-y-0.5">
<Row k="статус" v={node.status || 'unknown'} />
<Row k="позиція" v={`${Math.round(node.x)}, ${Math.round(node.y)}`} />
{node.rtt_ms !== undefined && <Row k="RTT" v={`${node.rtt_ms.toFixed(2)} мс`} />}
{node.loss_pct !== undefined && <Row k="втрати" v={`${node.loss_pct}%`} />}
</div>
{readOnly ? (
<p className="text-[11px] text-slate-600">Перегляд редагування потребує maps:write</p>
) : (
<div className="space-y-2">
<Field label="Підпис">
<input
className={`${inputClass} py-1.5`}
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="Значок">
<select
className={`${inputClass} py-1.5`}
value={icon}
onChange={(e) => setIcon(e.target.value)}
>
{ICON_CHOICES.map((c) => (
<option key={c.key} value={c.key}>
{c.label}
</option>
))}
</select>
</Field>
<Field label="Розмір підпису">
<select
className={`${inputClass} py-1.5`}
value={size}
onChange={(e) => setSize(e.target.value)}
>
{SIZE_CHOICES.map((c) => (
<option key={c.key} value={c.key}>
{c.label}
</option>
))}
</select>
</Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Ширина, px">
<input
className={`${inputClass} py-1.5`}
type="number"
min="100"
placeholder="150"
value={width}
onChange={(e) => setWidth(e.target.value)}
/>
</Field>
<Field label="Колір рамки">
<div className="flex items-center gap-1.5">
<input
type="color"
className="h-8 w-10 rounded border border-slate-700 bg-slate-800"
value={color || '#64748b'}
onChange={(e) => setColor(e.target.value)}
/>
{color && (
<button
className="text-[11px] text-slate-500 hover:text-slate-300"
onClick={() => setColor('')}
title="Повернути колір за статусом"
>
скинути
</button>
)}
</div>
</Field>
</div>
<label className="flex items-center gap-2 text-slate-400">
<input
type="checkbox"
checked={locked}
onChange={(e) => setLocked(e.target.checked)}
/>
Закріпити (не перетягується)
</label>
<div className="flex gap-1.5 pt-0.5">
<Button kind="primary" disabled={!dirty || busy} onClick={() => void apply()}>
{busy ? 'Збереження…' : 'Застосувати'}
</Button>
<Button kind="danger" onClick={onDelete} title="Прибрати вузол із полотна">
Видалити
</Button>
</div>
</div>
)}
</div>
)
}
function Row({ k, v }: { k: string; v: string }) {
return (
<div className="flex justify-between gap-2">
<span className="text-slate-500">{k}</span>
<span className="tabular-nums text-slate-300">{v}</span>
</div>
)
}

View file

@ -230,9 +230,13 @@ export function Toggle({
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-40
${checked ? 'bg-sky-600' : 'bg-slate-700'}`}
>
{/* left задано явно. Без нього ручка стає на статичну позицію, а
та у <button> зсунута типовим text-align: center знайдено
живим виміром: left виходив 18px замість 2px, і ручка
вилазила на 14px за межі треку, накриваючи сусідню кнопку. */}
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform
${checked ? 'translate-x-4' : 'translate-x-0.5'}`}
className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition-transform
${checked ? 'translate-x-4' : 'translate-x-0'}`}
/>
</button>
)

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useOutletContext } from 'react-router-dom'
import { api } from '../api/client'
import { api, type DeviceInput } from '../api/client'
import { session } from '../api/session'
import { liveEvents } from '../api/ws'
import type { LiveAlerts } from '../hooks/useAlerts'
@ -8,6 +8,7 @@ import {
Button,
DataTable,
ErrorNote,
Field,
Modal,
PageHeader,
Spinner,
@ -15,7 +16,7 @@ import {
ago,
inputClass,
} from '../components/ui'
import type { Alert, DeviceSummary } from '../types'
import type { AgentSummary, Alert, DeviceGroup, DeviceSummary } from '../types'
const statusLabel: Record<string, string> = {
up: 'на зв’язку',
@ -25,6 +26,24 @@ const statusLabel: Record<string, string> = {
unknown: 'невідомо',
}
// Порядок такий самий, як у ENUM inv.device_kind на сервері.
const kindLabel: Record<string, string> = {
router: 'Маршрутизатор',
switch: 'Комутатор',
firewall: 'Міжмережевий екран',
server: 'Сервер',
ap: 'Точка доступу',
olt: 'OLT',
onu: 'ONU',
ups: 'ДБЖ',
printer: 'Принтер',
camera: 'Камера',
sensor: 'Датчик',
vm: 'Віртуальна машина',
container: 'Контейнер',
other: 'Інше',
}
export function DevicesPage() {
// Алерти беремо з оболонки, а не тягнемо власним запитом: вона вже
// тримає їх живими для лічильника в шапці, і другий такий самий
@ -32,18 +51,30 @@ export function DevicesPage() {
const live = useOutletContext<LiveAlerts>()
const [devices, setDevices] = useState<DeviceSummary[]>([])
const [groups, setGroups] = useState<DeviceGroup[]>([])
const [agents, setAgents] = useState<AgentSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [groupFilter, setGroupFilter] = useState('')
const [onlyProblems, setOnlyProblems] = useState(false)
const [detail, setDetail] = useState<DeviceSummary | null>(null)
const [creating, setCreating] = useState(false)
const canWrite = session.can('devices:write')
const canMute = session.can('alerts:ack')
const alerts = live.alerts
const reload = useCallback(async () => {
try {
setDevices(await api.listDevices())
const [d, g, a] = await Promise.all([
api.listDevices(),
api.listDeviceGroups().catch(() => [] as DeviceGroup[]),
session.can('agents:read') ? api.listAgents() : Promise.resolve([] as AgentSummary[]),
])
setDevices(d)
setGroups(g)
setAgents(a)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
@ -56,11 +87,9 @@ export function DevicesPage() {
void reload()
}, [reload])
// Статус пристрою міняється не тут, а на зонді. Без підписки список
// показував би стан на момент відкриття сторінки й тихо старів.
//
// Зміна застосовується точково: це один рядок, і перечитувати заради
// нього весь список немає сенсу.
// Статус пристрою міняється не тут, а на зонді. Зміна застосовується
// точково: це один рядок, і перечитувати заради нього весь список
// немає сенсу.
useEffect(
() =>
liveEvents.subscribe((msg) => {
@ -85,10 +114,20 @@ export function DevicesPage() {
return m
}, [alerts])
const groupName = useMemo(() => {
const m = new Map<string, string>()
for (const g of groups) m.set(g.id, g.name)
return m
}, [groups])
const rows = useMemo(() => {
const q = query.trim().toLowerCase()
return devices.filter((d) => {
if (onlyProblems && d.status !== 'down' && d.status !== 'warning') return false
if (groupFilter === '__none' && (d.group_ids?.length ?? 0) > 0) return false
if (groupFilter && groupFilter !== '__none' && !d.group_ids?.includes(groupFilter)) {
return false
}
if (!q) return true
return (
d.name.toLowerCase().includes(q) ||
@ -96,14 +135,14 @@ export function DevicesPage() {
(d.vendor ?? '').toLowerCase().includes(q)
)
})
}, [devices, query, onlyProblems])
}, [devices, query, onlyProblems, groupFilter])
const problems = devices.filter((d) => d.status === 'down' || d.status === 'warning').length
return (
<>
<PageHeader
title="Пристрої"
title="Хости"
subtitle={
loading
? undefined
@ -114,18 +153,36 @@ export function DevicesPage() {
actions={
<>
<input
className={`${inputClass} w-40 md:w-56`}
className={`${inputClass} w-36 md:w-52`}
placeholder="пошук за іменем, IP…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<select
className={`${inputClass} w-36 md:w-44`}
value={groupFilter}
onChange={(e) => setGroupFilter(e.target.value)}
>
<option value="">усі групи</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name} ({g.device_count})
</option>
))}
<option value="__none">без групи</option>
</select>
<Button
kind={onlyProblems ? 'primary' : 'default'}
onClick={() => setOnlyProblems((v) => !v)}
title="Показати лише недоступні й проблемні"
>
Тільки проблеми
Проблеми
</Button>
{canWrite && (
<Button kind="primary" onClick={() => setCreating(true)}>
+ Хост
</Button>
)}
</>
}
/>
@ -146,13 +203,13 @@ export function DevicesPage() {
onRowClick={setDetail}
empty={
devices.length === 0
? 'Пристроїв ще немає — їх заводить автовиявлення або імпорт'
? 'Хостів ще немає — додайте вручну або дочекайтесь автовиявлення'
: 'Нічого не знайдено за цим фільтром'
}
columns={[
{
key: 'name',
header: 'Пристрій',
header: 'Хост',
cell: (d) => (
<span className="flex items-center gap-2">
<span className="truncate font-medium text-slate-100">{d.name}</span>
@ -161,6 +218,11 @@ export function DevicesPage() {
{alertsByDevice.get(d.id)!.length}
</span>
)}
{!d.enabled && (
<span className="rounded bg-slate-800 px-1.5 text-[11px] text-slate-500">
вимкнено
</span>
)}
</span>
),
},
@ -168,44 +230,81 @@ export function DevicesPage() {
key: 'status',
header: 'Стан',
cell: (d) => (
<StatusBadge status={d.status || 'unknown'} label={statusLabel[d.status || 'unknown']} />
<StatusBadge
status={d.status || 'unknown'}
label={statusLabel[d.status || 'unknown']}
/>
),
},
{ key: 'address', header: 'Адреса', cell: (d) => d.address ?? '—' },
{
key: 'kind',
header: 'Тип',
hideOnMobile: true,
cell: (d) => (
<span className="text-slate-400">
{d.kind}
{d.vendor ? ` · ${d.vendor}` : ''}
key: 'groups',
header: 'Групи',
cell: (d) =>
d.group_ids?.length ? (
<span className="flex flex-wrap gap-1">
{d.group_ids.map((g) => (
<span
key={g}
className="rounded bg-slate-800 px-1.5 py-0.5 text-[11px] text-slate-400"
>
{groupName.get(g) ?? '—'}
</span>
))}
</span>
) : (
<span className="text-slate-600"></span>
),
},
{
key: 'ifs',
header: 'Інтерфейси',
key: 'kind',
header: 'Тип',
hideOnMobile: true,
cell: (d) => <span className="tabular-nums">{d.interface_count}</span>,
cell: (d) => <span className="text-slate-400">{kindLabel[d.kind] ?? d.kind}</span>,
},
{
key: 'seen',
header: 'Востаннє',
cell: (d) => <span className="tabular-nums text-slate-400">{ago(d.last_seen_at)}</span>,
cell: (d) => (
<span className="tabular-nums text-slate-400">{ago(d.last_seen_at)}</span>
),
},
]}
/>
)}
</div>
{creating && (
<DeviceForm
groups={groups}
agents={agents}
onClose={() => setCreating(false)}
onSave={async (in_) => {
await api.createDevice(in_)
setCreating(false)
await reload()
}}
/>
)}
{detail && (
<DeviceDetail
device={detail}
groups={groups}
agents={agents}
alerts={alertsByDevice.get(detail.id) ?? []}
canMute={canMute}
onMute={async (minutes) => {
await live.mute(detail.id, minutes)
canWrite={canWrite && detail.writable}
canMute={canMute && detail.writable}
onMute={(minutes) => live.mute(detail.id, minutes)}
onSave={async (in_) => {
await api.updateDevice(detail.id, in_)
setDetail(null)
await reload()
}}
onDelete={async () => {
await api.deleteDevice(detail.id)
setDetail(null)
await reload()
}}
onClose={() => setDetail(null)}
/>
@ -214,32 +313,235 @@ export function DevicesPage() {
)
}
// ---------------------------------------------------------------------
function DeviceForm({
device,
groups,
agents,
onClose,
onSave,
onDelete,
}: {
device?: DeviceSummary
groups: DeviceGroup[]
agents: AgentSummary[]
onClose: () => void
onSave: (in_: DeviceInput) => Promise<void>
onDelete?: () => Promise<void>
}) {
const [name, setName] = useState(device?.name ?? '')
const [address, setAddress] = useState(device?.address ?? '')
const [kind, setKind] = useState(device?.kind ?? 'other')
const [vendor, setVendor] = useState(device?.vendor ?? '')
const [agentID, setAgentID] = useState('')
const [enabled, setEnabled] = useState(device?.enabled ?? true)
const [groupIDs, setGroupIDs] = useState<string[]>(device?.group_ids ?? [])
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const toggleGroup = (id: string) =>
setGroupIDs((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
return (
<Modal title={device ? `Хост: ${device.name}` : 'Новий хост'} onClose={onClose}>
<div className="space-y-3">
<Field label="Назва">
<input
className={inputClass}
autoFocus={!device}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="IP-адреса" hint="За нею опитує зонд">
<input
className={inputClass}
placeholder="192.168.1.1"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</Field>
<Field label="Тип">
<select className={inputClass} value={kind} onChange={(e) => setKind(e.target.value)}>
{Object.entries(kindLabel).map(([k, label]) => (
<option key={k} value={k}>
{label}
</option>
))}
</select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Виробник">
<input
className={inputClass}
value={vendor}
onChange={(e) => setVendor(e.target.value)}
/>
</Field>
<Field label="Зонд" hint="Хто опитує цей хост">
<select
className={inputClass}
value={agentID}
onChange={(e) => setAgentID(e.target.value)}
>
<option value=""> не змінювати </option>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</Field>
</div>
<div>
<span className="mb-1 block text-xs font-medium text-slate-400">Групи</span>
{groups.length === 0 ? (
<p className="text-xs text-slate-600">
Груп ще немає створіть їх у розділі «Групи»
</p>
) : (
<ul className="max-h-40 space-y-0.5 overflow-y-auto rounded border border-slate-800 p-1.5">
{groups.map((g) => (
<li key={g.id}>
<label className="flex items-center gap-2 rounded px-1 py-1 text-sm hover:bg-slate-800/50">
<input
type="checkbox"
checked={groupIDs.includes(g.id)}
onChange={() => toggleGroup(g.id)}
/>
<span className="text-slate-200">{g.name}</span>
</label>
</li>
))}
</ul>
)}
</div>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
Опитувати цей хост
</label>
<ErrorNote>{err}</ErrorNote>
<div className="flex flex-wrap justify-end gap-2 pt-1">
{onDelete && (
<Button
kind="danger"
disabled={busy}
className="mr-auto"
title="Хост зникне зі списків, але його історія збережеться"
onClick={async () => {
setBusy(true)
setErr(null)
try {
await onDelete()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
Видалити
</Button>
)}
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="primary"
disabled={busy || !name.trim()}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await onSave({
name: name.trim(),
address: address.trim(),
kind,
vendor: vendor.trim(),
agent_id: agentID,
enabled,
group_ids: groupIDs,
})
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
{busy ? 'Збереження…' : 'Зберегти'}
</Button>
</div>
</div>
</Modal>
)
}
function DeviceDetail({
device,
groups,
agents,
alerts,
canWrite,
canMute,
onMute,
onSave,
onDelete,
onClose,
}: {
device: DeviceSummary
groups: DeviceGroup[]
agents: AgentSummary[]
alerts: Alert[]
canWrite: boolean
canMute: boolean
onMute: (minutes: number) => Promise<void>
onSave: (in_: DeviceInput) => Promise<void>
onDelete: () => Promise<void>
onClose: () => void
}) {
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
if (canWrite) {
// Для того, хто має право писати, форма і є картка: окремий режим
// «перегляд» додав би зайвий клік до кожної правки.
return (
<DeviceForm
device={device}
groups={groups}
agents={agents}
onClose={onClose}
onSave={onSave}
onDelete={onDelete}
/>
)
}
return (
<Modal title={device.name} onClose={onClose}>
<dl className="space-y-1.5 text-sm">
<Pair k="Стан" v={<StatusBadge status={device.status || 'unknown'} label={statusLabel[device.status || 'unknown']} />} />
<Pair
k="Стан"
v={
<StatusBadge
status={device.status || 'unknown'}
label={statusLabel[device.status || 'unknown']}
/>
}
/>
<Pair k="Адреса" v={device.address ?? '—'} />
<Pair k="Тип" v={device.kind} />
<Pair k="Виробник" v={device.vendor ?? '—'} />
<Pair k="Тип" v={kindLabel[device.kind] ?? device.kind} />
<Pair k="Інтерфейсів" v={String(device.interface_count)} />
<Pair k="Востаннє на зв’язку" v={ago(device.last_seen_at)} />
<Pair k="Опитується" v={device.enabled ? 'так' : 'ні'} />
</dl>
<h3 className="mb-2 mt-5 text-xs font-semibold uppercase tracking-wide text-slate-500">
@ -258,36 +560,21 @@ function DeviceDetail({
</ul>
)}
{err && (
<div className="mt-3">
<ErrorNote>{err}</ErrorNote>
</div>
)}
{canMute && (
<div className="mt-5 flex flex-wrap gap-2">
{[60, 240, 1440].map((m) => (
<Button
key={m}
disabled={busy}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await onMute(m)
onClose()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
<Button key={m} onClick={() => void onMute(m).then(onClose)}>
Заглушити на {m >= 1440 ? '24 год' : m >= 240 ? '4 год' : '1 год'}
</Button>
))}
</div>
)}
{!canWrite && (
<p className="mt-4 text-[11px] leading-snug text-slate-600">
Доступ лише на читання редагувати цей хост дозволяє група доступу.
</p>
)}
</Modal>
)
}

View file

@ -0,0 +1,479 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api/client'
import { session } from '../api/session'
import {
Button,
Card,
DataTable,
ErrorNote,
Field,
Modal,
PageHeader,
Spinner,
inputClass,
} from '../components/ui'
import type { AccessLevel, DeviceGroup, TeamMember, UserGroup } from '../types'
const levelLabel: Record<AccessLevel | 'none', string> = {
none: 'немає',
read: 'читання',
write: 'запис',
deny: 'заборона',
}
const levelTone: Record<AccessLevel | 'none', string> = {
none: 'border-slate-700 text-slate-500',
read: 'border-sky-800 bg-sky-950/50 text-sky-300',
write: 'border-emerald-800 bg-emerald-950/50 text-emerald-300',
deny: 'border-red-900 bg-red-950/50 text-red-300',
}
/**
* Групи: хости зліва, доступ справа.
*
* Дві половини одного питання. Група пристроїв відповідає «що це за
* набір заліза», група користувачів «кому з ним працювати». Тримати
* їх на різних сторінках означало б змушувати людину клацати туди-сюди
* рівно тоді, коли вона налаштовує зв'язок між ними.
*/
export function GroupsPage() {
const [deviceGroups, setDeviceGroups] = useState<DeviceGroup[]>([])
const [userGroups, setUserGroups] = useState<UserGroup[]>([])
const [team, setTeam] = useState<TeamMember[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [newDeviceGroup, setNewDeviceGroup] = useState(false)
const [newUserGroup, setNewUserGroup] = useState(false)
const [editing, setEditing] = useState<UserGroup | null>(null)
const canWriteDevices = session.can('devices:write')
const canWriteUsers = session.can('users:write')
const canSeeUsers = session.can('users:read')
const reload = useCallback(async () => {
try {
const [dg, ug, t] = await Promise.all([
api.listDeviceGroups(),
canSeeUsers ? api.listUserGroups() : Promise.resolve([] as UserGroup[]),
canSeeUsers ? api.team() : Promise.resolve([] as TeamMember[]),
])
setDeviceGroups(dg)
setUserGroups(ug)
setTeam(t)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [canSeeUsers])
useEffect(() => {
void reload()
}, [reload])
async function act(fn: () => Promise<unknown>) {
try {
await fn()
await reload()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
return (
<>
<PageHeader
title="Групи"
subtitle={
loading ? undefined : `${deviceGroups.length} груп хостів · ${userGroups.length} груп доступу`
}
actions={
<>
{canWriteDevices && (
<Button onClick={() => setNewDeviceGroup(true)}>+ Група хостів</Button>
)}
{canWriteUsers && (
<Button kind="primary" onClick={() => setNewUserGroup(true)}>
+ Група доступу
</Button>
)}
</>
}
/>
<div className="min-h-0 flex-1 overflow-y-auto p-3 md:p-4">
{error && <ErrorNote>{error}</ErrorNote>}
{loading ? (
<Spinner />
) : (
<div className="grid gap-4 xl:grid-cols-2">
<Card>
<h2 className="border-b border-slate-800 px-4 py-2.5 text-xs font-semibold uppercase tracking-wide text-slate-500">
Групи хостів
</h2>
<DataTable
rows={deviceGroups}
rowKey={(g) => g.id}
empty="Груп хостів ще немає"
columns={[
{
key: 'name',
header: 'Назва',
cell: (g) => (
<span className="flex items-center gap-2">
{g.color && (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ background: g.color }}
/>
)}
<span className="font-medium text-slate-100">{g.name}</span>
</span>
),
},
{
key: 'count',
header: 'Хостів',
cell: (g) => <span className="tabular-nums">{g.device_count}</span>,
},
{
key: 'act',
header: '',
cell: (g) =>
canWriteDevices && (
<button
className="px-1 text-xs text-slate-600 hover:text-red-400"
title="Видалити групу — хости лишаться, зникне лише набір"
onClick={() => void act(() => api.deleteDeviceGroup(g.id))}
>
</button>
),
},
]}
/>
</Card>
{canSeeUsers && (
<Card>
<h2 className="border-b border-slate-800 px-4 py-2.5 text-xs font-semibold uppercase tracking-wide text-slate-500">
Групи доступу
</h2>
{userGroups.length === 0 ? (
<div className="px-4 py-6 text-xs leading-relaxed text-slate-600">
Груп доступу ще немає. Поки їх немає, кожен бачить усі хости межі задає
лише роль. Щойно людину додадуть хоч в одну групу, їй стане видно рівно те,
що ця група дозволяє.
</div>
) : (
<ul className="divide-y divide-slate-800/70">
{userGroups.map((g) => (
<li key={g.id} className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-100">{g.name}</span>
<span className="text-xs text-slate-600">
{g.member_ids.length} учасників
</span>
{canWriteUsers && (
<span className="ml-auto flex items-center gap-1.5">
<Button onClick={() => setEditing(g)}>Налаштувати</Button>
<button
className="px-1 text-xs text-slate-600 hover:text-red-400"
onClick={() => void act(() => api.deleteUserGroup(g.id))}
title="Видалити групу доступу"
>
</button>
</span>
)}
</div>
{g.description && (
<p className="mt-0.5 text-xs text-slate-500">{g.description}</p>
)}
<div className="mt-2 flex flex-wrap gap-1">
{deviceGroups.map((dg) => {
const lvl = g.permissions[dg.id]
if (!lvl) return null
return (
<span
key={dg.id}
className={`rounded border px-1.5 py-0.5 text-[11px] ${levelTone[lvl]}`}
>
{dg.name}: {levelLabel[lvl]}
</span>
)
})}
{Object.keys(g.permissions).length === 0 && (
<span className="text-[11px] text-slate-600">
прав не задано учасники не побачать жодного хоста
</span>
)}
</div>
</li>
))}
</ul>
)}
</Card>
)}
</div>
)}
</div>
{newDeviceGroup && (
<NameForm
title="Нова група хостів"
label="Назва"
withColor
onClose={() => setNewDeviceGroup(false)}
onSave={async (name, color) => {
await api.createDeviceGroup(name, color)
setNewDeviceGroup(false)
await reload()
}}
/>
)}
{newUserGroup && (
<NameForm
title="Нова група доступу"
label="Назва"
withDescription
onClose={() => setNewUserGroup(false)}
onSave={async (name, _c, description) => {
await api.createUserGroup(name, description)
setNewUserGroup(false)
await reload()
}}
/>
)}
{editing && (
<UserGroupForm
group={editing}
deviceGroups={deviceGroups}
team={team}
onClose={() => setEditing(null)}
onSaved={async () => {
setEditing(null)
await reload()
}}
/>
)}
</>
)
}
function NameForm({
title,
label,
withColor,
withDescription,
onClose,
onSave,
}: {
title: string
label: string
withColor?: boolean
withDescription?: boolean
onClose: () => void
onSave: (name: string, color: string, description: string) => Promise<void>
}) {
const [name, setName] = useState('')
const [color, setColor] = useState('#38bdf8')
const [description, setDescription] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
return (
<Modal title={title} onClose={onClose}>
<div className="space-y-3">
<Field label={label}>
<input
className={inputClass}
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
{withColor && (
<Field label="Колір" hint="Ним група позначається у списках">
<input
type="color"
className="h-9 w-16 rounded border border-slate-700 bg-slate-800"
value={color}
onChange={(e) => setColor(e.target.value)}
/>
</Field>
)}
{withDescription && (
<Field label="Опис">
<input
className={inputClass}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</Field>
)}
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="primary"
disabled={busy || !name.trim()}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await onSave(name.trim(), withColor ? color : '', description.trim())
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
{busy ? 'Збереження…' : 'Створити'}
</Button>
</div>
</div>
</Modal>
)
}
function UserGroupForm({
group,
deviceGroups,
team,
onClose,
onSaved,
}: {
group: UserGroup
deviceGroups: DeviceGroup[]
team: TeamMember[]
onClose: () => void
onSaved: () => Promise<void>
}) {
const [members, setMembers] = useState<string[]>(group.member_ids ?? [])
const [perms, setPerms] = useState<Record<string, AccessLevel>>({ ...group.permissions })
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const toggleMember = (id: string) =>
setMembers((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
const setLevel = (dgID: string, lvl: AccessLevel | 'none') =>
setPerms((prev) => {
const next = { ...prev }
if (lvl === 'none') delete next[dgID]
else next[dgID] = lvl
return next
})
return (
<Modal title={`Група доступу: ${group.name}`} onClose={onClose}>
<div className="space-y-4">
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
Учасники
</h3>
{team.length === 0 ? (
<p className="text-xs text-slate-600">Немає кого додати</p>
) : (
<ul className="space-y-1">
{team.map((m) => (
<li key={m.user_id}>
<label className="flex items-center gap-2 rounded px-1 py-1 text-sm hover:bg-slate-800/50">
<input
type="checkbox"
checked={members.includes(m.user_id)}
onChange={() => toggleMember(m.user_id)}
/>
<span className="text-slate-200">{m.username}</span>
<span className="text-xs text-slate-600">{m.full_name || m.role_name}</span>
</label>
</li>
))}
</ul>
)}
</div>
<div>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Доступ до груп хостів
</h3>
<p className="mb-2 text-[11px] leading-snug text-slate-600">
Заборона перемагає дозвіл: хост, що потрапив і в дозволену, і в заборонену групу,
лишається невидимим.
</p>
{deviceGroups.length === 0 ? (
<p className="text-xs text-slate-600">Спершу створіть групу хостів</p>
) : (
<ul className="space-y-1.5">
{deviceGroups.map((dg) => {
const cur = perms[dg.id] ?? 'none'
return (
<li key={dg.id} className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm text-slate-200">
{dg.name}
<span className="ml-1.5 text-xs text-slate-600">{dg.device_count}</span>
</span>
<div className="flex gap-1">
{(['none', 'read', 'write', 'deny'] as const).map((lvl) => (
<button
key={lvl}
onClick={() => setLevel(dg.id, lvl)}
className={`rounded border px-2 py-1 text-[11px] ${
cur === lvl ? levelTone[lvl] : 'border-slate-800 text-slate-600'
}`}
>
{levelLabel[lvl]}
</button>
))}
</div>
</li>
)
})}
</ul>
)}
</div>
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2">
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="primary"
disabled={busy}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await api.updateUserGroup(group.id, {
member_ids: members,
permissions: perms,
})
await onSaved()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
{busy ? 'Збереження…' : 'Зберегти'}
</Button>
</div>
</div>
</Modal>
)
}

View file

@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api } from '../api/client'
import { session } from '../api/session'
import { useLiveMap } from '../hooks/useLiveMap'
import { MapCanvas } from '../components/MapCanvas'
import { Button, ErrorNote } from '../components/ui'
import { NodeInspector } from '../components/NodeInspector'
import { Button, ErrorNote, Field, Modal, inputClass } from '../components/ui'
import type { MapSummary } from '../types'
export function MapPage() {
@ -11,20 +12,26 @@ export function MapPage() {
const [mapID, setMapID] = useState<string | null>(null)
const [selected, setSelected] = useState<string | null>(null)
const [listError, setListError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const live = useLiveMap(mapID)
const canWrite = session.can('maps:write')
useEffect(() => {
api
.listMaps()
.then((m) => {
const loadMaps = useCallback(async (selectID?: string) => {
try {
const m = await api.listMaps()
setMaps(m)
setMapID((cur) => cur ?? m.find((x) => x.is_default)?.id ?? m[0]?.id ?? null)
})
.catch((e) => setListError(e instanceof Error ? e.message : String(e)))
setMapID((cur) => selectID ?? cur ?? m.find((x) => x.is_default)?.id ?? m[0]?.id ?? null)
setListError(null)
} catch (e) {
setListError(e instanceof Error ? e.message : String(e))
}
}, [])
useEffect(() => {
void loadMaps()
}, [loadMaps])
const counts = useMemo(() => {
const nodes = live.state?.nodes ?? []
return {
@ -54,6 +61,9 @@ export function MapPage() {
{canWrite && (
<>
<Button onClick={() => setCreating(true)} title="Створити порожню мапу">
+ Мапа
</Button>
<Button
disabled={!mapID}
onClick={() => void live.build()}
@ -106,36 +116,28 @@ export function MapPage() {
</div>
{node && (
<div
className="shrink-0 border-t border-slate-800 bg-slate-900 px-3 py-2 text-xs
md:absolute md:bottom-3 md:right-3 md:w-64 md:rounded-lg md:border"
>
<div className="mb-1 flex items-center justify-between">
<span className="font-medium text-slate-100">{node.label}</span>
<button
className="px-2 text-slate-500 hover:text-slate-300"
onClick={() => setSelected(null)}
>
</button>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-0.5 md:block">
<Row k="статус" v={node.status || 'unknown'} />
{node.rtt_ms !== undefined && <Row k="RTT" v={`${node.rtt_ms.toFixed(3)} мс`} />}
{node.loss_pct !== undefined && <Row k="втрати" v={`${node.loss_pct}%`} />}
<Row k="координати" v={`${Math.round(node.x)}, ${Math.round(node.y)}`} />
</div>
</div>
<NodeInspector
node={node}
readOnly={!canWrite}
onClose={() => setSelected(null)}
onPatch={(patch, comment) => live.patch({ nodes: { upsert: [patch] } }, comment)}
onDelete={() => {
void live.patch({ nodes: { remove: [node.id] } }, 'видалення вузла')
setSelected(null)
}}
/>
)}
{creating && (
<NewMapForm
onClose={() => setCreating(false)}
onCreated={async (id) => {
setCreating(false)
await loadMaps(id)
}}
/>
)}
</div>
)
}
function Row({ k, v }: { k: string; v: string }) {
return (
<div className="flex justify-between gap-2 md:w-full">
<span className="text-slate-500">{k}</span>
<span className="tabular-nums text-slate-300">{v}</span>
</div>
)
}
@ -180,3 +182,96 @@ function ConnectionBadge({
</span>
)
}
/**
* Створення мапи.
*
* Slug виводиться з назви автоматично: він потрібен для посилань, але
* вигадувати його руками робота, якої можна не робити. Поле лишається
* видимим, бо мапу з назвою «Київ» інколи хочуть бачити як /kyiv-core.
*/
function NewMapForm({
onClose,
onCreated,
}: {
onClose: () => void
onCreated: (id: string) => Promise<void>
}) {
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [touched, setTouched] = useState(false)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const effectiveSlug = touched ? slug : slugify(name)
return (
<Modal title="Нова мапа" onClose={onClose}>
<div className="space-y-3">
<Field label="Назва">
<input
className={inputClass}
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field label="Ідентифікатор" hint="Латиниця, цифри й дефіси">
<input
className={inputClass}
value={effectiveSlug}
onChange={(e) => {
setTouched(true)
setSlug(e.target.value)
}}
/>
</Field>
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="primary"
disabled={busy || !name.trim() || !effectiveSlug}
onClick={async () => {
setBusy(true)
setErr(null)
try {
const m = await api.createMap(name.trim(), effectiveSlug)
await onCreated(m.id)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
{busy ? 'Створення…' : 'Створити'}
</Button>
</div>
</div>
</Modal>
)
}
// Кирилиця транслітерується: slug іде в URL, і %D0%9A%D0%B8%D1%97%D0%B2
// там не допомагає нікому.
const TRANSLIT: Record<string, string> = {
а: 'a', б: 'b', в: 'v', г: 'h', ґ: 'g', д: 'd', е: 'e', є: 'ie', ж: 'zh',
з: 'z', и: 'y', і: 'i', ї: 'i', й: 'i', к: 'k', л: 'l', м: 'm', н: 'n',
о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'kh', ц: 'ts',
ч: 'ch', ш: 'sh', щ: 'shch', ь: '', ю: 'iu', я: 'ia',
}
function slugify(v: string): string {
return v
.toLowerCase()
.split('')
.map((ch) => TRANSLIT[ch] ?? ch)
.join('')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60)
}

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api/client'
import { session } from '../api/session'
import { isAlertEvent, useLiveRefresh } from '../hooks/useLiveRefresh'
import {
Button,
DataTable,
@ -62,7 +63,7 @@ export function RulesPage() {
const canWrite = session.can('alerts:write')
const reload = async () => {
const reload = useCallback(async () => {
try {
setRules(await api.listAlertRules())
setError(null)
@ -71,11 +72,18 @@ export function RulesPage() {
} finally {
setLoading(false)
}
}
}, [])
useEffect(() => {
void reload()
}, [])
}, [reload])
// Лічильник «активних» рахує сам сервер, і міняє його движок, а не ця
// сторінка. Після вимкнення правила його алерти закриються одразу, а
// от після повернення вони піднімуться лише наступним тіком — тобто
// за секунди ПІСЛЯ нашого перечитування. Без підписки таблиця так і
// лишалася б із нулями до перезавантаження сторінки.
useLiveRefresh(isAlertEvent, reload)
return (
<>

View file

@ -84,9 +84,7 @@ export function TeamPage() {
header: 'Користувач',
cell: (m) => (
<span className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium text-slate-100">
{m.full_name || m.email}
</span>
<span className="truncate font-medium text-slate-100">{m.username}</span>
{m.user_id === me?.userID && (
<span className="rounded bg-slate-800 px-1.5 text-[11px] text-slate-400">
це ви
@ -95,11 +93,17 @@ export function TeamPage() {
</span>
),
},
{
key: 'name',
header: 'Імʼя',
hideOnMobile: true,
cell: (m) => <span className="text-slate-400">{m.full_name || '—'}</span>,
},
{
key: 'mail',
header: 'Email',
hideOnMobile: true,
cell: (m) => <span className="text-slate-400">{m.email}</span>,
cell: (m) => <span className="text-slate-400">{m.email || '—'}</span>,
},
{
key: 'role',
@ -203,6 +207,7 @@ function AddUserForm({
onClose: () => void
onSaved: () => Promise<void>
}) {
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [fullName, setFullName] = useState('')
const [password, setPassword] = useState('')
@ -210,14 +215,32 @@ function AddUserForm({
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
// Той самий формат, що й у сервера й у БД. Перевіряти тут — щоб
// людина побачила проблему до того, як натисне «Створити».
const usernameOK = /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(username.trim())
return (
<Modal title="Новий користувач" onClose={onClose}>
<div className="space-y-3">
<Field label="Email">
<Field
label="Логін"
hint="Латиниця, цифри, крапка, дефіс і підкреслення — 364 символи"
>
<input
className={inputClass}
autoFocus
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase())}
/>
</Field>
<Field label="Email" hint="Необовʼязково — потрібен лише для сповіщень">
<input
className={inputClass}
type="email"
autoFocus
autoComplete="off"
value={email}
onChange={(e) => setEmail(e.target.value)}
@ -255,19 +278,28 @@ function AddUserForm({
</select>
</Field>
{username.trim() !== '' && !usernameOK && (
<ErrorNote>Логін має бути 364 символи з латиниці, цифр, крапки, дефіса чи підкреслення</ErrorNote>
)}
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="primary"
disabled={busy || !email.trim() || password.length < 8}
disabled={busy || !usernameOK || password.length < 8}
title={password.length < 8 ? 'Пароль щонайменше 8 символів' : undefined}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await api.addMember(email.trim(), password, roleKey, fullName.trim())
await api.addMember({
username: username.trim(),
email: email.trim(),
password,
role_key: roleKey,
full_name: fullName.trim(),
})
await onSaved()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))

View file

@ -26,15 +26,36 @@ export interface TenantMembership {
export interface LoginResponse {
access_token?: string
expires_in?: number
user?: { id: string; email: string; full_name?: string }
user?: { id: string; username: string; email?: string; full_name?: string }
tenant?: TenantMembership | null
tenants?: TenantMembership[]
permissions?: string[]
}
export interface DeviceGroup {
id: string
name: string
kind: string
color?: string
device_count: number
}
/** deny перемагає read і write — так само, як на сервері. */
export type AccessLevel = 'deny' | 'read' | 'write'
export interface UserGroup {
id: string
name: string
description?: string
member_ids: string[]
/** device_group_id → рівень */
permissions: Record<string, AccessLevel>
}
export interface TeamMember {
user_id: string
email: string
username: string
email?: string
full_name?: string
role_id: string
role_key: string
@ -149,6 +170,9 @@ export interface DeviceSummary {
enabled: boolean
last_seen_at?: string
interface_count: number
group_ids: string[]
/** Чи можна редагувати саме цей хост — рахується з груп користувача. */
writable: boolean
}
export interface AgentSummary {
@ -177,6 +201,9 @@ export interface NodeInput {
label?: string
x?: number
y?: number
width?: number
height?: number
locked?: boolean
style?: Record<string, unknown>
}