feat(instance): глобальный бан пользователя и поиск в админ-панели
Сервер:
- миграция 00017: таблица instance_bans (причина, автор, дата);
- auth: ErrUserBanned, проверка бана в Login (код user.banned, 403) и в
ResolveSession — забаненный не получает сессию ни по cookie, ни по Bearer,
ни в Gateway, а прежняя сессия удаляется;
- store: BannedAt/BanReason в модели пользователя, BanInstanceUser,
UnbanInstanceUser, IsInstanceBanned, поиск и фильтр в ListUsers,
CountUsersFiltered; мягкое удаление аккаунта убирает и запись о бане;
- API: POST /instance/users/{id}/ban и /unban со step-up (AGENT.md 7.1),
отзыв сессий и SESSION_INVALIDATED, аудит instance.user_ban с причиной и
instance.user_unban; себя и инстанс-админа забанить нельзя;
- GET /instance/users: q (логин и отображаемое имя), banned=true, total.
Клиент:
- панель: поиск, фильтр «только забаненные», бейдж бана с причиной, кнопки
«Забанить» (с причиной) и «Разбанить» через общий шаг подтверждения
личности; i18n ru/en, включая текст ошибки user.banned;
- keepPreviousData в списке пользователей: без этого поле поиска
размонтировалось на первом же символе и набор обрывался.
Тесты: 4 Go-теста (бан блокирует вход и сессии, защита админов, поиск,
уборка бана при удалении), web-тест панели, живая проверка на стенде 19/19.
This commit is contained in:
@@ -32,6 +32,9 @@ var (
|
|||||||
ErrInstanceAdminTOTP = errors.New("auth.instance_admin_requires_2fa")
|
ErrInstanceAdminTOTP = errors.New("auth.instance_admin_requires_2fa")
|
||||||
ErrNoTOTPSecret = errors.New("auth.totp_not_configured")
|
ErrNoTOTPSecret = errors.New("auth.totp_not_configured")
|
||||||
ErrTOTPAlreadyEnabled = errors.New("auth.totp_already_enabled")
|
ErrTOTPAlreadyEnabled = errors.New("auth.totp_already_enabled")
|
||||||
|
// ErrUserBanned — глобальный бан инстанса (AGENT.md 7.18): вход запрещён,
|
||||||
|
// действующие сессии недействительны.
|
||||||
|
ErrUserBanned = errors.New("user.banned")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config — параметры сервиса аутентификации.
|
// Config — параметры сервиса аутентификации.
|
||||||
@@ -310,6 +313,15 @@ func (s *Service) Login(ctx context.Context, in LoginInput) (*store.User, string
|
|||||||
return nil, "", nil, err
|
return nil, "", nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Глобальный бан инстанса проверяется до пароля: причина отказа важнее
|
||||||
|
// (AGENT.md 7.18). Бан инстанс-админа невозможен, поэтому порядок безопасен.
|
||||||
|
if banned, err := s.store.IsInstanceBanned(ctx, user.ID); err != nil {
|
||||||
|
return nil, "", nil, err
|
||||||
|
} else if banned {
|
||||||
|
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_banned", in.IP, in.UserAgent, "")
|
||||||
|
return nil, "", nil, ErrUserBanned
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.hasher.Verify(in.Password, user.PasswordHash); err != nil {
|
if err := s.hasher.Verify(in.Password, user.PasswordHash); err != nil {
|
||||||
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_failed", in.IP, in.UserAgent, "")
|
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_failed", in.IP, in.UserAgent, "")
|
||||||
return nil, "", nil, ErrInvalidCredentials
|
return nil, "", nil, ErrInvalidCredentials
|
||||||
@@ -355,6 +367,12 @@ func (s *Service) ResolveSession(ctx context.Context, token string) (*store.User
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, ErrSessionExpired
|
return nil, nil, ErrSessionExpired
|
||||||
}
|
}
|
||||||
|
// Бан инстанса обрывает уже выданные сессии: сессию удаляем, чтобы клиент
|
||||||
|
// не возвращался к ней повторно (AGENT.md 7.18).
|
||||||
|
if user.BannedAt != nil {
|
||||||
|
_ = s.store.DeleteSession(ctx, session.ID)
|
||||||
|
return nil, nil, ErrUserBanned
|
||||||
|
}
|
||||||
_ = s.store.TouchSession(ctx, session.ID)
|
_ = s.store.TouchSession(ctx, session.ID)
|
||||||
return user, session, nil
|
return user, session, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Глобальные баны инстанса (AGENT.md 7.18, 7.19): администратор инстанса
|
||||||
|
-- блокирует аккаунт целиком, а не участие в отдельном сервере. Отдельная
|
||||||
|
-- таблица, а не колонка в users: причина и автор бана нужны для аудита, а
|
||||||
|
-- разбан должен сохранять историю (запись удаляется вместе с аккаунтом).
|
||||||
|
CREATE TABLE instance_bans (
|
||||||
|
user_id INTEGER PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
actor_id INTEGER REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE instance_bans;
|
||||||
+146
-15
@@ -59,14 +59,41 @@ type instanceUserPayload struct {
|
|||||||
IsInstanceAdmin bool `json:"is_instance_admin"`
|
IsInstanceAdmin bool `json:"is_instance_admin"`
|
||||||
Badges []string `json:"badges"`
|
Badges []string `json:"badges"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
// Глобальный бан инстанса (AGENT.md 7.18).
|
||||||
|
Banned bool `json:"banned"`
|
||||||
|
BannedAt string `json:"banned_at,omitempty"`
|
||||||
|
BanReason string `json:"ban_reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type instanceUserListOutput struct {
|
type instanceUserListOutput struct {
|
||||||
Body struct {
|
Body struct {
|
||||||
Users []instanceUserPayload `json:"users"`
|
Users []instanceUserPayload `json:"users"`
|
||||||
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// instanceUserPayloadFrom собирает ответ панели по модели пользователя.
|
||||||
|
func instanceUserPayloadFrom(user store.User) instanceUserPayload {
|
||||||
|
badges := user.Badges
|
||||||
|
if badges == nil {
|
||||||
|
badges = []string{}
|
||||||
|
}
|
||||||
|
payload := instanceUserPayload{
|
||||||
|
ID: formatSnowflake(user.ID),
|
||||||
|
Username: user.Username,
|
||||||
|
DisplayName: user.DisplayName,
|
||||||
|
IsInstanceAdmin: user.IsInstanceAdmin,
|
||||||
|
Badges: badges,
|
||||||
|
CreatedAt: user.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
Banned: user.BannedAt != nil,
|
||||||
|
BanReason: user.BanReason,
|
||||||
|
}
|
||||||
|
if user.BannedAt != nil {
|
||||||
|
payload.BannedAt = user.BannedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
type instanceSettingsPayload struct {
|
type instanceSettingsPayload struct {
|
||||||
RegistrationEnabled bool `json:"registration_enabled"`
|
RegistrationEnabled bool `json:"registration_enabled"`
|
||||||
AllowGuildCreation bool `json:"allow_guild_creation"`
|
AllowGuildCreation bool `json:"allow_guild_creation"`
|
||||||
@@ -345,33 +372,30 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
Tags: []string{"Instance"},
|
Tags: []string{"Instance"},
|
||||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||||
}, func(ctx context.Context, input *struct {
|
}, func(ctx context.Context, input *struct {
|
||||||
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
|
Query string `query:"q" maxLength:"64"`
|
||||||
Offset int `query:"offset" default:"0" minimum:"0"`
|
// Banned — показать только забаненных (AGENT.md 7.18).
|
||||||
|
Banned bool `query:"banned"`
|
||||||
|
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
|
||||||
|
Offset int `query:"offset" default:"0" minimum:"0"`
|
||||||
},
|
},
|
||||||
) (*instanceUserListOutput, error) {
|
) (*instanceUserListOutput, error) {
|
||||||
if _, err := requireInstanceAdmin(ctx); err != nil {
|
if _, err := requireInstanceAdmin(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
users, err := s.store.ListUsers(ctx, input.Limit, input.Offset)
|
users, err := s.store.ListUsers(ctx, input.Query, input.Banned, input.Limit, input.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
total, err := s.store.CountUsersFiltered(ctx, input.Query, input.Banned)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
output := &instanceUserListOutput{}
|
output := &instanceUserListOutput{}
|
||||||
output.Body.Users = make([]instanceUserPayload, 0, len(users))
|
output.Body.Users = make([]instanceUserPayload, 0, len(users))
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
badges := user.Badges
|
output.Body.Users = append(output.Body.Users, instanceUserPayloadFrom(user))
|
||||||
if badges == nil {
|
|
||||||
badges = []string{}
|
|
||||||
}
|
|
||||||
output.Body.Users = append(output.Body.Users, instanceUserPayload{
|
|
||||||
ID: formatSnowflake(user.ID),
|
|
||||||
Username: user.Username,
|
|
||||||
DisplayName: user.DisplayName,
|
|
||||||
IsInstanceAdmin: user.IsInstanceAdmin,
|
|
||||||
Badges: badges,
|
|
||||||
CreatedAt: user.CreatedAt.UTC().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
output.Body.Total = total
|
||||||
return output, nil
|
return output, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -501,6 +525,113 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
return newOKOutput(), nil
|
return newOKOutput(), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "adminBanUser",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/instance/users/{user_id}/ban",
|
||||||
|
Summary: "Забанить пользователя на инстансе",
|
||||||
|
Tags: []string{"Instance"},
|
||||||
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
UserID string `path:"user_id"`
|
||||||
|
Body struct {
|
||||||
|
Reason string `json:"reason,omitempty" maxLength:"400"`
|
||||||
|
// Бан — действие инстанс-админа: нужна свежая проверка (AGENT.md 7.1).
|
||||||
|
StepUpPassword string `json:"step_up_password,omitempty"`
|
||||||
|
StepUpTOTP string `json:"step_up_totp,omitempty"`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) (*userOutput, error) {
|
||||||
|
admin, session, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !admin.IsInstanceAdmin {
|
||||||
|
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
|
||||||
|
}
|
||||||
|
targetID, err := parseID("user_id", input.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if targetID == admin.ID {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot ban yourself")
|
||||||
|
}
|
||||||
|
target, err := s.store.GetUser(ctx, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
// Администратора инстанса забанить нельзя (AGENT.md 7.19).
|
||||||
|
if target.IsInstanceAdmin {
|
||||||
|
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_protected", "instance administrator cannot be banned")
|
||||||
|
}
|
||||||
|
if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
reason := strings.TrimSpace(input.Body.Reason)
|
||||||
|
if err := s.store.BanInstanceUser(ctx, target.ID, admin.ID, reason); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if _, err := s.store.RevokeUserSessions(ctx, target.ID); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if s.gateway != nil {
|
||||||
|
s.gateway.SendToUser(target.ID, "SESSION_INVALIDATED", map[string]any{"reason": "user_banned"})
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, admin, 0, "instance.user_ban", "user", &target.ID, reason)
|
||||||
|
updated, err := s.store.GetUser(ctx, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
output := &userOutput{}
|
||||||
|
output.Body.User = s.profileFromUser(ctx, updated, false)
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "adminUnbanUser",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/instance/users/{user_id}/unban",
|
||||||
|
Summary: "Снять бан инстанса",
|
||||||
|
Tags: []string{"Instance"},
|
||||||
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
UserID string `path:"user_id"`
|
||||||
|
Body struct {
|
||||||
|
StepUpPassword string `json:"step_up_password,omitempty"`
|
||||||
|
StepUpTOTP string `json:"step_up_totp,omitempty"`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) (*userOutput, error) {
|
||||||
|
admin, session, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !admin.IsInstanceAdmin {
|
||||||
|
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
|
||||||
|
}
|
||||||
|
targetID, err := parseID("user_id", input.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := s.store.GetUser(ctx, targetID); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if _, err := s.store.UnbanInstanceUser(ctx, targetID); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, admin, 0, "instance.user_unban", "user", &targetID, "")
|
||||||
|
updated, err := s.store.GetUser(ctx, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
output := &userOutput{}
|
||||||
|
output.Body.User = s.profileFromUser(ctx, updated, false)
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
huma.Register(api, huma.Operation{
|
huma.Register(api, huma.Operation{
|
||||||
OperationID: "adminDeleteUser",
|
OperationID: "adminDeleteUser",
|
||||||
Method: http.MethodDelete,
|
Method: http.MethodDelete,
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ func newAPIError(err error) apiError {
|
|||||||
case errors.Is(err, auth.ErrInstanceAdminTOTP):
|
case errors.Is(err, auth.ErrInstanceAdminTOTP):
|
||||||
candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_enrollment_required"
|
candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_enrollment_required"
|
||||||
candidate.Message = "instance administrator must enable two-factor authentication: run `glchat totp-setup --email <admin>` on the server"
|
candidate.Message = "instance administrator must enable two-factor authentication: run `glchat totp-setup --email <admin>` on the server"
|
||||||
|
case errors.Is(err, auth.ErrUserBanned):
|
||||||
|
candidate.Status, candidate.Code = http.StatusForbidden, "user.banned"
|
||||||
|
candidate.Message = "account is banned on this instance"
|
||||||
case errors.Is(err, auth.ErrSessionExpired):
|
case errors.Is(err, auth.ErrSessionExpired):
|
||||||
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.session_expired", "session expired"
|
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.session_expired", "session expired"
|
||||||
case errors.Is(err, auth.ErrStepUpRequired):
|
case errors.Is(err, auth.ErrStepUpRequired):
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"glchat/internal/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// instanceUsers вызывается администратором: список пользователей панели.
|
||||||
|
func instanceUsers(t *testing.T, srv *Server, cookie *http.Cookie, query string) struct {
|
||||||
|
Users []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Banned bool `json:"banned"`
|
||||||
|
Reason string `json:"ban_reason"`
|
||||||
|
} `json:"users"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
} {
|
||||||
|
t.Helper()
|
||||||
|
path := "/api/v1/instance/users"
|
||||||
|
if query != "" {
|
||||||
|
path += "?" + query
|
||||||
|
}
|
||||||
|
rec := doJSON(t, srv, http.MethodGet, path, "", cookie)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s = %d, body = %s", path, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
return decodeResponse[struct {
|
||||||
|
Users []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Banned bool `json:"banned"`
|
||||||
|
Reason string `json:"ban_reason"`
|
||||||
|
} `json:"users"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}](t, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstanceBanBlocksLoginAndSessions(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
adminCookie := registerAndLogin(t, srv, "ban_admin", "ban-admin@example.com")
|
||||||
|
promoteAdmin(t, srv, "ban-admin@example.com")
|
||||||
|
userCookie := registerAndLogin(t, srv, "ban_target", "ban-target@example.com")
|
||||||
|
|
||||||
|
target, err := srv.auth.UserByEmail(t.Context(), "ban-target@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
targetPath := "/api/v1/instance/users/" + formatSnowflake(target.ID)
|
||||||
|
|
||||||
|
// Бан без step-up отклоняется: действие чувствительное (AGENT.md 7.1).
|
||||||
|
noStepUp := doJSON(t, srv, http.MethodPost, targetPath+"/ban", `{"reason":"спам"}`, adminCookie)
|
||||||
|
if noStepUp.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("ban without step-up = %d, want 403", noStepUp.Code)
|
||||||
|
}
|
||||||
|
if code := errorCodeOf(t, noStepUp); code != "auth.step_up_required" {
|
||||||
|
t.Fatalf("ban without step-up code = %q", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
banned := doJSON(t, srv, http.MethodPost, targetPath+"/ban",
|
||||||
|
`{"reason":"спам в общем канале","step_up_password":"correct-horse-battery"}`, adminCookie)
|
||||||
|
if banned.Code != http.StatusOK {
|
||||||
|
t.Fatalf("ban = %d, body = %s", banned.Code, banned.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список панели показывает бан и причину, фильтр «только забаненные» работает.
|
||||||
|
list := instanceUsers(t, srv, adminCookie, "banned=true")
|
||||||
|
if len(list.Users) != 1 || list.Users[0].Username != "ban_target" || !list.Users[0].Banned {
|
||||||
|
t.Fatalf("banned filter = %+v", list.Users)
|
||||||
|
}
|
||||||
|
if list.Users[0].Reason != "спам в общем канале" {
|
||||||
|
t.Fatalf("ban reason = %q", list.Users[0].Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вход запрещён с отдельным кодом, а не «неверный пароль».
|
||||||
|
login := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
|
||||||
|
`{"email":"ban-target@example.com","password":"correct-horse-battery"}`)
|
||||||
|
if login.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("login as banned = %d, want 403, body = %s", login.Code, login.Body.String())
|
||||||
|
}
|
||||||
|
if code := errorCodeOf(t, login); code != "user.banned" {
|
||||||
|
t.Fatalf("login as banned code = %q", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выданная ранее сессия перестаёт работать и отозвана в базе.
|
||||||
|
me := doJSON(t, srv, http.MethodGet, "/api/v1/auth/sessions", "", userCookie)
|
||||||
|
if me.Code != http.StatusUnauthorized && me.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("banned session still works: %d, body = %s", me.Code, me.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := srv.store.GetSessionByTokenHash(t.Context(), crypto.HashToken(userCookie.Value)); err == nil {
|
||||||
|
t.Fatal("session of banned user must be revoked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разбан возвращает доступ: сессии не восстанавливаются, но вход снова работает.
|
||||||
|
unban := doJSON(t, srv, http.MethodPost, targetPath+"/unban",
|
||||||
|
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
|
||||||
|
if unban.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unban = %d, body = %s", unban.Code, unban.Body.String())
|
||||||
|
}
|
||||||
|
loginAgain := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
|
||||||
|
`{"email":"ban-target@example.com","password":"correct-horse-battery"}`)
|
||||||
|
if loginAgain.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login after unban = %d, body = %s", loginAgain.Code, loginAgain.Body.String())
|
||||||
|
}
|
||||||
|
if list := instanceUsers(t, srv, adminCookie, ""); len(list.Users) != 2 || list.Total != 2 {
|
||||||
|
t.Fatalf("users after unban = %+v (total %d)", list.Users, list.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Действия попали в аудит инстанса.
|
||||||
|
audit := doJSON(t, srv, http.MethodGet, "/api/v1/instance/audit", "", adminCookie)
|
||||||
|
entries := decodeResponse[struct {
|
||||||
|
Entries []struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
ActorInstanceAdmin bool `json:"actor_instance_admin"`
|
||||||
|
} `json:"entries"`
|
||||||
|
}](t, audit)
|
||||||
|
if !hasAction(entries.Entries, "instance.user_ban") || !hasAction(entries.Entries, "instance.user_unban") {
|
||||||
|
t.Fatalf("audit has no ban entries: %+v", entries.Entries)
|
||||||
|
}
|
||||||
|
for _, entry := range entries.Entries {
|
||||||
|
if entry.Action == "instance.user_ban" && entry.Reason != "спам в общем канале" {
|
||||||
|
t.Fatalf("audit ban reason = %q", entry.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstanceBanProtectsAdmins(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
adminCookie := registerAndLogin(t, srv, "protect_admin", "protect-admin@example.com")
|
||||||
|
promoteAdmin(t, srv, "protect-admin@example.com")
|
||||||
|
registerAndLogin(t, srv, "protect_admin2", "protect-admin2@example.com")
|
||||||
|
promoteAdmin(t, srv, "protect-admin2@example.com")
|
||||||
|
|
||||||
|
admin, err := srv.auth.UserByEmail(t.Context(), "protect-admin@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
other, err := srv.auth.UserByEmail(t.Context(), "protect-admin2@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инстанс-админа забанить нельзя (AGENT.md 7.19) — даже другому админу.
|
||||||
|
rec := doJSON(t, srv, http.MethodPost,
|
||||||
|
"/api/v1/instance/users/"+formatSnowflake(other.ID)+"/ban",
|
||||||
|
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("ban another admin = %d, want 403, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if code := errorCodeOf(t, rec); code != "instance.admin_protected" {
|
||||||
|
t.Fatalf("ban another admin code = %q", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Себя банить тоже нельзя.
|
||||||
|
self := doJSON(t, srv, http.MethodPost,
|
||||||
|
"/api/v1/instance/users/"+formatSnowflake(admin.ID)+"/ban",
|
||||||
|
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
|
||||||
|
if self.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("self ban = %d, want 422", self.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычный пользователь не может банить вообще.
|
||||||
|
userCookie := registerAndLogin(t, srv, "protect_user", "protect-user@example.com")
|
||||||
|
denied := doJSON(t, srv, http.MethodPost,
|
||||||
|
"/api/v1/instance/users/"+formatSnowflake(other.ID)+"/ban",
|
||||||
|
`{"step_up_password":"correct-horse-battery"}`, userCookie)
|
||||||
|
if denied.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("ban as user = %d, want 403", denied.Code)
|
||||||
|
}
|
||||||
|
if code := errorCodeOf(t, denied); code != "instance.admin_required" {
|
||||||
|
t.Fatalf("ban as user code = %q", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstanceUserSearch(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
adminCookie := registerAndLogin(t, srv, "search_admin", "search-admin@example.com")
|
||||||
|
promoteAdmin(t, srv, "search-admin@example.com")
|
||||||
|
registerAndLogin(t, srv, "search_alpha", "search-alpha@example.com")
|
||||||
|
registerAndLogin(t, srv, "search_beta", "search-beta@example.com")
|
||||||
|
|
||||||
|
found := instanceUsers(t, srv, adminCookie, "q=alpha")
|
||||||
|
if len(found.Users) != 1 || found.Users[0].Username != "search_alpha" {
|
||||||
|
t.Fatalf("search by username = %+v", found.Users)
|
||||||
|
}
|
||||||
|
if found.Total != 1 {
|
||||||
|
t.Fatalf("search total = %d, want 1", found.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск идёт и по отображаемому имени (оно равно логину при регистрации).
|
||||||
|
byDisplay := instanceUsers(t, srv, adminCookie, "q=BETA")
|
||||||
|
if len(byDisplay.Users) != 1 || byDisplay.Users[0].Username != "search_beta" {
|
||||||
|
t.Fatalf("search is case-insensitive by display name = %+v", byDisplay.Users)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := instanceUsers(t, srv, adminCookie, "q=nobody-here")
|
||||||
|
if len(empty.Users) != 0 || empty.Total != 0 {
|
||||||
|
t.Fatalf("search with no matches = %+v (total %d)", empty.Users, empty.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstanceDeleteClearsBan(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
adminCookie := registerAndLogin(t, srv, "clear_admin", "clear-admin@example.com")
|
||||||
|
promoteAdmin(t, srv, "clear-admin@example.com")
|
||||||
|
registerAndLogin(t, srv, "clear_target", "clear-target@example.com")
|
||||||
|
|
||||||
|
target, err := srv.auth.UserByEmail(t.Context(), "clear-target@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
targetPath := "/api/v1/instance/users/" + formatSnowflake(target.ID)
|
||||||
|
if rec := doJSON(t, srv, http.MethodPost, targetPath+"/ban",
|
||||||
|
`{"reason":"мусор","step_up_password":"correct-horse-battery"}`, adminCookie); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("ban = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if banned, err := srv.store.IsInstanceBanned(t.Context(), target.ID); err != nil || !banned {
|
||||||
|
t.Fatalf("IsInstanceBanned = %v, %v; want true", banned, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Мягкое удаление убирает и запись о бане: аккаунта больше нет.
|
||||||
|
if rec := doJSON(t, srv, http.MethodDelete, targetPath, "", adminCookie); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("delete = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if banned, err := srv.store.IsInstanceBanned(t.Context(), target.ID); err != nil || banned {
|
||||||
|
t.Fatalf("IsInstanceBanned after delete = %v, %v; want false", banned, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,5 +94,10 @@ func (s *Store) SoftDeleteUser(ctx context.Context, userID uint64) error {
|
|||||||
int64(userID), int64(userID)); err != nil {
|
int64(userID), int64(userID)); err != nil {
|
||||||
return mapError(err)
|
return mapError(err)
|
||||||
}
|
}
|
||||||
|
// Глобальный бан аккаунта снимается вместе с аккаунтом: запись о бане
|
||||||
|
// без пользователя только мешает разбору (`banned=true` в панели).
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM instance_bans WHERE user_id = ?`, int64(userID)); err != nil {
|
||||||
|
return mapError(err)
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,9 +278,9 @@ func (s *Store) SearchUsersByUsername(ctx context.Context, query string, exclude
|
|||||||
// ESCAPE обязателен: логины содержат «_», который в LIKE — подстановочный
|
// ESCAPE обязателен: логины содержат «_», который в LIKE — подстановочный
|
||||||
// символ, поэтому экранируем его и сообщаем об этом SQLite.
|
// символ, поэтому экранируем его и сообщаем об этом SQLite.
|
||||||
rows, err := s.reader.QueryContext(ctx, `
|
rows, err := s.reader.QueryContext(ctx, `
|
||||||
SELECT `+userColumns+` FROM users
|
SELECT `+userColumns+` FROM `+userFrom+`
|
||||||
WHERE deleted_at IS NULL AND id <> ? AND username_lower LIKE ? ESCAPE '\'
|
WHERE u.deleted_at IS NULL AND u.id <> ? AND u.username_lower LIKE ? ESCAPE '\'
|
||||||
ORDER BY username_lower LIMIT ?`,
|
ORDER BY u.username_lower LIMIT ?`,
|
||||||
int64(excludeID), escapeLike(strings.ToLower(query))+"%", limit)
|
int64(excludeID), escapeLike(strings.ToLower(query))+"%", limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
+100
-12
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,11 @@ type User struct {
|
|||||||
LastSeenAt *time.Time
|
LastSeenAt *time.Time
|
||||||
// Timezone — часовой пояс пользователя (IANA, по умолчанию Europe/Moscow).
|
// Timezone — часовой пояс пользователя (IANA, по умолчанию Europe/Moscow).
|
||||||
Timezone string
|
Timezone string
|
||||||
|
// BannedAt — глобальный бан инстанса (AGENT.md 7.18): вход запрещён,
|
||||||
|
// сессии отозваны. Причина хранится рядом, в BanReason.
|
||||||
|
BannedAt *time.Time
|
||||||
|
// BanReason — причина глобального бана (пустая, если бана нет).
|
||||||
|
BanReason string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUserParams — данные новой учётной записи: шифрование и blind index
|
// CreateUserParams — данные новой учётной записи: шифрование и blind index
|
||||||
@@ -49,10 +55,14 @@ type CreateUserParams struct {
|
|||||||
Locale string
|
Locale string
|
||||||
}
|
}
|
||||||
|
|
||||||
const userColumns = `id, username, display_name, email_enc, password_hash, avatar_file_id,
|
const userColumns = `u.id, u.username, u.display_name, u.email_enc, u.password_hash, u.avatar_file_id,
|
||||||
banner_file_id, bio, status, custom_status, custom_status_emoji, flags,
|
u.banner_file_id, u.bio, u.status, u.custom_status, u.custom_status_emoji, u.flags,
|
||||||
is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at,
|
u.is_instance_admin, u.badges_json, u.locale, u.created_at, u.updated_at, u.deleted_at,
|
||||||
onboarding_completed_at, last_seen_at, timezone`
|
u.onboarding_completed_at, u.last_seen_at, u.timezone, b.created_at, COALESCE(b.reason, '')`
|
||||||
|
|
||||||
|
// userFrom — источник строк для userColumns: бан инстанса подмешивается
|
||||||
|
// левым соединением, чтобы модель всегда знала о блокировке.
|
||||||
|
const userFrom = `users u LEFT JOIN instance_bans b ON b.user_id = u.id`
|
||||||
|
|
||||||
func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User, error) {
|
func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User, error) {
|
||||||
if params.ID == 0 {
|
if params.ID == 0 {
|
||||||
@@ -83,13 +93,13 @@ func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetUser(ctx context.Context, id uint64) (*User, error) {
|
func (s *Store) GetUser(ctx context.Context, id uint64) (*User, error) {
|
||||||
row := s.reader.QueryRowContext(ctx, `SELECT `+userColumns+` FROM users WHERE id = ? AND deleted_at IS NULL`, int64(id))
|
row := s.reader.QueryRowContext(ctx, `SELECT `+userColumns+` FROM `+userFrom+` WHERE u.id = ? AND u.deleted_at IS NULL`, int64(id))
|
||||||
return scanUser(row)
|
return scanUser(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||||
row := s.reader.QueryRowContext(ctx,
|
row := s.reader.QueryRowContext(ctx,
|
||||||
`SELECT `+userColumns+` FROM users WHERE username_lower = ? AND deleted_at IS NULL`,
|
`SELECT `+userColumns+` FROM `+userFrom+` WHERE u.username_lower = ? AND u.deleted_at IS NULL`,
|
||||||
strings.ToLower(username))
|
strings.ToLower(username))
|
||||||
return scanUser(row)
|
return scanUser(row)
|
||||||
}
|
}
|
||||||
@@ -98,7 +108,7 @@ func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User,
|
|||||||
// не участвует, поэтому поиск не требует расшифровки (AGENT.md 9.2).
|
// не участвует, поэтому поиск не требует расшифровки (AGENT.md 9.2).
|
||||||
func (s *Store) GetUserByEmailIndex(ctx context.Context, emailIndex string) (*User, error) {
|
func (s *Store) GetUserByEmailIndex(ctx context.Context, emailIndex string) (*User, error) {
|
||||||
row := s.reader.QueryRowContext(ctx,
|
row := s.reader.QueryRowContext(ctx,
|
||||||
`SELECT `+userColumns+` FROM users WHERE email_index = ? AND deleted_at IS NULL`, emailIndex)
|
`SELECT `+userColumns+` FROM `+userFrom+` WHERE u.email_index = ? AND u.deleted_at IS NULL`, emailIndex)
|
||||||
return scanUser(row)
|
return scanUser(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,17 +257,36 @@ func (s *Store) ClearAvatar(ctx context.Context, id uint64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUsers отдаёт страницу пользователей для админ-панели инстанса (AGENT.md 6.5).
|
// userFilter собирает WHERE для списка пользователей: поиск по логину и
|
||||||
func (s *Store) ListUsers(ctx context.Context, limit, offset int) ([]User, error) {
|
// отображаемому имени плюс фильтр «только забаненные» (AGENT.md 7.18).
|
||||||
|
func userFilter(query string, bannedOnly bool) (string, []any) {
|
||||||
|
where := []string{"u.deleted_at IS NULL"}
|
||||||
|
args := []any{}
|
||||||
|
if trimmed := strings.TrimSpace(query); trimmed != "" {
|
||||||
|
pattern := "%" + strings.ToLower(trimmed) + "%"
|
||||||
|
where = append(where, "(u.username_lower LIKE ? OR lower(u.display_name) LIKE ?)")
|
||||||
|
args = append(args, pattern, pattern)
|
||||||
|
}
|
||||||
|
if bannedOnly {
|
||||||
|
where = append(where, "b.user_id IS NOT NULL")
|
||||||
|
}
|
||||||
|
return strings.Join(where, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers отдаёт страницу пользователей для админ-панели инстанса
|
||||||
|
// (AGENT.md 6.5): поиск по логину и имени, опционально только забаненные.
|
||||||
|
func (s *Store) ListUsers(ctx context.Context, query string, bannedOnly bool, limit, offset int) ([]User, error) {
|
||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
if offset < 0 {
|
if offset < 0 {
|
||||||
offset = 0
|
offset = 0
|
||||||
}
|
}
|
||||||
|
where, args := userFilter(query, bannedOnly)
|
||||||
|
args = append(args, limit, offset)
|
||||||
rows, err := s.reader.QueryContext(ctx,
|
rows, err := s.reader.QueryContext(ctx,
|
||||||
`SELECT `+userColumns+` FROM users WHERE deleted_at IS NULL ORDER BY id LIMIT ? OFFSET ?`,
|
`SELECT `+userColumns+` FROM `+userFrom+` WHERE `+where+` ORDER BY u.id LIMIT ? OFFSET ?`,
|
||||||
limit, offset)
|
args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -274,6 +303,58 @@ func (s *Store) ListUsers(ctx context.Context, limit, offset int) ([]User, error
|
|||||||
return users, rows.Err()
|
return users, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountUsersFiltered — сколько пользователей попадает в тот же фильтр: нужно
|
||||||
|
// панели для пагинации.
|
||||||
|
func (s *Store) CountUsersFiltered(ctx context.Context, query string, bannedOnly bool) (int, error) {
|
||||||
|
where, args := userFilter(query, bannedOnly)
|
||||||
|
var count int
|
||||||
|
err := s.reader.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM `+userFrom+` WHERE `+where, args...).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BanInstanceUser блокирует аккаунт целиком (AGENT.md 7.18). Повторный бан
|
||||||
|
// обновляет причину и автора.
|
||||||
|
func (s *Store) BanInstanceUser(ctx context.Context, userID, actorID uint64, reason string) error {
|
||||||
|
_, err := s.writer.ExecContext(ctx, `
|
||||||
|
INSERT INTO instance_bans (user_id, actor_id, reason, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET actor_id = excluded.actor_id,
|
||||||
|
reason = excluded.reason, created_at = excluded.created_at`,
|
||||||
|
int64(userID), int64(actorID), reason, s.Now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnbanInstanceUser снимает глобальный бан. Отсутствие бана не ошибка.
|
||||||
|
func (s *Store) UnbanInstanceUser(ctx context.Context, userID uint64) (bool, error) {
|
||||||
|
result, err := s.writer.ExecContext(ctx, `DELETE FROM instance_bans WHERE user_id = ?`, int64(userID))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return affected > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInstanceBanned проверяет глобальный бан по идентификатору пользователя.
|
||||||
|
func (s *Store) IsInstanceBanned(ctx context.Context, userID uint64) (bool, error) {
|
||||||
|
var exists int
|
||||||
|
err := s.reader.QueryRowContext(ctx,
|
||||||
|
`SELECT 1 FROM instance_bans WHERE user_id = ?`, int64(userID)).Scan(&exists)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
||||||
var (
|
var (
|
||||||
user User
|
user User
|
||||||
@@ -287,12 +368,14 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
|||||||
onboardingAt sql.NullString
|
onboardingAt sql.NullString
|
||||||
lastSeenAt sql.NullString
|
lastSeenAt sql.NullString
|
||||||
emailEncrypted string
|
emailEncrypted string
|
||||||
|
bannedAt sql.NullString
|
||||||
|
banReason string
|
||||||
)
|
)
|
||||||
err := scanner.Scan(
|
err := scanner.Scan(
|
||||||
&user.ID, &user.Username, &user.DisplayName, &emailEncrypted, &user.PasswordHash,
|
&user.ID, &user.Username, &user.DisplayName, &emailEncrypted, &user.PasswordHash,
|
||||||
&avatarID, &bannerID, &user.Bio, &user.Status, &user.CustomStatus, &user.CustomStatusEmoji,
|
&avatarID, &bannerID, &user.Bio, &user.Status, &user.CustomStatus, &user.CustomStatusEmoji,
|
||||||
&user.Flags, &isAdmin, &badges, &user.Locale, &createdAt, &updatedAt, &deletedAt,
|
&user.Flags, &isAdmin, &badges, &user.Locale, &createdAt, &updatedAt, &deletedAt,
|
||||||
&onboardingAt, &lastSeenAt, &user.Timezone,
|
&onboardingAt, &lastSeenAt, &user.Timezone, &bannedAt, &banReason,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapError(err)
|
return nil, mapError(err)
|
||||||
@@ -326,6 +409,11 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
|||||||
if user.Timezone == "" {
|
if user.Timezone == "" {
|
||||||
user.Timezone = "Europe/Moscow"
|
user.Timezone = "Europe/Moscow"
|
||||||
}
|
}
|
||||||
|
if bannedAt.Valid {
|
||||||
|
value := parseTimestamp(bannedAt.String)
|
||||||
|
user.BannedAt = &value
|
||||||
|
user.BanReason = banReason
|
||||||
|
}
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+51
-4
@@ -57,12 +57,59 @@ export async function fetchInstanceGuilds(signal?: AbortSignal): Promise<Instanc
|
|||||||
return payload.guilds;
|
return payload.guilds;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchInstanceUsers(signal?: AbortSignal): Promise<InstanceUser[]> {
|
export interface InstanceUserFilter {
|
||||||
const payload = await request<{ users: InstanceUser[] }>(
|
/** Поиск по логину и отображаемому имени. */
|
||||||
'/instance/users',
|
query?: string;
|
||||||
|
/** Показать только забаненных. */
|
||||||
|
bannedOnly?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ключ списка пользователей: фильтр входит в ключ, иначе кэш смешает выборки. */
|
||||||
|
export function instanceUserListQueryKey(filter: InstanceUserFilter = {}) {
|
||||||
|
return [...instanceUsersQueryKey, filter.query ?? '', filter.bannedOnly === true] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInstanceUsers(
|
||||||
|
filter: InstanceUserFilter = {},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ users: InstanceUser[]; total: number }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filter.query !== undefined && filter.query !== '') {
|
||||||
|
params.set('q', filter.query);
|
||||||
|
}
|
||||||
|
if (filter.bannedOnly === true) {
|
||||||
|
params.set('banned', 'true');
|
||||||
|
}
|
||||||
|
if (filter.limit !== undefined) {
|
||||||
|
params.set('limit', String(filter.limit));
|
||||||
|
}
|
||||||
|
if (filter.offset !== undefined) {
|
||||||
|
params.set('offset', String(filter.offset));
|
||||||
|
}
|
||||||
|
const suffix = params.size === 0 ? '' : `?${params.toString()}`;
|
||||||
|
const payload = await request<{ users: InstanceUser[]; total: number }>(
|
||||||
|
`/instance/users${suffix}`,
|
||||||
signal === undefined ? {} : { signal },
|
signal === undefined ? {} : { signal },
|
||||||
);
|
);
|
||||||
return payload.users;
|
return { users: payload.users, total: payload.total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /instance/users/{id}/ban` — глобальный бан инстанса (AGENT.md 7.18):
|
||||||
|
* аккаунт не может войти, все сессии отзываются.
|
||||||
|
*/
|
||||||
|
export async function banUser(userId: string, reason: string): Promise<void> {
|
||||||
|
await request(`/instance/users/${encodeURIComponent(userId)}/ban`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { reason },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /instance/users/{id}/unban` — снять глобальный бан. */
|
||||||
|
export async function unbanUser(userId: string): Promise<void> {
|
||||||
|
await request(`/instance/users/${encodeURIComponent(userId)}/unban`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAudit(limit = 50, signal?: AbortSignal): Promise<AuditEntry[]> {
|
export async function fetchAudit(limit = 50, signal?: AbortSignal): Promise<AuditEntry[]> {
|
||||||
|
|||||||
@@ -189,6 +189,10 @@ export interface InstanceUser {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
is_instance_admin: boolean;
|
is_instance_admin: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/** Глобальный бан инстанса (AGENT.md 7.18). */
|
||||||
|
banned: boolean;
|
||||||
|
banned_at?: string;
|
||||||
|
ban_reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditEntry {
|
export interface AuditEntry {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { deleteUser, instanceUsersQueryKey, logoutUser, resetUserPassword } from '@/api/instance';
|
import { deleteUser, instanceUsersQueryKey, logoutUser, resetUserPassword } from '@/api/instance';
|
||||||
|
import type { InstanceUser } from '@/api/types';
|
||||||
import { Button } from '@/components/ui/primitives';
|
import { Button } from '@/components/ui/primitives';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Field } from '@/components/ui/Field';
|
import { Field } from '@/components/ui/Field';
|
||||||
@@ -11,10 +12,10 @@ import { useCurrentUser } from '@/lib/hooks';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
interface InstanceUsersPanelProps {
|
interface InstanceUsersPanelProps {
|
||||||
/** Свой id: себя удалять нельзя (сервер тоже это проверяет). */
|
/** Свой id: себя удалять и банить нельзя (сервер тоже это проверяет). */
|
||||||
currentUserId: string | undefined;
|
currentUserId: string | undefined;
|
||||||
/** Признак, что список загружен: панель показывает действия. */
|
/** Признак, что список загружен: панель показывает действия. */
|
||||||
users: { id: string; username: string; display_name: string; is_instance_admin: boolean }[];
|
users: InstanceUser[];
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
error: unknown;
|
error: unknown;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
@@ -23,8 +24,24 @@ interface InstanceUsersPanelProps {
|
|||||||
adminPending: boolean;
|
adminPending: boolean;
|
||||||
/** Ошибка смены прав: сервер требует step-up или отказывает. */
|
/** Ошибка смены прав: сервер требует step-up или отказывает. */
|
||||||
adminError?: unknown;
|
adminError?: unknown;
|
||||||
|
/** Глобальный бан и разбан (AGENT.md 7.18). */
|
||||||
|
onBan: (userId: string, reason: string) => void;
|
||||||
|
onUnban: (userId: string) => void;
|
||||||
|
banPending: boolean;
|
||||||
|
banError?: unknown;
|
||||||
|
/** Поиск по логину и имени и фильтр «только забаненные». */
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
bannedOnly: boolean;
|
||||||
|
onBannedOnlyChange: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Действие, для которого запрашивается подтверждение личности. */
|
||||||
|
type StepUpAction =
|
||||||
|
| { kind: 'admin'; userId: string; makeAdmin: boolean }
|
||||||
|
| { kind: 'ban'; userId: string; reason: string }
|
||||||
|
| { kind: 'unban'; userId: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Действия администратора инстанса над пользователем (AGENT.md 7.19):
|
* Действия администратора инстанса над пользователем (AGENT.md 7.19):
|
||||||
* временный пароль, выход со всех устройств и мягкое удаление.
|
* временный пароль, выход со всех устройств и мягкое удаление.
|
||||||
@@ -38,6 +55,14 @@ export function InstanceUsersPanel({
|
|||||||
onToggleAdmin,
|
onToggleAdmin,
|
||||||
adminPending,
|
adminPending,
|
||||||
adminError,
|
adminError,
|
||||||
|
onBan,
|
||||||
|
onUnban,
|
||||||
|
banPending,
|
||||||
|
banError,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
bannedOnly,
|
||||||
|
onBannedOnlyChange,
|
||||||
}: InstanceUsersPanelProps) {
|
}: InstanceUsersPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -45,8 +70,10 @@ export function InstanceUsersPanel({
|
|||||||
const [password, setPassword] = useState<{ userId: string; value: string } | null>(null);
|
const [password, setPassword] = useState<{ userId: string; value: string } | null>(null);
|
||||||
const [actionError, setActionError] = useState<unknown>(null);
|
const [actionError, setActionError] = useState<unknown>(null);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
// Смена администраторов требует подтверждения личности (AGENT.md 7.1).
|
// Чувствительные действия требуют подтверждения личности (AGENT.md 7.1).
|
||||||
const [stepUpFor, setStepUpFor] = useState<{ userId: string; makeAdmin: boolean } | null>(null);
|
const [stepUpFor, setStepUpFor] = useState<StepUpAction | null>(null);
|
||||||
|
// Причина бана вводится до подтверждения личности.
|
||||||
|
const [banFor, setBanFor] = useState<{ userId: string; reason: string } | null>(null);
|
||||||
const [stepUpPassword, setStepUpPassword] = useState('');
|
const [stepUpPassword, setStepUpPassword] = useState('');
|
||||||
const [stepUpCode, setStepUpCode] = useState('');
|
const [stepUpCode, setStepUpCode] = useState('');
|
||||||
|
|
||||||
@@ -56,9 +83,17 @@ export function InstanceUsersPanel({
|
|||||||
const target = stepUpFor;
|
const target = stepUpFor;
|
||||||
setStepUpPassword('');
|
setStepUpPassword('');
|
||||||
setStepUpCode('');
|
setStepUpCode('');
|
||||||
if (target !== null) {
|
if (target === null) {
|
||||||
onToggleAdmin(target.userId, target.makeAdmin);
|
return;
|
||||||
}
|
}
|
||||||
|
if (target.kind === 'admin') {
|
||||||
|
onToggleAdmin(target.userId, target.makeAdmin);
|
||||||
|
} else if (target.kind === 'ban') {
|
||||||
|
onBan(target.userId, target.reason);
|
||||||
|
} else {
|
||||||
|
onUnban(target.userId);
|
||||||
|
}
|
||||||
|
setStepUpFor(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,7 +138,8 @@ export function InstanceUsersPanel({
|
|||||||
onError: (cause: unknown) => setActionError(cause),
|
onError: (cause: unknown) => setActionError(cause),
|
||||||
});
|
});
|
||||||
|
|
||||||
const pending = reset.isPending || logout.isPending || remove.isPending || adminPending;
|
const pending =
|
||||||
|
reset.isPending || logout.isPending || remove.isPending || adminPending || banPending;
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return <p className="mt-2 text-sm text-fg-muted">{t('common.loading')}</p>;
|
return <p className="mt-2 text-sm text-fg-muted">{t('common.loading')}</p>;
|
||||||
@@ -119,6 +155,7 @@ export function InstanceUsersPanel({
|
|||||||
<div className="mt-3 flex flex-col gap-2" data-testid="instance-users-actions">
|
<div className="mt-3 flex flex-col gap-2" data-testid="instance-users-actions">
|
||||||
{actionError === null ? null : <ErrorNotice error={actionError} />}
|
{actionError === null ? null : <ErrorNotice error={actionError} />}
|
||||||
{adminError === null || adminError === undefined ? null : <ErrorNotice error={adminError} />}
|
{adminError === null || adminError === undefined ? null : <ErrorNotice error={adminError} />}
|
||||||
|
{banError === null || banError === undefined ? null : <ErrorNotice error={banError} />}
|
||||||
{notice === null ? null : (
|
{notice === null ? null : (
|
||||||
<p className="text-sm text-success" role="status">
|
<p className="text-sm text-success" role="status">
|
||||||
{notice}
|
{notice}
|
||||||
@@ -172,6 +209,25 @@ export function InstanceUsersPanel({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.search.label')}
|
||||||
|
name="instance-user-search"
|
||||||
|
value={search}
|
||||||
|
placeholder={t('settings.instance.search.placeholder')}
|
||||||
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 pb-1 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="instance-users-banned-only"
|
||||||
|
checked={bannedOnly}
|
||||||
|
onChange={(event) => onBannedOnlyChange(event.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('settings.instance.search.bannedOnly')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul className="flex flex-col gap-1 text-sm" data-testid="instance-users">
|
<ul className="flex flex-col gap-1 text-sm" data-testid="instance-users">
|
||||||
{users.map((user) => {
|
{users.map((user) => {
|
||||||
const self = user.id === currentUserId;
|
const self = user.id === currentUserId;
|
||||||
@@ -195,6 +251,45 @@ export function InstanceUsersPanel({
|
|||||||
{t('settings.instance.adminBadge')}
|
{t('settings.instance.adminBadge')}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{user.banned ? (
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded-full border border-danger/60 px-2 text-xs text-danger"
|
||||||
|
title={user.ban_reason === undefined ? undefined : user.ban_reason}
|
||||||
|
data-testid={`instance-user-banned-${user.id}`}
|
||||||
|
>
|
||||||
|
{t('settings.instance.bannedBadge')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{banFor !== null && banFor.userId === user.id ? (
|
||||||
|
<form
|
||||||
|
className="flex w-full flex-wrap items-end gap-2"
|
||||||
|
data-testid={`instance-user-ban-form-${user.id}`}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setStepUpFor({ kind: 'ban', userId: user.id, reason: banFor.reason });
|
||||||
|
setBanFor(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.actions.banReason')}
|
||||||
|
name="ban_reason"
|
||||||
|
maxLength={400}
|
||||||
|
value={banFor.reason}
|
||||||
|
onChange={(event) => setBanFor({ userId: user.id, reason: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
className="border border-danger/50 text-danger"
|
||||||
|
>
|
||||||
|
{t('settings.instance.actions.banSubmit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setBanFor(null)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -204,7 +299,11 @@ export function InstanceUsersPanel({
|
|||||||
setActionError(null);
|
setActionError(null);
|
||||||
onToggleAdmin(user.id, !user.is_instance_admin);
|
onToggleAdmin(user.id, !user.is_instance_admin);
|
||||||
// Подтверждение покажем, если сервер его потребует.
|
// Подтверждение покажем, если сервер его потребует.
|
||||||
setStepUpFor({ userId: user.id, makeAdmin: !user.is_instance_admin });
|
setStepUpFor({
|
||||||
|
kind: 'admin',
|
||||||
|
userId: user.id,
|
||||||
|
makeAdmin: !user.is_instance_admin,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
@@ -213,6 +312,25 @@ export function InstanceUsersPanel({
|
|||||||
: 'settings.instance.actions.makeAdmin',
|
: 'settings.instance.actions.makeAdmin',
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Инстанс-админа и себя банить нельзя (AGENT.md 7.19). */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={user.banned ? undefined : 'border border-danger/50 text-danger'}
|
||||||
|
data-testid={`instance-user-ban-${user.id}`}
|
||||||
|
disabled={pending || self || user.is_instance_admin}
|
||||||
|
onClick={() => {
|
||||||
|
setActionError(null);
|
||||||
|
if (user.banned) {
|
||||||
|
setStepUpFor({ kind: 'unban', userId: user.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBanFor({ userId: user.id, reason: '' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
user.banned ? 'settings.instance.actions.unban' : 'settings.instance.actions.ban',
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
data-testid={`instance-user-reset-${user.id}`}
|
data-testid={`instance-user-reset-${user.id}`}
|
||||||
|
|||||||
@@ -80,7 +80,8 @@
|
|||||||
"limit_reached": "The limit of sounds of this kind is reached.",
|
"limit_reached": "The limit of sounds of this kind is reached.",
|
||||||
"event_required": "Choose an event for a palette sound."
|
"event_required": "Choose an event for a palette sound."
|
||||||
},
|
},
|
||||||
"voice.not_connected": "Join a voice channel first."
|
"voice.not_connected": "Join a voice channel first.",
|
||||||
|
"user.banned": "This account is banned on the instance. Contact the administrator."
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"switchToLight": "Light theme",
|
"switchToLight": "Light theme",
|
||||||
@@ -872,7 +873,19 @@
|
|||||||
"openSettings": "Settings",
|
"openSettings": "Settings",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"newName": "New server name",
|
"newName": "New server name",
|
||||||
"deleteGuildConfirm": "Delete server “{{name}}” with all its channels?"
|
"deleteGuildConfirm": "Delete server “{{name}}” with all its channels?",
|
||||||
|
"ban": "Ban",
|
||||||
|
"unban": "Unban",
|
||||||
|
"banReason": "Ban reason",
|
||||||
|
"banSubmit": "Ban",
|
||||||
|
"banned": "User is banned on this instance, sessions revoked.",
|
||||||
|
"unbanned": "Ban lifted."
|
||||||
|
},
|
||||||
|
"bannedBadge": "Banned",
|
||||||
|
"search": {
|
||||||
|
"label": "Search",
|
||||||
|
"placeholder": "Username or name",
|
||||||
|
"bannedOnly": "Banned only"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
|
|||||||
@@ -80,7 +80,8 @@
|
|||||||
"limit_reached": "Достигнут лимит звуков этого вида.",
|
"limit_reached": "Достигнут лимит звуков этого вида.",
|
||||||
"event_required": "Для звука палитры выберите событие."
|
"event_required": "Для звука палитры выберите событие."
|
||||||
},
|
},
|
||||||
"voice.not_connected": "Сначала войдите в голосовую комнату."
|
"voice.not_connected": "Сначала войдите в голосовую комнату.",
|
||||||
|
"user.banned": "Аккаунт забанен на этом инстансе. Обратитесь к администратору."
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"switchToLight": "Светлая тема",
|
"switchToLight": "Светлая тема",
|
||||||
@@ -872,7 +873,19 @@
|
|||||||
"openSettings": "Настройки",
|
"openSettings": "Настройки",
|
||||||
"rename": "Переименовать",
|
"rename": "Переименовать",
|
||||||
"newName": "Новое имя сервера",
|
"newName": "Новое имя сервера",
|
||||||
"deleteGuildConfirm": "Удалить сервер «{{name}}» вместе со всеми комнатами?"
|
"deleteGuildConfirm": "Удалить сервер «{{name}}» вместе со всеми комнатами?",
|
||||||
|
"ban": "Забанить",
|
||||||
|
"unban": "Разбанить",
|
||||||
|
"banReason": "Причина бана",
|
||||||
|
"banSubmit": "Забанить",
|
||||||
|
"banned": "Пользователь забанен на инстансе, сессии отозваны.",
|
||||||
|
"unbanned": "Бан снят."
|
||||||
|
},
|
||||||
|
"bannedBadge": "Забанен",
|
||||||
|
"search": {
|
||||||
|
"label": "Поиск",
|
||||||
|
"placeholder": "Логин или имя",
|
||||||
|
"bannedOnly": "Только забаненные"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
banUser,
|
||||||
fetchAudit,
|
fetchAudit,
|
||||||
fetchInstanceGuilds,
|
fetchInstanceGuilds,
|
||||||
fetchInstanceSettings,
|
fetchInstanceSettings,
|
||||||
@@ -10,8 +11,10 @@ import {
|
|||||||
instanceAuditQueryKey,
|
instanceAuditQueryKey,
|
||||||
instanceGuildsQueryKey,
|
instanceGuildsQueryKey,
|
||||||
instanceSettingsQueryKey,
|
instanceSettingsQueryKey,
|
||||||
|
instanceUserListQueryKey,
|
||||||
instanceUsersQueryKey,
|
instanceUsersQueryKey,
|
||||||
setInstanceAdmin,
|
setInstanceAdmin,
|
||||||
|
unbanUser,
|
||||||
type InstanceSettings,
|
type InstanceSettings,
|
||||||
} from '@/api/instance';
|
} from '@/api/instance';
|
||||||
import { InstanceDashboard } from '@/components/instance/InstanceDashboard';
|
import { InstanceDashboard } from '@/components/instance/InstanceDashboard';
|
||||||
@@ -43,6 +46,9 @@ export default function InstanceSettingsPage() {
|
|||||||
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
|
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
|
||||||
const openSettings = useUiStore((state) => state.openSettings);
|
const openSettings = useUiStore((state) => state.openSettings);
|
||||||
const [tab, setTab] = useState<InstanceTab>('overview');
|
const [tab, setTab] = useState<InstanceTab>('overview');
|
||||||
|
// Поиск и фильтр пользователей панели (AGENT.md 7.18).
|
||||||
|
const [userSearch, setUserSearch] = useState('');
|
||||||
|
const [bannedOnly, setBannedOnly] = useState(false);
|
||||||
|
|
||||||
const settings = useQuery({
|
const settings = useQuery({
|
||||||
queryKey: instanceSettingsQueryKey,
|
queryKey: instanceSettingsQueryKey,
|
||||||
@@ -56,11 +62,15 @@ export default function InstanceSettingsPage() {
|
|||||||
enabled: isAdmin,
|
enabled: isAdmin,
|
||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
const userFilter = { query: userSearch.trim(), bannedOnly };
|
||||||
const users = useQuery({
|
const users = useQuery({
|
||||||
queryKey: instanceUsersQueryKey,
|
queryKey: instanceUserListQueryKey(userFilter),
|
||||||
queryFn: ({ signal }) => fetchInstanceUsers(signal),
|
queryFn: ({ signal }) => fetchInstanceUsers(userFilter, signal),
|
||||||
enabled: isAdmin,
|
enabled: isAdmin,
|
||||||
retry: 0,
|
retry: 0,
|
||||||
|
// Поиск не должен подменять список надписью «загрузка»: иначе поле ввода
|
||||||
|
// исчезает на каждом символе и набор обрывается.
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
const audit = useQuery({
|
const audit = useQuery({
|
||||||
queryKey: instanceAuditQueryKey(AUDIT_LIMIT),
|
queryKey: instanceAuditQueryKey(AUDIT_LIMIT),
|
||||||
@@ -71,13 +81,23 @@ export default function InstanceSettingsPage() {
|
|||||||
// Живые метрики: опрос раз в секунду, пока раздел открыт (AGENT.md 7.19).
|
// Живые метрики: опрос раз в секунду, пока раздел открыт (AGENT.md 7.19).
|
||||||
const metrics = useInstanceMetrics(isAdmin);
|
const metrics = useInstanceMetrics(isAdmin);
|
||||||
|
|
||||||
|
const afterUserChange = (): void => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: instanceAuditQueryKey(AUDIT_LIMIT) });
|
||||||
|
};
|
||||||
|
|
||||||
const admin = useMutation({
|
const admin = useMutation({
|
||||||
mutationFn: (input: { userId: string; makeAdmin: boolean }) =>
|
mutationFn: (input: { userId: string; makeAdmin: boolean }) =>
|
||||||
setInstanceAdmin(input.userId, input.makeAdmin),
|
setInstanceAdmin(input.userId, input.makeAdmin),
|
||||||
onSuccess: () => {
|
onSuccess: afterUserChange,
|
||||||
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
|
});
|
||||||
void queryClient.invalidateQueries({ queryKey: instanceAuditQueryKey(AUDIT_LIMIT) });
|
const ban = useMutation({
|
||||||
},
|
mutationFn: (input: { userId: string; reason: string }) => banUser(input.userId, input.reason),
|
||||||
|
onSuccess: afterUserChange,
|
||||||
|
});
|
||||||
|
const unban = useMutation({
|
||||||
|
mutationFn: (userId: string) => unbanUser(userId),
|
||||||
|
onSuccess: afterUserChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
@@ -92,7 +112,7 @@ export default function InstanceSettingsPage() {
|
|||||||
const tabLabels: Record<InstanceTab, string> = {
|
const tabLabels: Record<InstanceTab, string> = {
|
||||||
overview: t('settings.instance.tabs.overview'),
|
overview: t('settings.instance.tabs.overview'),
|
||||||
limits: t('settings.instance.tabs.limits'),
|
limits: t('settings.instance.tabs.limits'),
|
||||||
users: t('settings.instance.tabs.users', { count: users.data?.length ?? 0 }),
|
users: t('settings.instance.tabs.users', { count: users.data?.total ?? 0 }),
|
||||||
guilds: t('settings.instance.tabs.guilds', { count: guilds.data?.length ?? 0 }),
|
guilds: t('settings.instance.tabs.guilds', { count: guilds.data?.length ?? 0 }),
|
||||||
audit: t('settings.instance.tabs.audit'),
|
audit: t('settings.instance.tabs.audit'),
|
||||||
};
|
};
|
||||||
@@ -168,13 +188,21 @@ export default function InstanceSettingsPage() {
|
|||||||
<p className="mt-1 text-xs text-fg-muted">{t('settings.instance.usersIdHint')}</p>
|
<p className="mt-1 text-xs text-fg-muted">{t('settings.instance.usersIdHint')}</p>
|
||||||
<InstanceUsersPanel
|
<InstanceUsersPanel
|
||||||
currentUserId={currentUser.data?.id}
|
currentUserId={currentUser.data?.id}
|
||||||
users={users.data ?? []}
|
users={users.data?.users ?? []}
|
||||||
isPending={users.isPending}
|
isPending={users.isPending}
|
||||||
error={users.error}
|
error={users.error}
|
||||||
onRetry={() => void users.refetch()}
|
onRetry={() => void users.refetch()}
|
||||||
onToggleAdmin={(userId, makeAdmin) => admin.mutate({ userId, makeAdmin })}
|
onToggleAdmin={(userId, makeAdmin) => admin.mutate({ userId, makeAdmin })}
|
||||||
adminPending={admin.isPending}
|
adminPending={admin.isPending}
|
||||||
adminError={admin.error}
|
adminError={admin.error}
|
||||||
|
onBan={(userId, reason) => ban.mutate({ userId, reason })}
|
||||||
|
onUnban={(userId) => unban.mutate(userId)}
|
||||||
|
banPending={ban.isPending || unban.isPending}
|
||||||
|
banError={ban.error ?? unban.error}
|
||||||
|
search={userSearch}
|
||||||
|
onSearchChange={setUserSearch}
|
||||||
|
bannedOnly={bannedOnly}
|
||||||
|
onBannedOnlyChange={setBannedOnly}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -530,8 +530,10 @@ describe('настройки: аватар', () => {
|
|||||||
display_name: 'Alice',
|
display_name: 'Alice',
|
||||||
is_instance_admin: true,
|
is_instance_admin: true,
|
||||||
created_at: '2026-09-01T00:00:00Z',
|
created_at: '2026-09-01T00:00:00Z',
|
||||||
|
banned: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
total: 1,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
|
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
|
||||||
@@ -547,6 +549,87 @@ describe('настройки: аватар', () => {
|
|||||||
expect(await within(dialog).findByTestId('instance-user-id-u-42')).toHaveTextContent('u-42');
|
expect(await within(dialog).findByTestId('instance-user-id-u-42')).toHaveTextContent('u-42');
|
||||||
expect(within(dialog).getByText(/ID пользователей видны только администратору/)).toBeVisible();
|
expect(within(dialog).getByText(/ID пользователей видны только администратору/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('глобальный бан требует причину и step-up, разбан возвращает доступ', async () => {
|
||||||
|
const other = {
|
||||||
|
id: 'u-7',
|
||||||
|
username: 'bob',
|
||||||
|
display_name: 'Bob',
|
||||||
|
is_instance_admin: false,
|
||||||
|
created_at: '2026-09-02T00:00:00Z',
|
||||||
|
banned: false,
|
||||||
|
};
|
||||||
|
const bannedUser = {
|
||||||
|
...other,
|
||||||
|
id: 'u-8',
|
||||||
|
username: 'mallory',
|
||||||
|
display_name: 'Mallory',
|
||||||
|
banned: true,
|
||||||
|
ban_reason: 'спам',
|
||||||
|
};
|
||||||
|
const banRequests: { url: string; body: unknown }[] = [];
|
||||||
|
const listUrls: string[] = [];
|
||||||
|
const fetchMock = installFetch(
|
||||||
|
appRoutes(makeUser({ is_instance_admin: true }), [
|
||||||
|
{ match: '/api/v1/instance/settings', response: () => json({ settings: {} }) },
|
||||||
|
{ match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) },
|
||||||
|
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
|
||||||
|
{
|
||||||
|
match: '/api/v1/auth/step-up',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => json({ ok: true }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/users/u-7/ban',
|
||||||
|
method: 'POST',
|
||||||
|
response: (request) => {
|
||||||
|
banRequests.push({ url: request.url, body: request.body });
|
||||||
|
return json({ user: {} });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/users',
|
||||||
|
response: (request) => {
|
||||||
|
listUrls.push(request.url);
|
||||||
|
return json({ users: [other, bannedUser], total: 2 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openSettings();
|
||||||
|
await goToSection(dialog, 'Инстанс');
|
||||||
|
await userEvent.click(await within(dialog).findByTestId('instance-tab-users'));
|
||||||
|
|
||||||
|
// Забаненный помечен, у админа и себя кнопка бана недоступна.
|
||||||
|
expect(await within(dialog).findByTestId('instance-user-banned-u-8')).toHaveTextContent(
|
||||||
|
'Забанен',
|
||||||
|
);
|
||||||
|
expect(within(dialog).getByTestId('instance-user-ban-u-7')).toBeEnabled();
|
||||||
|
|
||||||
|
// Бан: сначала причина, затем подтверждение личности.
|
||||||
|
await userEvent.click(within(dialog).getByTestId('instance-user-ban-u-7'));
|
||||||
|
const reasonForm = await within(dialog).findByTestId('instance-user-ban-form-u-7');
|
||||||
|
await userEvent.type(within(reasonForm).getByLabelText('Причина бана'), 'флуд');
|
||||||
|
await userEvent.click(within(reasonForm).getByRole('button', { name: 'Забанить' }));
|
||||||
|
|
||||||
|
const stepUpForm = await within(dialog).findByTestId('instance-step-up');
|
||||||
|
await userEvent.type(within(stepUpForm).getByLabelText('Ваш пароль'), 'correct-horse-battery');
|
||||||
|
await userEvent.click(within(stepUpForm).getByRole('button', { name: 'Подтвердить' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(banRequests).toHaveLength(1));
|
||||||
|
expect(banRequests[0]?.url).toContain('/instance/users/u-7/ban');
|
||||||
|
expect(banRequests[0]?.body).toEqual({ reason: 'флуд' });
|
||||||
|
|
||||||
|
// Поиск уходит на сервер параметром q, фильтр — banned=true.
|
||||||
|
const search = within(dialog).getByLabelText('Поиск');
|
||||||
|
await userEvent.type(search, 'bob');
|
||||||
|
await waitFor(() => expect(listUrls.some((url) => url.includes('q=bob'))).toBe(true));
|
||||||
|
await userEvent.click(within(dialog).getByTestId('instance-users-banned-only'));
|
||||||
|
await waitFor(() => expect(listUrls.some((url) => url.includes('banned=true'))).toBe(true));
|
||||||
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('настройки: безопасность', () => {
|
describe('настройки: безопасность', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user