518 lines
27 KiB
Go
518 lines
27 KiB
Go
|
|
package handlers
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"errors"
|
|||
|
|
"log/slog"
|
|||
|
|
"net/http"
|
|||
|
|
"strings"
|
|||
|
|
"time"
|
|||
|
|
"unicode/utf8"
|
|||
|
|
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
"github.com/google/uuid"
|
|||
|
|
|
|||
|
|
"building-mgmt/internal/auth"
|
|||
|
|
"building-mgmt/internal/mailer"
|
|||
|
|
"building-mgmt/internal/models"
|
|||
|
|
"building-mgmt/internal/repository"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// UserHandler — админские эндпоинты: создание пользователей, выборка
|
|||
|
|
// по роли, включение/выключение аккаунтов, подтверждение регистрации,
|
|||
|
|
// удаление. Клиенту отсюда доступны только мастера: создание, список
|
|||
|
|
// своих (без чужих организаций), подтверждение созданного им мастера
|
|||
|
|
// и выключение мастера из своих проектов.
|
|||
|
|
type UserHandler struct {
|
|||
|
|
repo *repository.ProfileRepo
|
|||
|
|
rtRepo *repository.RefreshTokenRepo
|
|||
|
|
mailer mailer.Mailer
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewUserHandler собирает хендлер: refreshRepo нужен, чтобы деактивация
|
|||
|
|
// закрывала сессии пользователя немедленно, mailer — чтобы подтверждение
|
|||
|
|
// слало письмо «аккаунт подтверждён» (fail-open: без SMTP работает
|
|||
|
|
// как заглушка).
|
|||
|
|
func NewUserHandler(repo *repository.ProfileRepo, rtRepo *repository.RefreshTokenRepo, ml mailer.Mailer) *UserHandler {
|
|||
|
|
return &UserHandler{repo: repo, rtRepo: rtRepo, mailer: ml}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type createUserReq struct {
|
|||
|
|
Email string `json:"email" binding:"required,email"`
|
|||
|
|
Password string `json:"password" binding:"required"`
|
|||
|
|
Name string `json:"name" binding:"required"`
|
|||
|
|
Phone string `json:"phone"`
|
|||
|
|
Role string `json:"role" binding:"required,oneof=worker admin client"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Create — POST /users. Админ создаёт мастеров, админов и клиентов,
|
|||
|
|
// клиент — только мастера. Правило подтверждения — в SQL CreateBy:
|
|||
|
|
// созданное админом подтверждается сразу («создатель и есть
|
|||
|
|
// подтверждающий»), созданное клиентом ждёт его подтверждения
|
|||
|
|
// (POST /users/:id/confirm).
|
|||
|
|
//
|
|||
|
|
// @Summary Создать пользователя
|
|||
|
|
// @Description Админ создаёт мастеров, админов и клиентов; клиент — только мастеров (для себя, далее мастер добавляется в проект через POST /projects/{id}/workers). Роли admin и client клиентом недоступны. Пароль — по политике (10..72 символа, буквы и цифры). Подтверждение: аккаунт, созданный админом, подтверждён сразу; созданный клиентом — ждёт подтверждения этим же клиентом, после чего мастеру уходит письмо.
|
|||
|
|
// @Tags users
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Param body body createUserReq true "Данные пользователя"
|
|||
|
|
// @Success 201 {object} models.Profile
|
|||
|
|
// @Failure 400 {object} handlers.ErrorResponse "некорректное тело запроса или слабый пароль"
|
|||
|
|
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
|||
|
|
// @Failure 403 {object} handlers.ErrorResponse "клиент пытается создать админа или клиента"
|
|||
|
|
// @Failure 409 {object} handlers.ErrorResponse "email уже занят"
|
|||
|
|
// @Router /users [post]
|
|||
|
|
func (h *UserHandler) Create(c *gin.Context) {
|
|||
|
|
var req createUserReq
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
bindingError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Из клиента ни админа, ни второго клиента не завести —
|
|||
|
|
// это и есть граница полномочий: клиентов создаёт только админ.
|
|||
|
|
if c.GetString("role") == "client" && req.Role != "worker" {
|
|||
|
|
forbidden(c)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Политика пароля — после проверки полномочий: сначала «вам это
|
|||
|
|
// нельзя», потом «формат запроса».
|
|||
|
|
if err := auth.ValidatePassword(req.Password); err != nil {
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
hash, err := auth.HashPassword(req.Password)
|
|||
|
|
if err != nil {
|
|||
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "hash failed"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
profile := &models.Profile{
|
|||
|
|
ID: uuid.NewString(),
|
|||
|
|
Email: req.Email,
|
|||
|
|
PasswordHash: hash,
|
|||
|
|
Name: req.Name,
|
|||
|
|
Phone: req.Phone,
|
|||
|
|
Role: req.Role,
|
|||
|
|
IsActive: true,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := h.repo.CreateBy(c.Request.Context(), profile, c.GetString("user_id")); err != nil {
|
|||
|
|
if errors.Is(err, repository.ErrConflict) {
|
|||
|
|
c.JSON(http.StatusConflict, gin.H{"error": "email already exists"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusCreated, profile)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Confirm — POST /users/:id/confirm. Подтвердить аккаунт.
|
|||
|
|
// Право — целиком в SQL репозитория: админ подтверждает любой
|
|||
|
|
// неподтверждённый аккаунт, клиент — только созданного им мастера;
|
|||
|
|
// чужой и несуществующий неразличимы (404), повтор — 409.
|
|||
|
|
//
|
|||
|
|
// @Summary Подтвердить аккаунт
|
|||
|
|
// @Description Подтверждает неподтверждённый аккаунт и шлёт пользователю письмо «аккаунт подтверждён» со ссылкой на вход. Право — в SQL: админ подтверждает любой аккаунт, клиент — только созданного им мастера (иной и несуществующий не видны — 404). Повторное подтверждение — 409. Письмо отправляется после подтверждения; недоступный SMTP не откатывает подтверждение (письмо просто не уйдёт).
|
|||
|
|
// @Tags users
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Param id path string true "ID пользователя (uuid)"
|
|||
|
|
// @Success 200 {object} handlers.OKResponse
|
|||
|
|
// @Failure 400 {object} handlers.ErrorResponse "некорректный id"
|
|||
|
|
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
|||
|
|
// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin или client"
|
|||
|
|
// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден (для клиента — не созданный им мастер)"
|
|||
|
|
// @Failure 409 {object} handlers.ErrorResponse "аккаунт уже подтверждён"
|
|||
|
|
// @Router /users/{id}/confirm [post]
|
|||
|
|
func (h *UserHandler) Confirm(c *gin.Context) {
|
|||
|
|
id := c.Param("id")
|
|||
|
|
if _, err := uuid.Parse(id); err != nil {
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
if err := h.repo.ConfirmUser(ctx, id, c.GetString("user_id")); err != nil {
|
|||
|
|
switch {
|
|||
|
|
case errors.Is(err, repository.ErrAlreadyConfirmed):
|
|||
|
|
c.JSON(http.StatusConflict, gin.H{"error": "already confirmed"})
|
|||
|
|
case errors.Is(err, repository.ErrNotFound):
|
|||
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
|||
|
|
default:
|
|||
|
|
respondError(c, err)
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Письмо — после успешного подтверждения, fail-open: недоступный
|
|||
|
|
// SMTP (или несработавший re-read) не должен откатывать ответ —
|
|||
|
|
// подтверждение уже состоялось, в лог уходит warn.
|
|||
|
|
profile, err := h.repo.GetByID(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
slog.Warn("confirmation email skipped: profile reread failed", "err", err, "user_id", id)
|
|||
|
|
} else {
|
|||
|
|
// ConfirmUser уже выставил confirmed_at; защищаемся от nil
|
|||
|
|
// (теоретический рассинхрон репозитория) нулевым временем —
|
|||
|
|
// mailer тогда просто опустит строку с датой.
|
|||
|
|
var confirmedAt time.Time
|
|||
|
|
if profile.ConfirmedAt != nil {
|
|||
|
|
confirmedAt = *profile.ConfirmedAt
|
|||
|
|
}
|
|||
|
|
if err := h.mailer.SendAccountConfirmed(ctx, mailer.AccountConfirmed{
|
|||
|
|
To: profile.Email,
|
|||
|
|
Name: profile.Name,
|
|||
|
|
Role: profile.Role,
|
|||
|
|
ConfirmedAt: confirmedAt,
|
|||
|
|
}); err != nil {
|
|||
|
|
slog.Warn("confirmation email failed", "err", err, "user_id", id)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, OKResponse{OK: true})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// List — GET /users?role=worker. Админ — все пользователи по роли,
|
|||
|
|
// клиент — только мастера, состоящие в его проектах. Параметр q —
|
|||
|
|
// поиск по имени/email (так клиент находит существующего мастера,
|
|||
|
|
// чтобы пригласить его в проект). include_inactive=1 — админский
|
|||
|
|
// список показывает и отключённых (кнопка «Включить» после отключения
|
|||
|
|
// иначе оказалась бы тупиком); клиент этот флаг не получает.
|
|||
|
|
//
|
|||
|
|
// @Summary Список пользователей по роли
|
|||
|
|
// @Description Админ видит любую роль и всех; клиенту доступна только роль worker и только его собственные мастера (чужие организации не видны). С параметром q (минимум 2 символа) возвращается поиск по имени/email среди активных — так клиент пригласит в проект существующего мастера, не видя при этом полного списка. include_inactive=1 дополняет список отключёнными аккаунтами — только для админа.
|
|||
|
|
// @Tags users
|
|||
|
|
// @Produce json
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Param role query string false "Роль" Enums(worker, admin, client) Default(worker)
|
|||
|
|
// @Param q query string false "Поиск по имени или email, минимум 2 символа"
|
|||
|
|
// @Param include_inactive query string false "Показать и отключённых (только для админа)" Enums(0,1) Default(0)
|
|||
|
|
// @Param limit query int false "Строк на странице, 1..100" default(20)
|
|||
|
|
// @Param offset query int false "Сдвиг от начала выборки" default(0)
|
|||
|
|
// @Param sort query string false "Поле сортировки" Enums(name, created_at, email, id) Default(name)
|
|||
|
|
// @Param order query string false "Направление сортировки" Enums(asc, desc)
|
|||
|
|
// @Success 200 {object} handlers.ProfileListResponse
|
|||
|
|
// @Failure 400 {object} handlers.ErrorResponse "неизвестная роль, параметры страницы или короткий q"
|
|||
|
|
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
|||
|
|
// @Failure 403 {object} handlers.ErrorResponse "клиент запрашивает не мастеров"
|
|||
|
|
// @Router /users [get]
|
|||
|
|
func (h *UserHandler) List(c *gin.Context) {
|
|||
|
|
role := c.DefaultQuery("role", "worker")
|
|||
|
|
|
|||
|
|
if role != "worker" && role != "admin" && role != "client" {
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
page, ok := parsePage(c, repository.ProfileSorts)
|
|||
|
|
if !ok {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Поиск: короткая строка — опечатка в начале ввода, отвечаем сразу,
|
|||
|
|
// не ходя в базу за «всеми подряд». Считаем символы, не байты:
|
|||
|
|
// один кириллический «п» — уже два байта.
|
|||
|
|
q := strings.TrimSpace(c.Query("q"))
|
|||
|
|
if q != "" && utf8.RuneCountInString(q) < 2 {
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid q"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// include_inactive: у админа — обычный список любой роли; у клиента —
|
|||
|
|
// его собственные мастера (включая отключённых, чтобы «Включить»
|
|||
|
|
// после отключения не оказался тупиком). Клиентский поиск q
|
|||
|
|
// в ветке ниже всегда остаётся только по активным.
|
|||
|
|
inactiveQ := c.Query("include_inactive")
|
|||
|
|
includeInactive := inactiveQ == "1" || inactiveQ == "true"
|
|||
|
|
|
|||
|
|
respond := func(profiles []models.Profile, total int) {
|
|||
|
|
if profiles == nil {
|
|||
|
|
profiles = []models.Profile{}
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, ProfileListResponse{
|
|||
|
|
Items: profiles, Total: total, Limit: page.Limit, Offset: page.Offset,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Клиенту — только мастера: либо свои (без запроса), либо поиском (q).
|
|||
|
|
if c.GetString("role") == "client" {
|
|||
|
|
if role != "worker" {
|
|||
|
|
forbidden(c)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if q != "" {
|
|||
|
|
profiles, total, err := h.repo.SearchByRole(c.Request.Context(), "worker", q, false, page)
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
respond(profiles, total)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
profiles, total, err := h.repo.ListWorkersForClient(c.Request.Context(), c.GetString("user_id"), includeInactive, page)
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
respond(profiles, total)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Админ: q сужает обычный список по роли.
|
|||
|
|
if q != "" {
|
|||
|
|
profiles, total, err := h.repo.SearchByRole(c.Request.Context(), role, q, includeInactive, page)
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
respond(profiles, total)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
profiles, total, err := h.repo.ListByRole(c.Request.Context(), role, includeInactive, page)
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
respond(profiles, total)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type setUserReq struct {
|
|||
|
|
// *bool обязателен: на bool с binding:"required" запрос
|
|||
|
|
// «деактивировать» (is_active=false) не прошёл бы валидацию.
|
|||
|
|
IsActive *bool `json:"is_active" binding:"required"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Update — PATCH /users/:id. Включить или выключить аккаунт.
|
|||
|
|
// Админ — любой; клиент — только мастера, который активный участник
|
|||
|
|
// хотя бы одного его проекта (граница — в SQL репозитория, хендлер
|
|||
|
|
// не решает). Выключенный не может войти (Login проверяет is_active)
|
|||
|
|
// и теряет все открытые сессии немедленно, а не по истечении
|
|||
|
|
// access-токена. Для мастера флаг общий на все его проекты.
|
|||
|
|
//
|
|||
|
|
// @Summary Включить/выключить аккаунт
|
|||
|
|
// @Description Админ меняет любой аккаунт; клиент — только мастера, который активный участник одного из его проектов (иного клиента или чужого мастера не видит — 404). При деактивации все refresh-сессии пользователя закрываются сразу, мастер перестаёт работать во всех проектах. Выключить самого себя нельзя — вы остались бы без входа.
|
|||
|
|
// @Tags users
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Param id path string true "ID пользователя (uuid)"
|
|||
|
|
// @Param body body setUserReq true "Новый флаг"
|
|||
|
|
// @Success 200 {object} models.Profile
|
|||
|
|
// @Failure 400 {object} handlers.ErrorResponse "некорректный id или тело; попытка выключить себя"
|
|||
|
|
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
|||
|
|
// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin или client"
|
|||
|
|
// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден (для клиента — не его мастер)"
|
|||
|
|
// @Router /users/{id} [patch]
|
|||
|
|
func (h *UserHandler) Update(c *gin.Context) {
|
|||
|
|
id := c.Param("id")
|
|||
|
|
if _, err := uuid.Parse(id); err != nil {
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var req setUserReq
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
bindingError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if id == c.GetString("user_id") {
|
|||
|
|
// Иначе админ может выключить самого себя и остаться без входа.
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot change your own status"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Клиенту доступен только его мастер (проверяется в SQL: роль worker,
|
|||
|
|
// активное членство в проекте этого клиента) — иному выдаётся 404,
|
|||
|
|
// не 403: не раскрываем, существует ли вообще чужой аккаунт.
|
|||
|
|
var err error
|
|||
|
|
if c.GetString("role") == "client" {
|
|||
|
|
err = h.repo.SetActiveWorkerForClient(c.Request.Context(), id, c.GetString("user_id"), *req.IsActive)
|
|||
|
|
} else {
|
|||
|
|
err = h.repo.SetActive(c.Request.Context(), id, *req.IsActive)
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if !*req.IsActive {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
n, err := h.rtRepo.RevokeAllForUser(ctx, id)
|
|||
|
|
switch {
|
|||
|
|
case err != nil:
|
|||
|
|
slog.Warn("deactivation: failed to revoke sessions", "user_id", id, "err", err)
|
|||
|
|
case n > 0:
|
|||
|
|
slog.Info("deactivation: sessions revoked", "user_id", id, "count", n)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
profile, err := h.repo.GetByID(c.Request.Context(), id)
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, profile)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Delete — DELETE /users/:id. Админ — только «чистый» аккаунт: сессии и
|
|||
|
|
// токены сброса уходят каскадом, но если на пользователя ссылаются заявки,
|
|||
|
|
// проекты или участия — БД не даст удалить строку (409) — в этом случае
|
|||
|
|
// его отключают через PATCH. Клиент — только мастера, состоящего в одном
|
|||
|
|
// из его проектов: членства в проектах клиента удаляются вместе с
|
|||
|
|
// аккаунтом одной транзакцией, а заявки или чужие проекты дают тот же
|
|||
|
|
// 409 (откат — членства не трогаются). Со своей учётной записи
|
|||
|
|
// удалиться нельзя: админ остался бы без входа.
|
|||
|
|
//
|
|||
|
|
// @Summary Удалить пользователя
|
|||
|
|
// @Description Админ удаляет любой «чистый» аккаунт: refresh-сессии и токены сброса удаляются каскадом, но наличие заявок, проектов или участий блокирует удаление (409 user has related records) — в этом случае отключите аккаунт через PATCH /users/{id}, историю не стираем. Клиент удаляет только мастера, состоящего в одном из его проектов: членства клиента в проектах удаляются в той же транзакции, но если на мастера ссылаются заявки либо чужие проекты — тоже 409, ничего не меняется. Чужой мастер и несуществующий id неразличимы (404). Свой id удалить нельзя.
|
|||
|
|
// @Tags users
|
|||
|
|
// @Produce json
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Param id path string true "ID пользователя (uuid)"
|
|||
|
|
// @Success 200 {object} handlers.OKResponse
|
|||
|
|
// @Failure 400 {object} handlers.ErrorResponse "некорректный id; нельзя удалить себя"
|
|||
|
|
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
|||
|
|
// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin или client"
|
|||
|
|
// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден"
|
|||
|
|
// @Failure 409 {object} handlers.ErrorResponse "у пользователя есть связанные записи"
|
|||
|
|
// @Router /users/{id} [delete]
|
|||
|
|
func (h *UserHandler) Delete(c *gin.Context) {
|
|||
|
|
id := c.Param("id")
|
|||
|
|
if _, err := uuid.Parse(id); err != nil {
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if id == c.GetString("user_id") {
|
|||
|
|
// Иначе админ удалит сам себя и остался бы без входа.
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete yourself"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Граница клиента (мастер состоит в моём проекте), членства и
|
|||
|
|
// атомарность — в SQL-транзакции репозитория; хендлер лишь выбирает
|
|||
|
|
// ветку по роли.
|
|||
|
|
var err error
|
|||
|
|
if c.GetString("role") == "client" {
|
|||
|
|
err = h.repo.DeleteWorkerForClient(c.Request.Context(), id, c.GetString("user_id"))
|
|||
|
|
} else {
|
|||
|
|
err = h.repo.DeleteProfile(c.Request.Context(), id)
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
if errors.Is(err, repository.ErrUserHasData) {
|
|||
|
|
c.JSON(http.StatusConflict, gin.H{"error": "user has related records"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, OKResponse{OK: true})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// PasswordReset — POST /users/:id/password-reset. Выпустить токен смены.
|
|||
|
|
//
|
|||
|
|
// @Summary Выдать токен сброса пароля
|
|||
|
|
// @Description Токен возвращается в ответе — админ передаёт его пользователю сам, любым каналом. Для пользователя есть почта: POST /auth/forgot-password шлёт письмо со ссылкой. Токен одноразовый, живёт 24 часа (repository.ResetTTL); выпуск нового гасит прежние неиспользованные токены пользователя (включая свежие из писем). В БД хранится только sha256 токена.
|
|||
|
|
// @Tags users
|
|||
|
|
// @Produce json
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Param id path string true "ID пользователя (uuid)"
|
|||
|
|
// @Success 200 {object} handlers.PasswordResetResponse
|
|||
|
|
// @Failure 400 {object} handlers.ErrorResponse "некорректный id"
|
|||
|
|
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
|||
|
|
// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin"
|
|||
|
|
// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден"
|
|||
|
|
// @Router /users/{id}/password-reset [post]
|
|||
|
|
func (h *UserHandler) PasswordReset(c *gin.Context) {
|
|||
|
|
id := c.Param("id")
|
|||
|
|
if _, err := uuid.Parse(id); err != nil {
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Проверяем существование до выпуска: для чужого id — 404, а не
|
|||
|
|
// токен на несуществующего пользователя (его бы никто не смог применить).
|
|||
|
|
if _, err := h.repo.GetByID(c.Request.Context(), id); err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
token, expires, err := h.repo.CreateResetToken(c.Request.Context(), id)
|
|||
|
|
if err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, PasswordResetResponse{OK: true, Token: token, ExpiresAt: expires})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type setPasswordReq struct {
|
|||
|
|
Password string `json:"password" binding:"required"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SetPassword — POST /users/:id/password-set. Задать пароль напрямую.
|
|||
|
|
//
|
|||
|
|
// @Summary Задать пароль напрямую
|
|||
|
|
// @Description Админ ставит пароль пользователю сам, без ссылки. Политика та же, что при создании и при сбросе по токену (10..72 символа, буквы и цифры). Успех закрывает все refresh-сессии пользователя и гасит неиспользованные токены сброса — со старым паролем в системе остаться нельзя, старые ссылки перестают работать. Альтернатива — одноразовая ссылка через POST /users/{id}/password-reset.
|
|||
|
|
// @Tags users
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Param id path string true "ID пользователя (uuid)"
|
|||
|
|
// @Param body body setPasswordReq true "Новый пароль"
|
|||
|
|
// @Success 200 {object} handlers.OKResponse
|
|||
|
|
// @Failure 400 {object} handlers.ErrorResponse "некорректный id, тело или слабый пароль"
|
|||
|
|
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
|||
|
|
// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin"
|
|||
|
|
// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден"
|
|||
|
|
// @Router /users/{id}/password-set [post]
|
|||
|
|
func (h *UserHandler) SetPassword(c *gin.Context) {
|
|||
|
|
id := c.Param("id")
|
|||
|
|
if _, err := uuid.Parse(id); err != nil {
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var req setPasswordReq
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
bindingError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := auth.ValidatePassword(req.Password); err != nil {
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
hash, err := auth.HashPassword(req.Password)
|
|||
|
|
if err != nil {
|
|||
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "hash failed"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
// Смена и гашение токенов — одна операция репозитория; 404 на
|
|||
|
|
// несуществующий id отдаёт respondError.
|
|||
|
|
if _, err := h.repo.SetPassword(ctx, id, hash); err != nil {
|
|||
|
|
respondError(c, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Сессии закрываем, как при сбросе по токену: прежний владелец не
|
|||
|
|
// должен остаться в системе с refresh-сессией, а access-токен живёт
|
|||
|
|
// до часа. Ошибка разлогина — не повод отдавать 500: пароль-то уже
|
|||
|
|
// обновлён, деталь уходит в лог.
|
|||
|
|
if n, err := h.rtRepo.RevokeAllForUser(ctx, id); err != nil {
|
|||
|
|
slog.Warn("password set: failed to revoke sessions", "user_id", id, "err", err)
|
|||
|
|
} else {
|
|||
|
|
slog.Info("security: password set by admin", "user_id", id,
|
|||
|
|
"admin_id", c.GetString("user_id"), "sessions_revoked", n)
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, OKResponse{OK: true})
|
|||
|
|
}
|