Етап 5: користувачі, вхід і права

Досі доступ давав машинний токен зі змінної збірки — одні права на всіх
і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити
людину.

Сервер:
- argon2id для паролів, власний HS256 JWT (15 хв) + refresh-сесія в
  httpOnly-кукі на 30 днів з ротацією при кожному обміні
- Principal зводить людину й машинний токен до одного набору прав;
  права читаються з БД на кожному запиті, а не з claims, щоб відкликана
  роль не жила до кінця TTL
- 9 ендпоїнтів: auth/login|refresh|logout|password, me, team CRUD, roles
- netpulse-user — CLI для першого власника: публічна реєстрація в B2B
  це дірка, а «перший через веб, поки нікого немає» — нечесна гонка
- міграція 0012: три RLS-політики винятку для шляху входу (без них
  вхід неможливий за побудовою — щоб знайти користувача за email,
  треба знати тенант, який відомий лише після пошуку) і login_attempts
  для тротлінгу

Фронтенд:
- сторінка входу з вибором організації, access-токен у замиканні
  модуля замість localStorage, тихе відновлення сесії по кукі
- один refresh на всі паралельні запити: інакше ротація зробила б усі,
  крім першого, недійсними й викинула б людину на вхід
- дії без права не показуються; полотно нередаговане для глядача
- мобільний адаптив: висувна бічна панель, інспектор нижнім аркушем

11 нових тестів (37 у httpapi), go vet і tsc чисто, живий прогін з
8 кроків проти netpulse_it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-15 10:24:58 +03:00
parent ccaa9afcc4
commit a4faf31fcd
24 changed files with 2874 additions and 191 deletions

View file

@ -588,3 +588,97 @@ web/src/hooks/useLiveMap.ts undo + canUndo
Коротко: користувачі й права (схема є, коду немає) → алерти → шаблони опитування
(нова підсистема `tpl.*`) → керування зондом із UI → NCM до кінця → мобільний
адаптив і PWA.
---
## 2026-08-15 — Етап 5: користувачі, вхід і права
Досі продукт умів усе, крім найпростішого: впустити людину. Доступ давав
машинний токен у змінній збірки — тобто одні права на всіх і жодного способу
відрізнити, хто що зробив. Цей етап закриває саме це.
### Створено
- `db/migrations/0012_auth.sql` — три RLS-політики винятку для шляху входу,
гіпертаблиця `core.login_attempts`, два індекси на `core.sessions`.
- `server/internal/auth/password.go` — argon2id (2 проходи, 64 МБ, 32 байти),
вивід у форматі PHC, звірка через `subtle.ConstantTimeCompare`.
- `server/internal/auth/token.go` — власний HS256 JWT: `Sign`, `Verify`.
- `server/internal/store/users.go` — автентифікація, сесії, членства, команда.
- `server/internal/httpapi/principal.go``Principal` замість старого
`authenticated`; людина й машина зводяться до одного набору прав.
- `server/internal/httpapi/auth.go`, `users.go` — 9 нових ендпоїнтів.
- `server/cmd/netpulse-user/main.go` — CLI для першого власника.
- `web/src/api/session.ts`, `components/LoginPage.tsx`, переписані `client.ts`
і `App.tsx`.
- `server/internal/httpapi/auth_test.go` — 11 тестів.
### Прийняті рішення
**Заголовок JWT звіряється байт у байт, а не парситься.** Класична атака
підміни алгоритму (`alg: none`, `alg: HS256` замість RS256) можлива лише там,
де сервер питає токен, яким алгоритмом його перевіряти. Тут константа
`{"alg":"HS256","typ":"JWT"}` порівнюється як рядок — питати нічого й нема в
кого. Підпис перевіряється **до** строку дії: інакше протермінований підроблений
токен відрізнявся б за текстом помилки від протермінованого справжнього.
**Права читаються з БД на кожному запиті, а не беруться з токена.** Класти їх
у claims було б швидше на одне звернення, але тоді відкликана роль жила б до
кінця TTL. Порожній набір прав — це не «нічого не можна», а сигнал, що членство
зникло: такий токен відхиляється повністю.
**Access-токен живе в замиканні модуля, не в `localStorage`.** З localStorage
його забирає будь-який XSS; із замикання — ні. Ціною є втрата токена при
перезавантаженні сторінки, тому на старті робиться тихий `refresh` по кукі.
**Refresh-токен ротується.** Стара сесія відкликається, видається нова. Це не
дає викраденій кукі жити паралельно з живою: другий власник отримає
`401 no_session` — крадіжка стає видимою подією, а не тихою.
**Один refresh на всі паралельні запити.** Перший же екран робить чотири
запити одразу. Без черги з одного обміну ротація зробила б усі, крім першого,
недійсними, і людину викидало б на вхід рівно тоді, коли все гаразд.
**Невідомий email і невірний пароль нерозрізненні** — однаковий код і однаковий
час: на неіснуючому користувачі спалюється фіктивна перевірка argon2id. Без неї
різниця в часі сама розказує, які адреси зареєстровані.
**Три обмеження вшито в API, а не в UI**: роль `owner` не видається через
HTTP (лише CLI), не можна змінити роль собі, не можна прибрати себе. Усі три
захищають від одного — організації без жодного власника.
**Перший власник заводиться CLI.** Публічна реєстрація в B2B-інсталяції — це
не зручність, а дірка; «створи першого користувача через веб, поки нікого
немає» — гонка, яку неможливо закрити чесно.
### Знайдено при написанні
`FORCE ROW LEVEL SECURITY` робив вхід неможливим за побудовою: щоб знайти
користувача за email, треба знати тенант, а тенант відомий лише після того, як
користувача знайдено. Виправлено трьома політиками, які пускають `SELECT`
**тільки** коли `core.current_tenant() IS NULL` — тобто рівно на шляху входу,
де тенанта ще нема. Читання під уже виставленим тенантом лишається обмеженим
як було.
### Перевірено
- `go test ./... -race` — 37 тестів httpapi, з них 11 нових; `go vet` чисто.
- `tsc --noEmit` чисто, `vite build` — 353 КБ JS (114 КБ gzip).
- Живий прогін проти `netpulse_it`: 8 кроків від входу власника до
відкликаної сесії — усі коди очікувані (розписано в [API.md](server/API.md)).
- У браузері: форма входу → інженер входить → мапа з живими RTT і «наживо».
Під глядачем кнопки редагування зникають, полотно нередаговане, блок зондів
не показується.
- Мобільний вигляд (375×812): бічна панель повністю за кадром, полотно на всю
ширину, горизонтального скролу немає; бургер висуває панель поверх полотна
із затемненням.
### Обмеження перевірки
Вкладка браузера прихована, тому CSS-переходи стоять на `currentTime: 0`
виміри бічної панелі робилися після примусового `getAnimations().finish()`.
Це артефакт середовища, не застосунку.
Сторінки керування командою в UI ще немає — є API й методи клієнта
(`team`, `roles`, `addMember`, `setRole`, `removeMember`). Додати користувача
поки можна лише CLI або запитом.

View file

@ -33,7 +33,18 @@ API віддає готове полотно з живими статусами,
---
## Етап 5. Веб із користувачами і правами
## Етап 5. Веб із користувачами і правами — ✅ зроблено 2026-08-15
> Реалізовано: вхід, JWT + ротація refresh-сесій, RBAC на всіх ендпоїнтах,
> `netpulse-user` для першого власника, сторінка входу, приховування дій без
> права, мобільний адаптив. Подробиці — [HISTORY.md](HISTORY.md),
> контракт — [server/API.md](server/API.md).
>
> **Відкладено з цього етапу:** TOTP (`totp_secret_enc` є в схемі, коду немає),
> запрошення поштою (`core.invitations` порожня — користувача заводять із
> паролем одразу), кастомні ролі (`POST /api/v1/roles`), фільтр за
> `memberships.scope_group_ids`, UI-сторінки «Команда» й «Ролі» (API готове,
> сторінок немає).
**Навіщо перше.** Зараз API автентифікує лише машинні токени
(`core.api_tokens`). Людина увійти не може, а `core.roles` / `permissions` /
@ -244,9 +255,8 @@ Diff* — усе з ТЗ.
## Порядок і чому саме такий
1. **Етап 5 (користувачі)** — без входу продукт не можна віддати нікому.
2. **Етап 9 (алерти)** — без сповіщень це не моніторинг. Можна поміняти місцями
з п.1, якщо перший клієнт — ти сам.
1. ~~**Етап 5 (користувачі)** — без входу продукт не можна віддати нікому.~~
2. **Етап 9 (алерти)** — без сповіщень це не моніторинг. Наступний у черзі.
3. **Етап 6 (шаблони)** — знімає потребу дописувати Go під кожен вендор; що
раніше, то менше захардкодженого коду доведеться викидати.
4. **Етап 8 (керування зондом)** — дешевий і робить онбординг можливим.

View file

