feat(instance): админ-панель с лимитами медиа и действиями над пользователями
- лимиты медиа живут в настройках инстанса (миграция 00016): аватары, оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел оттуда, значения по умолчанию — из AGENT.md 7.7 - действия администратора: временный пароль (показывается один раз, сессии отзываются), выход со всех устройств, мягкое удаление пользователя (сообщения и аудит остаются), переименование и удаление любого сервера - админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи, серверы, аудит; смена администраторов подтверждается личностью (step-up) - тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest (вкладки, сброс пароля, выход, удаление, переименование сервера)
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Глобальные лимиты медиа (AGENT.md 7.7): меняются в админ-панели инстанса и
|
||||||
|
-- действуют на все сервера. Вложения регулируются профилем производительности
|
||||||
|
-- (MAX_UPLOAD_SIZE), поэтому здесь их нет.
|
||||||
|
INSERT INTO instance_settings (key, value) VALUES
|
||||||
|
('max_avatar_size', '5242880'),
|
||||||
|
('max_guild_image_size', '5242880'),
|
||||||
|
('max_emoji_size', '524288'),
|
||||||
|
('max_sound_size', '524288'),
|
||||||
|
('max_cosmetic_size', '5242880')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DELETE FROM instance_settings WHERE key IN
|
||||||
|
('max_avatar_size', 'max_guild_image_size', 'max_emoji_size', 'max_sound_size', 'max_cosmetic_size');
|
||||||
@@ -22,8 +22,6 @@ import (
|
|||||||
const (
|
const (
|
||||||
// maxCosmeticsPerGuild — предел галереи сервера.
|
// maxCosmeticsPerGuild — предел галереи сервера.
|
||||||
maxCosmeticsPerGuild = 50
|
maxCosmeticsPerGuild = 50
|
||||||
// maxCosmeticSize — предел размера картинки оформления.
|
|
||||||
maxCosmeticSize = 5 << 20
|
|
||||||
// maxCosmeticNameLength — длина имени элемента галереи.
|
// maxCosmeticNameLength — длина имени элемента галереи.
|
||||||
maxCosmeticNameLength = 40
|
maxCosmeticNameLength = 40
|
||||||
// scopeGlobal — область личного стиля «для друзей».
|
// scopeGlobal — область личного стиля «для друзей».
|
||||||
@@ -255,18 +253,19 @@ func (s *Server) handleCosmeticUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
limit := s.mediaLimit(ctx, mediaCosmetic)
|
||||||
file, header, err := r.FormFile("file")
|
file, header, err := r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
if header.Size > maxCosmeticSize {
|
if header.Size > limit {
|
||||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic is too large")
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic is too large")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := io.ReadAll(io.LimitReader(file, maxCosmeticSize+1))
|
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||||
if err != nil || int64(len(data)) > maxCosmeticSize {
|
if err != nil || int64(len(data)) > limit {
|
||||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic is too large")
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic is too large")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -757,9 +756,6 @@ func optionalString(value *string) string {
|
|||||||
return *value
|
return *value
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxChannelBackgroundSize — предел размера фона комнаты (AGENT.md 7.7).
|
|
||||||
const maxChannelBackgroundSize = 5 << 20
|
|
||||||
|
|
||||||
// registerChannelBackgroundRoutes описывает фон комнаты (AGENT.md 7.5, 7.7):
|
// registerChannelBackgroundRoutes описывает фон комнаты (AGENT.md 7.5, 7.7):
|
||||||
// картинка за лентой сообщений, право MANAGE_CHANNEL_BACKGROUND.
|
// картинка за лентой сообщений, право MANAGE_CHANNEL_BACKGROUND.
|
||||||
func (s *Server) registerChannelBackgroundRoutes(router chi.Router) {
|
func (s *Server) registerChannelBackgroundRoutes(router chi.Router) {
|
||||||
@@ -779,18 +775,19 @@ func (s *Server) handleChannelBackgroundUpload(w http.ResponseWriter, r *http.Re
|
|||||||
writeHumaAPIError(w, err)
|
writeHumaAPIError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
limit := s.mediaLimit(ctx, mediaGuildImage)
|
||||||
file, header, err := r.FormFile("file")
|
file, header, err := r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
if header.Size > maxChannelBackgroundSize {
|
if header.Size > limit {
|
||||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background is too large")
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background is too large")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := io.ReadAll(io.LimitReader(file, maxChannelBackgroundSize+1))
|
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||||
if err != nil || int64(len(data)) > maxChannelBackgroundSize {
|
if err != nil || int64(len(data)) > limit {
|
||||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background is too large")
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background is too large")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import (
|
|||||||
|
|
||||||
// Лимиты кастомных эмодзи (AGENT.md 7.12).
|
// Лимиты кастомных эмодзи (AGENT.md 7.12).
|
||||||
const (
|
const (
|
||||||
maxEmojiSize = 512 << 10
|
|
||||||
maxEmojisPerGuild = 100
|
maxEmojisPerGuild = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -202,7 +201,8 @@ func (s *Server) handleEmojiUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxEmojiSize+maxMultipartOverhead)
|
limit := s.mediaLimit(ctx, mediaEmoji)
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, limit+maxMultipartOverhead)
|
||||||
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader
|
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader
|
||||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
|
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
|
||||||
return
|
return
|
||||||
@@ -225,12 +225,12 @@ func (s *Server) handleEmojiUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
if header.Size > maxEmojiSize {
|
if header.Size > limit {
|
||||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "эмодзи больше 512 КБ")
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "эмодзи больше 512 КБ")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := io.ReadAll(io.LimitReader(file, maxEmojiSize+1))
|
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||||
if err != nil || len(data) > maxEmojiSize {
|
if err != nil || int64(len(data)) > limit {
|
||||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "эмодзи больше 512 КБ")
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "эмодзи больше 512 КБ")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ const (
|
|||||||
maxMultipartOverhead = 1 << 20
|
maxMultipartOverhead = 1 << 20
|
||||||
// maxMultipartMemory — сколько multipart держим в памяти, остальное — на диске.
|
// maxMultipartMemory — сколько multipart держим в памяти, остальное — на диске.
|
||||||
maxMultipartMemory = 8 << 20
|
maxMultipartMemory = 8 << 20
|
||||||
// maxAvatarSize — предел размера аватара (AGENT.md 7.2).
|
|
||||||
maxAvatarSize = 8 << 20
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// uploadPayload — результат загрузки файла: метаданные для вложения.
|
// uploadPayload — результат загрузки файла: метаданные для вложения.
|
||||||
@@ -86,7 +84,8 @@ func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxAvatarSize+maxMultipartOverhead)
|
limit := s.mediaLimit(ctx, mediaAvatar)
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, limit+maxMultipartOverhead)
|
||||||
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader
|
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader
|
||||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
|
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
|
||||||
return
|
return
|
||||||
@@ -102,14 +101,14 @@ func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
if header.Size > maxAvatarSize {
|
if header.Size > limit {
|
||||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "avatar is too large")
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "avatar is too large")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Аватар читаем в память (не больше 8 МБ): нужно проверить, что это
|
// Аватар читаем в память (не больше 8 МБ): нужно проверить, что это
|
||||||
// действительно изображение, а не переименованный файл (AGENT.md 9.2).
|
// действительно изображение, а не переименованный файл (AGENT.md 9.2).
|
||||||
data, err := io.ReadAll(io.LimitReader(file, maxAvatarSize+1))
|
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||||
if err != nil || int64(len(data)) > maxAvatarSize {
|
if err != nil || int64(len(data)) > limit {
|
||||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "avatar is too large")
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "avatar is too large")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ import (
|
|||||||
// страницы приглашения и акцентный цвет. Файлы хранятся обычными загрузками с
|
// страницы приглашения и акцентный цвет. Файлы хранятся обычными загрузками с
|
||||||
// отдельными purpose, поэтому анимированные GIF/APNG/WebP не теряют анимацию.
|
// отдельными purpose, поэтому анимированные GIF/APNG/WebP не теряют анимацию.
|
||||||
|
|
||||||
// maxGuildImageSize — предел размера для иконки, баннера и splash.
|
|
||||||
const maxGuildImageSize = 8 << 20
|
|
||||||
|
|
||||||
type appearanceKind struct {
|
type appearanceKind struct {
|
||||||
// field — колонка гильдии, purpose — назначение файла.
|
// field — колонка гильдии, purpose — назначение файла.
|
||||||
field string
|
field string
|
||||||
@@ -65,20 +62,21 @@ func (s *Server) handleGuildAppearanceUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
limit := s.mediaLimit(ctx, mediaGuildImage)
|
||||||
file, header, err := r.FormFile("file")
|
file, header, err := r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
if header.Size > maxGuildImageSize {
|
if header.Size > limit {
|
||||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", kind.label+" is too large")
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", kind.label+" is too large")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Читаем в память (не больше 8 МБ): нужно убедиться, что это изображение,
|
// Читаем в память (не больше 8 МБ): нужно убедиться, что это изображение,
|
||||||
// а не переименованный файл (AGENT.md 9.2).
|
// а не переименованный файл (AGENT.md 9.2).
|
||||||
data, err := io.ReadAll(io.LimitReader(file, maxGuildImageSize+1))
|
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||||
if err != nil || int64(len(data)) > maxGuildImageSize {
|
if err != nil || int64(len(data)) > limit {
|
||||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", kind.label+" is too large")
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", kind.label+" is too large")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+230
-14
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -72,6 +73,36 @@ type instanceSettingsPayload struct {
|
|||||||
MaxGuildsPerUser int `json:"max_guilds_per_user"`
|
MaxGuildsPerUser int `json:"max_guilds_per_user"`
|
||||||
MaxMembersPerGuild int `json:"max_members_per_guild"`
|
MaxMembersPerGuild int `json:"max_members_per_guild"`
|
||||||
MaxMessageLength int `json:"max_message_length"`
|
MaxMessageLength int `json:"max_message_length"`
|
||||||
|
// Лимиты медиа в байтах (AGENT.md 7.7): действуют на все сервера.
|
||||||
|
MaxAvatarSize int64 `json:"max_avatar_size"`
|
||||||
|
MaxGuildImageSize int64 `json:"max_guild_image_size"`
|
||||||
|
MaxEmojiSize int64 `json:"max_emoji_size"`
|
||||||
|
MaxSoundSize int64 `json:"max_sound_size"`
|
||||||
|
MaxCosmeticSize int64 `json:"max_cosmetic_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// instanceSettingsFromStore собирает ответ настроек в одном месте.
|
||||||
|
func instanceSettingsFromStore(settings *store.InstanceSettings) instanceSettingsPayload {
|
||||||
|
return instanceSettingsPayload{
|
||||||
|
RegistrationEnabled: settings.RegistrationEnabled,
|
||||||
|
AllowGuildCreation: settings.AllowGuildCreation,
|
||||||
|
MaxGuildsPerUser: settings.MaxGuildsPerUser,
|
||||||
|
MaxMembersPerGuild: settings.MaxMembersPerGuild,
|
||||||
|
MaxMessageLength: settings.MaxMessageLength,
|
||||||
|
MaxAvatarSize: settings.MaxAvatarSize,
|
||||||
|
MaxGuildImageSize: settings.MaxGuildImageSize,
|
||||||
|
MaxEmojiSize: settings.MaxEmojiSize,
|
||||||
|
MaxSoundSize: settings.MaxSoundSize,
|
||||||
|
MaxCosmeticSize: settings.MaxCosmeticSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminPasswordOutput struct {
|
||||||
|
Body struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
SessionsRevoked int64 `json:"sessions_revoked"`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type instanceSettingsOutput struct {
|
type instanceSettingsOutput struct {
|
||||||
@@ -122,13 +153,7 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
output := &instanceSettingsOutput{}
|
output := &instanceSettingsOutput{}
|
||||||
output.Body.Settings = instanceSettingsPayload{
|
output.Body.Settings = instanceSettingsFromStore(settings)
|
||||||
RegistrationEnabled: settings.RegistrationEnabled,
|
|
||||||
AllowGuildCreation: settings.AllowGuildCreation,
|
|
||||||
MaxGuildsPerUser: settings.MaxGuildsPerUser,
|
|
||||||
MaxMembersPerGuild: settings.MaxMembersPerGuild,
|
|
||||||
MaxMessageLength: settings.MaxMessageLength,
|
|
||||||
}
|
|
||||||
return output, nil
|
return output, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -146,6 +171,12 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
MaxGuildsPerUser *int `json:"max_guilds_per_user,omitempty" minimum:"1" maximum:"10000"`
|
MaxGuildsPerUser *int `json:"max_guilds_per_user,omitempty" minimum:"1" maximum:"10000"`
|
||||||
MaxMembersPerGuild *int `json:"max_members_per_guild,omitempty" minimum:"1" maximum:"1000000"`
|
MaxMembersPerGuild *int `json:"max_members_per_guild,omitempty" minimum:"1" maximum:"1000000"`
|
||||||
MaxMessageLength *int `json:"max_message_length,omitempty" minimum:"1" maximum:"100000"`
|
MaxMessageLength *int `json:"max_message_length,omitempty" minimum:"1" maximum:"100000"`
|
||||||
|
// Лимиты медиа: 64 КБ — 512 МБ (AGENT.md 7.7).
|
||||||
|
MaxAvatarSize *int64 `json:"max_avatar_size,omitempty" minimum:"65536" maximum:"536870912"`
|
||||||
|
MaxGuildImageSize *int64 `json:"max_guild_image_size,omitempty" minimum:"65536" maximum:"536870912"`
|
||||||
|
MaxEmojiSize *int64 `json:"max_emoji_size,omitempty" minimum:"65536" maximum:"536870912"`
|
||||||
|
MaxSoundSize *int64 `json:"max_sound_size,omitempty" minimum:"65536" maximum:"536870912"`
|
||||||
|
MaxCosmeticSize *int64 `json:"max_cosmetic_size,omitempty" minimum:"65536" maximum:"536870912"`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) (*instanceSettingsOutput, error) {
|
) (*instanceSettingsOutput, error) {
|
||||||
@@ -169,6 +200,17 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
if input.Body.MaxMessageLength != nil {
|
if input.Body.MaxMessageLength != nil {
|
||||||
updates["max_message_length"] = strconv.Itoa(*input.Body.MaxMessageLength)
|
updates["max_message_length"] = strconv.Itoa(*input.Body.MaxMessageLength)
|
||||||
}
|
}
|
||||||
|
for key, value := range map[string]*int64{
|
||||||
|
"max_avatar_size": input.Body.MaxAvatarSize,
|
||||||
|
"max_guild_image_size": input.Body.MaxGuildImageSize,
|
||||||
|
"max_emoji_size": input.Body.MaxEmojiSize,
|
||||||
|
"max_sound_size": input.Body.MaxSoundSize,
|
||||||
|
"max_cosmetic_size": input.Body.MaxCosmeticSize,
|
||||||
|
} {
|
||||||
|
if value != nil {
|
||||||
|
updates[key] = strconv.FormatInt(*value, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
for key, value := range updates {
|
for key, value := range updates {
|
||||||
if err := s.store.SetInstanceSetting(ctx, key, value); err != nil {
|
if err := s.store.SetInstanceSetting(ctx, key, value); err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
@@ -180,13 +222,7 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
}
|
}
|
||||||
s.recordAudit(ctx, user, 0, "instance.settings_update", "instance", nil, "")
|
s.recordAudit(ctx, user, 0, "instance.settings_update", "instance", nil, "")
|
||||||
output := &instanceSettingsOutput{}
|
output := &instanceSettingsOutput{}
|
||||||
output.Body.Settings = instanceSettingsPayload{
|
output.Body.Settings = instanceSettingsFromStore(settings)
|
||||||
RegistrationEnabled: settings.RegistrationEnabled,
|
|
||||||
AllowGuildCreation: settings.AllowGuildCreation,
|
|
||||||
MaxGuildsPerUser: settings.MaxGuildsPerUser,
|
|
||||||
MaxMembersPerGuild: settings.MaxMembersPerGuild,
|
|
||||||
MaxMessageLength: settings.MaxMessageLength,
|
|
||||||
}
|
|
||||||
return output, nil
|
return output, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -392,6 +428,166 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
return output, nil
|
return output, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "adminResetUserPassword",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/instance/users/{user_id}/reset-password",
|
||||||
|
Summary: "Сбросить пароль пользователя (администратор)",
|
||||||
|
Tags: []string{"Instance"},
|
||||||
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
UserID string `path:"user_id"`
|
||||||
|
},
|
||||||
|
) (*adminPasswordOutput, error) {
|
||||||
|
admin, err := requireInstanceAdmin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
targetID, err := parseID("user_id", input.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
target, err := s.store.GetUser(ctx, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
// Пароль показывается один раз: администратор передаёт его владельцу.
|
||||||
|
password, err := newTemporaryPassword()
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
// Считаем сессии до смены пароля: SetPassword их уже отзывает.
|
||||||
|
revoked, err := s.store.RevokeUserSessions(ctx, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if err := s.auth.SetPassword(ctx, target.ID, password); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, admin, 0, "instance.user_password_reset", "user", &target.ID, "")
|
||||||
|
output := &adminPasswordOutput{}
|
||||||
|
output.Body.UserID = formatSnowflake(target.ID)
|
||||||
|
output.Body.Password = password
|
||||||
|
output.Body.SessionsRevoked = revoked
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "adminLogoutUser",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/instance/users/{user_id}/logout",
|
||||||
|
Summary: "Выйти со всех устройств пользователя",
|
||||||
|
Tags: []string{"Instance"},
|
||||||
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
UserID string `path:"user_id"`
|
||||||
|
},
|
||||||
|
) (*okOutput, error) {
|
||||||
|
admin, err := requireInstanceAdmin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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.store.RevokeUserSessions(ctx, targetID); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, admin, 0, "instance.user_logout", "user", &targetID, "")
|
||||||
|
return newOKOutput(), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "adminDeleteUser",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Path: "/instance/users/{user_id}",
|
||||||
|
Summary: "Удалить пользователя (администратор)",
|
||||||
|
Tags: []string{"Instance"},
|
||||||
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
UserID string `path:"user_id"`
|
||||||
|
},
|
||||||
|
) (*okOutput, error) {
|
||||||
|
admin, err := requireInstanceAdmin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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 delete yourself")
|
||||||
|
}
|
||||||
|
if _, err := s.store.GetUser(ctx, targetID); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
// Мягкое удаление: сообщения и аудит остаются (AGENT.md 6.4).
|
||||||
|
if err := s.store.SoftDeleteUser(ctx, targetID); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if s.gateway != nil {
|
||||||
|
s.gateway.SendToUser(targetID, "SESSION_INVALIDATED", map[string]any{"reason": "user_deleted"})
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, admin, 0, "instance.user_delete", "user", &targetID, "")
|
||||||
|
return newOKOutput(), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "adminUpdateGuild",
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Path: "/instance/guilds/{guild_id}",
|
||||||
|
Summary: "Переименовать сервер (администратор инстанса)",
|
||||||
|
Tags: []string{"Instance"},
|
||||||
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
GuildID string `path:"guild_id"`
|
||||||
|
Body struct {
|
||||||
|
Name *string `json:"name,omitempty" maxLength:"64"`
|
||||||
|
Description *string `json:"description,omitempty" maxLength:"400"`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) (*okOutput, error) {
|
||||||
|
admin, err := requireInstanceAdmin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guildID, err := parseID("guild_id", input.GuildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guild, err := s.store.GetGuild(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if input.Body.Name != nil {
|
||||||
|
name := strings.TrimSpace(*input.Body.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty")
|
||||||
|
}
|
||||||
|
guild.Name = name
|
||||||
|
}
|
||||||
|
if input.Body.Description != nil {
|
||||||
|
guild.Description = strings.TrimSpace(*input.Body.Description)
|
||||||
|
}
|
||||||
|
updated, err := s.store.UpdateGuild(ctx, guildID, store.UpdateGuildParams{
|
||||||
|
Name: &guild.Name,
|
||||||
|
Description: &guild.Description,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
s.invalidateGuild(guildID)
|
||||||
|
s.dispatchGuildUpdate(*updated)
|
||||||
|
s.recordAudit(ctx, admin, guildID, "instance.guild_update", "guild", &guildID, "")
|
||||||
|
return newOKOutput(), nil
|
||||||
|
})
|
||||||
|
|
||||||
huma.Register(api, huma.Operation{
|
huma.Register(api, huma.Operation{
|
||||||
OperationID: "listInstanceAudit",
|
OperationID: "listInstanceAudit",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
@@ -473,3 +669,23 @@ func (s *Server) createGuildAsAdmin(ctx context.Context, admin, owner *store.Use
|
|||||||
}
|
}
|
||||||
return guild, nil
|
return guild, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// temporaryPasswordAlphabet — символы временного пароля: без похожих друг на
|
||||||
|
// друга, чтобы его можно было продиктовать.
|
||||||
|
const temporaryPasswordAlphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
// newTemporaryPassword генерирует пароль для выдачи пользователю: 16 символов
|
||||||
|
// из криптографического источника (AGENT.md 9.2).
|
||||||
|
func newTemporaryPassword() (string, error) {
|
||||||
|
const length = 16
|
||||||
|
buf := make([]byte, length)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.Grow(length)
|
||||||
|
for _, value := range buf {
|
||||||
|
builder.WriteByte(temporaryPasswordAlphabet[int(value)%len(temporaryPasswordAlphabet)])
|
||||||
|
}
|
||||||
|
return builder.String(), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -442,9 +442,6 @@ func newInviteCode() (string, error) {
|
|||||||
return builder.String(), nil
|
return builder.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxInviteBackgroundSize — предел размера переопределения splash (AGENT.md 7.7).
|
|
||||||
const maxInviteBackgroundSize = 5 << 20
|
|
||||||
|
|
||||||
// registerInviteBackgroundRoutes описывает картинку приглашения: создатель
|
// registerInviteBackgroundRoutes описывает картинку приглашения: создатель
|
||||||
// приглашения или MANAGE_GUILD сервера может задать своё оформление страницы
|
// приглашения или MANAGE_GUILD сервера может задать своё оформление страницы
|
||||||
// (AGENT.md 7.9). Ручка multipart, поэтому живёт на роутере.
|
// (AGENT.md 7.9). Ручка multipart, поэтому живёт на роутере.
|
||||||
@@ -464,18 +461,19 @@ func (s *Server) handleInviteBackgroundUpload(w http.ResponseWriter, r *http.Req
|
|||||||
writeHumaAPIError(w, err)
|
writeHumaAPIError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
limit := s.mediaLimit(ctx, mediaGuildImage)
|
||||||
file, header, err := r.FormFile("file")
|
file, header, err := r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
if header.Size > maxInviteBackgroundSize {
|
if header.Size > limit {
|
||||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large")
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := io.ReadAll(io.LimitReader(file, maxInviteBackgroundSize+1))
|
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||||
if err != nil || int64(len(data)) > maxInviteBackgroundSize {
|
if err != nil || int64(len(data)) > limit {
|
||||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large")
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
|
|
||||||
// Лимиты звуков сервера (AGENT.md 7.13).
|
// Лимиты звуков сервера (AGENT.md 7.13).
|
||||||
const (
|
const (
|
||||||
maxSoundSize = 512 << 10
|
|
||||||
maxSoundboardSounds = 30
|
maxSoundboardSounds = 30
|
||||||
maxUISounds = 30
|
maxUISounds = 30
|
||||||
)
|
)
|
||||||
@@ -275,7 +274,8 @@ func (s *Server) handleSoundUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxSoundSize+maxMultipartOverhead)
|
sizeLimit := s.mediaLimit(ctx, mediaSound)
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, sizeLimit+maxMultipartOverhead)
|
||||||
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader
|
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader
|
||||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
|
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
|
||||||
return
|
return
|
||||||
@@ -306,12 +306,12 @@ func (s *Server) handleSoundUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
if header.Size > maxSoundSize {
|
if header.Size > sizeLimit {
|
||||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "звук больше 512 КБ")
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "звук больше 512 КБ")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := io.ReadAll(io.LimitReader(file, maxSoundSize+1))
|
data, err := io.ReadAll(io.LimitReader(file, sizeLimit+1))
|
||||||
if err != nil || len(data) > maxSoundSize {
|
if err != nil || int64(len(data)) > sizeLimit {
|
||||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "звук больше 512 КБ")
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "звук больше 512 КБ")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -804,3 +804,144 @@ func TestStaticServesPWAAssets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInstanceAdminActions(t *testing.T) {
|
||||||
|
srv, admin, member, guildID, memberID := cosmeticsFixture(t)
|
||||||
|
// Первый зарегистрированный пользователь — администратор инстанса.
|
||||||
|
promoteAdmin(t, srv, "ban-owner@example.com")
|
||||||
|
|
||||||
|
// Переименование чужого сервера администратором.
|
||||||
|
renamed := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/guilds/"+guildID,
|
||||||
|
`{"name":"Новое имя","description":"Описание"}`, admin)
|
||||||
|
if renamed.Code != http.StatusOK {
|
||||||
|
t.Fatalf("переименование сервера = %d (%s)", renamed.Code, renamed.Body.String())
|
||||||
|
}
|
||||||
|
detail := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID, "", admin)
|
||||||
|
guild := decodeResponse[struct {
|
||||||
|
Guild struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"guild"`
|
||||||
|
}](t, detail).Guild
|
||||||
|
if guild.Name != "Новое имя" || guild.Description != "Описание" {
|
||||||
|
t.Fatalf("сервер не обновлён: %+v", guild)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сначала проверим, что обычный участник админ-ручки не трогает: дальше его
|
||||||
|
// сессия будет отозвана сбросом пароля.
|
||||||
|
if rec := doJSON(t, srv, http.MethodDelete, "/api/v1/instance/users/"+memberID, "", member); rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("удаление без прав = %d, ожидался 403 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сброс пароля участнику: пароль выдаётся один раз, сессии отзываются.
|
||||||
|
before := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
|
||||||
|
`{"email":"ban-member@example.com","password":"correct-horse-battery"}`)
|
||||||
|
if before.Code != http.StatusOK {
|
||||||
|
t.Fatalf("вход до сброса = %d (%s)", before.Code, before.Body.String())
|
||||||
|
}
|
||||||
|
reset := doJSON(t, srv, http.MethodPost,
|
||||||
|
"/api/v1/instance/users/"+memberID+"/reset-password", "", admin)
|
||||||
|
if reset.Code != http.StatusOK {
|
||||||
|
t.Fatalf("сброс пароля = %d (%s)", reset.Code, reset.Body.String())
|
||||||
|
}
|
||||||
|
password := decodeResponse[struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
SessionsRevoked int64 `json:"sessions_revoked"`
|
||||||
|
}](t, reset)
|
||||||
|
if len(password.Password) < 12 {
|
||||||
|
t.Fatalf("временный пароль слишком короткий: %q", password.Password)
|
||||||
|
}
|
||||||
|
if password.SessionsRevoked < 1 {
|
||||||
|
t.Fatalf("сессии не отозваны: %d", password.SessionsRevoked)
|
||||||
|
}
|
||||||
|
// Старый пароль больше не работает, новый — работает.
|
||||||
|
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
|
||||||
|
`{"email":"ban-member@example.com","password":"correct-horse-battery"}`); rec.Code == http.StatusOK {
|
||||||
|
t.Fatal("старый пароль продолжает работать")
|
||||||
|
}
|
||||||
|
relogin := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
|
||||||
|
`{"email":"ban-member@example.com","password":"`+password.Password+`"}`)
|
||||||
|
if relogin.Code != http.StatusOK {
|
||||||
|
t.Fatalf("вход с новым паролем = %d (%s)", relogin.Code, relogin.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выход со всех устройств.
|
||||||
|
logout := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+memberID+"/logout", "", admin)
|
||||||
|
if logout.Code != http.StatusOK {
|
||||||
|
t.Fatalf("выход со всех устройств = %d (%s)", logout.Code, logout.Body.String())
|
||||||
|
}
|
||||||
|
if rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", member); rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("сессия участника жива: %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Администратор удаляет пользователя: мягкое удаление (AGENT.md 6.4).
|
||||||
|
deleted := doJSON(t, srv, http.MethodDelete, "/api/v1/instance/users/"+memberID, "", admin)
|
||||||
|
if deleted.Code != http.StatusOK {
|
||||||
|
t.Fatalf("удаление пользователя = %d (%s)", deleted.Code, deleted.Body.String())
|
||||||
|
}
|
||||||
|
users := doJSON(t, srv, http.MethodGet, "/api/v1/instance/users", "", admin)
|
||||||
|
list := decodeResponse[struct {
|
||||||
|
Users []struct {
|
||||||
|
UserID string `json:"id"`
|
||||||
|
} `json:"users"`
|
||||||
|
}](t, users)
|
||||||
|
for _, user := range list.Users {
|
||||||
|
if user.UserID == memberID {
|
||||||
|
t.Fatal("удалённый пользователь остался в списке")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstanceMediaLimitsEnforced(t *testing.T) {
|
||||||
|
srv, admin, _, guildID, _ := cosmeticsFixture(t)
|
||||||
|
promoteAdmin(t, srv, "ban-owner@example.com")
|
||||||
|
|
||||||
|
// Ограничиваем эмодзи 64 КБ и пробуем загрузить картинку больше лимита.
|
||||||
|
patched := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings",
|
||||||
|
`{"max_emoji_size":65536}`, admin)
|
||||||
|
if patched.Code != http.StatusOK {
|
||||||
|
t.Fatalf("настройки = %d (%s)", patched.Code, patched.Body.String())
|
||||||
|
}
|
||||||
|
settings := doJSON(t, srv, http.MethodGet, "/api/v1/instance/settings", "", admin)
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Settings struct {
|
||||||
|
MaxEmojiSize int64 `json:"max_emoji_size"`
|
||||||
|
} `json:"settings"`
|
||||||
|
}](t, settings)
|
||||||
|
if payload.Settings.MaxEmojiSize != 65536 {
|
||||||
|
t.Fatalf("лимит эмодзи = %d", payload.Settings.MaxEmojiSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
big := make([]byte, 70<<10)
|
||||||
|
copy(big, pngBytes())
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&buf)
|
||||||
|
if err := writer.WriteField("name", "big_emoji"); err != nil {
|
||||||
|
t.Fatalf("multipart name: %v", err)
|
||||||
|
}
|
||||||
|
part, err := writer.CreateFormFile("file", "big.png")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("multipart file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(big); err != nil {
|
||||||
|
t.Fatalf("multipart write: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("multipart close: %v", err)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||||
|
"/api/v1/guilds/"+guildID+"/emojis", &buf)
|
||||||
|
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
request.AddCookie(admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, request)
|
||||||
|
if rec.Code != http.StatusRequestEntityTooLarge && rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("загрузка сверх лимита = %d, ожидался отказ (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращаем лимит по умолчанию.
|
||||||
|
if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings",
|
||||||
|
`{"max_emoji_size":524288}`, admin); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("возврат лимита = %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Лимиты медиа (AGENT.md 7.7): значения живут в настройках инстанса и меняются
|
||||||
|
// в админ-панели, а обработчики загрузок спрашивают их здесь.
|
||||||
|
|
||||||
|
// Пределы по умолчанию (AGENT.md 7.7): 5 МБ — аватары, баннеры и оформление,
|
||||||
|
// 512 КБ — эмодзи и звуки. Администратор инстанса меняет их в админ-панели.
|
||||||
|
const (
|
||||||
|
maxAvatarSize int64 = 5 << 20
|
||||||
|
maxGuildImageSize int64 = 5 << 20
|
||||||
|
maxEmojiSize int64 = 512 << 10
|
||||||
|
maxSoundSize int64 = 512 << 10
|
||||||
|
maxCosmeticSize int64 = 5 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// mediaKind — вид загрузки, для которого действует свой лимит.
|
||||||
|
type mediaKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
mediaAvatar mediaKind = "avatar"
|
||||||
|
mediaGuildImage mediaKind = "guild_image"
|
||||||
|
mediaEmoji mediaKind = "emoji"
|
||||||
|
mediaSound mediaKind = "sound"
|
||||||
|
mediaCosmetic mediaKind = "cosmetic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mediaLimit возвращает предел размера файла в байтах. Настройки читаются на
|
||||||
|
// каждую загрузку: админ мог поменять лимит только что, а кэш настроек здесь не
|
||||||
|
// нужен — запрос к базе дешёвый.
|
||||||
|
func (s *Server) mediaLimit(ctx context.Context, kind mediaKind) int64 {
|
||||||
|
fallback := defaultMediaLimit(kind)
|
||||||
|
if s.store == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
settings, err := s.store.InstanceSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.WarnContext(ctx, "failed to read media limits", slog.Any("error", err))
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case mediaAvatar:
|
||||||
|
return settings.MaxAvatarSize
|
||||||
|
case mediaGuildImage:
|
||||||
|
return settings.MaxGuildImageSize
|
||||||
|
case mediaEmoji:
|
||||||
|
return settings.MaxEmojiSize
|
||||||
|
case mediaSound:
|
||||||
|
return settings.MaxSoundSize
|
||||||
|
case mediaCosmetic:
|
||||||
|
return settings.MaxCosmeticSize
|
||||||
|
default:
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultMediaLimit — значения по умолчанию, если настройка недоступна:
|
||||||
|
// именованные константы рядом с обработчиками задают те же пределы.
|
||||||
|
func defaultMediaLimit(kind mediaKind) int64 {
|
||||||
|
switch kind {
|
||||||
|
case mediaAvatar:
|
||||||
|
return maxAvatarSize
|
||||||
|
case mediaGuildImage:
|
||||||
|
return maxGuildImageSize
|
||||||
|
case mediaEmoji:
|
||||||
|
return maxEmojiSize
|
||||||
|
case mediaSound:
|
||||||
|
return maxSoundSize
|
||||||
|
case mediaCosmetic:
|
||||||
|
return maxCosmeticSize
|
||||||
|
default:
|
||||||
|
return maxGuildImageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,9 +19,24 @@ type InstanceSettings struct {
|
|||||||
MaxMembersPerGuild int
|
MaxMembersPerGuild int
|
||||||
MaxMessageLength int
|
MaxMessageLength int
|
||||||
AuditRetentionDays int
|
AuditRetentionDays int
|
||||||
|
// Лимиты медиа в байтах (AGENT.md 7.7): 0 — значение по умолчанию.
|
||||||
|
MaxAvatarSize int64
|
||||||
|
MaxGuildImageSize int64
|
||||||
|
MaxEmojiSize int64
|
||||||
|
MaxSoundSize int64
|
||||||
|
MaxCosmeticSize int64
|
||||||
raw map[string]string
|
raw map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MediaLimits по умолчанию: совпадают со спецификацией 7.7.
|
||||||
|
const (
|
||||||
|
DefaultAvatarSize int64 = 5 << 20
|
||||||
|
DefaultGuildImageSize int64 = 5 << 20
|
||||||
|
DefaultEmojiSize int64 = 512 << 10
|
||||||
|
DefaultSoundSize int64 = 512 << 10
|
||||||
|
DefaultCosmeticSize int64 = 5 << 20
|
||||||
|
)
|
||||||
|
|
||||||
const instanceSettingsQuery = `SELECT key, value FROM instance_settings`
|
const instanceSettingsQuery = `SELECT key, value FROM instance_settings`
|
||||||
|
|
||||||
func (s *Store) InstanceSettings(ctx context.Context) (*InstanceSettings, error) {
|
func (s *Store) InstanceSettings(ctx context.Context) (*InstanceSettings, error) {
|
||||||
@@ -53,6 +69,11 @@ func settingsFromMap(values map[string]string) *InstanceSettings {
|
|||||||
MaxMembersPerGuild: parseSettingInt(values["max_members_per_guild"], 250),
|
MaxMembersPerGuild: parseSettingInt(values["max_members_per_guild"], 250),
|
||||||
MaxMessageLength: parseSettingInt(values["max_message_length"], 4000),
|
MaxMessageLength: parseSettingInt(values["max_message_length"], 4000),
|
||||||
AuditRetentionDays: parseSettingInt(values["audit_retention_days"], 90),
|
AuditRetentionDays: parseSettingInt(values["audit_retention_days"], 90),
|
||||||
|
MaxAvatarSize: parseSettingInt64(values["max_avatar_size"], DefaultAvatarSize),
|
||||||
|
MaxGuildImageSize: parseSettingInt64(values["max_guild_image_size"], DefaultGuildImageSize),
|
||||||
|
MaxEmojiSize: parseSettingInt64(values["max_emoji_size"], DefaultEmojiSize),
|
||||||
|
MaxSoundSize: parseSettingInt64(values["max_sound_size"], DefaultSoundSize),
|
||||||
|
MaxCosmeticSize: parseSettingInt64(values["max_cosmetic_size"], DefaultCosmeticSize),
|
||||||
raw: values,
|
raw: values,
|
||||||
}
|
}
|
||||||
if mainGuild := values["main_guild_id"]; mainGuild != "" {
|
if mainGuild := values["main_guild_id"]; mainGuild != "" {
|
||||||
@@ -161,6 +182,18 @@ func optionalID(value sql.NullInt64) *uint64 {
|
|||||||
return &converted
|
return &converted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseSettingInt64 читает настройку-размер в байтах.
|
||||||
|
func parseSettingInt64(value string, fallback int64) int64 {
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
func parseSettingInt(value string, fallback int) int {
|
func parseSettingInt(value string, fallback int) int {
|
||||||
parsed, ok := parseUint(value)
|
parsed, ok := parseUint(value)
|
||||||
if !ok || parsed > math.MaxInt32 {
|
if !ok || parsed > math.MaxInt32 {
|
||||||
|
|||||||
@@ -53,3 +53,46 @@ func (s *Store) RebuildSearchIndex(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RevokeUserSessions удаляет все сессии пользователя: администратор инстанса
|
||||||
|
// выкидывает его со всех устройств (AGENT.md 7.19).
|
||||||
|
func (s *Store) RevokeUserSessions(ctx context.Context, userID uint64) (int64, error) {
|
||||||
|
result, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, int64(userID))
|
||||||
|
if err != nil {
|
||||||
|
return 0, mapError(err)
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return affected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDeleteUser помечает пользователя удалённым и убирает его из серверов:
|
||||||
|
// сообщения остаются, чтобы история и ссылки не ломались (AGENT.md 6.4).
|
||||||
|
func (s *Store) SoftDeleteUser(ctx context.Context, userID uint64) error {
|
||||||
|
tx, err := s.writer.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE users SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||||
|
s.Now(), s.Now(), int64(userID)); err != nil {
|
||||||
|
return mapError(err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM guild_members WHERE user_id = ?`, int64(userID)); err != nil {
|
||||||
|
return mapError(err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, int64(userID)); err != nil {
|
||||||
|
return mapError(err)
|
||||||
|
}
|
||||||
|
// Личные связи и блокировки удалённого пользователя больше не нужны.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`DELETE FROM relationships WHERE user_id = ? OR target_id = ?`,
|
||||||
|
int64(userID), int64(userID)); err != nil {
|
||||||
|
return mapError(err)
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import { request } from './client';
|
|||||||
import type { AuditEntry, InstanceGuild, InstanceInfo, InstanceUser } from './types';
|
import type { AuditEntry, InstanceGuild, InstanceInfo, InstanceUser } from './types';
|
||||||
|
|
||||||
export interface InstanceSettings {
|
export interface InstanceSettings {
|
||||||
|
registration_enabled: boolean;
|
||||||
|
allow_guild_creation: boolean;
|
||||||
|
max_guilds_per_user: number;
|
||||||
|
max_members_per_guild: number;
|
||||||
|
max_message_length: number;
|
||||||
|
/** Лимиты медиа в байтах (AGENT.md 7.7): действуют на все сервера. */
|
||||||
|
max_avatar_size: number;
|
||||||
|
max_guild_image_size: number;
|
||||||
|
max_emoji_size: number;
|
||||||
|
max_sound_size: number;
|
||||||
|
max_cosmetic_size: number;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,3 +72,48 @@ export async function fetchAudit(limit = 50, signal?: AbortSignal): Promise<Audi
|
|||||||
);
|
);
|
||||||
return payload.entries;
|
return payload.entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `POST /instance/users/{id}/admin` — выдать или снять права администратора. */
|
||||||
|
export async function setInstanceAdmin(userId: string, makeAdmin: boolean): Promise<void> {
|
||||||
|
await request(`/instance/users/${encodeURIComponent(userId)}/admin`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { admin: makeAdmin },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /instance/users/{id}/reset-password` — временный пароль (один раз). */
|
||||||
|
export async function resetUserPassword(
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ user_id: string; password: string; sessions_revoked: number }> {
|
||||||
|
const payload = await request<{ user_id: string; password: string; sessions_revoked: number }>(
|
||||||
|
`/instance/users/${encodeURIComponent(userId)}/reset-password`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /instance/users/{id}/logout` — выйти со всех устройств пользователя. */
|
||||||
|
export async function logoutUser(userId: string): Promise<void> {
|
||||||
|
await request(`/instance/users/${encodeURIComponent(userId)}/logout`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `DELETE /instance/users/{id}` — мягкое удаление пользователя. */
|
||||||
|
export async function deleteUser(userId: string): Promise<void> {
|
||||||
|
await request(`/instance/users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PATCH /instance/guilds/{id}` — переименование любого сервера (админ). */
|
||||||
|
export async function updateInstanceGuild(
|
||||||
|
guildId: string,
|
||||||
|
input: { name?: string; description?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await request(`/instance/guilds/${encodeURIComponent(guildId)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `DELETE /instance/guilds/{id}` — удаление любого сервера (админ). */
|
||||||
|
export async function deleteInstanceGuild(guildId: string): Promise<void> {
|
||||||
|
await request(`/instance/guilds/${encodeURIComponent(guildId)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteInstanceGuild,
|
||||||
|
instanceGuildsQueryKey,
|
||||||
|
instanceUsersQueryKey,
|
||||||
|
updateInstanceGuild,
|
||||||
|
} from '@/api/instance';
|
||||||
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Field } from '@/components/ui/Field';
|
||||||
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import type { InstanceGuild } from '@/api/types';
|
||||||
|
|
||||||
|
interface InstanceGuildsPanelProps {
|
||||||
|
guilds: InstanceGuild[];
|
||||||
|
isPending: boolean;
|
||||||
|
error: unknown;
|
||||||
|
onRetry: () => void;
|
||||||
|
/** Открыть настройки сервера: админ правит комнаты, роли и ники там. */
|
||||||
|
onOpenSettings: (guildId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Серверы инстанса (AGENT.md 7.19): администратор переименовывает и удаляет
|
||||||
|
* любой сервер и открывает его настройки — комнаты, роли и ники правятся
|
||||||
|
* обычными разделами, права администратора инстанса действуют везде.
|
||||||
|
*/
|
||||||
|
export function InstanceGuildsPanel({
|
||||||
|
guilds,
|
||||||
|
isPending,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
onOpenSettings,
|
||||||
|
}: InstanceGuildsPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [renaming, setRenaming] = useState<InstanceGuild | null>(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [actionError, setActionError] = useState<unknown>(null);
|
||||||
|
|
||||||
|
const invalidate = (): void => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: instanceGuildsQueryKey });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
|
||||||
|
};
|
||||||
|
|
||||||
|
const rename = useMutation({
|
||||||
|
mutationFn: (input: { id: string; name: string }) =>
|
||||||
|
updateInstanceGuild(input.id, { name: input.name }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setActionError(null);
|
||||||
|
setRenaming(null);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (cause: unknown) => setActionError(cause),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (guildId: string) => deleteInstanceGuild(guildId),
|
||||||
|
onSuccess: () => {
|
||||||
|
setActionError(null);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (cause: unknown) => setActionError(cause),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = rename.isPending || remove.isPending;
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <p className="mt-2 text-sm text-fg-muted">{t('common.loading')}</p>;
|
||||||
|
}
|
||||||
|
if (error !== null && error !== undefined) {
|
||||||
|
return <ErrorNotice className="mt-3" error={error} onRetry={onRetry} />;
|
||||||
|
}
|
||||||
|
if (guilds.length === 0) {
|
||||||
|
return <p className="mt-2 text-sm text-fg-muted">{t('settings.instance.guildsEmpty')}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex flex-col gap-2" data-testid="instance-guilds-actions">
|
||||||
|
{actionError === null ? null : <ErrorNotice error={actionError} />}
|
||||||
|
<ul className="flex flex-col gap-1 text-sm" data-testid="instance-guilds">
|
||||||
|
{guilds.map((guild) => (
|
||||||
|
<li
|
||||||
|
key={guild.id}
|
||||||
|
data-testid={`instance-guild-${guild.id}`}
|
||||||
|
className="flex flex-wrap items-center gap-2 border-b border-border/40 py-2 last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{guild.name}</span>
|
||||||
|
<span className="shrink-0 text-xs text-fg-muted">
|
||||||
|
{guild.is_main ? `${t('settings.instance.mainBadge')} · ` : ''}
|
||||||
|
{t('settings.instance.memberCount', { count: guild.member_count })}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid={`instance-guild-open-${guild.id}`}
|
||||||
|
onClick={() => onOpenSettings(guild.id)}
|
||||||
|
>
|
||||||
|
{t('settings.instance.actions.openSettings')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid={`instance-guild-rename-${guild.id}`}
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
setRenaming(guild);
|
||||||
|
setName(guild.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.instance.actions.rename')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="border border-danger/50 text-danger"
|
||||||
|
data-testid={`instance-guild-delete-${guild.id}`}
|
||||||
|
disabled={pending || guild.is_main}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
t('settings.instance.actions.deleteGuildConfirm', { name: guild.name }),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
remove.mutate(guild.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{renaming === null ? null : (
|
||||||
|
<form
|
||||||
|
className="flex items-end gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (renaming !== null && name.trim() !== '') {
|
||||||
|
rename.mutate({ id: renaming.id, name: name.trim() });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.actions.newName')}
|
||||||
|
value={name}
|
||||||
|
maxLength={64}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={rename.isPending}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setRenaming(null)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { updateInstanceSettings, type InstanceSettings } from '@/api/instance';
|
||||||
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Field } from '@/components/ui/Field';
|
||||||
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
|
||||||
|
interface InstanceLimitsPanelProps {
|
||||||
|
settings: InstanceSettings;
|
||||||
|
onSaved: (settings: InstanceSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Мегабайты в байтах: в интерфейсе лимиты показываем в МБ и КБ. */
|
||||||
|
function bytesToMb(bytes: number): string {
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mbToBytes(value: string): number | null {
|
||||||
|
const parsed = Number(value.replace(',', '.'));
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.round(parsed * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лимиты инстанса (AGENT.md 6.5, 7.7): регистрация, создание серверов,
|
||||||
|
* размеры сообщений и медиа. Лимиты медиа действуют на все сервера сразу —
|
||||||
|
* обработчики загрузок берут их из настроек.
|
||||||
|
*/
|
||||||
|
export function InstanceLimitsPanel({ settings, onSaved }: InstanceLimitsPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
registration: settings.registration_enabled,
|
||||||
|
creation: settings.allow_guild_creation,
|
||||||
|
guilds: String(settings.max_guilds_per_user),
|
||||||
|
members: String(settings.max_members_per_guild),
|
||||||
|
message: String(settings.max_message_length),
|
||||||
|
avatar: bytesToMb(settings.max_avatar_size),
|
||||||
|
guildImage: bytesToMb(settings.max_guild_image_size),
|
||||||
|
emoji: bytesToMb(settings.max_emoji_size),
|
||||||
|
sound: bytesToMb(settings.max_sound_size),
|
||||||
|
cosmetic: bytesToMb(settings.max_cosmetic_size),
|
||||||
|
});
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: (patch: Partial<InstanceSettings>) => updateInstanceSettings(patch),
|
||||||
|
onSuccess: (updated) => {
|
||||||
|
setError(null);
|
||||||
|
// Форму приводим к ответу сервера: он источник правды по лимитам.
|
||||||
|
setForm({
|
||||||
|
registration: updated.registration_enabled,
|
||||||
|
creation: updated.allow_guild_creation,
|
||||||
|
guilds: String(updated.max_guilds_per_user),
|
||||||
|
members: String(updated.max_members_per_guild),
|
||||||
|
message: String(updated.max_message_length),
|
||||||
|
avatar: bytesToMb(updated.max_avatar_size),
|
||||||
|
guildImage: bytesToMb(updated.max_guild_image_size),
|
||||||
|
emoji: bytesToMb(updated.max_emoji_size),
|
||||||
|
sound: bytesToMb(updated.max_sound_size),
|
||||||
|
cosmetic: bytesToMb(updated.max_cosmetic_size),
|
||||||
|
});
|
||||||
|
onSaved(updated);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (): void => {
|
||||||
|
const sizes = {
|
||||||
|
max_avatar_size: mbToBytes(form.avatar),
|
||||||
|
max_guild_image_size: mbToBytes(form.guildImage),
|
||||||
|
max_emoji_size: mbToBytes(form.emoji),
|
||||||
|
max_sound_size: mbToBytes(form.sound),
|
||||||
|
max_cosmetic_size: mbToBytes(form.cosmetic),
|
||||||
|
};
|
||||||
|
if (Object.values(sizes).some((value) => value === null)) {
|
||||||
|
setError(t('settings.instance.limits.invalidSize'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const guilds = Number(form.guilds);
|
||||||
|
const members = Number(form.members);
|
||||||
|
const message = Number(form.message);
|
||||||
|
if (!Number.isFinite(guilds) || !Number.isFinite(members) || !Number.isFinite(message)) {
|
||||||
|
setError(t('settings.instance.limits.invalidNumber'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
// Размеры проверены выше: null уже отсеян, здесь только числа.
|
||||||
|
save.mutate({
|
||||||
|
registration_enabled: form.registration,
|
||||||
|
allow_guild_creation: form.creation,
|
||||||
|
max_guilds_per_user: guilds,
|
||||||
|
max_members_per_guild: members,
|
||||||
|
max_message_length: message,
|
||||||
|
max_avatar_size: sizes.max_avatar_size ?? settings.max_avatar_size,
|
||||||
|
max_guild_image_size: sizes.max_guild_image_size ?? settings.max_guild_image_size,
|
||||||
|
max_emoji_size: sizes.max_emoji_size ?? settings.max_emoji_size,
|
||||||
|
max_sound_size: sizes.max_sound_size ?? settings.max_sound_size,
|
||||||
|
max_cosmetic_size: sizes.max_cosmetic_size ?? settings.max_cosmetic_size,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-3 flex flex-col gap-4"
|
||||||
|
data-testid="instance-limits"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.registration}
|
||||||
|
data-testid="instance-registration"
|
||||||
|
onChange={(event) => setForm({ ...form, registration: event.target.checked })}
|
||||||
|
/>
|
||||||
|
{t('settings.instance.limits.registration')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.creation}
|
||||||
|
data-testid="instance-creation"
|
||||||
|
onChange={(event) => setForm({ ...form, creation: event.target.checked })}
|
||||||
|
/>
|
||||||
|
{t('settings.instance.limits.creation')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.limits.guilds')}
|
||||||
|
type="number"
|
||||||
|
value={form.guilds}
|
||||||
|
onChange={(event) => setForm({ ...form, guilds: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.limits.members')}
|
||||||
|
type="number"
|
||||||
|
value={form.members}
|
||||||
|
onChange={(event) => setForm({ ...form, members: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.limits.message')}
|
||||||
|
type="number"
|
||||||
|
value={form.message}
|
||||||
|
onChange={(event) => setForm({ ...form, message: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Лимиты медиа: в мегабайтах, сервер получает байты. */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.limits.avatar')}
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={form.avatar}
|
||||||
|
onChange={(event) => setForm({ ...form, avatar: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.limits.guildImage')}
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={form.guildImage}
|
||||||
|
onChange={(event) => setForm({ ...form, guildImage: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.limits.cosmetic')}
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={form.cosmetic}
|
||||||
|
onChange={(event) => setForm({ ...form, cosmetic: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.limits.emoji')}
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={form.emoji}
|
||||||
|
onChange={(event) => setForm({ ...form, emoji: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.limits.sound')}
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={form.sound}
|
||||||
|
onChange={(event) => setForm({ ...form, sound: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error === null ? null : <ErrorNotice error={new Error(error)} />}
|
||||||
|
{save.isError ? <ErrorNotice error={save.error} /> : null}
|
||||||
|
{save.isSuccess ? (
|
||||||
|
<p className="text-sm text-success" role="status">
|
||||||
|
{t('settings.instance.limits.saved')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" data-testid="instance-limits-save" disabled={save.isPending}>
|
||||||
|
{t(save.isPending ? 'common.saving' : 'common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { deleteUser, instanceUsersQueryKey, logoutUser, resetUserPassword } from '@/api/instance';
|
||||||
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Field } from '@/components/ui/Field';
|
||||||
|
import { stepUp } from '@/api/auth';
|
||||||
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface InstanceUsersPanelProps {
|
||||||
|
/** Свой id: себя удалять нельзя (сервер тоже это проверяет). */
|
||||||
|
currentUserId: string | undefined;
|
||||||
|
/** Признак, что список загружен: панель показывает действия. */
|
||||||
|
users: { id: string; username: string; display_name: string; is_instance_admin: boolean }[];
|
||||||
|
isPending: boolean;
|
||||||
|
error: unknown;
|
||||||
|
onRetry: () => void;
|
||||||
|
/** Выдать или снять права администратора инстанса. */
|
||||||
|
onToggleAdmin: (userId: string, makeAdmin: boolean) => void;
|
||||||
|
adminPending: boolean;
|
||||||
|
/** Ошибка смены прав: сервер требует step-up или отказывает. */
|
||||||
|
adminError?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Действия администратора инстанса над пользователем (AGENT.md 7.19):
|
||||||
|
* временный пароль, выход со всех устройств и мягкое удаление.
|
||||||
|
*/
|
||||||
|
export function InstanceUsersPanel({
|
||||||
|
currentUserId,
|
||||||
|
users,
|
||||||
|
isPending,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
onToggleAdmin,
|
||||||
|
adminPending,
|
||||||
|
adminError,
|
||||||
|
}: InstanceUsersPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
const [password, setPassword] = useState<{ userId: string; value: string } | null>(null);
|
||||||
|
const [actionError, setActionError] = useState<unknown>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
// Смена администраторов требует подтверждения личности (AGENT.md 7.1).
|
||||||
|
const [stepUpFor, setStepUpFor] = useState<{ userId: string; makeAdmin: boolean } | null>(null);
|
||||||
|
const [stepUpPassword, setStepUpPassword] = useState('');
|
||||||
|
const [stepUpCode, setStepUpCode] = useState('');
|
||||||
|
|
||||||
|
const confirm = useMutation({
|
||||||
|
mutationFn: () => stepUp(stepUpPassword, stepUpCode === '' ? undefined : stepUpCode),
|
||||||
|
onSuccess: () => {
|
||||||
|
const target = stepUpFor;
|
||||||
|
setStepUpPassword('');
|
||||||
|
setStepUpCode('');
|
||||||
|
if (target !== null) {
|
||||||
|
onToggleAdmin(target.userId, target.makeAdmin);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пароль показывается один раз: через минуту убираем из интерфейса.
|
||||||
|
useEffect(() => {
|
||||||
|
if (password === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => setPassword(null), 60_000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [password]);
|
||||||
|
|
||||||
|
const invalidate = (): void => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = useMutation({
|
||||||
|
mutationFn: (userId: string) => resetUserPassword(userId),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setActionError(null);
|
||||||
|
setPassword({ userId: result.user_id, value: result.password });
|
||||||
|
},
|
||||||
|
onError: (cause: unknown) => setActionError(cause),
|
||||||
|
});
|
||||||
|
|
||||||
|
const logout = useMutation({
|
||||||
|
mutationFn: (userId: string) => logoutUser(userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
setActionError(null);
|
||||||
|
setNotice(t('settings.instance.actions.loggedOut'));
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (cause: unknown) => setActionError(cause),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (userId: string) => deleteUser(userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
setActionError(null);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (cause: unknown) => setActionError(cause),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = reset.isPending || logout.isPending || remove.isPending || adminPending;
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <p className="mt-2 text-sm text-fg-muted">{t('common.loading')}</p>;
|
||||||
|
}
|
||||||
|
if (error !== null && error !== undefined) {
|
||||||
|
return <ErrorNotice className="mt-3" error={error} onRetry={onRetry} />;
|
||||||
|
}
|
||||||
|
if (users.length === 0) {
|
||||||
|
return <p className="mt-2 text-sm text-fg-muted">{t('settings.instance.usersEmpty')}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex flex-col gap-2" data-testid="instance-users-actions">
|
||||||
|
{actionError === null ? null : <ErrorNotice error={actionError} />}
|
||||||
|
{adminError === null || adminError === undefined ? null : <ErrorNotice error={adminError} />}
|
||||||
|
{notice === null ? null : (
|
||||||
|
<p className="text-sm text-success" role="status">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{stepUpFor === null ? null : (
|
||||||
|
<form
|
||||||
|
className="flex flex-wrap items-end gap-2 rounded-[var(--radius-md)] border border-warning/50 bg-warning/10 p-2"
|
||||||
|
data-testid="instance-step-up"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
confirm.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label={t('settings.security.stepUpPassword')}
|
||||||
|
type="password"
|
||||||
|
name="step_up_password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={stepUpPassword}
|
||||||
|
onChange={(event) => setStepUpPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{currentUser.data?.totp_enabled === true ? (
|
||||||
|
<Field
|
||||||
|
label={t('auth.login.totp')}
|
||||||
|
name="step_up_totp"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={8}
|
||||||
|
value={stepUpCode}
|
||||||
|
onChange={(event) => setStepUpCode(event.target.value)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{confirm.isError ? <ErrorNotice error={confirm.error} /> : null}
|
||||||
|
<Button type="submit" disabled={confirm.isPending}>
|
||||||
|
{t('settings.security.stepUpSubmit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setStepUpFor(null)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{password === null ? null : (
|
||||||
|
<p className="rounded-[var(--radius-md)] border border-warning/50 bg-warning/10 p-2 text-sm">
|
||||||
|
<span className="text-fg-muted">{t('settings.instance.actions.passwordOnce')}: </span>
|
||||||
|
<code className="font-mono" data-testid="instance-temp-password">
|
||||||
|
{password.value}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="flex flex-col gap-1 text-sm" data-testid="instance-users">
|
||||||
|
{users.map((user) => {
|
||||||
|
const self = user.id === currentUserId;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={user.id}
|
||||||
|
className="flex flex-wrap items-center gap-2 border-b border-border/40 py-2 last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{user.display_name} <span className="text-fg-muted">@{user.username}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="shrink-0 font-mono text-xs text-fg-muted"
|
||||||
|
data-testid={`instance-user-id-${user.id}`}
|
||||||
|
title={t('settings.instance.userId')}
|
||||||
|
>
|
||||||
|
{user.id}
|
||||||
|
</span>
|
||||||
|
{user.is_instance_admin ? (
|
||||||
|
<span className="shrink-0 rounded-full border border-accent/50 px-2 text-xs text-accent">
|
||||||
|
{t('settings.instance.adminBadge')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid={`instance-user-admin-${user.id}`}
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
setActionError(null);
|
||||||
|
onToggleAdmin(user.id, !user.is_instance_admin);
|
||||||
|
// Подтверждение покажем, если сервер его потребует.
|
||||||
|
setStepUpFor({ userId: user.id, makeAdmin: !user.is_instance_admin });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
user.is_instance_admin
|
||||||
|
? 'settings.instance.actions.removeAdmin'
|
||||||
|
: 'settings.instance.actions.makeAdmin',
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid={`instance-user-reset-${user.id}`}
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => reset.mutate(user.id)}
|
||||||
|
>
|
||||||
|
{t('settings.instance.actions.resetPassword')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid={`instance-user-logout-${user.id}`}
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => logout.mutate(user.id)}
|
||||||
|
>
|
||||||
|
{t('settings.instance.actions.logout')}
|
||||||
|
</Button>
|
||||||
|
{/* Себя удалять нельзя: сервер отвечает 422. */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="border border-danger/50 text-danger"
|
||||||
|
data-testid={`instance-user-delete-${user.id}`}
|
||||||
|
disabled={pending || self}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
t('settings.instance.actions.deleteConfirm', { name: user.username }),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
remove.mutate(user.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.instance.actions.delete')}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -832,6 +832,47 @@
|
|||||||
"gateway": "Gateway",
|
"gateway": "Gateway",
|
||||||
"metrics": "Metrics"
|
"metrics": "Metrics"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"label": "Admin panel sections",
|
||||||
|
"overview": "Overview",
|
||||||
|
"limits": "Limits",
|
||||||
|
"users": "Users ({{count}})",
|
||||||
|
"guilds": "Servers ({{count}})",
|
||||||
|
"audit": "Audit"
|
||||||
|
},
|
||||||
|
"limits": {
|
||||||
|
"title": "Limits and registration",
|
||||||
|
"description": "Limits apply to the whole instance: media sizes are checked on every upload.",
|
||||||
|
"registration": "Registration is open",
|
||||||
|
"creation": "Users may create servers",
|
||||||
|
"guilds": "Servers per user",
|
||||||
|
"members": "Members per server",
|
||||||
|
"message": "Message length",
|
||||||
|
"avatar": "Avatar, MB",
|
||||||
|
"guildImage": "Server artwork, MB",
|
||||||
|
"cosmetic": "Frames and icons, MB",
|
||||||
|
"emoji": "Emoji, MB",
|
||||||
|
"sound": "Sounds, MB",
|
||||||
|
"invalidSize": "Sizes must be positive numbers in megabytes",
|
||||||
|
"invalidNumber": "Limits must be numbers",
|
||||||
|
"saved": "Limits saved."
|
||||||
|
},
|
||||||
|
"usersTitle": "Instance users",
|
||||||
|
"guildsTitle": "Instance servers",
|
||||||
|
"actions": {
|
||||||
|
"makeAdmin": "Make admin",
|
||||||
|
"removeAdmin": "Revoke admin",
|
||||||
|
"resetPassword": "Reset password",
|
||||||
|
"logout": "Log out everywhere",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteConfirm": "Delete user {{name}}? They leave all servers, message history stays.",
|
||||||
|
"loggedOut": "User sessions revoked.",
|
||||||
|
"passwordOnce": "Temporary password (shown once)",
|
||||||
|
"openSettings": "Settings",
|
||||||
|
"rename": "Rename",
|
||||||
|
"newName": "New server name",
|
||||||
|
"deleteGuildConfirm": "Delete server “{{name}}” with all its channels?"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
|
|||||||
@@ -832,6 +832,47 @@
|
|||||||
"gateway": "Gateway",
|
"gateway": "Gateway",
|
||||||
"metrics": "Метрики"
|
"metrics": "Метрики"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"label": "Разделы админ-панели",
|
||||||
|
"overview": "Обзор",
|
||||||
|
"limits": "Лимиты",
|
||||||
|
"users": "Пользователи ({{count}})",
|
||||||
|
"guilds": "Серверы ({{count}})",
|
||||||
|
"audit": "Аудит"
|
||||||
|
},
|
||||||
|
"limits": {
|
||||||
|
"title": "Лимиты и регистрация",
|
||||||
|
"description": "Лимиты действуют на весь инстанс: размеры медиа проверяются при каждой загрузке.",
|
||||||
|
"registration": "Регистрация открыта",
|
||||||
|
"creation": "Пользователи могут создавать серверы",
|
||||||
|
"guilds": "Серверов на пользователя",
|
||||||
|
"members": "Участников на сервер",
|
||||||
|
"message": "Длина сообщения",
|
||||||
|
"avatar": "Аватар, МБ",
|
||||||
|
"guildImage": "Оформление сервера, МБ",
|
||||||
|
"cosmetic": "Рамки и иконки, МБ",
|
||||||
|
"emoji": "Эмодзи, МБ",
|
||||||
|
"sound": "Звуки, МБ",
|
||||||
|
"invalidSize": "Размеры указываются положительными числами в мегабайтах",
|
||||||
|
"invalidNumber": "Лимиты указываются числами",
|
||||||
|
"saved": "Лимиты сохранены."
|
||||||
|
},
|
||||||
|
"usersTitle": "Пользователи инстанса",
|
||||||
|
"guildsTitle": "Серверы инстанса",
|
||||||
|
"actions": {
|
||||||
|
"makeAdmin": "Сделать админом",
|
||||||
|
"removeAdmin": "Снять админа",
|
||||||
|
"resetPassword": "Сбросить пароль",
|
||||||
|
"logout": "Выйти везде",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"deleteConfirm": "Удалить пользователя {{name}}? Он исчезнет из серверов, история сообщений останется.",
|
||||||
|
"loggedOut": "Сессии пользователя отозваны.",
|
||||||
|
"passwordOnce": "Временный пароль (показывается один раз)",
|
||||||
|
"openSettings": "Настройки",
|
||||||
|
"rename": "Переименовать",
|
||||||
|
"newName": "Новое имя сервера",
|
||||||
|
"deleteGuildConfirm": "Удалить сервер «{{name}}» вместе со всеми комнатами?"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -10,9 +11,16 @@ import {
|
|||||||
instanceGuildsQueryKey,
|
instanceGuildsQueryKey,
|
||||||
instanceSettingsQueryKey,
|
instanceSettingsQueryKey,
|
||||||
instanceUsersQueryKey,
|
instanceUsersQueryKey,
|
||||||
|
setInstanceAdmin,
|
||||||
|
type InstanceSettings,
|
||||||
} from '@/api/instance';
|
} from '@/api/instance';
|
||||||
import { InstanceDashboard } from '@/components/instance/InstanceDashboard';
|
import { InstanceDashboard } from '@/components/instance/InstanceDashboard';
|
||||||
|
import { InstanceGuildsPanel } from '@/components/instance/InstanceGuildsPanel';
|
||||||
import { InstanceHealth } from '@/components/instance/InstanceHealth';
|
import { InstanceHealth } from '@/components/instance/InstanceHealth';
|
||||||
|
import { InstanceLimitsPanel } from '@/components/instance/InstanceLimitsPanel';
|
||||||
|
import { InstanceUsersPanel } from '@/components/instance/InstanceUsersPanel';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { useUiStore } from '@/stores/ui';
|
||||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Card } from '@/components/ui/primitives';
|
import { Card } from '@/components/ui/primitives';
|
||||||
import { formatDateTime } from '@/lib/identity';
|
import { formatDateTime } from '@/lib/identity';
|
||||||
@@ -21,12 +29,20 @@ import { useInstanceMetrics } from '@/lib/useInstanceMetrics';
|
|||||||
|
|
||||||
const AUDIT_LIMIT = 50;
|
const AUDIT_LIMIT = 50;
|
||||||
|
|
||||||
|
/** Разделы админ-панели (AGENT.md 7.19). */
|
||||||
|
const tabs = ['overview', 'limits', 'users', 'guilds', 'audit'] as const;
|
||||||
|
type InstanceTab = (typeof tabs)[number];
|
||||||
|
|
||||||
/** Админский раздел инстанса: настройки, серверы, пользователи, аудит (read-only). */
|
/** Админский раздел инстанса: настройки, серверы, пользователи, аудит (read-only). */
|
||||||
export default function InstanceSettingsPage() {
|
export default function InstanceSettingsPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const locale = i18n.resolvedLanguage ?? 'ru';
|
const locale = i18n.resolvedLanguage ?? 'ru';
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const isAdmin = currentUser.data?.is_instance_admin === true;
|
const isAdmin = currentUser.data?.is_instance_admin === true;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
|
||||||
|
const openSettings = useUiStore((state) => state.openSettings);
|
||||||
|
const [tab, setTab] = useState<InstanceTab>('overview');
|
||||||
|
|
||||||
const settings = useQuery({
|
const settings = useQuery({
|
||||||
queryKey: instanceSettingsQueryKey,
|
queryKey: instanceSettingsQueryKey,
|
||||||
@@ -55,6 +71,15 @@ export default function InstanceSettingsPage() {
|
|||||||
// Живые метрики: опрос раз в секунду, пока раздел открыт (AGENT.md 7.19).
|
// Живые метрики: опрос раз в секунду, пока раздел открыт (AGENT.md 7.19).
|
||||||
const metrics = useInstanceMetrics(isAdmin);
|
const metrics = useInstanceMetrics(isAdmin);
|
||||||
|
|
||||||
|
const admin = useMutation({
|
||||||
|
mutationFn: (input: { userId: string; makeAdmin: boolean }) =>
|
||||||
|
setInstanceAdmin(input.userId, input.makeAdmin),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: instanceAuditQueryKey(AUDIT_LIMIT) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -64,8 +89,13 @@ export default function InstanceSettingsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsEntries =
|
const tabLabels: Record<InstanceTab, string> = {
|
||||||
settings.data === undefined ? [] : Object.entries(settings.data).slice(0, 30);
|
overview: t('settings.instance.tabs.overview'),
|
||||||
|
limits: t('settings.instance.tabs.limits'),
|
||||||
|
users: t('settings.instance.tabs.users', { count: users.data?.length ?? 0 }),
|
||||||
|
guilds: t('settings.instance.tabs.guilds', { count: guilds.data?.length ?? 0 }),
|
||||||
|
audit: t('settings.instance.tabs.audit'),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
@@ -74,10 +104,43 @@ export default function InstanceSettingsPage() {
|
|||||||
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.description')}</p>
|
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.description')}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<InstanceDashboard state={metrics} />
|
{/* Вкладки админ-панели: обзор и здоровье, лимиты, пользователи, серверы, аудит. */}
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label={t('settings.instance.tabs.label')}
|
||||||
|
className="flex flex-wrap gap-1 border-b border-border/40"
|
||||||
|
data-testid="instance-tabs"
|
||||||
|
>
|
||||||
|
{tabs.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === item}
|
||||||
|
data-testid={`instance-tab-${item}`}
|
||||||
|
onClick={() => setTab(item)}
|
||||||
|
className={`-mb-px rounded-t-[var(--radius-sm)] border-b-2 px-3 py-1.5 text-sm transition-colors duration-[var(--duration-fast)] ${
|
||||||
|
tab === item
|
||||||
|
? 'border-accent text-fg'
|
||||||
|
: 'border-transparent text-fg-muted hover:text-fg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tabLabels[item]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'overview' ? (
|
||||||
|
<>
|
||||||
|
<InstanceDashboard state={metrics} />
|
||||||
|
<InstanceHealth state={metrics} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tab === 'limits' ? (
|
||||||
<Card>
|
<Card>
|
||||||
<h3 className="text-base font-semibold">{t('settings.instance.settings')}</h3>
|
<h3 className="text-base font-semibold">{t('settings.instance.limits.title')}</h3>
|
||||||
|
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.limits.description')}</p>
|
||||||
{settings.isPending ? <LoadingNotice /> : null}
|
{settings.isPending ? <LoadingNotice /> : null}
|
||||||
{settings.isError ? (
|
{settings.isError ? (
|
||||||
<ErrorNotice
|
<ErrorNotice
|
||||||
@@ -86,108 +149,66 @@ export default function InstanceSettingsPage() {
|
|||||||
onRetry={() => void settings.refetch()}
|
onRetry={() => void settings.refetch()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!settings.isPending && !settings.isError && settingsEntries.length === 0 ? (
|
{settings.data === undefined ? null : (
|
||||||
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.settingsEmpty')}</p>
|
<InstanceLimitsPanel
|
||||||
) : null}
|
settings={settings.data}
|
||||||
{settingsEntries.length === 0 ? null : (
|
onSaved={(updated: InstanceSettings) => {
|
||||||
<dl className="mt-3 text-sm">
|
void queryClient.setQueryData(instanceSettingsQueryKey, updated);
|
||||||
{settingsEntries.map(([key, value]) => (
|
}}
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex justify-between gap-4 border-b border-border/40 py-2 last:border-b-0"
|
|
||||||
>
|
|
||||||
<dt className="text-fg-muted">{key}</dt>
|
|
||||||
<dd className="max-w-[60%] truncate text-right font-medium" title={format(value)}>
|
|
||||||
{format(value)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<h3 className="text-base font-semibold">
|
|
||||||
{t('settings.instance.guilds', { count: guilds.data?.length ?? 0 })}
|
|
||||||
</h3>
|
|
||||||
{guilds.isPending ? <LoadingNotice /> : null}
|
|
||||||
{guilds.isError ? (
|
|
||||||
<ErrorNotice
|
|
||||||
className="mt-3"
|
|
||||||
error={guilds.error}
|
|
||||||
onRetry={() => void guilds.refetch()}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
{guilds.data !== undefined && guilds.data.length === 0 ? (
|
|
||||||
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.guildsEmpty')}</p>
|
|
||||||
) : null}
|
|
||||||
{guilds.data === undefined || guilds.data.length === 0 ? null : (
|
|
||||||
<ul className="mt-3 flex flex-col gap-1 text-sm" data-testid="instance-guilds">
|
|
||||||
{guilds.data.map((guild) => (
|
|
||||||
<li
|
|
||||||
key={guild.id}
|
|
||||||
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
|
|
||||||
>
|
|
||||||
<span className="truncate">{guild.name}</span>
|
|
||||||
<span className="shrink-0 text-xs text-fg-muted">
|
|
||||||
{guild.is_main ? `${t('settings.instance.mainBadge')} · ` : ''}
|
|
||||||
{t('settings.instance.memberCount', { count: guild.member_count })}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tab === 'users' ? (
|
||||||
<Card>
|
<Card>
|
||||||
<h3 className="text-base font-semibold">
|
<h3 className="text-base font-semibold">{t('settings.instance.usersTitle')}</h3>
|
||||||
{t('settings.instance.users', { count: users.data?.length ?? 0 })}
|
|
||||||
</h3>
|
|
||||||
{/* ID пользователей — единственное место, где он виден: раздел открыт
|
{/* ID пользователей — единственное место, где он виден: раздел открыт
|
||||||
только администратору инстанса. */}
|
только администратору инстанса. */}
|
||||||
<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>
|
||||||
{users.isPending ? <LoadingNotice /> : null}
|
<InstanceUsersPanel
|
||||||
{users.isError ? (
|
currentUserId={currentUser.data?.id}
|
||||||
<ErrorNotice className="mt-3" error={users.error} onRetry={() => void users.refetch()} />
|
users={users.data ?? []}
|
||||||
) : null}
|
isPending={users.isPending}
|
||||||
{users.data !== undefined && users.data.length === 0 ? (
|
error={users.error}
|
||||||
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.usersEmpty')}</p>
|
onRetry={() => void users.refetch()}
|
||||||
) : null}
|
onToggleAdmin={(userId, makeAdmin) => admin.mutate({ userId, makeAdmin })}
|
||||||
{users.data === undefined || users.data.length === 0 ? null : (
|
adminPending={admin.isPending}
|
||||||
<ul className="mt-3 flex flex-col gap-1 text-sm" data-testid="instance-users">
|
adminError={admin.error}
|
||||||
{users.data.map((user) => (
|
/>
|
||||||
<li
|
|
||||||
key={user.id}
|
|
||||||
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 truncate">
|
|
||||||
{user.display_name} <span className="text-fg-muted">@{user.username}</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="shrink-0 font-mono text-xs text-fg-muted"
|
|
||||||
data-testid={`instance-user-id-${user.id}`}
|
|
||||||
title={t('settings.instance.userId')}
|
|
||||||
>
|
|
||||||
{user.id}
|
|
||||||
</span>
|
|
||||||
{user.is_instance_admin ? (
|
|
||||||
<span className="shrink-0 rounded-full border border-accent/50 px-2 text-xs text-accent">
|
|
||||||
{t('settings.instance.adminBadge')}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tab === 'guilds' ? (
|
||||||
|
<Card>
|
||||||
|
<h3 className="text-base font-semibold">{t('settings.instance.guildsTitle')}</h3>
|
||||||
|
<InstanceGuildsPanel
|
||||||
|
guilds={guilds.data ?? []}
|
||||||
|
isPending={guilds.isPending}
|
||||||
|
error={guilds.error}
|
||||||
|
onRetry={() => void guilds.refetch()}
|
||||||
|
onOpenSettings={(guildId) => {
|
||||||
|
// Настройки сервера открываются обычным разделом: у администратора
|
||||||
|
// инстанса там все права (AGENTS.md 7.19).
|
||||||
|
selectSettingsGuild(guildId);
|
||||||
|
openSettings({ section: 'server', guildId });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tab === 'audit' ? (
|
||||||
<Card>
|
<Card>
|
||||||
<h3 className="text-base font-semibold">
|
<h3 className="text-base font-semibold">
|
||||||
{t('settings.instance.audit', { count: audit.data?.length ?? 0 })}
|
{t('settings.instance.audit', { count: audit.data?.length ?? 0 })}
|
||||||
</h3>
|
</h3>
|
||||||
{audit.isPending ? <LoadingNotice /> : null}
|
{audit.isPending ? <LoadingNotice /> : null}
|
||||||
{audit.isError ? (
|
{audit.isError ? (
|
||||||
<ErrorNotice className="mt-3" error={audit.error} onRetry={() => void audit.refetch()} />
|
<ErrorNotice
|
||||||
|
className="mt-3"
|
||||||
|
error={audit.error}
|
||||||
|
onRetry={() => void audit.refetch()}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{audit.data !== undefined && audit.data.length === 0 ? (
|
{audit.data !== undefined && audit.data.length === 0 ? (
|
||||||
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.auditEmpty')}</p>
|
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.auditEmpty')}</p>
|
||||||
@@ -211,22 +232,7 @@ export default function InstanceSettingsPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
<InstanceHealth state={metrics} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function format(value: unknown): string {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+128
-3
@@ -541,6 +541,8 @@ describe('настройки: аватар', () => {
|
|||||||
renderApp('/app/empty');
|
renderApp('/app/empty');
|
||||||
const dialog = await openSettings();
|
const dialog = await openSettings();
|
||||||
await goToSection(dialog, 'Инстанс');
|
await goToSection(dialog, 'Инстанс');
|
||||||
|
// Админ-панель разбита на вкладки: пользователи живут в своей.
|
||||||
|
await userEvent.click(await within(dialog).findByTestId('instance-tab-users'));
|
||||||
|
|
||||||
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();
|
||||||
@@ -722,9 +724,132 @@ describe('настройки: безопасность', () => {
|
|||||||
|
|
||||||
renderApp('/settings/account/instance');
|
renderApp('/settings/account/instance');
|
||||||
|
|
||||||
|
// Серверы инстанса.
|
||||||
|
await userEvent.click(await screen.findByTestId('instance-tab-guilds'));
|
||||||
expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main');
|
expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main');
|
||||||
expect(screen.getByTestId('instance-users')).toHaveTextContent('Alice');
|
|
||||||
expect(screen.getByTestId('instance-audit')).toHaveTextContent('guild.create');
|
// Пользователи инстанса.
|
||||||
expect(screen.getByText('motd')).toBeVisible();
|
await userEvent.click(screen.getByTestId('instance-tab-users'));
|
||||||
|
expect(await screen.findByTestId('instance-users')).toHaveTextContent('Alice');
|
||||||
|
|
||||||
|
// Журнал действий администраторов.
|
||||||
|
await userEvent.click(screen.getByTestId('instance-tab-audit'));
|
||||||
|
expect(await screen.findByTestId('instance-audit')).toHaveTextContent('guild.create');
|
||||||
|
|
||||||
|
// Лимиты: панель показывает значения из настроек.
|
||||||
|
await userEvent.click(screen.getByTestId('instance-tab-limits'));
|
||||||
|
expect(await screen.findByTestId('instance-limits')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('админ-панель: действия', () => {
|
||||||
|
it('сбрасывает пароль, выкидывает со всех устройств и удаляет пользователя', async () => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
const admin = makeUser({ is_instance_admin: true });
|
||||||
|
const fetchMock = installFetch([
|
||||||
|
{ match: '/api/v1/users/@me', response: () => json({ user: admin }) },
|
||||||
|
{ match: '/api/v1/instance/settings', response: () => json({ settings: {} }) },
|
||||||
|
{ match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) },
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/users/u-7/reset-password',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => json({ user_id: 'u-7', password: 'Temp-Pass-123456', sessions_revoked: 2 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/users/u-7/logout',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => json({ ok: true }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/users/u-7',
|
||||||
|
method: 'DELETE',
|
||||||
|
response: () => json({ ok: true }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/users',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 'u-7',
|
||||||
|
username: 'bob',
|
||||||
|
display_name: 'Bob',
|
||||||
|
is_instance_admin: false,
|
||||||
|
created_at: '2026-09-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderApp('/settings/account/instance');
|
||||||
|
await userEvent.click(await screen.findByTestId('instance-tab-users'));
|
||||||
|
const panel = await screen.findByTestId('instance-users-actions');
|
||||||
|
|
||||||
|
// Временный пароль показывается один раз.
|
||||||
|
await userEvent.click(within(panel).getByTestId('instance-user-reset-u-7'));
|
||||||
|
expect(await screen.findByTestId('instance-temp-password')).toHaveTextContent(
|
||||||
|
'Temp-Pass-123456',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Выход со всех устройств.
|
||||||
|
await userEvent.click(within(panel).getByTestId('instance-user-logout-u-7'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
recordedRequests(fetchMock).some(
|
||||||
|
(request) => request.method === 'POST' && request.url.includes('/users/u-7/logout'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Мягкое удаление.
|
||||||
|
await userEvent.click(within(panel).getByTestId('instance-user-delete-u-7'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
recordedRequests(fetchMock).some(
|
||||||
|
(request) => request.method === 'DELETE' && request.url.endsWith('/users/u-7'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('переименовывает сервер инстанса', async () => {
|
||||||
|
const admin = makeUser({ is_instance_admin: true });
|
||||||
|
const fetchMock = installFetch([
|
||||||
|
{ match: '/api/v1/users/@me', response: () => json({ user: admin }) },
|
||||||
|
{ match: '/api/v1/instance/settings', response: () => json({ settings: {} }) },
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/guilds/g-1',
|
||||||
|
method: 'PATCH',
|
||||||
|
response: () => json({ ok: true }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/guilds',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
guilds: [{ id: 'g-1', name: 'Старое', member_count: 3, owner_id: 'u', is_main: false }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/instance/users', response: () => json({ users: [] }) },
|
||||||
|
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderApp('/settings/account/instance');
|
||||||
|
await userEvent.click(await screen.findByTestId('instance-tab-guilds'));
|
||||||
|
const panel = await screen.findByTestId('instance-guilds-actions');
|
||||||
|
|
||||||
|
await userEvent.click(within(panel).getByTestId('instance-guild-rename-g-1'));
|
||||||
|
const nameInput = screen.getByLabelText('Новое имя сервера');
|
||||||
|
await userEvent.clear(nameInput);
|
||||||
|
await userEvent.type(nameInput, 'Новое');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const request = recordedRequests(fetchMock).find(
|
||||||
|
(entry) => entry.method === 'PATCH' && entry.url.includes('/instance/guilds/g-1'),
|
||||||
|
);
|
||||||
|
expect(request?.body).toMatchObject({ name: 'Новое' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user