From ef871bcd9674b8f6622dce1d84a0553b10f69865 Mon Sep 17 00:00:00 2001 From: grendervill Date: Mon, 21 Sep 2026 22:37:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(instance):=20=D0=B0=D0=B4=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD-=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=D1=81=20=D0=BB?= =?UTF-8?q?=D0=B8=D0=BC=D0=B8=D1=82=D0=B0=D0=BC=D0=B8=20=D0=BC=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=B0=20=D0=B8=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B8=D1=8F=D0=BC=D0=B8=20=D0=BD=D0=B0=D0=B4=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?= =?UTF-8?q?=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - лимиты медиа живут в настройках инстанса (миграция 00016): аватары, оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел оттуда, значения по умолчанию — из AGENT.md 7.7 - действия администратора: временный пароль (показывается один раз, сессии отзываются), выход со всех устройств, мягкое удаление пользователя (сообщения и аудит остаются), переименование и удаление любого сервера - админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи, серверы, аудит; смена администраторов подтверждается личностью (step-up) - тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest (вкладки, сброс пароля, выход, удаление, переименование сервера) --- .../00016_instance_media_limits.sql | 15 + internal/server/api_cosmetics.go | 19 +- internal/server/api_emojis.go | 10 +- internal/server/api_files.go | 11 +- internal/server/api_guild_appearance.go | 10 +- internal/server/api_instance.go | 244 +++++++++++++- internal/server/api_invites.go | 10 +- internal/server/api_sounds.go | 10 +- internal/server/cosmetics_test.go | 141 ++++++++ internal/server/media_limits.go | 78 +++++ internal/store/instance.go | 35 +- internal/store/retention.go | 43 +++ web/src/api/instance.ts | 56 ++++ .../instance/InstanceGuildsPanel.tsx | 160 ++++++++++ .../instance/InstanceLimitsPanel.tsx | 210 ++++++++++++ .../instance/InstanceUsersPanel.tsx | 256 +++++++++++++++ web/src/i18n/locales/en.json | 41 +++ web/src/i18n/locales/ru.json | 41 +++ .../pages/settings/InstanceSettingsPage.tsx | 300 +++++++++--------- web/tests/settings.test.tsx | 131 +++++++- 20 files changed, 1617 insertions(+), 204 deletions(-) create mode 100644 internal/database/migrations/00016_instance_media_limits.sql create mode 100644 internal/server/media_limits.go create mode 100644 web/src/components/instance/InstanceGuildsPanel.tsx create mode 100644 web/src/components/instance/InstanceLimitsPanel.tsx create mode 100644 web/src/components/instance/InstanceUsersPanel.tsx diff --git a/internal/database/migrations/00016_instance_media_limits.sql b/internal/database/migrations/00016_instance_media_limits.sql new file mode 100644 index 0000000..42ff0cb --- /dev/null +++ b/internal/database/migrations/00016_instance_media_limits.sql @@ -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'); diff --git a/internal/server/api_cosmetics.go b/internal/server/api_cosmetics.go index adfa00e..39f4072 100644 --- a/internal/server/api_cosmetics.go +++ b/internal/server/api_cosmetics.go @@ -22,8 +22,6 @@ import ( const ( // maxCosmeticsPerGuild — предел галереи сервера. maxCosmeticsPerGuild = 50 - // maxCosmeticSize — предел размера картинки оформления. - maxCosmeticSize = 5 << 20 // maxCosmeticNameLength — длина имени элемента галереи. maxCosmeticNameLength = 40 // scopeGlobal — область личного стиля «для друзей». @@ -255,18 +253,19 @@ func (s *Server) handleCosmeticUpload(w http.ResponseWriter, r *http.Request) { return } + limit := s.mediaLimit(ctx, mediaCosmetic) file, header, err := r.FormFile("file") if err != nil { httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`) return } defer func() { _ = file.Close() }() - if header.Size > maxCosmeticSize { + if header.Size > limit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic is too large") return } - data, err := io.ReadAll(io.LimitReader(file, maxCosmeticSize+1)) - if err != nil || int64(len(data)) > maxCosmeticSize { + data, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil || int64(len(data)) > limit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic is too large") return } @@ -757,9 +756,6 @@ func optionalString(value *string) string { return *value } -// maxChannelBackgroundSize — предел размера фона комнаты (AGENT.md 7.7). -const maxChannelBackgroundSize = 5 << 20 - // registerChannelBackgroundRoutes описывает фон комнаты (AGENT.md 7.5, 7.7): // картинка за лентой сообщений, право MANAGE_CHANNEL_BACKGROUND. func (s *Server) registerChannelBackgroundRoutes(router chi.Router) { @@ -779,18 +775,19 @@ func (s *Server) handleChannelBackgroundUpload(w http.ResponseWriter, r *http.Re writeHumaAPIError(w, err) return } + limit := s.mediaLimit(ctx, mediaGuildImage) file, header, err := r.FormFile("file") if err != nil { httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`) return } defer func() { _ = file.Close() }() - if header.Size > maxChannelBackgroundSize { + if header.Size > limit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background is too large") return } - data, err := io.ReadAll(io.LimitReader(file, maxChannelBackgroundSize+1)) - if err != nil || int64(len(data)) > maxChannelBackgroundSize { + data, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil || int64(len(data)) > limit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background is too large") return } diff --git a/internal/server/api_emojis.go b/internal/server/api_emojis.go index c7d6f23..3d57500 100644 --- a/internal/server/api_emojis.go +++ b/internal/server/api_emojis.go @@ -18,7 +18,6 @@ import ( // Лимиты кастомных эмодзи (AGENT.md 7.12). const ( - maxEmojiSize = 512 << 10 maxEmojisPerGuild = 100 ) @@ -202,7 +201,8 @@ func (s *Server) handleEmojiUpload(w http.ResponseWriter, r *http.Request) { 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 httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body") return @@ -225,12 +225,12 @@ func (s *Server) handleEmojiUpload(w http.ResponseWriter, r *http.Request) { return } defer func() { _ = file.Close() }() - if header.Size > maxEmojiSize { + if header.Size > limit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "эмодзи больше 512 КБ") return } - data, err := io.ReadAll(io.LimitReader(file, maxEmojiSize+1)) - if err != nil || len(data) > maxEmojiSize { + data, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil || int64(len(data)) > limit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "эмодзи больше 512 КБ") return } diff --git a/internal/server/api_files.go b/internal/server/api_files.go index 1b01b14..5ce4af7 100644 --- a/internal/server/api_files.go +++ b/internal/server/api_files.go @@ -28,8 +28,6 @@ const ( maxMultipartOverhead = 1 << 20 // maxMultipartMemory — сколько multipart держим в памяти, остальное — на диске. maxMultipartMemory = 8 << 20 - // maxAvatarSize — предел размера аватара (AGENT.md 7.2). - maxAvatarSize = 8 << 20 ) // uploadPayload — результат загрузки файла: метаданные для вложения. @@ -86,7 +84,8 @@ func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) { return } 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 httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body") return @@ -102,14 +101,14 @@ func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) { return } defer func() { _ = file.Close() }() - if header.Size > maxAvatarSize { + if header.Size > limit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "avatar is too large") return } // Аватар читаем в память (не больше 8 МБ): нужно проверить, что это // действительно изображение, а не переименованный файл (AGENT.md 9.2). - data, err := io.ReadAll(io.LimitReader(file, maxAvatarSize+1)) - if err != nil || int64(len(data)) > maxAvatarSize { + data, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil || int64(len(data)) > limit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "avatar is too large") return } diff --git a/internal/server/api_guild_appearance.go b/internal/server/api_guild_appearance.go index 783ac01..aed7311 100644 --- a/internal/server/api_guild_appearance.go +++ b/internal/server/api_guild_appearance.go @@ -16,9 +16,6 @@ import ( // страницы приглашения и акцентный цвет. Файлы хранятся обычными загрузками с // отдельными purpose, поэтому анимированные GIF/APNG/WebP не теряют анимацию. -// maxGuildImageSize — предел размера для иконки, баннера и splash. -const maxGuildImageSize = 8 << 20 - type appearanceKind struct { // field — колонка гильдии, purpose — назначение файла. field string @@ -65,20 +62,21 @@ func (s *Server) handleGuildAppearanceUpload(w http.ResponseWriter, r *http.Requ return } + limit := s.mediaLimit(ctx, mediaGuildImage) file, header, err := r.FormFile("file") if err != nil { httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`) return } defer func() { _ = file.Close() }() - if header.Size > maxGuildImageSize { + if header.Size > limit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", kind.label+" is too large") return } // Читаем в память (не больше 8 МБ): нужно убедиться, что это изображение, // а не переименованный файл (AGENT.md 9.2). - data, err := io.ReadAll(io.LimitReader(file, maxGuildImageSize+1)) - if err != nil || int64(len(data)) > maxGuildImageSize { + data, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil || int64(len(data)) > limit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", kind.label+" is too large") return } diff --git a/internal/server/api_instance.go b/internal/server/api_instance.go index 07a8458..35d8d68 100644 --- a/internal/server/api_instance.go +++ b/internal/server/api_instance.go @@ -2,6 +2,7 @@ package server import ( "context" + "crypto/rand" "net/http" "strconv" "strings" @@ -72,6 +73,36 @@ type instanceSettingsPayload struct { MaxGuildsPerUser int `json:"max_guilds_per_user"` MaxMembersPerGuild int `json:"max_members_per_guild"` 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 { @@ -122,13 +153,7 @@ func (s *Server) registerInstanceRoutes(api huma.API) { return nil, humaError(err) } output := &instanceSettingsOutput{} - output.Body.Settings = instanceSettingsPayload{ - RegistrationEnabled: settings.RegistrationEnabled, - AllowGuildCreation: settings.AllowGuildCreation, - MaxGuildsPerUser: settings.MaxGuildsPerUser, - MaxMembersPerGuild: settings.MaxMembersPerGuild, - MaxMessageLength: settings.MaxMessageLength, - } + output.Body.Settings = instanceSettingsFromStore(settings) 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"` MaxMembersPerGuild *int `json:"max_members_per_guild,omitempty" minimum:"1" maximum:"1000000"` 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) { @@ -169,6 +200,17 @@ func (s *Server) registerInstanceRoutes(api huma.API) { if input.Body.MaxMessageLength != nil { 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 { if err := s.store.SetInstanceSetting(ctx, key, value); err != nil { 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, "") output := &instanceSettingsOutput{} - output.Body.Settings = instanceSettingsPayload{ - RegistrationEnabled: settings.RegistrationEnabled, - AllowGuildCreation: settings.AllowGuildCreation, - MaxGuildsPerUser: settings.MaxGuildsPerUser, - MaxMembersPerGuild: settings.MaxMembersPerGuild, - MaxMessageLength: settings.MaxMessageLength, - } + output.Body.Settings = instanceSettingsFromStore(settings) return output, nil }) @@ -392,6 +428,166 @@ func (s *Server) registerInstanceRoutes(api huma.API) { 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{ OperationID: "listInstanceAudit", Method: http.MethodGet, @@ -473,3 +669,23 @@ func (s *Server) createGuildAsAdmin(ctx context.Context, admin, owner *store.Use } 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 +} diff --git a/internal/server/api_invites.go b/internal/server/api_invites.go index 823c156..abd87e2 100644 --- a/internal/server/api_invites.go +++ b/internal/server/api_invites.go @@ -442,9 +442,6 @@ func newInviteCode() (string, error) { return builder.String(), nil } -// maxInviteBackgroundSize — предел размера переопределения splash (AGENT.md 7.7). -const maxInviteBackgroundSize = 5 << 20 - // registerInviteBackgroundRoutes описывает картинку приглашения: создатель // приглашения или MANAGE_GUILD сервера может задать своё оформление страницы // (AGENT.md 7.9). Ручка multipart, поэтому живёт на роутере. @@ -464,18 +461,19 @@ func (s *Server) handleInviteBackgroundUpload(w http.ResponseWriter, r *http.Req writeHumaAPIError(w, err) return } + limit := s.mediaLimit(ctx, mediaGuildImage) file, header, err := r.FormFile("file") if err != nil { httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`) return } defer func() { _ = file.Close() }() - if header.Size > maxInviteBackgroundSize { + if header.Size > limit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large") return } - data, err := io.ReadAll(io.LimitReader(file, maxInviteBackgroundSize+1)) - if err != nil || int64(len(data)) > maxInviteBackgroundSize { + data, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil || int64(len(data)) > limit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large") return } diff --git a/internal/server/api_sounds.go b/internal/server/api_sounds.go index 2b19183..81078df 100644 --- a/internal/server/api_sounds.go +++ b/internal/server/api_sounds.go @@ -19,7 +19,6 @@ import ( // Лимиты звуков сервера (AGENT.md 7.13). const ( - maxSoundSize = 512 << 10 maxSoundboardSounds = 30 maxUISounds = 30 ) @@ -275,7 +274,8 @@ func (s *Server) handleSoundUpload(w http.ResponseWriter, r *http.Request) { 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 httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body") return @@ -306,12 +306,12 @@ func (s *Server) handleSoundUpload(w http.ResponseWriter, r *http.Request) { return } defer func() { _ = file.Close() }() - if header.Size > maxSoundSize { + if header.Size > sizeLimit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "звук больше 512 КБ") return } - data, err := io.ReadAll(io.LimitReader(file, maxSoundSize+1)) - if err != nil || len(data) > maxSoundSize { + data, err := io.ReadAll(io.LimitReader(file, sizeLimit+1)) + if err != nil || int64(len(data)) > sizeLimit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "звук больше 512 КБ") return } diff --git a/internal/server/cosmetics_test.go b/internal/server/cosmetics_test.go index d66bcf6..7f475fc 100644 --- a/internal/server/cosmetics_test.go +++ b/internal/server/cosmetics_test.go @@ -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) + } +} diff --git a/internal/server/media_limits.go b/internal/server/media_limits.go new file mode 100644 index 0000000..be9ca0c --- /dev/null +++ b/internal/server/media_limits.go @@ -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 + } +} diff --git a/internal/store/instance.go b/internal/store/instance.go index 9a2db40..9364203 100644 --- a/internal/store/instance.go +++ b/internal/store/instance.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "math" + "strconv" "time" ) @@ -18,9 +19,24 @@ type InstanceSettings struct { MaxMembersPerGuild int MaxMessageLength int AuditRetentionDays int - raw map[string]string + // Лимиты медиа в байтах (AGENT.md 7.7): 0 — значение по умолчанию. + MaxAvatarSize int64 + MaxGuildImageSize int64 + MaxEmojiSize int64 + MaxSoundSize int64 + MaxCosmeticSize int64 + 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` 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), MaxMessageLength: parseSettingInt(values["max_message_length"], 4000), 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, } if mainGuild := values["main_guild_id"]; mainGuild != "" { @@ -161,6 +182,18 @@ func optionalID(value sql.NullInt64) *uint64 { 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 { parsed, ok := parseUint(value) if !ok || parsed > math.MaxInt32 { diff --git a/internal/store/retention.go b/internal/store/retention.go index 57344d7..a2df12b 100644 --- a/internal/store/retention.go +++ b/internal/store/retention.go @@ -53,3 +53,46 @@ func (s *Store) RebuildSearchIndex(ctx context.Context) error { } 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() +} diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts index 80c9268..bf824f8 100644 --- a/web/src/api/instance.ts +++ b/web/src/api/instance.ts @@ -2,6 +2,17 @@ import { request } from './client'; import type { AuditEntry, InstanceGuild, InstanceInfo, InstanceUser } from './types'; 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; } @@ -61,3 +72,48 @@ export async function fetchAudit(limit = 50, signal?: AbortSignal): Promise { + 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 { + await request(`/instance/users/${encodeURIComponent(userId)}/logout`, { method: 'POST' }); +} + +/** `DELETE /instance/users/{id}` — мягкое удаление пользователя. */ +export async function deleteUser(userId: string): Promise { + await request(`/instance/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }); +} + +/** `PATCH /instance/guilds/{id}` — переименование любого сервера (админ). */ +export async function updateInstanceGuild( + guildId: string, + input: { name?: string; description?: string }, +): Promise { + await request(`/instance/guilds/${encodeURIComponent(guildId)}`, { + method: 'PATCH', + body: input, + }); +} + +/** `DELETE /instance/guilds/{id}` — удаление любого сервера (админ). */ +export async function deleteInstanceGuild(guildId: string): Promise { + await request(`/instance/guilds/${encodeURIComponent(guildId)}`, { method: 'DELETE' }); +} diff --git a/web/src/components/instance/InstanceGuildsPanel.tsx b/web/src/components/instance/InstanceGuildsPanel.tsx new file mode 100644 index 0000000..3f8777d --- /dev/null +++ b/web/src/components/instance/InstanceGuildsPanel.tsx @@ -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(null); + const [name, setName] = useState(''); + const [actionError, setActionError] = useState(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