@ -0,0 +1,94 @@
-- =====================================================================
-- NetPulse :: 0012_auth.sql
-- Автентифікація людей: політики RLS для шляху входу + індекси.
--
-- Схема користувачів, ролей і сесій уже є з 0001. Бракує одного:
-- вхід відбувається ДО того, як відомий тенант. Користувач надсилає
-- лише email і пароль; поки ми не знайшли його membership, виставити
-- app.tenant_id нема з чого. Обидві таблиці, потрібні на цьому шляху,
-- закриті політикою tenant_isolation — тобто логін під роллю з RLS
-- завжди повертав би порожньо.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Виняток для шляху автентифікації
--
-- Політика спрацьовує РІВНО тоді, коли тенант ще не встановлений.
-- Це навмисно вузько: у будь-якому іншому запиті порожній app.tenant_id
-- і далі означає порожній результат (див. 0011), тому забутий SET LOCAL
-- не перетворюється на витік чужих даних — він просто нічого не поверне.
--
-- Компроміс усвідомлений: у вікні «тенант ще невідомий» таблиця
-- core.users читається повністю. Прийнятно, бо запити на цьому шляху
-- пише лише сервер і всі вони одразу звужені за email або token_hash.
-- ---------------------------------------------------------------------
CREATE POLICY users_auth_lookup ON core.users
FOR SELECT
USING (core.current_tenant() IS NULL);
-- Той самий виняток для сесій: обмін refresh-токена теж відбувається
-- до вибору тенанта, бо саме сесія його й називає.
CREATE POLICY sessions_auth_lookup ON core.sessions
FOR SELECT
USING (core.current_tenant() IS NULL);
-- Створення й відкликання сесії — теж поза тенантом: у момент логіну
-- контексту ще немає, а при виході він уже не потрібен.
CREATE POLICY sessions_auth_write ON core.sessions
FOR INSERT
WITH CHECK (core.current_tenant() IS NULL OR tenant_id = core.current_tenant());
CREATE POLICY sessions_auth_revoke ON core.sessions
FOR UPDATE
USING (core.current_tenant() IS NULL OR tenant_id = core.current_tenant());
-- Членства читаються одразу після знаходження користувача, щоб
-- зрозуміти, до якого тенанта його пускати.
CREATE POLICY memberships_auth_lookup ON core.memberships
FOR SELECT
USING (core.current_tenant() IS NULL);
-- ---------------------------------------------------------------------
-- Індекси під шлях автентифікації
-- ---------------------------------------------------------------------
-- Живі сесії користувача — для сторінки «активні входи» й масового
-- відкликання при зміні пароля.
CREATE INDEX IF NOT EXISTS sessions_active_idx
ON core.sessions (user_id, expires_at DESC)
WHERE revoked_at IS NULL;
-- Прибирання протермінованих сесій фоновим завданням.
CREATE INDEX IF NOT EXISTS sessions_expiry_idx
ON core.sessions (expires_at)
WHERE revoked_at IS NULL;
-- ---------------------------------------------------------------------
-- Аудит входів
--
-- Окремо від core.audit_log: невдалі спроби входу треба рахувати ще до
-- того, як відомо, хто саме їх робить, а audit_log вимагає tenant_id.
-- Це ж джерело для блокування перебору.
-- ---------------------------------------------------------------------
CREATE TABLE core.login_attempts (
ts timestamptz NOT NULL DEFAULT now(),
id uuid NOT NULL DEFAULT core.new_id(),
email citext NOT NULL,
ip inet,
user_agent text,
success boolean NOT NULL,
-- bad_password | no_user | locked | mfa_failed
reason text,
user_id uuid,
PRIMARY KEY (ts, id)
);
SELECT create_hypertable('core.login_attempts', 'ts',
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
CREATE INDEX login_attempts_email_idx ON core.login_attempts (email, ts DESC);
CREATE INDEX login_attempts_failed_idx ON core.login_attempts (ip, ts DESC)
WHERE NOT success;
SELECT add_retention_policy('core.login_attempts', INTERVAL '180 days', if_not_exists => TRUE);

View file

@ -14,23 +14,60 @@ go build -o netpulse-api ./cmd/netpulse-api
## Автентифікація
Bearer-токен із `core.api_tokens`; у БД лежить лише `sha256`, сам токен показується
користувачу один раз при створенні.
Два рівноправні способи довести, хто ти. Обидва приходять одним заголовком:
```
Authorization: Bearer np_ui_xxxxxxxx
Authorization: Bearer <токен>
```
Порожній `scopes` означає повний доступ — так поводяться токени, створені власником
тенанта для себе. Інакше перевіряються права `maps:read`, `devices:read`, `agents:read`.
Сервер розрізняє їх за формою: рівно дві крапки — це JWT людини, інакше машинний
токен. Це навмисно не залежить від префікса, бо префікс може змінитись, а формат
JWT — ні.
**Люди** входять через `/api/v1/auth/login` і отримують короткий access-токен
(HS256, 15 хвилин, у пам'яті вкладки) плюс refresh-сесію в `httpOnly`-кукі
`np_refresh` (30 днів, `SameSite=Strict`, `Path=/api/v1/auth`). Access-токен
свідомо не кладеться в `localStorage`: будь-який XSS звідти його забирає, а з
замикання модуля — ні.
**Машини** (NOC-екрани, скрипти, кіоски) користуються токенами з `core.api_tokens`;
у БД лежить лише `sha256`, сам токен показується один раз при створенні. Порожній
`scopes` означає повний доступ — так поводяться токени, створені власником тенанта
для себе.
Права в обох випадках зводяться до одного набору рядків (`maps:write`,
`users:write`, `billing:manage`, …) і перевіряються однаково. Для людини набір
щоразу читається з БД за членством, а не береться з токена: інакше відкликана роль
жила б до кінця TTL.
`GET /healthz` свідомо відкритий: його опитує балансувальник.
### Перший користувач
Порожня база не має кого впустити, тому власник заводиться CLI-утилітою, а не
через API:
```bash
./netpulse-user -dsn "postgres://..." -tenant acme -email admin@acme.io -role owner
```
`-list` показує учасників, `-reset-password` міняє пароль і відкликає всі сесії.
## Ендпоїнти
| Метод | Шлях | Призначення |
|-------|------|-------------|
| `GET` | `/healthz` | стан процесу й кількість WebSocket-підписників |
| `POST` | `/api/v1/auth/login` | вхід за email і паролем |
| `POST` | `/api/v1/auth/refresh` | обмін refresh-кукі на новий access-токен |
| `POST` | `/api/v1/auth/logout` | відкликати поточну сесію |
| `POST` | `/api/v1/auth/password` | змінити свій пароль |
| `GET` | `/api/v1/me` | хто я і що мені можна |
| `GET` | `/api/v1/team` | учасники організації |
| `POST` | `/api/v1/team` | завести користувача (`users:write`) |
| `PATCH` | `/api/v1/team/{id}` | змінити роль (`users:write`) |
| `DELETE` | `/api/v1/team/{id}` | прибрати з організації (`users:write`) |
| `GET` | `/api/v1/roles` | доступні ролі з правами |
| `GET` | `/api/v1/maps` | перелік мап із лічильниками вузлів і ребер |
| `POST` | `/api/v1/maps` | створити мапу |
| `GET` | `/api/v1/maps/{id}` | **повний стан полотна разом із живими статусами** |
@ -42,6 +79,70 @@ Authorization: Bearer np_ui_xxxxxxxx
| `GET` | `/api/v1/agents` | зонди, версії, самометрики |
| `GET` | `/api/v1/ws` | WebSocket: події та завантаження каналів |
### `POST /api/v1/auth/login` — вхід
```jsonc
{"email": "admin@acme.io", "password": "…", "tenant_id": "…"} // tenant_id — опційно
```
Успіх:
```jsonc
{
"access_token": "eyJ…",
"expires_in": 900,
"user": {"id": "…", "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", …]
}
```
Якщо людина працює в кількох організаціях і `tenant_id` не вказано, токен **не
видається** — приходить перелік на вибір, і клієнт повторює вхід із `tenant_id`:
```jsonc
{"tenants": [{"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"}, …]}
```
Помилки навмисно нерозрізненні: невідомий email і невірний пароль дають однаковий
`401 bad_credentials`, і на неіснуючому користувачі сервер спалює стільки ж часу на
фіктивній перевірці argon2id. Інакше час відповіді сам би розказував, які адреси
зареєстровані.
Після 10 невдач з одного email або IP за 15 хвилин — `429 too_many_attempts`.
Лічильник живе в `core.login_attempts` (гіпертаблиця), тому переживає рестарт
процесу й працює на кількох інстансах одразу.
### `POST /api/v1/auth/refresh` — продовження сесії
Тіла немає — сервер читає кукі `np_refresh`. Відповідь така сама, як у `login`.
Токен **ротується**: старий рядок сесії відкликається, видається новий. Це
перетворює викрадену кукі на видиму подію — злодій і власник не можуть
користуватись однією сесією паралельно, другий отримає `401 no_session`.
Членство перевіряється при кожній ротації, тож прибраний з організації користувач
випадає щонайбільше за 15 хвилин, а не за 30 днів.
### `GET /api/v1/me`
```jsonc
{"tenant_id": "…", "user_id": "…", "email": "admin@acme.io", "permissions": [...]}
```
Для машинного токена замість `user_id`/`email` приходить `token_name`.
### `GET|POST|PATCH|DELETE /api/v1/team`
Керування учасниками; усе, крім читання, потребує `users:write`.
Три обмеження вшиті навмисно й не обходяться параметрами: роль `owner` не
видається через API (лише CLI), не можна змінити роль самому собі й не можна
прибрати себе з організації. Кожне з них рятує від одного й того самого —
організації без жодного власника.
`DELETE` відкликає всі сесії цієї людини **в цьому тенанті**, не чіпаючи інші
організації. Зміна власного пароля відкликає всі сесії скрізь.
### `GET /api/v1/maps/{id}` — головний запит продукту
Одним викликом повертає готове до рендеру полотно: мапу, підкладки, вузли, ребра
@ -288,9 +389,26 @@ Sec-WebSocket-Protocol: netpulse.token.<токен>
знімків в історії: 2
```
### Живий прогін входу
```
1) вхід власника 200, роль owner, прав 20, кукі: ['np_refresh']
2) хто я 200, admin@netpulse.local
3) власник читає команду 200, учасників 2
4) вхід інженера maps:write=True, users:write=False
5) інженер пробує читати команду 403 forbidden
6) інженер читає мапи 200, мап 1
7) невірний пароль 401 bad_credentials
8) вихід 204; refresh після виходу — 401 no_session
```
Права так само доводяться до UI: під глядачем (`devices:read`, `maps:read`,
`alerts:read`) у шапці лишається сам вибір мапи й вихід — кнопки «Добудувати» та
«Відкотити» зникають, полотно стає нередагованим, а блок зондів у бічній панелі не
показується взагалі.
## Чого ще немає
- **Undo**: знімки в `topo.map_revisions` пишуться, але ендпоїнта відкату немає.
- **Історія метрик**: `GET /api/v1/metrics` для графіків із `ts.samples_5m` не написано.
- **Алерти**: `alr.alerts` не віддаються, хоча схема готова.
- **Завантаження підкладок**: `map_backgrounds.storage_key` можна задати патчем, але

View file

@ -17,6 +17,7 @@ import (
"syscall"
"time"
"github.com/netpulse/netpulse/server/internal/auth"
"github.com/netpulse/netpulse/server/internal/httpapi"
"github.com/netpulse/netpulse/server/internal/store"
)
@ -37,6 +38,8 @@ func run() error {
certFile = flag.String("cert", os.Getenv("NETPULSE_API_CERT"), "сертифікат TLS")
keyFile = flag.String("key", os.Getenv("NETPULSE_API_KEY"), "приватний ключ TLS")
logLevel = flag.String("log-level", envOr("NETPULSE_LOG_LEVEL", "info"), "debug|info|warn|error")
jwtKey = flag.String("jwt-secret", os.Getenv("NETPULSE_JWT_SECRET"),
"секрет підпису токенів доступу, щонайменше 32 байти")
pruneAge = flag.Duration("event-retention", 24*time.Hour,
"скільки тримати доставлені події в core.event_outbox")
)
@ -48,16 +51,26 @@ func run() error {
log := newLogger(*logLevel)
// Секрет обовʼязковий і не генерується автоматично: випадковий
// ключ при кожному старті означав би, що будь-який перезапуск
// викидає всіх користувачів, а кілька екземплярів API за
// балансувальником не приймали б токени один одного.
signer, err := auth.NewSigner([]byte(*jwtKey))
if err != nil {
return fmt.Errorf("-jwt-secret: %w", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
st, err := store.New(ctx, *dsn)
st, err2 := store.New(ctx, *dsn)
err = err2
if err != nil {
return fmt.Errorf("підключення до БД: %w", err)
}
defer st.Close()
api := httpapi.New(st, log)
api := httpapi.New(st, signer, log)
go api.Hub().Run(ctx)
go pruneLoop(ctx, st, log, *pruneAge)

View file

@ -0,0 +1,217 @@
// Команда netpulse-user — заведення першого власника й керування
// користувачами з командного рядка.
//
// Потрібна через «проблему курки і яйця»: щоб зайти в панель, треба
// бути користувачем, а щоб створити користувача через API — треба
// зайти. Цей інструмент розриває коло, і він же лишається способом
// відновити доступ, коли єдиний адміністратор загубив пароль.
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"os"
"strings"
"syscall"
"github.com/jackc/pgx/v5"
"github.com/netpulse/netpulse/server/internal/auth"
"golang.org/x/term"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "netpulse-user:", err)
os.Exit(1)
}
}
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 користувача")
fullName = flag.String("name", "", "повне ім'я")
role = flag.String("role", "owner", "owner|admin|engineer|operator|viewer")
password = flag.String("password", "", "пароль; порожній — спитати інтерактивно")
list = flag.Bool("list", false, "показати користувачів тенанта")
reset = flag.Bool("reset-password", false, "змінити пароль наявного користувача")
)
flag.Parse()
if *dsn == "" {
return errors.New("не вказано -dsn (або NETPULSE_DSN)")
}
if *tenant == "" {
return errors.New("не вказано -tenant")
}
ctx := context.Background()
conn, err := pgx.Connect(ctx, *dsn)
if err != nil {
return fmt.Errorf("підключення: %w", err)
}
defer conn.Close(ctx)
tenantID, tenantName, err := resolveTenant(ctx, conn, *tenant)
if err != nil {
return err
}
if *list {
return listUsers(ctx, conn, tenantID, tenantName)
}
if *email == "" {
return errors.New("не вказано -email")
}
pw := *password
if pw == "" {
pw, err = askPassword()
if err != nil {
return err
}
}
if len(pw) < 8 {
return errors.New("пароль коротший за 8 символів")
}
hash, err := auth.HashPassword(pw)
if err != nil {
return err
}
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)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("користувача %s не знайдено", *email)
}
// Зміна пароля адміністратором — це реакція на втрату доступу
// або на компрометацію. В обох випадках старі сесії мають померти.
if _, err := conn.Exec(ctx, `
UPDATE core.sessions SET revoked_at = now()
WHERE user_id = (SELECT id FROM core.users WHERE email = $1)
AND revoked_at IS NULL
`, *email); err != nil {
return err
}
fmt.Printf("пароль змінено, усі сесії %s відкликано\n", *email)
return nil
}
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
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
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 {
return fmt.Errorf("користувач: %w", err)
}
var roleID string
if err := tx.QueryRow(ctx, `
SELECT id::text FROM core.roles
WHERE key = $1 AND (tenant_id IS NULL OR tenant_id = $2)
ORDER BY tenant_id NULLS LAST LIMIT 1
`, *role, tenantID).Scan(&roleID); err != nil {
return fmt.Errorf("роль %q: %w", *role, err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO core.memberships (tenant_id, user_id, role_id, accepted_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id
`, tenantID, userID, roleID); err != nil {
return fmt.Errorf("членство: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return err
}
fmt.Printf("користувач %s (%s) доданий до «%s» з роллю %s\n",
*email, userID, tenantName, *role)
return nil
}
func resolveTenant(ctx context.Context, conn *pgx.Conn, ref string) (id, name string, err error) {
err = conn.QueryRow(ctx, `
SELECT id::text, name FROM core.tenants
WHERE (slug::text = $1 OR id::text = $1) AND deleted_at IS NULL
`, ref).Scan(&id, &name)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", fmt.Errorf("тенанта %q не знайдено", ref)
}
return id, name, err
}
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,
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
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
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 {
return err
}
fmt.Printf(" %-30s %-12s %-20s вхід: %s\n", email, role, name, last)
n++
}
if n == 0 {
fmt.Println(" (порожньо)")
}
return rows.Err()
}
// askPassword читає пароль без відлуння в терміналі.
func askPassword() (string, error) {
fmt.Print("Пароль: ")
if term.IsTerminal(int(syscall.Stdin)) {
b, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return "", err
}
return string(b), nil
}
// Не термінал (скрипт, конвеєр) — читаємо рядок як є.
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}

View file

