Досі доступ давав машинний токен зі змінної збірки — одні права на всіх і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити людину. Сервер: - 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>
131 lines
3.9 KiB
Go
131 lines
3.9 KiB
Go
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)
|
||
}
|