{t('common.loading')}

; + } + if (error !== null && error !== undefined) { + return ; + } + if (guilds.length === 0) { + return

{t('settings.instance.guildsEmpty')}

; + } + + return ( +
+ {actionError === null ? null : } +
    + {guilds.map((guild) => ( +
  • + {guild.name} + + {guild.is_main ? `${t('settings.instance.mainBadge')} · ` : ''} + {t('settings.instance.memberCount', { count: guild.member_count })} + + + + +
  • + ))} +
+ + {renaming === null ? null : ( +
{ + event.preventDefault(); + if (renaming !== null && name.trim() !== '') { + rename.mutate({ id: renaming.id, name: name.trim() }); + } + }} + > + setName(event.target.value)} + /> + + + + )} +
+ ); +} diff --git a/web/src/components/instance/InstanceLimitsPanel.tsx b/web/src/components/instance/InstanceLimitsPanel.tsx new file mode 100644 index 0000000..41210ad --- /dev/null +++ b/web/src/components/instance/InstanceLimitsPanel.tsx @@ -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(null); + + const save = useMutation({ + mutationFn: (patch: Partial) => 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 ( +
{ + event.preventDefault(); + onSubmit(); + }} + > +
+ + +
+ +
+ setForm({ ...form, guilds: event.target.value })} + /> + setForm({ ...form, members: event.target.value })} + /> + setForm({ ...form, message: event.target.value })} + /> +
+ + {/* Лимиты медиа: в мегабайтах, сервер получает байты. */} +
+ setForm({ ...form, avatar: event.target.value })} + /> + setForm({ ...form, guildImage: event.target.value })} + /> + setForm({ ...form, cosmetic: event.target.value })} + /> + setForm({ ...form, emoji: event.target.value })} + /> + setForm({ ...form, sound: event.target.value })} + /> +
+ + {error === null ? null : } + {save.isError ? : null} + {save.isSuccess ? ( +

+ {t('settings.instance.limits.saved')} +

+ ) : null} + +
+ +
+ + ); +} diff --git a/web/src/components/instance/InstanceUsersPanel.tsx b/web/src/components/instance/InstanceUsersPanel.tsx new file mode 100644 index 0000000..bc57e97 --- /dev/null +++ b/web/src/components/instance/InstanceUsersPanel.tsx @@ -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(null); + const [notice, setNotice] = useState(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

{t('common.loading')}

; + } + if (error !== null && error !== undefined) { + return ; + } + if (users.length === 0) { + return

{t('settings.instance.usersEmpty')}

; + } + + return ( +
+ {actionError === null ? null : } + {adminError === null || adminError === undefined ? null : } + {notice === null ? null : ( +

+ {notice} +

+ )} + {stepUpFor === null ? null : ( +
{ + event.preventDefault(); + confirm.mutate(); + }} + > + setStepUpPassword(event.target.value)} + required + /> + {currentUser.data?.totp_enabled === true ? ( + setStepUpCode(event.target.value)} + /> + ) : null} + {confirm.isError ? : null} + + + + )} + + {password === null ? null : ( +

+ {t('settings.instance.actions.passwordOnce')}: + + {password.value} + +

+ )} + +
    + {users.map((user) => { + const self = user.id === currentUserId; + return ( +
  • + + {user.display_name} @{user.username} + + + {user.id} + + {user.is_instance_admin ? ( + + {t('settings.instance.adminBadge')} + + ) : null} + + + + + {/* Себя удалять нельзя: сервер отвечает 422. */} + +
  • + ); + })} +