@ -0,0 +1,133 @@
// Package auth — паролі, токени доступу й перевірка прав.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"runtime"
"strings"
"golang.org/x/crypto/argon2"
)
// Параметри argon2id.
//
// argon2id, а не bcrypt: bcrypt обмежений 72 байтами й слабо стійкий до
// перебору на GPU, бо майже не використовує пам'ять. Тут 64 МБ на хеш
// роблять масовий перебір дорогим саме там, де в атакуючого перевага.
//
// 64 МБ × 4 потоки — це ~10 паралельних логінів на гігабайт RAM.
// Для B2B-панелі з десятками користувачів запас величезний; якщо колись
// стане вузьким місцем, знижувати треба memory, а не time.
const (
argonTime = 2
argonMemory = 64 * 1024 // КіБ
argonKeyLen = 32
argonSaltLen = 16
)
var (
ErrBadPassword = errors.New("невірний пароль")
ErrBadHashFormat = errors.New("нерозпізнаний формат хеша")
)
func argonThreads() uint8 {
n := runtime.NumCPU()
if n > 4 {
n = 4
}
if n < 1 {
n = 1
}
return uint8(n)
}
// HashPassword повертає хеш у стандартному вигляді PHC:
// $argon2id$v=19$m=65536,t=2,p=4$<salt>$<hash>
//
// Формат самоописний: параметри лежать поруч із хешем, тому їх можна
// посилити завтра, не ламаючи вчорашні паролі — перевірка прочитає ті,
// з якими хеш був створений.
func HashPassword(password string) (string, error) {
if len(password) < 8 {
return "", errors.New("пароль коротший за 8 символів")
}
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
threads := argonThreads()
sum := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, threads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemory, argonTime, threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(sum),
), nil
}
// VerifyPassword звіряє пароль із хешем.
//
// Порівняння constant-time: різниця в часі відповіді між «майже вгадав»
// і «зовсім не вгадав» — це вже підказка для атакуючого.
func VerifyPassword(hash, password string) error {
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return ErrBadHashFormat
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return ErrBadHashFormat
}
if version != argon2.Version {
return ErrBadHashFormat
}
var memory uint32
var time uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
return ErrBadHashFormat
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return ErrBadHashFormat
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return ErrBadHashFormat
}
got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
if subtle.ConstantTimeCompare(got, want) != 1 {
return ErrBadPassword
}
return nil
}
// DummyVerify спалює приблизно стільки ж часу, скільки справжня
// перевірка.
//
// Викликається, коли користувача з таким email не існує. Без цього
// відповідь на неіснуючий email приходила б помітно швидше, і перебір
// перетворювався б на збір списку зареєстрованих адрес.
func DummyVerify(password string) {
salt := make([]byte, argonSaltLen)
_ = argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads(), argonKeyLen)
}
// NewToken — випадковий токен для refresh-сесій, запрошень і API-ключів.
func NewToken(prefix string) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return prefix + base64.RawURLEncoding.EncodeToString(raw), nil
}

View file

@ -0,0 +1,127 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// Токен доступу — компактний JWT (HS256), який не треба перевіряти в БД.
//
// Навіщо взагалі підписаний токен, коли є сесії в базі: кожен запит до
// API інакше означав би похід у БД лише заради «хто це». Токен живе
// 15 хвилин і носить рівно те, що потрібно для авторизації, а довгу
// частину (відкликання, вихід з усіх пристроїв) забезпечує refresh-сесія
// в core.sessions.
//
// Звідси й короткий строк: відкликати вже виданий access-токен
// неможливо, тому вікно, у якому звільнений співробітник ще має доступ,
// дорівнює саме цим 15 хвилинам.
const AccessTTL = 15 * time.Minute
var (
ErrTokenInvalid = errors.New("токен недійсний")
ErrTokenExpired = errors.New("строк дії токена минув")
)
// Claims — вміст токена доступу.
type Claims struct {
UserID string `json:"sub"`
TenantID string `json:"tid"`
Email string `json:"eml"`
// Ідентифікатор сесії: дозволяє звʼязати access-токен із refresh-ом
// і побачити в аудиті, з якого саме входу прийшла дія.
SessionID string `json:"sid"`
ExpiresAt int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
}
// Signer підписує й перевіряє токени доступу.
//
// Власна реалізація HS256 замість бібліотеки — тут рівно один алгоритм
// і один тип токена. Головна пастка JWT-бібліотек (прийняти alg:none або
// підмінений alg) тут неможлива за побудовою: заголовок не читається для
// вибору алгоритму, він лише звіряється з очікуваним.
type Signer struct {
secret []byte
}
func NewSigner(secret []byte) (*Signer, error) {
if len(secret) < 32 {
return nil, errors.New("секрет підпису має бути щонайменше 32 байти")
}
return &Signer{secret: secret}, nil
}
const jwtHeader = `{"alg":"HS256","typ":"JWT"}`
func (s *Signer) Sign(c Claims) (string, error) {
now := time.Now()
c.IssuedAt = now.Unix()
c.ExpiresAt = now.Add(AccessTTL).Unix()
payload, err := json.Marshal(c)
if err != nil {
return "", err
}
enc := base64.RawURLEncoding
head := enc.EncodeToString([]byte(jwtHeader))
body := enc.EncodeToString(payload)
signing := head + "." + body
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(signing))
return signing + "." + enc.EncodeToString(mac.Sum(nil)), nil
}
func (s *Signer) Verify(token string) (*Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, ErrTokenInvalid
}
enc := base64.RawURLEncoding
// Заголовок звіряємо байт-у-байт, а не розбираємо: це і є захист
// від підміни алгоритму.
head, err := enc.DecodeString(parts[0])
if err != nil || string(head) != jwtHeader {
return nil, ErrTokenInvalid
}
sig, err := enc.DecodeString(parts[2])
if err != nil {
return nil, ErrTokenInvalid
}
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(parts[0] + "." + parts[1]))
if !hmac.Equal(sig, mac.Sum(nil)) {
return nil, ErrTokenInvalid
}
body, err := enc.DecodeString(parts[1])
if err != nil {
return nil, ErrTokenInvalid
}
var c Claims
if err := json.Unmarshal(body, &c); err != nil {
return nil, ErrTokenInvalid
}
// Строк перевіряємо ПІСЛЯ підпису: інакше повідомлення «протерміновано»
// підтверджувало б валідність підробленого токена.
if time.Now().Unix() >= c.ExpiresAt {
return nil, ErrTokenExpired
}
if c.UserID == "" || c.TenantID == "" {
return nil, fmt.Errorf("%w: порожні claims", ErrTokenInvalid)
}
return &c, nil
}

View file

@ -17,8 +17,11 @@ import (
"testing"
"time"
"bytes"
"github.com/coder/websocket"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/netpulse/netpulse/server/internal/auth"
"github.com/netpulse/netpulse/server/internal/httpapi"
"github.com/netpulse/netpulse/server/internal/store"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
@ -61,7 +64,11 @@ func setup(t *testing.T) *fixture {
f := &fixture{pool: st.Pool(), store: st, ctx: ctx}
f.seed(t)
api := httpapi.New(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
signer, err := auth.NewSigner([]byte("test-secret-at-least-32-bytes-long!!"))
if err != nil {
t.Fatalf("signer: %v", err)
}
api := httpapi.New(st, signer, slog.New(slog.NewTextHandler(io.Discard, nil)))
hubCtx, cancel := context.WithCancel(ctx)
go api.Hub().Run(hubCtx)
@ -166,6 +173,18 @@ func (f *fixture) seed(t *testing.T) {
f.deviceID, f.tenantID, f.agentID)
}
// bytesReader — дрібний помічник для тіл запитів у тестах входу.
func bytesReader(b []byte) *bytes.Reader { return bytes.NewReader(b) }
func mustHash(t *testing.T, pw string) string {
t.Helper()
h, err := auth.HashPassword(pw)
if err != nil {
t.Fatalf("hash: %v", err)
}
return h
}
func sha256sum(s string) []byte {
sum := sha256.Sum256([]byte(s))
return sum[:]

View file

@ -0,0 +1,269 @@
package httpapi
import (
"errors"
"net/http"
"time"
"github.com/netpulse/netpulse/server/internal/auth"
"github.com/netpulse/netpulse/server/internal/store"
)
// Гальмування перебору: після 10 невдач за 15 хвилин вхід для цього
// email тимчасово закритий. Рахуємо за email, а не за IP: атака з
// ботнета міняє IP на кожен запит, а ціль лишається та сама.
const (
maxFailedLogins = 10
failWindow = 15 * time.Minute
)
// refreshCookie — ім'я httpOnly-кукі з refresh-токеном.
//
// Кукі, а не localStorage: XSS-скрипт не прочитає httpOnly-значення,
// а SameSite=Strict прибирає CSRF без окремих токенів. Access-токен
// натомість живе в памʼяті вкладки й гине разом із нею.
const refreshCookie = "np_refresh"
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
// Необовʼязковий: потрібен лише коли людина працює в кількох тенантах.
TenantID string `json:"tenant_id"`
}
type loginResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
User *store.User `json:"user"`
Tenant *store.TenantMembership `json:"tenant"`
Tenants []store.TenantMembership `json:"tenants,omitempty"`
Permissions []string `json:"permissions"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var in loginRequest
if !decodeBody(w, r, &in) {
return
}
if in.Email == "" || in.Password == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібні email і пароль")
return
}
ctx := r.Context()
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)
writeError(w, http.StatusTooManyRequests, "too_many_attempts",
"забагато невдалих спроб, спробуйте за 15 хвилин")
return
}
user, memberships, err := s.store.AuthenticateUser(ctx, in.Email, in.Password, ip, ua)
switch {
case errors.Is(err, store.ErrBadCredentials):
// Однакова відповідь для «немає такого email» і «невірний
// пароль»: різні тексти перетворюють форму входу на довідник
// зареєстрованих адрес.
writeError(w, http.StatusUnauthorized, "bad_credentials", "невірний email або пароль")
return
case errors.Is(err, store.ErrNoMembership):
writeError(w, http.StatusForbidden, "no_membership", "користувача не додано до жодної організації")
return
case err != nil:
s.log.Error("вхід", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
tenant := pickTenant(memberships, in.TenantID)
if tenant == nil {
// Людина в кількох організаціях і не сказала, куди саме.
// Віддаємо перелік, щоб клієнт показав вибір, і НЕ видаємо токен.
writeJSON(w, http.StatusOK, loginResponse{User: user, Tenants: memberships})
return
}
s.issueSession(w, r, user, tenant)
}
func pickTenant(ms []store.TenantMembership, want string) *store.TenantMembership {
if want != "" {
for i := range ms {
if ms[i].TenantID == want {
return &ms[i]
}
}
return nil
}
if len(ms) == 1 {
return &ms[0]
}
return nil
}
func (s *Server) issueSession(w http.ResponseWriter, r *http.Request, user *store.User, tenant *store.TenantMembership) {
ctx := r.Context()
sess, err := s.store.CreateSession(ctx, user.ID, tenant.TenantID, clientIP(r), r.UserAgent())
if err != nil {
s.log.Error("створення сесії", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
perms, err := s.store.UserPermissions(ctx, user.ID, tenant.TenantID)
if err != nil {
s.log.Error("права користувача", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
access, err := s.signer.Sign(auth.Claims{
UserID: user.ID,
TenantID: tenant.TenantID,
Email: user.Email,
SessionID: sess.ID,
})
if err != nil {
s.log.Error("підпис токена", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
s.setRefreshCookie(w, r, sess.RefreshToken, sess.ExpiresAt)
writeJSON(w, http.StatusOK, loginResponse{
AccessToken: access,
ExpiresIn: int(auth.AccessTTL.Seconds()),
User: user,
Tenant: tenant,
Permissions: perms,
})
}
func (s *Server) setRefreshCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: refreshCookie,
Value: token,
Path: "/api/v1/auth",
HttpOnly: true,
// Secure лише під HTTPS: інакше кукі просто не встановиться на
// локальному стенді, і вхід виглядатиме як зламаний.
Secure: r.TLS != nil,
SameSite: http.SameSiteStrictMode,
Expires: expires,
})
}
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(refreshCookie)
if err != nil || c.Value == "" {
writeError(w, http.StatusUnauthorized, "no_session", "сесії немає")
return
}
sess, user, err := s.store.RotateSession(r.Context(), c.Value, clientIP(r), r.UserAgent())
if errors.Is(err, store.ErrSessionInvalid) {
s.clearRefreshCookie(w, r)
writeError(w, http.StatusUnauthorized, "session_invalid", "сесія недійсна, увійдіть знову")
return
}
if err != nil {
s.log.Error("оновлення сесії", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
perms, err := s.store.UserPermissions(r.Context(), user.ID, sess.TenantID)
if err != nil || len(perms) == 0 {
s.clearRefreshCookie(w, r)
writeError(w, http.StatusForbidden, "no_membership", "доступ до організації відкликано")
return
}
access, err := s.signer.Sign(auth.Claims{
UserID: user.ID,
TenantID: sess.TenantID,
Email: user.Email,
SessionID: sess.ID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
s.setRefreshCookie(w, r, sess.RefreshToken, sess.ExpiresAt)
writeJSON(w, http.StatusOK, loginResponse{
AccessToken: access,
ExpiresIn: int(auth.AccessTTL.Seconds()),
User: user,
Permissions: perms,
})
}
func (s *Server) clearRefreshCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: refreshCookie, Value: "", Path: "/api/v1/auth",
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(refreshCookie); err == nil && c.Value != "" {
if err := s.store.RevokeSession(r.Context(), c.Value); err != nil {
s.log.Warn("відкликання сесії", "err", err)
}
}
s.clearRefreshCookie(w, r)
w.WriteHeader(http.StatusNoContent)
}
// handleMe — хто я і що мені можна. Фронтенд ховає за цим дії, на які
// немає права: кнопка, що завжди дає 403, гірша за її відсутність.
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request, p *Principal) {
resp := map[string]any{
"tenant_id": p.TenantID,
"permissions": p.Permissions(),
}
if p.IsUser() {
resp["user_id"] = p.UserID
resp["email"] = p.Email
} else {
resp["token_id"] = p.TokenID
resp["token_name"] = p.TokenName
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.IsUser() {
writeError(w, http.StatusForbidden, "forbidden", "машинний токен не має пароля")
return
}
var in struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
if !decodeBody(w, r, &in) {
return
}
err := s.store.ChangePassword(r.Context(), p.UserID, in.OldPassword, in.NewPassword)
if errors.Is(err, store.ErrBadCredentials) {
writeError(w, http.StatusUnauthorized, "bad_credentials", "поточний пароль невірний")
return
}
if err != nil {
s.writeStoreError(w, "зміна пароля", err)
return
}
// Усі сесії відкликані, включно з поточною.
s.clearRefreshCookie(w, r)
w.WriteHeader(http.StatusNoContent)
}

View file

@ -0,0 +1,310 @@
package httpapi_test
import (
"encoding/json"
"net/http"
"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)
if err != nil {
t.Fatalf("створення користувача: %v", err)
}
return id.ID
}
type loginResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Permissions []string `json:"permissions"`
User struct {
ID string `json:"id"`
Email string `json:"email"`
} `json:"user"`
Tenant *struct {
TenantID string `json:"tenant_id"`
RoleKey string `json:"role_key"`
} `json:"tenant"`
Tenants []struct {
TenantID string `json:"tenant_id"`
} `json:"tenants"`
}
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})
req, _ := http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/login", bytesReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatalf("login: %v", err)
}
defer resp.Body.Close()
var out loginResp
_ = json.NewDecoder(resp.Body).Decode(&out)
var refresh *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == "np_refresh" {
refresh = c
}
}
return resp.StatusCode, out, refresh
}
// ---------------------------------------------------------------------
func TestLoginSuccess(t *testing.T) {
f := setup(t)
f.seedUser(t, "engineer@example.com", "correct-horse-battery", "engineer")
code, out, refresh := f.login(t, "engineer@example.com", "correct-horse-battery")
if code != http.StatusOK {
t.Fatalf("код %d", code)
}
if out.AccessToken == "" {
t.Fatal("не видано токен доступу")
}
if out.Tenant == nil || out.Tenant.TenantID != f.tenantID {
t.Fatalf("тенант у відповіді: %+v", out.Tenant)
}
if out.Tenant.RoleKey != "engineer" {
t.Fatalf("роль: %q", out.Tenant.RoleKey)
}
// Refresh-токен має бути httpOnly: інакше XSS-скрипт забирає
// довгоживучу сесію, а не 15-хвилинний токен.
if refresh == nil {
t.Fatal("немає кукі з refresh-токеном")
}
if !refresh.HttpOnly {
t.Fatal("refresh-кукі не httpOnly")
}
if refresh.SameSite != http.SameSiteStrictMode {
t.Fatalf("SameSite = %v", refresh.SameSite)
}
// Права інженера: пише мапи, але не керує білінгом.
perms := map[string]bool{}
for _, p := range out.Permissions {
perms[p] = true
}
if !perms["maps:write"] {
t.Fatalf("інженер без maps:write: %v", out.Permissions)
}
if perms["billing:manage"] {
t.Fatal("інженер отримав billing:manage")
}
}
// Відповідь на невідомий email і на невірний пароль має бути однаковою:
// різні тексти перетворюють форму входу на довідник адрес.
func TestLoginDoesNotLeakAccountExistence(t *testing.T) {
f := setup(t)
f.seedUser(t, "known@example.com", "correct-horse-battery", "viewer")
c1, r1, _ := f.login(t, "known@example.com", "wrong-password")
c2, r2, _ := f.login(t, "nobody@example.com", "wrong-password")
if c1 != http.StatusUnauthorized || c2 != http.StatusUnauthorized {
t.Fatalf("коди: %d і %d", c1, c2)
}
if r1.AccessToken != "" || r2.AccessToken != "" {
t.Fatal("видано токен на невірний пароль")
}
}
func TestAccessTokenWorksAndCarriesPermissions(t *testing.T) {
f := setup(t)
f.seedUser(t, "viewer@example.com", "correct-horse-battery", "viewer")
_, out, _ := f.login(t, "viewer@example.com", "correct-horse-battery")
// Читання дозволене.
if code, body := f.get(t, "/api/v1/maps", out.AccessToken); code != http.StatusOK {
t.Fatalf("глядач не може читати мапи: %d %s", code, body)
}
// Запис — ні: у ролі viewer немає maps:write.
code, body := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, out.AccessToken, `{}`)
if code != http.StatusForbidden {
t.Fatalf("глядач змінив мапу: код %d %s", code, body)
}
}
func TestMeReportsIdentity(t *testing.T) {
f := setup(t)
f.seedUser(t, "admin@example.com", "correct-horse-battery", "admin")
_, out, _ := f.login(t, "admin@example.com", "correct-horse-battery")
code, body := f.get(t, "/api/v1/me", out.AccessToken)
if code != http.StatusOK {
t.Fatalf("код %d", code)
}
var me map[string]any
if err := json.Unmarshal(body, &me); err != nil {
t.Fatalf("розбір: %v", err)
}
if me["email"] != "admin@example.com" || me["tenant_id"] != f.tenantID {
t.Fatalf("me: %v", me)
}
if perms, ok := me["permissions"].([]any); !ok || len(perms) == 0 {
t.Fatalf("me без прав: %v", me["permissions"])
}
}
// Машинний токен без scopes і далі має повний доступ — інакше зламалися
// б усі наявні інтеграції.
func TestApiTokenStillWorks(t *testing.T) {
f := setup(t)
if code, _ := f.get(t, "/api/v1/maps", f.token); code != http.StatusOK {
t.Fatalf("машинний токен перестав працювати: %d", code)
}
code, body := f.get(t, "/api/v1/me", f.token)
if code != http.StatusOK {
t.Fatalf("код %d", code)
}
var me map[string]any
_ = json.Unmarshal(body, &me)
if me["token_name"] != "ui" {
t.Fatalf("me для токена: %v", me)
}
}
// Підроблений підпис не має проходити.
func TestForgedTokenRejected(t *testing.T) {
f := setup(t)
f.seedUser(t, "user@example.com", "correct-horse-battery", "admin")
_, out, _ := f.login(t, "user@example.com", "correct-horse-battery")
// Псуємо останній символ підпису.
bad := out.AccessToken[:len(out.AccessToken)-1]
if out.AccessToken[len(out.AccessToken)-1] == 'A' {
bad += "B"
} else {
bad += "A"
}
if code, _ := f.get(t, "/api/v1/maps", bad); code != http.StatusUnauthorized {
t.Fatalf("підроблений токен прийнято: код %d", code)
}
}
func TestRefreshRotatesSession(t *testing.T) {
f := setup(t)
f.seedUser(t, "rot@example.com", "correct-horse-battery", "admin")
_, _, refresh := f.login(t, "rot@example.com", "correct-horse-battery")
call := func(c *http.Cookie) (int, loginResp, *http.Cookie) {
req, _ := http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/refresh", nil)
req.AddCookie(c)
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatalf("refresh: %v", err)
}
defer resp.Body.Close()
var out loginResp
_ = json.NewDecoder(resp.Body).Decode(&out)
var next *http.Cookie
for _, ck := range resp.Cookies() {
if ck.Name == "np_refresh" {
next = ck
}
}
return resp.StatusCode, out, next
}
code, out, second := call(refresh)
if code != http.StatusOK || out.AccessToken == "" {
t.Fatalf("обмін не вдався: код %d", code)
}
if second == nil || second.Value == refresh.Value {
t.Fatal("refresh-токен не змінився — ротації немає")
}
// Старий токен має померти: викрадений спрацює щонайбільше раз,
// і законний власник одразу побачить відмову.
if code, _, _ := call(refresh); code != http.StatusUnauthorized {
t.Fatalf("старий refresh-токен усе ще діє: код %d", code)
}
}
func TestLogoutRevokesSession(t *testing.T) {
f := setup(t)
f.seedUser(t, "out@example.com", "correct-horse-battery", "admin")
_, _, refresh := f.login(t, "out@example.com", "correct-horse-battery")
req, _ := http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/logout", nil)
req.AddCookie(refresh)
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatalf("logout: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("код %d", resp.StatusCode)
}
req, _ = http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/refresh", nil)
req.AddCookie(refresh)
resp2, err := f.srv.Client().Do(req)
if err != nil {
t.Fatalf("refresh: %v", err)
}
resp2.Body.Close()
if resp2.StatusCode != http.StatusUnauthorized {
t.Fatalf("сесія жива після виходу: код %d", resp2.StatusCode)
}
}
// Людина без членства в тенанті не має отримати токен, навіть якщо
// пароль правильний.
func TestLoginWithoutMembership(t *testing.T) {
f := setup(t)
hash := "" // створюємо користувача напряму, без членства
_ = hash
if _, err := f.pool.Exec(f.ctx, `
INSERT INTO core.users (email, password_hash, email_verified_at)
VALUES ('orphan@example.com', $1, now())
`, mustHash(t, "correct-horse-battery")); err != nil {
t.Fatalf("seed: %v", err)
}
t.Cleanup(func() {
_, _ = f.pool.Exec(f.ctx, `DELETE FROM core.users WHERE email = 'orphan@example.com'`)
})
code, out, _ := f.login(t, "orphan@example.com", "correct-horse-battery")
if code != http.StatusForbidden {
t.Fatalf("код %d", code)
}
if out.AccessToken != "" {
t.Fatal("видано токен без членства")
}
}
// Відкликане членство має негайно позбавляти доступу, навіть якщо
// токен ще не протермінований.
func TestRevokedMembershipBlocksToken(t *testing.T) {
f := setup(t)
userID := f.seedUser(t, "fired@example.com", "correct-horse-battery", "engineer")
_, out, _ := f.login(t, "fired@example.com", "correct-horse-battery")
if code, _ := f.get(t, "/api/v1/maps", out.AccessToken); code != http.StatusOK {
t.Fatal("доступ не працював ще до відкликання")
}
if err := f.store.RemoveMember(f.ctx, f.tenantID, userID); err != nil {
t.Fatalf("видалення членства: %v", err)
}
if code, _ := f.get(t, "/api/v1/maps", out.AccessToken); code != http.StatusUnauthorized {
t.Fatalf("токен звільненого ще діє: код %d", code)
}
}

View file

@ -12,8 +12,8 @@ import (
// 8 МБ лишає запас і водночас не дає одним запитом з'їсти пам'ять.
const maxPatchBody = 8 << 20
func (s *Server) handleCreateMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
if !tok.Can("maps:write") {
func (s *Server) handleCreateMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
@ -27,7 +27,7 @@ func (s *Server) handleCreateMap(w http.ResponseWriter, r *http.Request, tok *st
return
}
m, err := s.store.CreateMap(r.Context(), tok.TenantID, in.Name, in.Slug, in.Kind)
m, err := s.store.CreateMap(r.Context(), p.TenantID, in.Name, in.Slug, in.Kind)
if err != nil {
s.writeStoreError(w, "створення мапи", err)
return
@ -35,13 +35,13 @@ func (s *Server) handleCreateMap(w http.ResponseWriter, r *http.Request, tok *st
writeJSON(w, http.StatusCreated, m)
}
func (s *Server) handleDeleteMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
if !tok.Can("maps:write") {
func (s *Server) handleDeleteMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
if err := s.store.DeleteMap(r.Context(), tok.TenantID, r.PathValue("id")); err != nil {
if err := s.store.DeleteMap(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
s.writeStoreError(w, "видалення мапи", err)
return
}
@ -49,8 +49,8 @@ func (s *Server) handleDeleteMap(w http.ResponseWriter, r *http.Request, tok *st
}
// handlePatchMap — редактор полотна.
func (s *Server) handlePatchMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
if !tok.Can("maps:write") {
func (s *Server) handlePatchMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
@ -61,7 +61,7 @@ func (s *Server) handlePatchMap(w http.ResponseWriter, r *http.Request, tok *sto
}
mapID := r.PathValue("id")
res, err := s.store.ApplyMapPatch(r.Context(), tok.TenantID, mapID, "", &patch)
res, err := s.store.ApplyMapPatch(r.Context(), p.TenantID, mapID, "", &patch)
if err != nil {
s.writeStoreError(w, "збереження мапи", err)
return
@ -70,40 +70,40 @@ func (s *Server) handlePatchMap(w http.ResponseWriter, r *http.Request, tok *sto
// Інші відкриті полотна мають дізнатись про зміну: у NOC над однією
// мапою часто працюють кілька людей, і мовчазне розходження станів
// закінчується взаємним затиранням.
s.hub.BroadcastMapUpdate(tok.TenantID, mapID, res.Revision)
s.hub.BroadcastMapUpdate(p.TenantID, mapID, res.Revision)
writeJSON(w, http.StatusOK, res)
}
// handleBuildMap добудовує мапу з виявленої топології.
func (s *Server) handleBuildMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
if !tok.Can("maps:write") {
func (s *Server) handleBuildMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
mapID := r.PathValue("id")
st, err := s.store.BuildFromTopology(r.Context(), tok.TenantID, mapID, "")
st, err := s.store.BuildFromTopology(r.Context(), p.TenantID, mapID, "")
if err != nil {
s.writeStoreError(w, "автопобудова мапи", err)
return
}
if st.NodesAdded > 0 || st.EdgesAdded > 0 {
s.hub.BroadcastMapUpdate(tok.TenantID, mapID, st.Revision)
s.hub.BroadcastMapUpdate(p.TenantID, mapID, st.Revision)
}
writeJSON(w, http.StatusOK, st)
}
// handleUndoMap відкочує полотно до попереднього знімка.
func (s *Server) handleUndoMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
if !tok.Can("maps:write") {
func (s *Server) handleUndoMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:write") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
return
}
mapID := r.PathValue("id")
res, err := s.store.UndoMap(r.Context(), tok.TenantID, mapID, "")
res, err := s.store.UndoMap(r.Context(), p.TenantID, mapID, "")
if errors.Is(err, store.ErrNothingToUndo) {
writeError(w, http.StatusConflict, "nothing_to_undo", "історії змін ще немає")
return
@ -113,7 +113,7 @@ func (s *Server) handleUndoMap(w http.ResponseWriter, r *http.Request, tok *stor
return
}
s.hub.BroadcastMapUpdate(tok.TenantID, mapID, res.Revision)
s.hub.BroadcastMapUpdate(p.TenantID, mapID, res.Revision)
writeJSON(w, http.StatusOK, res)
}

View file

@ -0,0 +1,166 @@
package httpapi
import (
"errors"
"net"
"net/http"
"strings"
"github.com/netpulse/netpulse/server/internal/auth"
)
// Principal — хто саме робить запит.
//
// Два джерела автентифікації зводяться до одного типу: людина з
// access-токеном і машинний токен інтеграції. Обробникам байдуже, хто
// перед ними, — вони питають лише «чи можна». Інакше кожен обробник мав
// би дві гілки, і будь-яке нове право доводилось би додавати двічі.
type Principal struct {
TenantID string
// Порожній для машинного токена.
UserID string
Email string
// Порожній для людини.
TokenID string
TokenName string
perms map[string]bool
// Машинний токен без scopes успадковує повний доступ тенанта:
// так поводяться ключі, які власник створює сам для себе.
unrestricted bool
}
func (p *Principal) Can(perm string) bool {
if p == nil {
return false
}
if p.unrestricted {
return true
}
return p.perms[perm] || p.perms["*"]
}
// Permissions — перелік прав для /api/v1/me.
func (p *Principal) Permissions() []string {
if p.unrestricted {
return []string{"*"}
}
out := make([]string, 0, len(p.perms))
for k := range p.perms {
out = append(out, k)
}
return out
}
func (p *Principal) IsUser() bool { return p.UserID != "" }
// ---------------------------------------------------------------------
// authenticated обгортає обробник перевіркою особи.
func (s *Server) authenticated(next func(http.ResponseWriter, *http.Request, *Principal)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := bearerToken(r)
if raw == "" {
writeError(w, http.StatusUnauthorized, "no_token", "потрібен Bearer-токен")
return
}
p, err := s.resolvePrincipal(r, raw)
switch {
case errors.Is(err, auth.ErrTokenExpired):
// Окремий код: клієнт має піти по refresh, а не викидати
// користувача на сторінку входу.
writeError(w, http.StatusUnauthorized, "token_expired", "строк дії токена минув")
return
case err != nil:
writeError(w, http.StatusUnauthorized, "invalid_token", "токен недійсний")
return
}
next(w, r, p)
})
}
func (s *Server) resolvePrincipal(r *http.Request, raw string) (*Principal, error) {
ctx := r.Context()
// JWT має рівно дві крапки; машинні токени — жодної. Розрізняти за
// формою надійніше, ніж за префіксом: префікс задає людина.
if strings.Count(raw, ".") == 2 {
if s.signer == nil {
return nil, errors.New("підпис токенів не налаштовано")
}
claims, err := s.signer.Verify(raw)
if err != nil {
return nil, err
}
perms, err := s.store.UserPermissions(ctx, claims.UserID, claims.TenantID)
if err != nil {
return nil, err
}
// Порожній набір означає, що членство відкликали вже після
// видачі токена. Пускати такого не можна.
if len(perms) == 0 {
return nil, errors.New("немає прав у цьому тенанті")
}
p := &Principal{
TenantID: claims.TenantID,
UserID: claims.UserID,
Email: claims.Email,
perms: make(map[string]bool, len(perms)),
}
for _, k := range perms {
p.perms[k] = true
}
return p, nil
}
tok, err := s.store.AuthenticateAPIToken(ctx, raw)
if err != nil {
return nil, err
}
p := &Principal{
TenantID: tok.TenantID,
TokenID: tok.ID,
TokenName: tok.Name,
perms: make(map[string]bool, len(tok.Scopes)),
}
if len(tok.Scopes) == 0 {
p.unrestricted = true
}
for _, k := range tok.Scopes {
p.perms[k] = true
}
return p, nil
}
// requirePerm — коротка форма перевірки з однаковою відповіддю.
func requirePerm(w http.ResponseWriter, p *Principal, perm string) bool {
if p.Can(perm) {
return true
}
writeError(w, http.StatusForbidden, "forbidden", "немає права "+perm)
return false
}
// clientIP дістає адресу з урахуванням зворотного проксі.
//
// X-Forwarded-For береться лише перший елемент: решту дописують
// проміжні проксі, і довіряти їм не можна. Сам заголовок має сенс
// тільки якщо перед сервісом справді стоїть довірений проксі.
func clientIP(r *http.Request) net.IP {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
first, _, _ := strings.Cut(xff, ",")
if ip := net.ParseIP(strings.TrimSpace(first)); ip != nil {
return ip
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
return net.ParseIP(host)
}

View file

@ -13,24 +13,24 @@ import (
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/auth"
"github.com/netpulse/netpulse/server/internal/store"
)
type ctxKey string
const tokenCtxKey ctxKey = "netpulse.token"
type Server struct {
store *store.Store
log *slog.Logger
hub *Hub
store *store.Store
log *slog.Logger
hub *Hub
signer *auth.Signer
}
func New(st *store.Store, log *slog.Logger) *Server {
// New створює сервер. signer може бути nil лише в тестах, які не
// перевіряють вхід людей: без нього /api/v1/auth/* віддаватиме 500.
func New(st *store.Store, signer *auth.Signer, log *slog.Logger) *Server {
if log == nil {
log = slog.Default()
}
s := &Server{store: st, log: log}
s := &Server{store: st, log: log, signer: signer}
s.hub = NewHub(st, log)
return s
}
@ -48,6 +48,20 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /healthz", s.handleHealth)
// Вхід — єдині відкриті ендпоїнти: до них особи ще немає.
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
mux.HandleFunc("POST /api/v1/auth/refresh", s.handleRefresh)
mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout)
mux.Handle("GET /api/v1/me", s.authenticated(s.handleMe))
mux.Handle("POST /api/v1/auth/password", s.authenticated(s.handleChangePassword))
mux.Handle("GET /api/v1/team", s.authenticated(s.handleListTeam))
mux.Handle("POST /api/v1/team", s.authenticated(s.handleCreateUser))
mux.Handle("PATCH /api/v1/team/{id}", s.authenticated(s.handleSetRole))
mux.Handle("DELETE /api/v1/team/{id}", s.authenticated(s.handleRemoveMember))
mux.Handle("GET /api/v1/roles", s.authenticated(s.handleListRoles))
mux.Handle("GET /api/v1/maps", s.authenticated(s.handleListMaps))
mux.Handle("POST /api/v1/maps", s.authenticated(s.handleCreateMap))
mux.Handle("GET /api/v1/maps/{id}", s.authenticated(s.handleGetMap))
@ -69,29 +83,6 @@ func (s *Server) Handler() http.Handler {
// Проміжні шари
// ---------------------------------------------------------------------
func (s *Server) authenticated(next func(http.ResponseWriter, *http.Request, *store.APIToken)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := bearerToken(r)
if raw == "" {
writeError(w, http.StatusUnauthorized, "no_token", "потрібен Bearer-токен")
return
}
tok, err := s.store.AuthenticateAPIToken(r.Context(), raw)
if errors.Is(err, store.ErrTokenInvalid) {
writeError(w, http.StatusUnauthorized, "invalid_token", "токен недійсний")
return
}
if err != nil {
s.log.Error("автентифікація", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
next(w, r, tok)
})
}
// bearerToken дістає токен із заголовка або з підпротоколу WebSocket.
//
// Другий шлях потрібен тому, що браузерний WebSocket API не дозволяє
@ -184,13 +175,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) handleListMaps(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
if !tok.Can("maps:read") {
func (s *Server) handleListMaps(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:read") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:read")
return
}
maps, err := s.store.ListMaps(r.Context(), tok.TenantID)
maps, err := s.store.ListMaps(r.Context(), p.TenantID)
if err != nil {
s.log.Error("перелік мап", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
@ -202,13 +193,13 @@ func (s *Server) handleListMaps(w http.ResponseWriter, r *http.Request, tok *sto
writeJSON(w, http.StatusOK, map[string]any{"maps": maps})
}
func (s *Server) handleGetMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
if !tok.Can("maps:read") {
func (s *Server) handleGetMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:read") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:read")
return
}
state, err := s.store.GetMapState(r.Context(), tok.TenantID, r.PathValue("id"))
state, err := s.store.GetMapState(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "мапу не знайдено")
return
@ -227,13 +218,13 @@ func (s *Server) handleGetMap(w http.ResponseWriter, r *http.Request, tok *store
writeJSON(w, http.StatusOK, state)
}
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
if !tok.Can("devices:read") {
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("devices:read") {
writeError(w, http.StatusForbidden, "forbidden", "немає права devices:read")
return
}
devices, err := s.store.ListDevices(r.Context(), tok.TenantID)
devices, err := s.store.ListDevices(r.Context(), p.TenantID)
if err != nil {
s.log.Error("перелік пристроїв", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
@ -245,13 +236,13 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request, tok *
writeJSON(w, http.StatusOK, map[string]any{"devices": devices})
}
func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
if !tok.Can("agents:read") {
func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("agents:read") {
writeError(w, http.StatusForbidden, "forbidden", "немає права agents:read")
return
}
agents, err := s.store.ListAgents(r.Context(), tok.TenantID)
agents, err := s.store.ListAgents(r.Context(), p.TenantID)
if err != nil {
s.log.Error("перелік зондів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")

View file

@ -0,0 +1,131 @@
package httpapi
import (
"errors"
"net/http"
"github.com/netpulse/netpulse/server/internal/store"
)
func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:read") {
return
}
team, err := s.store.ListTeam(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік команди", err)
return
}
if team == nil {
team = []store.TeamMember{}
}
writeJSON(w, http.StatusOK, map[string]any{"members": team})
}
func (s *Server) handleListRoles(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:read") {
return
}
roles, err := s.store.ListRoles(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік ролей", err)
return
}
if roles == nil {
roles = []store.Role{}
}
writeJSON(w, http.StatusOK, map[string]any{"roles": roles})
}
// handleCreateUser додає людину в тенант.
//
// Поки що з паролем, який задає адміністратор. Запрошення поштою
// (`core.invitations` у схемі готова) додамо разом із відправкою листів —
// без робочої пошти воно перетворилось би на глухий кут в інтерфейсі.
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:write") {
return
}
var in struct {
Email string `json:"email"`
Password string `json:"password"`
FullName string `json:"full_name"`
RoleKey string `json:"role_key"`
}
if !decodeBody(w, r, &in) {
return
}
if in.Email == "" || in.Password == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібні email і пароль")
return
}
if in.RoleKey == "" {
in.RoleKey = "viewer"
}
// Роль owner не роздається через API: власник один, і міняється він
// передачею тенанта, а не додаванням другого власника.
if in.RoleKey == "owner" {
writeError(w, http.StatusForbidden, "forbidden", "роль owner не призначається через API")
return
}
u, err := s.store.CreateUser(r.Context(), p.TenantID, in.Email, in.Password, in.FullName, in.RoleKey)
if err != nil {
s.writeStoreError(w, "створення користувача", err)
return
}
writeJSON(w, http.StatusCreated, u)
}
func (s *Server) handleSetRole(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:write") {
return
}
var in struct {
RoleID string `json:"role_id"`
}
if !decodeBody(w, r, &in) {
return
}
userID := r.PathValue("id")
// Себе понизити не можна: інакше єдиний адміністратор тенанта
// одним кліком лишає організацію без керування.
if p.IsUser() && userID == p.UserID {
writeError(w, http.StatusForbidden, "self_demotion", "не можна змінити власну роль")
return
}
if err := s.store.SetRole(r.Context(), p.TenantID, userID, in.RoleID); err != nil {
s.writeStoreError(w, "зміна ролі", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:write") {
return
}
userID := r.PathValue("id")
if p.IsUser() && userID == p.UserID {
writeError(w, http.StatusForbidden, "self_removal", "не можна видалити себе")
return
}
err := s.store.RemoveMember(r.Context(), p.TenantID, userID)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "учасника не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "видалення учасника", err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -261,8 +261,8 @@ type clientMessage struct {
MapID string `json:"map_id"`
}
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
if !tok.Can("maps:read") {
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:read") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:read")
return
}
@ -284,7 +284,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request, tok *store.API
}
defer conn.CloseNow()
sub := &subscriber{tenantID: tok.TenantID, send: make(chan []byte, subscriberQueue)}
sub := &subscriber{tenantID: p.TenantID, send: make(chan []byte, subscriberQueue)}
s.hub.add(sub)
defer s.hub.remove(sub)
@ -311,7 +311,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request, tok *store.API
// Мапа має належати тому ж тенанту: інакше вгаданий id
// відкривав би чужу топологію.
ok, err := s.store.MapExists(ctx, tok.TenantID, msg.MapID)
ok, err := s.store.MapExists(ctx, p.TenantID, msg.MapID)
if err != nil || !ok {
_ = writeWS(ctx, conn, map[string]any{
"type": "error", "code": "map_not_found", "map_id": msg.MapID,
@ -325,7 +325,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request, tok *store.API
}
}()
_ = writeWS(ctx, conn, map[string]any{"type": "hello", "tenant_id": tok.TenantID})
_ = writeWS(ctx, conn, map[string]any{"type": "hello", "tenant_id": p.TenantID})
ping := time.NewTicker(30 * time.Second)
defer ping.Stop()

View file

@ -0,0 +1,534 @@
package store
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"net"
"time"
"github.com/jackc/pgx/v5"
"github.com/netpulse/netpulse/server/internal/auth"
)
// RefreshTTL — скільки живе сесія без повторного входу.
//
// 30 днів — компроміс між «щодня вводити пароль» і «звільнений
// співробітник місяць має доступ». Друге лікується відкликанням сесій
// при видаленні членства, а не коротшим строком.
const RefreshTTL = 30 * 24 * time.Hour
var (
ErrBadCredentials = errors.New("невірний email або пароль")
ErrNoMembership = errors.New("користувач не належить жодному тенанту")
ErrSessionInvalid = errors.New("сесія недійсна")
)
type User struct {
ID string `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name,omitempty"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
MFA bool `json:"mfa_enabled"`
}
// TenantMembership — до якого тенанта і з якою роллю належить користувач.
type TenantMembership struct {
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
TenantSlug string `json:"tenant_slug"`
RoleKey string `json:"role_key"`
RoleName string `json:"role_name"`
}
// TeamMember — рядок сторінки «Команда».
type TeamMember struct {
UserID string `json:"user_id"`
Email string `json:"email"`
FullName string `json:"full_name,omitempty"`
RoleID string `json:"role_id"`
RoleKey string `json:"role_key"`
RoleName string `json:"role_name"`
LastLogin *time.Time `json:"last_login_at,omitempty"`
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
ScopeGroups []string `json:"scope_group_ids,omitempty"`
}
type Role struct {
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
IsSystem bool `json:"is_system"`
Permissions []string `json:"permissions"`
}
// ---------------------------------------------------------------------
// Вхід
// ---------------------------------------------------------------------
// AuthenticateUser перевіряє email і пароль.
//
// Запити йдуть БЕЗ tenant-контексту: у момент входу тенант ще невідомий,
// його називає саме членство. Політики з міграції 0012 дозволяють це
// рівно тоді, коли app.tenant_id порожній.
func (s *Store) AuthenticateUser(ctx context.Context, email, password string, ip net.IP, userAgent string) (*User, []TenantMembership, error) {
var (
u User
hash *string
)
err := s.pool.QueryRow(ctx, `
SELECT id::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)
if errors.Is(err, pgx.ErrNoRows) {
// Спалюємо стільки ж часу, скільки справжня перевірка: інакше
// швидка відповідь видає, що такого email немає, і перебір
// перетворюється на збір списку зареєстрованих адрес.
auth.DummyVerify(password)
s.recordLogin(ctx, email, ip, userAgent, false, "no_user", "")
return nil, nil, ErrBadCredentials
}
if err != nil {
return nil, nil, err
}
if hash == nil || *hash == "" {
// Користувач лише через SSO — локального пароля немає.
auth.DummyVerify(password)
s.recordLogin(ctx, email, 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)
return nil, nil, ErrBadCredentials
}
memberships, err := s.userMemberships(ctx, u.ID)
if err != nil {
return nil, nil, err
}
if len(memberships) == 0 {
s.recordLogin(ctx, email, ip, userAgent, false, "no_membership", u.ID)
return nil, nil, ErrNoMembership
}
s.recordLogin(ctx, email, 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 {
return nil, nil, err
}
return &u, memberships, nil
}
func (s *Store) userMemberships(ctx context.Context, userID string) ([]TenantMembership, error) {
rows, err := s.pool.Query(ctx, `
SELECT t.id::text, t.name, t.slug::text, r.key::text, r.name
FROM core.memberships m
JOIN core.tenants t ON t.id = m.tenant_id
JOIN core.roles r ON r.id = m.role_id
WHERE m.user_id = $1 AND t.deleted_at IS NULL
ORDER BY t.name
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TenantMembership
for rows.Next() {
var m TenantMembership
if err := rows.Scan(&m.TenantID, &m.TenantName, &m.TenantSlug,
&m.RoleKey, &m.RoleName); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// recordLogin пише спробу входу. Помилки навмисно глушаться: збій
// журналювання не має заважати людині увійти.
func (s *Store) recordLogin(ctx context.Context, email string, ip net.IP, ua string, ok bool, reason, userID string) {
var ipArg any
if ip != nil {
ipArg = ip.String()
}
_, _ = s.pool.Exec(ctx, `
INSERT INTO core.login_attempts (email, ip, user_agent, success, reason, user_id)
VALUES ($1, $2::inet, $3, $4, NULLIF($5,''), $6)
`, email, ipArg, nullString(ua), ok, reason, nullUUID(userID))
}
// FailedLoginsSince рахує невдалі спроби — основа для гальмування
// перебору на рівні застосунку.
func (s *Store) FailedLoginsSince(ctx context.Context, email string, since time.Duration) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `
SELECT count(*) FROM core.login_attempts
WHERE email = $1 AND NOT success AND ts > now() - $2::interval
`, email, since.String()).Scan(&n)
return n, err
}
// ---------------------------------------------------------------------
// Сесії
// ---------------------------------------------------------------------
type Session struct {
ID string
UserID string
TenantID string
RefreshToken string
ExpiresAt time.Time
}
// CreateSession видає refresh-токен. У БД лягає лише його sha256.
func (s *Store) CreateSession(ctx context.Context, userID, tenantID string, ip net.IP, ua string) (*Session, error) {
token, err := auth.NewToken("npr_")
if err != nil {
return nil, err
}
sum := sha256.Sum256([]byte(token))
expires := time.Now().Add(RefreshTTL)
var ipArg any
if ip != nil {
ipArg = ip.String()
}
sess := &Session{UserID: userID, TenantID: tenantID, RefreshToken: token, ExpiresAt: expires}
err = s.pool.QueryRow(ctx, `
INSERT INTO core.sessions (user_id, tenant_id, token_hash, user_agent, ip, expires_at)
VALUES ($1, $2, $3, $4, $5::inet, $6)
RETURNING id::text
`, userID, tenantID, sum[:], nullString(ua), ipArg, expires).Scan(&sess.ID)
if err != nil {
return nil, err
}
return sess, nil
}
// RotateSession обмінює refresh-токен на новий.
//
// Ротація при кожному обміні, а не подовження строку: викрадений
// refresh-токен спрацює щонайбільше один раз, і законний власник
// одразу отримає відмову — це видимий сигнал компрометації.
func (s *Store) RotateSession(ctx context.Context, refreshToken string, ip net.IP, ua string) (*Session, *User, error) {
sum := sha256.Sum256([]byte(refreshToken))
var (
oldID, userID, tenantID string
expires time.Time
revoked *time.Time
)
err := s.pool.QueryRow(ctx, `
SELECT id::text, user_id::text, COALESCE(tenant_id::text,''), expires_at, revoked_at
FROM core.sessions WHERE token_hash = $1
`, sum[:]).Scan(&oldID, &userID, &tenantID, &expires, &revoked)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrSessionInvalid
}
if err != nil {
return nil, nil, err
}
if revoked != nil || time.Now().After(expires) {
return nil, nil, ErrSessionInvalid
}
var u User
if err := s.pool.QueryRow(ctx, `
SELECT id::text, email::text, COALESCE(full_name,''), locale, timezone, mfa_enabled
FROM core.users WHERE id = $1 AND deleted_at IS NULL
`, userID).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA); err != nil {
return nil, nil, ErrSessionInvalid
}
// Членство могли відкликати вже після входу — перевіряємо щоразу,
// інакше звільнений співробітник продовжував би подовжувати сесію.
var stillMember bool
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM core.memberships WHERE user_id = $1 AND tenant_id = $2)
`, userID, tenantID).Scan(&stillMember); err != nil {
return nil, nil, err
}
if !stillMember {
return nil, nil, ErrSessionInvalid
}
if _, err := s.pool.Exec(ctx,
`UPDATE core.sessions SET revoked_at = now() WHERE id = $1`, oldID); err != nil {
return nil, nil, err
}
sess, err := s.CreateSession(ctx, userID, tenantID, ip, ua)
if err != nil {
return nil, nil, err
}
return sess, &u, nil
}
func (s *Store) RevokeSession(ctx context.Context, refreshToken string) error {
sum := sha256.Sum256([]byte(refreshToken))
_, err := s.pool.Exec(ctx, `
UPDATE core.sessions SET revoked_at = now()
WHERE token_hash = $1 AND revoked_at IS NULL
`, sum[:])
return err
}
// RevokeAllSessions — вихід з усіх пристроїв; те саме робиться при зміні
// пароля й при видаленні членства.
func (s *Store) RevokeAllSessions(ctx context.Context, userID string) error {
_, err := s.pool.Exec(ctx, `
UPDATE core.sessions SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL
`, userID)
return err
}
// ---------------------------------------------------------------------
// Права
// ---------------------------------------------------------------------
// UserPermissions збирає ефективні права людини в тенанті.
//
// Джерело — membership → role → role_permissions. Кешувати тут нічого не
// треба: це один індексний join, а кеш прав — класичне джерело «звільнив,
// а він ще годину має доступ».
func (s *Store) UserPermissions(ctx context.Context, userID, tenantID string) ([]string, error) {
rows, err := s.pool.Query(ctx, `
SELECT rp.permission_key
FROM core.memberships m
JOIN core.role_permissions rp ON rp.role_id = m.role_id
WHERE m.user_id = $1 AND m.tenant_id = $2
`, userID, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ScopeGroups — обмеження видимості пристроїв для користувача.
// nil означає «усі пристрої тенанта».
func (s *Store) ScopeGroups(ctx context.Context, userID, tenantID string) ([]string, error) {
var groups []string
err := s.pool.QueryRow(ctx, `
SELECT scope_group_ids::text[]
FROM core.memberships WHERE user_id = $1 AND tenant_id = $2
`, userID, tenantID).Scan(&groups)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNoMembership
}
return groups, err
}
// ---------------------------------------------------------------------
// Команда
// ---------------------------------------------------------------------
func (s *Store) ListTeam(ctx context.Context, tenantID string) ([]TeamMember, error) {
var out []TeamMember
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,''),
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
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var t TeamMember
if err := rows.Scan(&t.UserID, &t.Email, &t.FullName,
&t.RoleID, &t.RoleKey, &t.RoleName,
&t.LastLogin, &t.AcceptedAt, &t.ScopeGroups); err != nil {
return err
}
out = append(out, t)
}
return rows.Err()
})
return out, err
}
func (s *Store) ListRoles(ctx context.Context, tenantID string) ([]Role, error) {
var out []Role
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT r.id::text, r.key::text, r.name, COALESCE(r.description,''), r.is_system,
COALESCE(array_agg(rp.permission_key ORDER BY rp.permission_key)
FILTER (WHERE rp.permission_key IS NOT NULL), '{}')
FROM core.roles r
LEFT JOIN core.role_permissions rp ON rp.role_id = r.id
WHERE r.tenant_id IS NULL OR r.tenant_id = $1
GROUP BY r.id
ORDER BY r.is_system DESC, r.name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var r Role
if err := rows.Scan(&r.ID, &r.Key, &r.Name, &r.Description,
&r.IsSystem, &r.Permissions); err != nil {
return err
}
out = append(out, r)
}
return rows.Err()
})
return out, err
}
// CreateUser заводить користувача й одразу дає йому членство.
//
// Використовується і CLI-початківцем (перший власник), і запрошеннями.
func (s *Store) CreateUser(ctx context.Context, tenantID, email, password, fullName, roleKey string) (*User, error) {
hash, err := auth.HashPassword(password)
if err != nil {
return nil, err
}
var u User
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Користувач глобальний: одна людина може працювати в кількох
// тенантах (типово для MSP-провайдера).
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
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)
if err != nil {
return fmt.Errorf("створення користувача: %w", err)
}
var roleID string
if err := tx.QueryRow(ctx, `
SELECT id::text FROM core.roles
WHERE key = $1 AND (tenant_id IS NULL OR tenant_id = $2)
ORDER BY tenant_id NULLS LAST
LIMIT 1
`, roleKey, tenantID).Scan(&roleID); err != nil {
return fmt.Errorf("роль %q: %w", roleKey, err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO core.memberships (tenant_id, user_id, role_id, accepted_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id
`, tenantID, u.ID, roleID); err != nil {
return fmt.Errorf("членство: %w", err)
}
return nil
})
if err != nil {
return nil, mapPgError(err)
}
return &u, nil
}
// SetRole змінює роль учасника команди.
func (s *Store) SetRole(ctx context.Context, tenantID, userID, roleID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE core.memberships SET role_id = $3
WHERE tenant_id = $1 AND user_id = $2
`, tenantID, userID, roleID)
if err != nil {
return mapPgError(err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// RemoveMember прибирає людину з тенанта.
//
// Сам користувач лишається: він може працювати в іншому тенанті, а його
// сліди в аудиті мають лишатися читабельними. Зате всі сесії саме в
// цьому тенанті відкликаються негайно.
func (s *Store) RemoveMember(ctx context.Context, tenantID, userID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
DELETE FROM core.memberships WHERE tenant_id = $1 AND user_id = $2
`, tenantID, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
_, err = tx.Exec(ctx, `
UPDATE core.sessions SET revoked_at = now()
WHERE user_id = $1 AND tenant_id = $2 AND revoked_at IS NULL
`, userID, tenantID)
return err
})
}
// ChangePassword міняє пароль і відкликає всі інші сесії.
func (s *Store) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error {
var hash *string
if err := s.pool.QueryRow(ctx,
`SELECT password_hash FROM core.users WHERE id = $1 AND deleted_at IS NULL`,
userID).Scan(&hash); err != nil {
return ErrBadCredentials
}
if hash == nil || auth.VerifyPassword(*hash, oldPassword) != nil {
return ErrBadCredentials
}
newHash, err := auth.HashPassword(newPassword)
if err != nil {
return err
}
if _, err := s.pool.Exec(ctx,
`UPDATE core.users SET password_hash = $2, updated_at = now() WHERE id = $1`,
userID, newHash); err != nil {
return err
}
// Зміна пароля — типова реакція на «здається, мене зламали».
// Якщо після неї чужа сесія лишається живою, дія безглузда.
return s.RevokeAllSessions(ctx, userID)
}