+
+ ); +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5c32c23..fb6a37a 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -832,6 +832,47 @@ "gateway": "Gateway", "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": { diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index dd77296..5583a47 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -832,6 +832,47 @@ "gateway": "Gateway", "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": { diff --git a/web/src/pages/settings/InstanceSettingsPage.tsx b/web/src/pages/settings/InstanceSettingsPage.tsx index 38a2d5a..34b2478 100644 --- a/web/src/pages/settings/InstanceSettingsPage.tsx +++ b/web/src/pages/settings/InstanceSettingsPage.tsx @@ -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 { @@ -10,9 +11,16 @@ import { instanceGuildsQueryKey, instanceSettingsQueryKey, instanceUsersQueryKey, + setInstanceAdmin, + type InstanceSettings, } from '@/api/instance'; import { InstanceDashboard } from '@/components/instance/InstanceDashboard'; +import { InstanceGuildsPanel } from '@/components/instance/InstanceGuildsPanel'; 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 { Card } from '@/components/ui/primitives'; import { formatDateTime } from '@/lib/identity'; @@ -21,12 +29,20 @@ import { useInstanceMetrics } from '@/lib/useInstanceMetrics'; const AUDIT_LIMIT = 50; +/** Разделы админ-панели (AGENT.md 7.19). */ +const tabs = ['overview', 'limits', 'users', 'guilds', 'audit'] as const; +type InstanceTab = (typeof tabs)[number]; + /** Админский раздел инстанса: настройки, серверы, пользователи, аудит (read-only). */ export default function InstanceSettingsPage() { const { t, i18n } = useTranslation(); const locale = i18n.resolvedLanguage ?? 'ru'; const currentUser = useCurrentUser(); 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('overview'); const settings = useQuery({ queryKey: instanceSettingsQueryKey, @@ -55,6 +71,15 @@ export default function InstanceSettingsPage() { // Живые метрики: опрос раз в секунду, пока раздел открыт (AGENT.md 7.19). 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) { return ( @@ -64,8 +89,13 @@ export default function InstanceSettingsPage() { ); } - const settingsEntries = - settings.data === undefined ? [] : Object.entries(settings.data).slice(0, 30); + const tabLabels: Record = { + 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 (
@@ -74,159 +104,135 @@ export default function InstanceSettingsPage() {

{t('settings.instance.description')}

- + {/* Вкладки админ-панели: обзор и здоровье, лимиты, пользователи, серверы, аудит. */} +
+ {tabs.map((item) => ( + + ))} +
- -

{t('settings.instance.settings')}

- {settings.isPending ? : null} - {settings.isError ? ( - void settings.refetch()} + {tab === 'overview' ? ( + <> + + + + ) : null} + + {tab === 'limits' ? ( + +

{t('settings.instance.limits.title')}

+

{t('settings.instance.limits.description')}

+ {settings.isPending ? : null} + {settings.isError ? ( + void settings.refetch()} + /> + ) : null} + {settings.data === undefined ? null : ( + { + void queryClient.setQueryData(instanceSettingsQueryKey, updated); + }} + /> + )} +
+ ) : null} + + {tab === 'users' ? ( + +

{t('settings.instance.usersTitle')}

+ {/* ID пользователей — единственное место, где он виден: раздел открыт + только администратору инстанса. */} +

{t('settings.instance.usersIdHint')}

+ void users.refetch()} + onToggleAdmin={(userId, makeAdmin) => admin.mutate({ userId, makeAdmin })} + adminPending={admin.isPending} + adminError={admin.error} /> - ) : null} - {!settings.isPending && !settings.isError && settingsEntries.length === 0 ? ( -

{t('settings.instance.settingsEmpty')}

- ) : null} - {settingsEntries.length === 0 ? null : ( -
- {settingsEntries.map(([key, value]) => ( -
-
{key}
-
- {format(value)} -
-
- ))} -
- )} -
+
+ ) : null} - -

- {t('settings.instance.guilds', { count: guilds.data?.length ?? 0 })} -

- {guilds.isPending ? : null} - {guilds.isError ? ( - +

{t('settings.instance.guildsTitle')}

+ void guilds.refetch()} + onOpenSettings={(guildId) => { + // Настройки сервера открываются обычным разделом: у администратора + // инстанса там все права (AGENTS.md 7.19). + selectSettingsGuild(guildId); + openSettings({ section: 'server', guildId }); + }} /> - ) : null} - {guilds.data !== undefined && guilds.data.length === 0 ? ( -

{t('settings.instance.guildsEmpty')}

- ) : null} - {guilds.data === undefined || guilds.data.length === 0 ? null : ( -
    - {guilds.data.map((guild) => ( -
  • - {guild.name} - - {guild.is_main ? `${t('settings.instance.mainBadge')} · ` : ''} - {t('settings.instance.memberCount', { count: guild.member_count })} - -
  • - ))} -
- )} -
+ + ) : null} - -

- {t('settings.instance.users', { count: users.data?.length ?? 0 })} -

- {/* ID пользователей — единственное место, где он виден: раздел открыт - только администратору инстанса. */} -

{t('settings.instance.usersIdHint')}

- {users.isPending ? : null} - {users.isError ? ( - void users.refetch()} /> - ) : null} - {users.data !== undefined && users.data.length === 0 ? ( -

{t('settings.instance.usersEmpty')}

- ) : null} - {users.data === undefined || users.data.length === 0 ? null : ( -
    - {users.data.map((user) => ( -
  • - - {user.display_name} @{user.username} - - +

    + {t('settings.instance.audit', { count: audit.data?.length ?? 0 })} +

    + {audit.isPending ? : null} + {audit.isError ? ( + void audit.refetch()} + /> + ) : null} + {audit.data !== undefined && audit.data.length === 0 ? ( +

    {t('settings.instance.auditEmpty')}

    + ) : null} + {audit.data === undefined || audit.data.length === 0 ? null : ( +
      + {audit.data.map((entry) => ( +
    • - {user.id} - - {user.is_instance_admin ? ( - - {t('settings.instance.adminBadge')} + + {entry.action} + {entry.target === undefined ? '' : ` → ${entry.target}`} - ) : null} -
    • - ))} -
    - )} - - - -

    - {t('settings.instance.audit', { count: audit.data?.length ?? 0 })} -

    - {audit.isPending ? : null} - {audit.isError ? ( - void audit.refetch()} /> - ) : null} - {audit.data !== undefined && audit.data.length === 0 ? ( -

    {t('settings.instance.auditEmpty')}

    - ) : null} - {audit.data === undefined || audit.data.length === 0 ? null : ( -
      - {audit.data.map((entry) => ( -
    • - - {entry.action} - {entry.target === undefined ? '' : ` → ${entry.target}`} - - - {formatDateTime(entry.created_at, locale)} - -
    • - ))} -
    - )} -
    - - + + {formatDateTime(entry.created_at, locale)} + +
  • + ))} +
+ )} +
+ ) : null}
); } - -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 '—'; - } -} diff --git a/web/tests/settings.test.tsx b/web/tests/settings.test.tsx index 1a3ffe9..a5c889d 100644 --- a/web/tests/settings.test.tsx +++ b/web/tests/settings.test.tsx @@ -541,6 +541,8 @@ describe('настройки: аватар', () => { renderApp('/app/empty'); const dialog = await openSettings(); await goToSection(dialog, 'Инстанс'); + // Админ-панель разбита на вкладки: пользователи живут в своей. + await userEvent.click(await within(dialog).findByTestId('instance-tab-users')); expect(await within(dialog).findByTestId('instance-user-id-u-42')).toHaveTextContent('u-42'); expect(within(dialog).getByText(/ID пользователей видны только администратору/)).toBeVisible(); @@ -722,9 +724,132 @@ describe('настройки: безопасность', () => { renderApp('/settings/account/instance'); + // Серверы инстанса. + await userEvent.click(await screen.findByTestId('instance-tab-guilds')); 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: 'Новое' }); + }); }); });