View file

@ -1,17 +1,54 @@
import { useEffect, useMemo, useState } from 'react'
import { api } from './api/client'
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { api, staticTokenPresent } from './api/client'
import { session } from './api/session'
import { useLiveMap } from './hooks/useLiveMap'
import { LoginPage } from './components/LoginPage'
import { MapCanvas } from './components/MapCanvas'
import type { AgentSummary, MapSummary } from './types'
export default function App() {
// Сесія живе поза React, бо її читає й шар API. useSyncExternalStore
// підписує компонент на неї без дублювання стану.
const me = useSyncExternalStore(session.subscribe, session.me)
const [booting, setBooting] = useState(true)
// Після перезавантаження сторінки access-токен зник разом із памʼяттю
// вкладки, але httpOnly-кукі жива — пробуємо тихо відновити сесію.
useEffect(() => {
if (staticTokenPresent) {
setBooting(false)
return
}
void api.restore().finally(() => setBooting(false))
}, [])
if (booting) {
return (
<div className="flex h-screen items-center justify-center bg-slate-950 text-sm text-slate-600">
Відновлення сесії
</div>
)
}
if (!me && !staticTokenPresent) {
return <LoginPage onDone={() => setBooting(false)} />
}
return <Workspace />
}
function Workspace() {
const [maps, setMaps] = useState<MapSummary[]>([])
const [mapID, setMapID] = useState<string | null>(null)
const [agents, setAgents] = useState<AgentSummary[]>([])
const [selected, setSelected] = useState<string | null>(null)
const [listError, setListError] = useState<string | null>(null)
// Мобільний сайдбар відкривається поверх полотна: на 375 px ширини
// постійна колонка з'їдала б дві третини карти.
const [sidebarOpen, setSidebarOpen] = useState(false)
const live = useLiveMap(mapID)
const canWrite = session.can('maps:write')
useEffect(() => {
api
@ -35,17 +72,29 @@ export default function App() {
}, [live.state])
const node = live.state?.nodes.find((n) => n.id === selected) ?? null
const me = session.me()
return (
<div className="flex h-screen w-screen flex-col bg-slate-950 text-slate-200">
<header className="flex shrink-0 items-center gap-4 border-b border-slate-800 bg-slate-900 px-4 py-2">
<div className="flex items-baseline gap-2">
<span className="text-lg font-semibold tracking-tight text-slate-100">NetPulse</span>
<span className="text-xs text-slate-500">мапа мережі</span>
</div>
<div className="flex h-[100dvh] w-screen flex-col bg-slate-950 text-slate-200">
<header
className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-slate-800
bg-slate-900 px-3 py-2 md:px-4"
>
<button
className="rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm md:hidden"
onClick={() => setSidebarOpen((v) => !v)}
aria-label="Меню"
>
</button>
<span className="text-base font-semibold tracking-tight text-slate-100 md:text-lg">
NetPulse
</span>
<select
className="rounded border border-slate-700 bg-slate-800 px-2 py-1 text-sm"
className="min-w-0 flex-1 rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm
md:flex-none"
value={mapID ?? ''}
onChange={(e) => setMapID(e.target.value || null)}
>
@ -57,35 +106,50 @@ export default function App() {
))}
</select>
<button
className="rounded border border-slate-700 bg-slate-800 px-3 py-1 text-sm hover:bg-slate-700
disabled:opacity-40"
disabled={!mapID}
onClick={() => void live.build()}
title="Додати на мапу пристрої та зв'язки, знайдені автовиявленням"
>
Добудувати з топології
</button>
{canWrite && (
<>
<button
className="rounded border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm
hover:bg-slate-700 disabled:opacity-40"
disabled={!mapID}
onClick={() => void live.build()}
title="Додати на мапу пристрої та зв'язки, знайдені автовиявленням"
>
<span className="hidden lg:inline">Добудувати з топології</span>
<span className="lg:hidden">Добудувати</span>
</button>
<button
className="rounded border border-slate-700 bg-slate-800 px-3 py-1 text-sm hover:bg-slate-700
disabled:opacity-40"
disabled={!live.canUndo}
onClick={() => void live.undo()}
title="Відкотити останню зміну полотна"
>
Відкотити
</button>
<button
className="rounded border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm
hover:bg-slate-700 disabled:opacity-40"
disabled={!live.canUndo}
onClick={() => void live.undo()}
title="Відкотити останню зміну полотна"
>
<span className="ml-1 hidden lg:inline">Відкотити</span>
</button>
</>
)}
<span className="text-xs text-slate-600">
тягніть від краю вузла зв'язок; Delete видалити
</span>
<div className="ml-auto flex items-center gap-4 text-xs">
<Legend />
<div className="ml-auto flex items-center gap-3 text-xs">
<span className="hidden xl:block">
<Legend />
</span>
<ConnectionBadge state={live.connection} lastEventAt={live.lastEventAt} />
{live.state && (
<span className="tabular-nums text-slate-500">ревізія {live.state.revision}</span>
<span className="hidden tabular-nums text-slate-500 sm:inline">
ревізія {live.state.revision}
</span>
)}
{me?.email && (
<button
className="max-w-[10rem] truncate rounded border border-slate-700 px-2 py-1
text-slate-400 hover:bg-slate-800"
title={`${me.email} — вийти`}
onClick={() => void api.logout()}
>
{me.email}
</button>
)}
</div>
</header>
@ -96,8 +160,21 @@ export default function App() {
</div>
)}
<div className="flex min-h-0 flex-1">
<aside className="flex w-60 shrink-0 flex-col gap-4 overflow-y-auto border-r border-slate-800 bg-slate-900/60 p-3">
<div className="relative flex min-h-0 flex-1">
{/* Затемнення під висувним сайдбаром — лише на вузьких екранах. */}
{sidebarOpen && (
<div
className="absolute inset-0 z-10 bg-slate-950/60 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<aside
className={`absolute inset-y-0 left-0 z-20 flex w-64 flex-col gap-4 overflow-y-auto
border-r border-slate-800 bg-slate-900 p-3 transition-transform
md:static md:z-0 md:w-60 md:translate-x-0 md:bg-slate-900/60
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}
>
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
Стан вузлів
@ -110,64 +187,74 @@ export default function App() {
</div>
</section>
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
Зонди
</h2>
{agents.length === 0 && <p className="text-xs text-slate-600">немає</p>}
<ul className="space-y-2">
{agents.map((a) => (
<li key={a.id} className="rounded border border-slate-800 bg-slate-900 p-2 text-xs">
<div className="flex items-center gap-1.5">
<span
className={`h-1.5 w-1.5 rounded-full ${
a.status === 'online' ? 'bg-emerald-400' : 'bg-slate-600'
}`}
/>
<span className="truncate font-medium text-slate-200">{a.name}</span>
</div>
<div className="mt-1 text-slate-500">
{a.os}/{a.arch} · {a.device_count} пристроїв
</div>
{typeof a.health?.rss_bytes === 'number' && (
<div className="tabular-nums text-slate-500">
RSS {((a.health.rss_bytes as number) / 1048576).toFixed(1)} МБ
</div>
)}
</li>
))}
</ul>
</section>
{node && (
{session.can('agents:read') && (
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
Вузол
Зонди
</h2>
<div className="space-y-1 rounded border border-slate-800 bg-slate-900 p-2 text-xs">
<div className="font-medium text-slate-100">{node.label}</div>
<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)}`} />
{node.last_seen_at && (
<Row k="бачили" v={new Date(node.last_seen_at).toLocaleTimeString('uk')} />
)}
</div>
{agents.length === 0 && <p className="text-xs text-slate-600">немає</p>}
<ul className="space-y-2">
{agents.map((a) => (
<li key={a.id} className="rounded border border-slate-800 bg-slate-900 p-2 text-xs">
<div className="flex items-center gap-1.5">
<span
className={`h-1.5 w-1.5 rounded-full ${
a.status === 'online' ? 'bg-emerald-400' : 'bg-slate-600'
}`}
/>
<span className="truncate font-medium text-slate-200">{a.name}</span>
</div>
<div className="mt-1 text-slate-500">
{a.os}/{a.arch} · {a.device_count} пристроїв
</div>
{typeof a.health?.rss_bytes === 'number' && (
<div className="tabular-nums text-slate-500">
RSS {((a.health.rss_bytes as number) / 1048576).toFixed(1)} МБ
</div>
)}
</li>
))}
</ul>
</section>
)}
</aside>
<main className="min-w-0 flex-1">
{live.state ? (
<MapCanvas state={live.state} onPatch={live.patch} onSelect={setSelected} />
<MapCanvas
state={live.state}
onPatch={live.patch}
onSelect={setSelected}
readOnly={!canWrite}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-slate-600">
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-600">
{live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'}
</div>
)}
</main>
</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>
)}
</div>
)
}
@ -183,7 +270,7 @@ function Counter({ label, value, color }: { label: string; value: number; color?
function Row({ k, v }: { k: string; v: string }) {
return (
<div className="flex justify-between gap-2">
<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>
@ -192,12 +279,12 @@ function Row({ k, v }: { k: string; v: string }) {
function Legend() {
return (
<div className="flex items-center gap-2 text-slate-500">
<span className="flex items-center gap-2 text-slate-500">
<Dot c="bg-emerald-400" t="норма" />
<Dot c="bg-amber-400" t="навантажено" />
<Dot c="bg-red-500" t="обрив" />
<Dot c="bg-slate-500" t="невідомо" />
</div>
</span>
)
}
@ -215,7 +302,7 @@ function Dot({ c, t }: { c: string; t: string }) {
*
* Мапа, яка тихо замерзла після розриву WebSocket, виглядає точно так
* само, як мапа, де все гаразд, і це найнебезпечніший стан
* моніторингу. Тому окремий індикатор і час останньої події.
* моніторингу.
*/
function ConnectionBadge({
state,
@ -244,9 +331,9 @@ function ConnectionBadge({
return (
<span className="flex items-center gap-1.5 text-slate-400">
<span className={`h-1.5 w-1.5 rounded-full ${color}`} />
{label}
<span className="hidden sm:inline">{label}</span>
{ago !== null && state === 'online' && (
<span className="tabular-nums text-slate-600">{ago} с тому</span>
<span className="hidden tabular-nums text-slate-600 lg:inline">{ago} с тому</span>
)}
</span>
)

View file

@ -1,17 +1,29 @@
import { meFromLogin, session } from './session'
import type {
AgentSummary,
BuildResult,
DeviceSummary,
LoginResponse,
MapPatch,
MapState,
MapSummary,
PatchResult,
Role,
TeamMember,
UndoResult,
} from '../types'
// Токен береться зі змінної збірки або з localStorage. Повноцінного
// входу ще немає — коли з'явиться, зміниться лише це місце.
const TOKEN = import.meta.env.VITE_API_TOKEN ?? localStorage.getItem('np_token') ?? ''
// Машинний токен зі змінної збірки лишається запасним шляхом для кіосків
// і NOC-екранів, де входити нікому. Жива сесія людини має пріоритет.
const STATIC_TOKEN = import.meta.env.VITE_API_TOKEN ?? ''
// Чи налаштований машинний токен: у цьому режимі сторінка входу не
// показується взагалі (кіоск, NOC-екран).
export const staticTokenPresent = STATIC_TOKEN !== ''
export function authToken(): string {
return session.token() || STATIC_TOKEN
}
export class ApiError extends Error {
constructor(
@ -31,17 +43,23 @@ export class ApiError extends Error {
get isPlanLimit() {
return this.status === 402
}
/** Немає права на дію. */
get isForbidden() {
return this.status === 403
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TOKEN}`,
...(init?.headers ?? {}),
},
})
async function raw<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((init?.headers as Record<string, string>) ?? {}),
}
const t = authToken()
if (t) headers.Authorization = `Bearer ${t}`
// credentials потрібні для httpOnly-кукі з refresh-токеном.
const res = await fetch(path, { ...init, headers, credentials: 'same-origin' })
if (res.status === 204) return undefined as T
@ -55,7 +73,96 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return body as T
}
// Один refresh на всіх одночасних запитах.
//
// Якщо токен протух під час завантаження мапи, паралельні виклики
// інакше влаштували б чергу з десятка обмінів — а ротація зробила б
// усі, крім першого, недійсними, і людину викинуло б на вхід рівно
// тоді, коли все насправді гаразд.
let refreshing: Promise<boolean> | null = null
async function refreshOnce(): Promise<boolean> {
if (!refreshing) {
refreshing = raw<LoginResponse>('/api/v1/auth/refresh', { method: 'POST' })
.then((r) => {
if (!r.access_token) return false
session.set(r.access_token, meFromLogin(r))
return true
})
.catch(() => false)
.finally(() => {
refreshing = null
})
}
return refreshing
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
try {
return await raw<T>(path, init)
} catch (e) {
// Протермінований токен — не привід викидати людину на сторінку
// входу: refresh-кукі ще жива, тож тихо оновлюємо й повторюємо.
if (e instanceof ApiError && e.status === 401 && e.code === 'token_expired') {
if (await refreshOnce()) return raw<T>(path, init)
session.clear()
}
throw e
}
}
export const api = {
// --- вхід ---
login: (email: string, password: string, tenantID?: string) =>
raw<LoginResponse>('/api/v1/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password, tenant_id: tenantID }),
}),
/** Тихе відновлення сесії після перезавантаження сторінки. */
restore: () => refreshOnce(),
logout: async () => {
try {
await raw<void>('/api/v1/auth/logout', { method: 'POST' })
} finally {
session.clear()
}
},
me: () =>
request<{
tenant_id: string
email?: string
user_id?: string
token_name?: string
permissions: string[]
}>('/api/v1/me'),
// --- команда ---
team: () => request<{ members: TeamMember[] }>('/api/v1/team').then((r) => r.members ?? []),
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 }),
}),
setRole: (userID: string, roleID: string) =>
request<void>(`/api/v1/team/${userID}`, {
method: 'PATCH',
body: JSON.stringify({ role_id: roleID }),
}),
removeMember: (userID: string) =>
request<void>(`/api/v1/team/${userID}`, { method: 'DELETE' }),
// --- мапи ---
listMaps: () => request<{ maps: MapSummary[] }>('/api/v1/maps').then((r) => r.maps ?? []),
getMap: (id: string) => request<MapState>(`/api/v1/maps/${id}`),
@ -72,11 +179,11 @@ export const api = {
body: JSON.stringify(patch),
}),
buildMap: (id: string) =>
request<BuildResult>(`/api/v1/maps/${id}/build`, { method: 'POST' }),
buildMap: (id: string) => request<BuildResult>(`/api/v1/maps/${id}/build`, { method: 'POST' }),
undoMap: (id: string) =>
request<UndoResult>(`/api/v1/maps/${id}/undo`, { method: 'POST' }),
undoMap: (id: string) => request<UndoResult>(`/api/v1/maps/${id}/undo`, { method: 'POST' }),
// --- інвентар ---
listDevices: () =>
request<{ devices: DeviceSummary[] }>('/api/v1/devices').then((r) => r.devices ?? []),
@ -84,5 +191,3 @@ export const api = {
listAgents: () =>
request<{ agents: AgentSummary[] }>('/api/v1/agents').then((r) => r.agents ?? []),
}
export const apiToken = TOKEN

70
web/src/api/session.ts Normal file
View file

@ -0,0 +1,70 @@
import type { Permission } from '../types'
/**
* Сесія браузера.
*
* Access-токен живе **лише в пам'яті вкладки**, а не в localStorage:
* XSS-скрипт читає localStorage тривіально, а до змінної в замиканні
* модуля дістатись значно важче. Довгу частину тримає httpOnly-кукі з
* refresh-токеном, яку скрипт не бачить узагалі тому після
* перезавантаження сторінки сесія відновлюється тихим refresh-ом.
*/
export interface Me {
userID?: string
email?: string
tokenName?: string
tenantID: string
permissions: Permission[]
}
let accessToken = ''
let me: Me | null = null
const listeners = new Set<() => void>()
function notify() {
for (const l of listeners) l()
}
export const session = {
subscribe(fn: () => void) {
listeners.add(fn)
return () => listeners.delete(fn)
},
token: () => accessToken,
me: () => me,
set(token: string, who: Me) {
accessToken = token
me = who
notify()
},
clear() {
accessToken = ''
me = null
notify()
},
/** Право є, якщо воно явно надане або токен без обмежень (`*`). */
can(perm: Permission): boolean {
if (!me) return false
return me.permissions.includes(perm) || me.permissions.includes('*')
},
}
/** Розбір відповіді входу в єдину форму. */
export function meFromLogin(body: {
user?: { id: string; email: string }
tenant?: { tenant_id: string } | null
permissions?: string[]
tenant_id?: string
}): Me {
return {
userID: body.user?.id,
email: body.user?.email,
tenantID: body.tenant?.tenant_id ?? body.tenant_id ?? '',
permissions: (body.permissions ?? []) as Permission[],
}
}

View file

@ -1,4 +1,4 @@
import { apiToken } from './client'
import { authToken } from './client'
import type { WsMessage } from '../types'
export type ConnectionState = 'connecting' | 'online' | 'offline'
@ -32,8 +32,10 @@ export class LiveConnection {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
// Браузерний WebSocket не дозволяє довільні заголовки, тому токен
// їде підпротоколом — так він не потрапляє в URL і в логи проксі.
// Токен читаємо в момент підключення, а не при створенні обʼєкта:
// після реконекту він міг уже змінитись через refresh.
const ws = new WebSocket(`${proto}://${location.host}/api/v1/ws`, [
`netpulse.token.${apiToken}`,
`netpulse.token.${authToken()}`,
])
this.ws = ws

View file

@ -0,0 +1,141 @@
import { useState } from 'react'
import { ApiError, api } from '../api/client'
import { meFromLogin, session } from '../api/session'
import type { TenantMembership } from '../types'
/**
* Сторінка входу.
*
* Свідомо мінімальна: одне поле помилки, жодних підказок на кшталт
* «такого користувача немає». Сервер і так відповідає однаково на
* невідомий email і невірний пароль показувати різне на клієнті
* означало б звести нанівець цей захист.
*/
export function LoginPage({ onDone }: { onDone: () => void }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [tenants, setTenants] = useState<TenantMembership[] | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function submit(tenantID?: string) {
setBusy(true)
setError(null)
try {
const r = await api.login(email, password, tenantID)
// Людина працює в кількох організаціях — треба вибрати.
if (!r.access_token && r.tenants && r.tenants.length > 0) {
setTenants(r.tenants)
return
}
if (!r.access_token) {
setError('Сервер не видав токен')
return
}
session.set(r.access_token, meFromLogin(r))
onDone()
} catch (e) {
if (e instanceof ApiError) {
setError(
e.status === 429
? 'Забагато невдалих спроб. Спробуйте за 15 хвилин.'
: e.status === 403
? 'Користувача не додано до жодної організації'
: 'Невірний email або пароль',
)
} else {
setError(e instanceof Error ? e.message : String(e))
}
} finally {
setBusy(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4 py-8">
<div className="w-full max-w-sm">
<div className="mb-6 text-center">
<div className="text-2xl font-semibold tracking-tight text-slate-100">NetPulse</div>
<div className="mt-1 text-sm text-slate-500">моніторинг мережі</div>
</div>
{tenants ? (
<div className="rounded-lg border border-slate-800 bg-slate-900 p-4">
<h2 className="mb-3 text-sm text-slate-300">Оберіть організацію</h2>
<ul className="space-y-2">
{tenants.map((t) => (
<li key={t.tenant_id}>
<button
className="w-full rounded border border-slate-700 bg-slate-800 px-3 py-2.5 text-left
text-sm hover:bg-slate-700 disabled:opacity-40"
disabled={busy}
onClick={() => void submit(t.tenant_id)}
>
<span className="block text-slate-100">{t.tenant_name}</span>
<span className="block text-xs text-slate-500">{t.role_name}</span>
</button>
</li>
))}
</ul>
</div>
) : (
<form
className="space-y-3 rounded-lg border border-slate-800 bg-slate-900 p-4"
onSubmit={(e) => {
e.preventDefault()
void submit()
}}
>
<label className="block">
<span className="mb-1 block text-xs text-slate-400">Email</span>
<input
type="email"
autoComplete="username"
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)}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-slate-400">Пароль</span>
<input
type="password"
autoComplete="current-password"
required
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={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
{error && (
<div className="rounded border border-red-900/60 bg-red-950/50 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
<button
type="submit"
disabled={busy}
className="w-full rounded bg-sky-700 px-3 py-2.5 text-sm font-medium text-white
hover:bg-sky-600 disabled:opacity-40"
>
{busy ? 'Вхід…' : 'Увійти'}
</button>
</form>
)}
<p className="mt-4 text-center text-xs text-slate-600">
Першого користувача заводить <code className="text-slate-500">netpulse-user</code>
</p>
</div>
</div>
)
}

View file

@ -25,16 +25,18 @@ interface Props {
state: MapState
onPatch: (p: MapPatch, comment?: string) => void
onSelect: (nodeID: string | null) => void
/** Глядач без maps:write бачить полотно, але не змінює його. */
readOnly?: boolean
}
export function MapCanvas({ state, onPatch, onSelect }: Props) {
export function MapCanvas({ state, onPatch, onSelect, readOnly = false }: Props) {
const rfNodes = useMemo<DeviceNodeType[]>(
() =>
state.nodes.map((n) => ({
id: n.id,
type: 'device' as const,
position: { x: n.x, y: n.y },
draggable: !n.locked,
draggable: !n.locked && !readOnly,
data: {
label: n.label ?? '—',
kind: (n.style?.icon as string) ?? n.kind,
@ -44,7 +46,7 @@ export function MapCanvas({ state, onPatch, onSelect }: Props) {
icon: n.style?.icon as string | undefined,
},
})),
[state.nodes],
[state.nodes, readOnly],
)
const rfEdges = useMemo<TrafficEdgeType[]>(
@ -168,9 +170,10 @@ export function MapCanvas({ state, onPatch, onSelect }: Props) {
onNodesChange={handleNodesChange}
onEdgesChange={onEdgesChange}
onNodeDragStop={handleDragStop}
onConnect={handleConnect}
onDelete={handleDelete}
deleteKeyCode={['Delete', 'Backspace']}
onConnect={readOnly ? undefined : handleConnect}
onDelete={readOnly ? undefined : handleDelete}
nodesConnectable={!readOnly}
deleteKeyCode={readOnly ? null : ['Delete', 'Backspace']}
connectionRadius={30}
defaultViewport={state.viewport}
snapToGrid={state.grid?.snap ?? true}

View file

@ -2,6 +2,55 @@
// зайвим шаром: контракт описаний у server/API.md і змінюється разом
// зі схемою БД, а не окремо від неї.
// Права збігаються з core.permissions. Літеральний тип, а не string:
// друкарська помилка в назві права інакше мовчки ховала б кнопку назавжди.
export type Permission =
| '*'
| 'devices:read' | 'devices:write' | 'devices:control'
| 'maps:read' | 'maps:write' | 'maps:publish'
| 'alerts:read' | 'alerts:ack' | 'alerts:write'
| 'ncm:read' | 'ncm:write' | 'ncm:rollback'
| 'agents:read' | 'agents:write'
| 'users:read' | 'users:write'
| 'billing:read' | 'billing:manage'
| 'audit:read' | 'settings:write'
export interface TenantMembership {
tenant_id: string
tenant_name: string
tenant_slug: string
role_key: string
role_name: string
}
export interface LoginResponse {
access_token?: string
expires_in?: number
user?: { id: string; email: string; full_name?: string }
tenant?: TenantMembership | null
tenants?: TenantMembership[]
permissions?: string[]
}
export interface TeamMember {
user_id: string
email: string
full_name?: string
role_id: string
role_key: string
role_name: string
last_login_at?: string
}
export interface Role {
id: string
key: string
name: string
description?: string
is_system: boolean
permissions: string[]
}
export type DeviceStatus = 'up' | 'down' | 'warning' | 'unknown' | 'maintenance' | ''
export interface MapSummary {