feat(invites): приглашения, каталог публичных серверов и публичность сервера
- POST /guilds/{id}/invites (CREATE_INVITES): код из 10 символов, лимит
использований и срок жизни; GET /guilds/{id}/invites (MANAGE_GUILD);
DELETE /invites/{code} — создатель или MANAGE_GUILD;
- GET /invites/{code} — предпросмотр с карточкой сервера, 410 для истёкших и
исчерпанных; POST /invites/{code} — принятие с проверкой лимита участников,
повторное принятие не расходует использование;
- PATCH /guilds/{id} принимает `public`: сервер попадает в каталог и открыт для
входа без приглашения (главный сервер публиковать нельзя);
- GET /guilds/public — каталог (имя, описание, иконка, число участников);
- аудит invite.create/invite.revoke/invite.accept, события INVITE_CREATE и
INVITE_DELETE владельцу сессии;
- тесты: полный жизненный цикл приглашения, исчерпание, отзыв, права,
публичный каталог и вход в публичный сервер.
This commit is contained in:
@@ -221,6 +221,9 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
|||||||
Body struct {
|
Body struct {
|
||||||
Name *string `json:"name,omitempty" maxLength:"64"`
|
Name *string `json:"name,omitempty" maxLength:"64"`
|
||||||
Description *string `json:"description,omitempty" maxLength:"500"`
|
Description *string `json:"description,omitempty" maxLength:"500"`
|
||||||
|
// Публичность сервера: попадает в каталог и открывает вход без
|
||||||
|
// приглашения (AGENT.md 7.20).
|
||||||
|
Public *bool `json:"public,omitempty"`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) (*guildOutput, error) {
|
) (*guildOutput, error) {
|
||||||
@@ -233,6 +236,19 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
params := store.UpdateGuildParams{Description: input.Body.Description}
|
params := store.UpdateGuildParams{Description: input.Body.Description}
|
||||||
|
if input.Body.Public != nil {
|
||||||
|
// Главный сервер публичным не делаем: он и так точка входа.
|
||||||
|
if *input.Body.Public {
|
||||||
|
guild, err := s.store.GetGuild(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if guild.IsMain {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "main guild is always open and cannot be published")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params.IsDiscoverable = input.Body.Public
|
||||||
|
}
|
||||||
if input.Body.Name != nil {
|
if input.Body.Name != nil {
|
||||||
trimmed := strings.TrimSpace(*input.Body.Name)
|
trimmed := strings.TrimSpace(*input.Body.Name)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
|
||||||
|
"glchat/internal/permissions"
|
||||||
|
"glchat/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// inviteCodeAlphabet — символы кода приглашения (без похожих друг на друга).
|
||||||
|
const inviteCodeAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||||
|
|
||||||
|
type invitePayload struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
GuildID string `json:"guild_id"`
|
||||||
|
ChannelID string `json:"channel_id,omitempty"`
|
||||||
|
CreatorID string `json:"creator_id,omitempty"`
|
||||||
|
MaxUses int `json:"max_uses"`
|
||||||
|
Uses int `json:"uses"`
|
||||||
|
MaxAgeSec int `json:"max_age_seconds"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inviteListOutput struct {
|
||||||
|
Body struct {
|
||||||
|
Invites []invitePayload `json:"invites"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type inviteOutput struct {
|
||||||
|
Body struct {
|
||||||
|
Invite invitePayload `json:"invite"`
|
||||||
|
Guild struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IconFileID string `json:"icon_file_id,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
MemberCount int `json:"member_count"`
|
||||||
|
IsMember bool `json:"is_member"`
|
||||||
|
} `json:"guild"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type publicGuildPayload struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IconFileID string `json:"icon_file_id,omitempty"`
|
||||||
|
MemberCount int `json:"member_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type publicGuildListOutput struct {
|
||||||
|
Body struct {
|
||||||
|
Guilds []publicGuildPayload `json:"guilds"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerInviteRoutes описывает приглашения и каталог публичных серверов
|
||||||
|
// (AGENT.md 7.9, 7.20).
|
||||||
|
func (s *Server) registerInviteRoutes(api huma.API) {
|
||||||
|
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "createInvite",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/guilds/{guild_id}/invites",
|
||||||
|
Summary: "Создать приглашение",
|
||||||
|
Tags: []string{"Invites"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
GuildID string `path:"guild_id"`
|
||||||
|
Body struct {
|
||||||
|
ChannelID string `json:"channel_id,omitempty"`
|
||||||
|
MaxUses int `json:"max_uses,omitempty" minimum:"0" maximum:"1000"`
|
||||||
|
MaxAgeSec int `json:"max_age_seconds,omitempty" minimum:"0" maximum:"2592000"`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) (*inviteOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.CreateInvites)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
params := store.CreateInviteParams{
|
||||||
|
Code: newInviteCode(),
|
||||||
|
GuildID: guildID,
|
||||||
|
CreatorID: user.ID,
|
||||||
|
MaxUses: input.Body.MaxUses,
|
||||||
|
MaxAgeSec: input.Body.MaxAgeSec,
|
||||||
|
}
|
||||||
|
if input.Body.ChannelID != "" {
|
||||||
|
channelID, err := parseID("channel_id", input.Body.ChannelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
params.ChannelID = &channelID
|
||||||
|
}
|
||||||
|
invite, err := s.store.CreateInvite(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, user, guildID, "invite.create", "invite", nil, "")
|
||||||
|
if s.gateway != nil {
|
||||||
|
s.gateway.SendToUser(user.ID, "INVITE_CREATE", s.invitePayload(invite))
|
||||||
|
}
|
||||||
|
return s.inviteOutput(ctx, user, invite), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "listGuildInvites",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/guilds/{guild_id}/invites",
|
||||||
|
Summary: "Приглашения сервера",
|
||||||
|
Tags: []string{"Invites"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
GuildID string `path:"guild_id"`
|
||||||
|
},
|
||||||
|
) (*inviteListOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageGuild)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
invites, err := s.store.ListGuildInvites(ctx, guildID, 100)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
output := &inviteListOutput{}
|
||||||
|
output.Body.Invites = make([]invitePayload, 0, len(invites))
|
||||||
|
for i := range invites {
|
||||||
|
output.Body.Invites = append(output.Body.Invites, s.invitePayload(&invites[i]))
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "deleteInvite",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Path: "/invites/{code}",
|
||||||
|
Summary: "Отозвать приглашение",
|
||||||
|
Tags: []string{"Invites"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
Code string `path:"code"`
|
||||||
|
},
|
||||||
|
) (*okOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
invite, err := s.store.GetInvite(ctx, input.Code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
isCreator := invite.CreatorID != nil && *invite.CreatorID == user.ID
|
||||||
|
if !isCreator {
|
||||||
|
if _, _, err := s.requireGuildPermission(ctx, formatSnowflake(invite.GuildID), user, permissions.ManageGuild); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteInvite(ctx, input.Code); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, user, invite.GuildID, "invite.revoke", "invite", nil, "")
|
||||||
|
if s.gateway != nil && invite.CreatorID != nil {
|
||||||
|
s.gateway.SendToUser(*invite.CreatorID, "INVITE_DELETE", map[string]any{"code": input.Code})
|
||||||
|
}
|
||||||
|
return newOKOutput(), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "getInvite",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/invites/{code}",
|
||||||
|
Summary: "Предпросмотр приглашения",
|
||||||
|
Tags: []string{"Invites"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
Code string `path:"code"`
|
||||||
|
},
|
||||||
|
) (*inviteOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
invite, err := s.store.GetInvite(ctx, input.Code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now().UTC()) {
|
||||||
|
return nil, humaErrorStatus(http.StatusGone, "invite.expired", "invite has expired")
|
||||||
|
}
|
||||||
|
if invite.MaxUses > 0 && invite.Uses >= invite.MaxUses {
|
||||||
|
return nil, humaErrorStatus(http.StatusGone, "invite.exhausted", "invite has no uses left")
|
||||||
|
}
|
||||||
|
return s.inviteOutput(ctx, user, invite), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "acceptInvite",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/invites/{code}",
|
||||||
|
Summary: "Принять приглашение",
|
||||||
|
Tags: []string{"Invites"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
Code string `path:"code"`
|
||||||
|
},
|
||||||
|
) (*guildIDOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
invite, err := s.store.GetInvite(ctx, input.Code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
// Уже участник: повторное принятие не расходует использование.
|
||||||
|
if _, err := s.store.GetGuildMember(ctx, invite.GuildID, user.ID); err == nil {
|
||||||
|
output := &guildIDOutput{}
|
||||||
|
output.Body.GuildID = formatSnowflake(invite.GuildID)
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
settings, err := s.store.InstanceSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
count, err := s.store.CountGuildMembers(ctx, invite.GuildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if count >= settings.MaxMembersPerGuild && !user.IsInstanceAdmin {
|
||||||
|
return nil, humaErrorStatus(http.StatusForbidden, "limits.members_reached", "guild is full")
|
||||||
|
}
|
||||||
|
if _, err := s.store.UseInvite(ctx, input.Code); err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return nil, humaErrorStatus(http.StatusGone, "invite.exhausted", "invite is no longer valid")
|
||||||
|
}
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if _, err := s.store.AddGuildMember(ctx, invite.GuildID, user.ID, ""); err != nil && !errors.Is(err, store.ErrConflict) {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
s.invalidateGuild(invite.GuildID)
|
||||||
|
s.recordAudit(ctx, user, invite.GuildID, "invite.accept", "user", &user.ID, "")
|
||||||
|
if s.gateway != nil {
|
||||||
|
s.gateway.SendToUser(user.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(invite.GuildID)})
|
||||||
|
}
|
||||||
|
output := &guildIDOutput{}
|
||||||
|
output.Body.GuildID = formatSnowflake(invite.GuildID)
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "listPublicGuilds",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/guilds/public",
|
||||||
|
Summary: "Каталог публичных серверов",
|
||||||
|
Tags: []string{"Guilds"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, _ *struct{}) (*publicGuildListOutput, error) {
|
||||||
|
if _, _, err := requireUser(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guilds, err := s.store.ListPublicGuilds(ctx, 50)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
output := &publicGuildListOutput{}
|
||||||
|
output.Body.Guilds = make([]publicGuildPayload, 0, len(guilds))
|
||||||
|
for _, guild := range guilds {
|
||||||
|
item := publicGuildPayload{
|
||||||
|
ID: formatSnowflake(guild.ID),
|
||||||
|
Name: guild.Name,
|
||||||
|
Description: guild.Description,
|
||||||
|
}
|
||||||
|
if guild.IconFileID != nil {
|
||||||
|
item.IconFileID = formatSnowflake(*guild.IconFileID)
|
||||||
|
}
|
||||||
|
if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil {
|
||||||
|
item.MemberCount = count
|
||||||
|
}
|
||||||
|
output.Body.Guilds = append(output.Body.Guilds, item)
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type guildIDOutput struct {
|
||||||
|
Body struct {
|
||||||
|
GuildID string `json:"guild_id"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invitePayload собирает приглашение для ответа API.
|
||||||
|
func (s *Server) invitePayload(invite *store.Invite) invitePayload {
|
||||||
|
payload := invitePayload{
|
||||||
|
Code: invite.Code,
|
||||||
|
GuildID: formatSnowflake(invite.GuildID),
|
||||||
|
MaxUses: invite.MaxUses,
|
||||||
|
Uses: invite.Uses,
|
||||||
|
MaxAgeSec: invite.MaxAgeSec,
|
||||||
|
CreatedAt: invite.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
URL: s.cfg.BaseURL() + "/invite/" + invite.Code,
|
||||||
|
}
|
||||||
|
if invite.ChannelID != nil {
|
||||||
|
payload.ChannelID = formatSnowflake(*invite.ChannelID)
|
||||||
|
}
|
||||||
|
if invite.CreatorID != nil {
|
||||||
|
payload.CreatorID = formatSnowflake(*invite.CreatorID)
|
||||||
|
}
|
||||||
|
if invite.ExpiresAt != nil {
|
||||||
|
payload.ExpiresAt = invite.ExpiresAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// inviteOutput собирает приглашение вместе с карточкой сервера для страницы
|
||||||
|
// предпросмотра (AGENT.md 7.9).
|
||||||
|
func (s *Server) inviteOutput(ctx context.Context, user *store.User, invite *store.Invite) *inviteOutput {
|
||||||
|
output := &inviteOutput{}
|
||||||
|
output.Body.Invite = s.invitePayload(invite)
|
||||||
|
guild, err := s.store.GetGuild(ctx, invite.GuildID)
|
||||||
|
if err != nil {
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
output.Body.Guild.ID = formatSnowflake(guild.ID)
|
||||||
|
output.Body.Guild.Name = guild.Name
|
||||||
|
output.Body.Guild.Description = guild.Description
|
||||||
|
if guild.IconFileID != nil {
|
||||||
|
output.Body.Guild.IconFileID = formatSnowflake(*guild.IconFileID)
|
||||||
|
}
|
||||||
|
if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil {
|
||||||
|
output.Body.Guild.MemberCount = count
|
||||||
|
}
|
||||||
|
if _, err := s.store.GetGuildMember(ctx, guild.ID, user.ID); err == nil {
|
||||||
|
output.Body.Guild.IsMember = true
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
// newInviteCode генерирует код приглашения криптографическим источником.
|
||||||
|
func newInviteCode() string {
|
||||||
|
const length = 10
|
||||||
|
buf := make([]byte, length)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
// Откат на время: код остаётся уникальным за счёт монотонности.
|
||||||
|
seed := uint64(time.Now().UnixNano()) //nolint:gosec // не криптография: запасной путь при отказе rand
|
||||||
|
for i := range buf {
|
||||||
|
buf[i] = byte(seed >> (uint(i) * 5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.Grow(length)
|
||||||
|
for _, value := range buf {
|
||||||
|
builder.WriteByte(inviteCodeAlphabet[int(value)%len(inviteCodeAlphabet)])
|
||||||
|
}
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInviteLifecycle(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
ownerCookie := registerAndLogin(t, srv, "inv_owner", "inv-owner@example.com")
|
||||||
|
guestCookie := registerAndLogin(t, srv, "inv_guest", "inv-guest@example.com")
|
||||||
|
|
||||||
|
// Первый сервер становится главным (в него может войти кто угодно),
|
||||||
|
// приглашения проверяем на втором — закрытом.
|
||||||
|
doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Главный"}`, ownerCookie)
|
||||||
|
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Закрытый"}`, ownerCookie)
|
||||||
|
guild := decodeResponse[struct {
|
||||||
|
Guild struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"guild"`
|
||||||
|
}](t, created)
|
||||||
|
|
||||||
|
// Посторонний не видит закрытый сервер.
|
||||||
|
hidden := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID, "", guestCookie)
|
||||||
|
if hidden.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("guest guild access = %d, want 404", hidden.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приглашение с лимитом в одно использование.
|
||||||
|
inviteRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/invites",
|
||||||
|
`{"max_uses":1,"max_age_seconds":3600}`, ownerCookie)
|
||||||
|
if inviteRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create invite = %d, body = %s", inviteRec.Code, inviteRec.Body.String())
|
||||||
|
}
|
||||||
|
invite := decodeResponse[struct {
|
||||||
|
Invite struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"invite"`
|
||||||
|
Guild struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MemberCount int `json:"member_count"`
|
||||||
|
IsMember bool `json:"is_member"`
|
||||||
|
} `json:"guild"`
|
||||||
|
}](t, inviteRec)
|
||||||
|
if len(invite.Invite.Code) != 10 {
|
||||||
|
t.Fatalf("invite code = %q, want 10 символов", invite.Invite.Code)
|
||||||
|
}
|
||||||
|
// В ответе на создание IsMember отражает самого создателя (он в сервере).
|
||||||
|
if invite.Guild.Name != "Закрытый" || !invite.Guild.IsMember {
|
||||||
|
t.Fatalf("unexpected preview: %+v", invite.Guild)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Предпросмотр доступен гостю и показывает сервер.
|
||||||
|
preview := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie)
|
||||||
|
if preview.Code != http.StatusOK {
|
||||||
|
t.Fatalf("preview = %d, body = %s", preview.Code, preview.Body.String())
|
||||||
|
}
|
||||||
|
previewPayload := decodeResponse[struct {
|
||||||
|
Guild struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MemberCount int `json:"member_count"`
|
||||||
|
IsMember bool `json:"is_member"`
|
||||||
|
} `json:"guild"`
|
||||||
|
}](t, preview)
|
||||||
|
if previewPayload.Guild.Name != "Закрытый" || previewPayload.Guild.IsMember || previewPayload.Guild.MemberCount != 1 {
|
||||||
|
t.Fatalf("guest preview = %+v", previewPayload.Guild)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Принятие: гость становится участником.
|
||||||
|
accept := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie)
|
||||||
|
if accept.Code != http.StatusOK {
|
||||||
|
t.Fatalf("accept invite = %d, body = %s", accept.Code, accept.Body.String())
|
||||||
|
}
|
||||||
|
member := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID, "", guestCookie)
|
||||||
|
if member.Code != http.StatusOK {
|
||||||
|
t.Fatalf("guild after invite = %d, body = %s", member.Code, member.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторное принятие тем же пользователем не расходует использования.
|
||||||
|
again := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie)
|
||||||
|
if again.Code != http.StatusOK {
|
||||||
|
t.Fatalf("repeat accept = %d, body = %s", again.Code, again.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Третьего участника приглашение уже не пускает: лимит исчерпан.
|
||||||
|
thirdCookie := registerAndLogin(t, srv, "inv_third", "inv-third@example.com")
|
||||||
|
exhausted := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+invite.Invite.Code, "", thirdCookie)
|
||||||
|
if exhausted.Code != http.StatusGone {
|
||||||
|
t.Fatalf("exhausted invite = %d, want 410", exhausted.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отзыв приглашения владельцем и проверка списка.
|
||||||
|
list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/invites", "", ownerCookie)
|
||||||
|
invites := decodeResponse[struct {
|
||||||
|
Invites []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Uses int `json:"uses"`
|
||||||
|
} `json:"invites"`
|
||||||
|
}](t, list)
|
||||||
|
if len(invites.Invites) != 1 || invites.Invites[0].Uses != 1 {
|
||||||
|
t.Fatalf("invites = %+v", invites.Invites)
|
||||||
|
}
|
||||||
|
revoke := doJSON(t, srv, http.MethodDelete, "/api/v1/invites/"+invite.Invite.Code, "", ownerCookie)
|
||||||
|
if revoke.Code != http.StatusOK {
|
||||||
|
t.Fatalf("revoke = %d, body = %s", revoke.Code, revoke.Body.String())
|
||||||
|
}
|
||||||
|
afterRevoke := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie)
|
||||||
|
if afterRevoke.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("revoked invite = %d, want 404", afterRevoke.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычный участник без CREATE_INVITES приглашение создать не может.
|
||||||
|
memberInvite := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/invites", `{}`, guestCookie)
|
||||||
|
if memberInvite.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("member invite = %d, want 403", memberInvite.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicGuildsCatalog(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
ownerCookie := registerAndLogin(t, srv, "pub_owner", "pub-owner@example.com")
|
||||||
|
guestCookie := registerAndLogin(t, srv, "pub_guest", "pub-guest@example.com")
|
||||||
|
|
||||||
|
doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Главный"}`, ownerCookie)
|
||||||
|
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Публичный"}`, ownerCookie)
|
||||||
|
guild := decodeResponse[struct {
|
||||||
|
Guild struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"guild"`
|
||||||
|
}](t, created)
|
||||||
|
|
||||||
|
// Пока сервер закрыт, в каталоге его нет.
|
||||||
|
closed := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/public", "", guestCookie)
|
||||||
|
closedList := decodeResponse[struct {
|
||||||
|
Guilds []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"guilds"`
|
||||||
|
}](t, closed)
|
||||||
|
for _, item := range closedList.Guilds {
|
||||||
|
if item.Name == "Публичный" {
|
||||||
|
t.Fatal("closed guild must not be in the public catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Делаем сервер публичным: появляется в каталоге и открыт для входа.
|
||||||
|
if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guild.Guild.ID,
|
||||||
|
`{"public":true}`, ownerCookie); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("publish guild = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
open := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/public", "", guestCookie)
|
||||||
|
openList := decodeResponse[struct {
|
||||||
|
Guilds []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MemberCount int `json:"member_count"`
|
||||||
|
} `json:"guilds"`
|
||||||
|
}](t, open)
|
||||||
|
found := false
|
||||||
|
for _, item := range openList.Guilds {
|
||||||
|
if item.ID == guild.Guild.ID {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("public guild is missing from the catalog: %+v", openList.Guilds)
|
||||||
|
}
|
||||||
|
|
||||||
|
join := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", guestCookie)
|
||||||
|
if join.Code != http.StatusOK {
|
||||||
|
t.Fatalf("join public guild = %d, body = %s", join.Code, join.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,6 +100,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
s.registerGuildRoutes(s.api)
|
s.registerGuildRoutes(s.api)
|
||||||
s.registerInstanceRoutes(s.api)
|
s.registerInstanceRoutes(s.api)
|
||||||
s.registerMessageRoutes(s.api)
|
s.registerMessageRoutes(s.api)
|
||||||
|
s.registerInviteRoutes(s.api)
|
||||||
}
|
}
|
||||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Invite — приглашение на сервер (AGENT.md 7.9).
|
||||||
|
type Invite struct {
|
||||||
|
Code string
|
||||||
|
GuildID uint64
|
||||||
|
ChannelID *uint64
|
||||||
|
CreatorID *uint64
|
||||||
|
MaxUses int
|
||||||
|
Uses int
|
||||||
|
MaxAgeSec int
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInviteParams — параметры приглашения.
|
||||||
|
type CreateInviteParams struct {
|
||||||
|
Code string
|
||||||
|
GuildID uint64
|
||||||
|
ChannelID *uint64
|
||||||
|
CreatorID uint64
|
||||||
|
MaxUses int
|
||||||
|
MaxAgeSec int
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteColumns = `code, guild_id, channel_id, creator_id, max_uses, uses,
|
||||||
|
max_age_sec, expires_at, created_at`
|
||||||
|
|
||||||
|
// CreateInvite сохраняет приглашение с кодом, лимитом использований и сроком.
|
||||||
|
func (s *Store) CreateInvite(ctx context.Context, params CreateInviteParams) (*Invite, error) {
|
||||||
|
var expiresAt any
|
||||||
|
if params.MaxAgeSec > 0 {
|
||||||
|
expiresAt = s.Timestamp(time.Now().UTC().Add(time.Duration(params.MaxAgeSec) * time.Second))
|
||||||
|
}
|
||||||
|
_, err := s.writer.ExecContext(ctx, `
|
||||||
|
INSERT INTO invites (code, guild_id, channel_id, creator_id, max_uses, uses, max_age_sec, expires_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`,
|
||||||
|
params.Code, int64(params.GuildID), nullableID(params.ChannelID), int64(params.CreatorID),
|
||||||
|
params.MaxUses, params.MaxAgeSec, expiresAt, s.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
return s.GetInvite(ctx, params.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetInvite(ctx context.Context, code string) (*Invite, error) {
|
||||||
|
row := s.reader.QueryRowContext(ctx, `SELECT `+inviteColumns+` FROM invites WHERE code = ?`, code)
|
||||||
|
return scanInvite(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGuildInvites возвращает приглашения сервера (для панели управления).
|
||||||
|
func (s *Store) ListGuildInvites(ctx context.Context, guildID uint64, limit int) ([]Invite, error) {
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.reader.QueryContext(ctx, `
|
||||||
|
SELECT `+inviteColumns+` FROM invites WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?`,
|
||||||
|
int64(guildID), limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
invites := make([]Invite, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
invite, err := scanInvite(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
invites = append(invites, *invite)
|
||||||
|
}
|
||||||
|
return invites, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseInvite атомарно увеличивает счётчик использований, проверяя лимит и срок.
|
||||||
|
// Возвращает ErrNotFound, если приглашение исчерпано или истекло (AGENT.md 7.9).
|
||||||
|
func (s *Store) UseInvite(ctx context.Context, code string) (*Invite, error) {
|
||||||
|
result, err := s.writer.ExecContext(ctx, `
|
||||||
|
UPDATE invites SET uses = uses + 1
|
||||||
|
WHERE code = ?
|
||||||
|
AND (max_uses = 0 OR uses < max_uses)
|
||||||
|
AND (expires_at IS NULL OR expires_at > ?)`, code, s.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return s.GetInvite(ctx, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteInvite(ctx context.Context, code string) error {
|
||||||
|
result, err := s.writer.ExecContext(ctx, `DELETE FROM invites WHERE code = ?`, code)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublicGuilds возвращает публичные серверы для каталога (AGENT.md 7.20).
|
||||||
|
func (s *Store) ListPublicGuilds(ctx context.Context, limit int) ([]Guild, error) {
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.reader.QueryContext(ctx, `
|
||||||
|
SELECT `+guildColumns+` FROM guilds
|
||||||
|
WHERE is_discoverable = 1 ORDER BY id LIMIT ?`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
guilds := make([]Guild, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
guild, err := scanGuild(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guilds = append(guilds, *guild)
|
||||||
|
}
|
||||||
|
return guilds, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanInvite(scanner interface{ Scan(...any) error }) (*Invite, error) {
|
||||||
|
var (
|
||||||
|
invite Invite
|
||||||
|
channelID sql.NullInt64
|
||||||
|
creatorID sql.NullInt64
|
||||||
|
expiresAt sql.NullString
|
||||||
|
createdAt string
|
||||||
|
)
|
||||||
|
err := scanner.Scan(&invite.Code, &invite.GuildID, &channelID, &creatorID, &invite.MaxUses,
|
||||||
|
&invite.Uses, &invite.MaxAgeSec, &expiresAt, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
invite.ChannelID = optionalID(channelID)
|
||||||
|
invite.CreatorID = optionalID(creatorID)
|
||||||
|
if expiresAt.Valid {
|
||||||
|
value := parseTimestamp(expiresAt.String)
|
||||||
|
invite.ExpiresAt = &value
|
||||||
|
}
|
||||||
|
invite.CreatedAt = parseTimestamp(createdAt)
|
||||||
|
return &invite, nil
|
||||||
|
}
|
||||||
+6
-3
@@ -1,15 +1,18 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { RouterProvider } from 'react-router';
|
import { RouterProvider } from 'react-router';
|
||||||
|
|
||||||
import { LoadingNotice } from '@/components/ui/ErrorNotice';
|
import { Card } from '@/components/ui/primitives';
|
||||||
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
import { router } from '@/router';
|
import { router } from '@/router';
|
||||||
import '@/i18n';
|
import '@/i18n';
|
||||||
|
|
||||||
/** Заглушка на время загрузки ленивых страниц. */
|
/** Заглушка на время загрузки ленивых страниц: скелетон вместо пустоты. */
|
||||||
export function RouteFallback() {
|
export function RouteFallback() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full items-center justify-center p-6">
|
<div className="flex min-h-full items-center justify-center p-6">
|
||||||
<LoadingNotice />
|
<Card className="w-full max-w-md">
|
||||||
|
<SkeletonLines rows={4} />
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function Button({ variant = 'primary', className = '', ...rest }: ButtonP
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`inline-flex items-center justify-center gap-2 rounded-[var(--radius-md)] px-3 py-1.5 text-sm font-medium transition-colors duration-[var(--duration-fast)] disabled:cursor-not-allowed disabled:opacity-50 ${variants[variant]} ${className}`}
|
className={`inline-flex items-center justify-center gap-2 rounded-[var(--radius-md)] px-3 py-1.5 text-sm font-medium transition-[background-color,opacity,transform,border-color] duration-[var(--duration-fast)] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 ${variants[variant]} ${className}`}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -183,6 +183,7 @@
|
|||||||
"empty": "В этом сервере пока нет видимых комнат.",
|
"empty": "В этом сервере пока нет видимых комнат.",
|
||||||
"select": "Открыть комнату {{name}}",
|
"select": "Открыть комнату {{name}}",
|
||||||
"add": "Создать комнату",
|
"add": "Создать комнату",
|
||||||
|
"settings": "Настройки сервера",
|
||||||
"create": {
|
"create": {
|
||||||
"title": "Новая комната",
|
"title": "Новая комната",
|
||||||
"name": "Название комнаты",
|
"name": "Название комнаты",
|
||||||
@@ -214,6 +215,13 @@
|
|||||||
"user": {
|
"user": {
|
||||||
"panelLabel": "Панель пользователя",
|
"panelLabel": "Панель пользователя",
|
||||||
"settings": "Настройки пользователя",
|
"settings": "Настройки пользователя",
|
||||||
|
"menu": {
|
||||||
|
"label": "Меню пользователя",
|
||||||
|
"open": "Открыть меню пользователя",
|
||||||
|
"account": "Настройки аккаунта",
|
||||||
|
"guilds": "Настройки серверов",
|
||||||
|
"status": "Статус"
|
||||||
|
},
|
||||||
"logout": "Выйти",
|
"logout": "Выйти",
|
||||||
"loggingOut": "Выходим…",
|
"loggingOut": "Выходим…",
|
||||||
"statusLabel": "Статус: {{status}}",
|
"statusLabel": "Статус: {{status}}",
|
||||||
@@ -240,6 +248,11 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
"back": "В приложение",
|
"back": "В приложение",
|
||||||
|
"switcher": {
|
||||||
|
"label": "Разделы настроек",
|
||||||
|
"account": "Настройки сервера",
|
||||||
|
"servers": "Мои серверы"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"label": "Разделы настроек",
|
"label": "Разделы настроек",
|
||||||
"profile": "Профиль",
|
"profile": "Профиль",
|
||||||
@@ -331,6 +344,61 @@
|
|||||||
"memberCount": "{{count}} участн.",
|
"memberCount": "{{count}} участн.",
|
||||||
"adminBadge": "админ",
|
"adminBadge": "админ",
|
||||||
"mainBadge": "главный"
|
"mainBadge": "главный"
|
||||||
|
},
|
||||||
|
"servers": {
|
||||||
|
"pickerLabel": "Мои серверы",
|
||||||
|
"empty": "У вас пока нет серверов.",
|
||||||
|
"emptyTitle": "Пока нет ни одного сервера",
|
||||||
|
"emptyHint": "Присоединитесь к главному серверу инстанса или создайте свой — тогда здесь появятся его настройки.",
|
||||||
|
"select": "Открыть настройки сервера {{name}}",
|
||||||
|
"notFound": "Сервер не найден среди доступных вам.",
|
||||||
|
"readOnlyHint": "Редактирование доступно владельцу сервера и участникам с правами ADMINISTRATOR или MANAGE_GUILD.",
|
||||||
|
"general": {
|
||||||
|
"title": "Общее",
|
||||||
|
"description": "Название и описание сервера видят все участники.",
|
||||||
|
"name": "Название сервера",
|
||||||
|
"guildDescription": "Описание",
|
||||||
|
"descriptionPlaceholder": "О чём этот сервер",
|
||||||
|
"saved": "Настройки сервера сохранены."
|
||||||
|
},
|
||||||
|
"nickname": {
|
||||||
|
"title": "Мой профиль на сервере",
|
||||||
|
"description": "Никнейм виден только на этом сервере.",
|
||||||
|
"label": "Никнейм",
|
||||||
|
"placeholder": "Оставьте пустым, чтобы сбросить ник",
|
||||||
|
"current": "Текущий никнейм:",
|
||||||
|
"none": "не задан",
|
||||||
|
"saved": "Никнейм сохранён."
|
||||||
|
},
|
||||||
|
"members": {
|
||||||
|
"title": "Участники",
|
||||||
|
"description": "Первые {{limit}} участников сервера.",
|
||||||
|
"empty": "Участники не найдены.",
|
||||||
|
"roles": "Ролей: {{roles}}",
|
||||||
|
"more": "Полный список участников с ролями появится в следующих фазах."
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"title": "Роли",
|
||||||
|
"description": "Цвет и позиция ролей. Управление ролями появится в следующих фазах.",
|
||||||
|
"empty": "На сервере пока нет ролей.",
|
||||||
|
"position": "Позиция {{position}}",
|
||||||
|
"default": "по умолчанию"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"title": "Опасная зона",
|
||||||
|
"description": "Эти действия необратимы.",
|
||||||
|
"leaveOwnerHint": "Владелец не может покинуть свой сервер: сначала передайте владение или удалите сервер.",
|
||||||
|
"leaveHint": "Выйти из сервера и потерять доступ к его комнатам.",
|
||||||
|
"leave": "Покинуть сервер",
|
||||||
|
"leaveConfirmTitle": "Покинуть сервер?",
|
||||||
|
"leaveConfirmBody": "Вы выйдете из сервера «{{name}}». Вернуться можно будет по приглашению.",
|
||||||
|
"leaveConfirm": "Покинуть",
|
||||||
|
"deleteHint": "Удалить сервер вместе с комнатами, ролями и участниками.",
|
||||||
|
"delete": "Удалить сервер",
|
||||||
|
"deleteConfirmTitle": "Удалить сервер?",
|
||||||
|
"deleteConfirmBody": "Сервер «{{name}}» будет удалён без возможности восстановления.",
|
||||||
|
"deleteConfirm": "Удалить"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,8 @@ export default function AppLayout() {
|
|||||||
/>
|
/>
|
||||||
<ChannelSidebar guildId={guildId} channelId={channelId} />
|
<ChannelSidebar guildId={guildId} channelId={channelId} />
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<div className="min-h-0 flex-1 overflow-auto">
|
{/* Ключ по пути: смена комнаты/сервера появляется с анимацией. */}
|
||||||
|
<div key={location.pathname} className="gl-page min-h-0 flex-1 overflow-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
<UserPanel />
|
<UserPanel />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { NavLink, useNavigate } from 'react-router';
|
import { Link, NavLink, useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { createChannel, fetchChannels, guildChannelsQueryKey, leaveGuild } from '@/api/guilds';
|
import { createChannel, fetchChannels, guildChannelsQueryKey, leaveGuild } from '@/api/guilds';
|
||||||
import { myGuildsQueryKey } from '@/api/users';
|
import { myGuildsQueryKey } from '@/api/users';
|
||||||
@@ -9,8 +9,9 @@ import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
|||||||
import { Field, SelectField } from '@/components/ui/Field';
|
import { Field, SelectField } from '@/components/ui/Field';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Button } from '@/components/ui/primitives';
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
import { groupChannels } from '@/lib/channels';
|
import { groupChannels } from '@/lib/channels';
|
||||||
import { canManageGuild } from '@/lib/identity';
|
import { canManageGuild, canManageGuildSettings } from '@/lib/identity';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
@@ -91,6 +92,12 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
);
|
);
|
||||||
const canManage =
|
const canManage =
|
||||||
guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id);
|
guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id);
|
||||||
|
// Шестерёнка настроек сервера — только владельцу и правам управления.
|
||||||
|
const canManageSettings =
|
||||||
|
guild !== null &&
|
||||||
|
canManageGuildSettings(guild.my_permissions, guild.owner_id, currentUser.data?.id);
|
||||||
|
// Каналы ещё едут по REST — показываем скелетон вместо «пусто».
|
||||||
|
const channelsLoading = needsChannels && remoteChannels.isPending;
|
||||||
|
|
||||||
if (guildId === null) {
|
if (guildId === null) {
|
||||||
return (
|
return (
|
||||||
@@ -107,22 +114,36 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
<h1 className="truncate text-sm font-semibold" title={guild?.name}>
|
<h1 className="truncate text-sm font-semibold" title={guild?.name}>
|
||||||
{guild?.name ?? t('channels.notInGuild.title')}
|
{guild?.name ?? t('channels.notInGuild.title')}
|
||||||
</h1>
|
</h1>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{canManageSettings ? (
|
||||||
|
<Link
|
||||||
|
to={`/settings/servers/${guildId}`}
|
||||||
|
aria-label={t('channels.settings')}
|
||||||
|
title={t('channels.settings')}
|
||||||
|
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
|
||||||
|
>
|
||||||
|
⚙
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t('channels.add')}
|
aria-label={t('channels.add')}
|
||||||
title={t('channels.add')}
|
title={t('channels.add')}
|
||||||
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted hover:text-fg"
|
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
|
||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setCreateOpen(true)}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
||||||
{guild === null ? (
|
{guild === null ? (
|
||||||
<p className="px-2 text-sm text-fg-muted">{t('channels.notInGuild.body')}</p>
|
<p className="px-2 text-sm text-fg-muted">{t('channels.notInGuild.body')}</p>
|
||||||
|
) : channelsLoading ? (
|
||||||
|
<SkeletonLines className="px-2" rows={5} />
|
||||||
) : groups.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<p className="px-2 text-sm text-fg-muted">{t('channels.empty')}</p>
|
<p className="px-2 text-sm text-fg-muted">{t('channels.empty')}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -134,7 +155,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
{group.category.name}
|
{group.category.name}
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
<ul className="flex flex-col gap-0.5">
|
<ul className="gl-stagger flex flex-col gap-0.5">
|
||||||
{group.channels.map((channel) => (
|
{group.channels.map((channel) => (
|
||||||
<li key={channel.id}>
|
<li key={channel.id}>
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -142,9 +163,9 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
aria-current={channel.id === channelId ? 'page' : undefined}
|
aria-current={channel.id === channelId ? 'page' : undefined}
|
||||||
aria-label={t('channels.select', { name: channel.name })}
|
aria-label={t('channels.select', { name: channel.name })}
|
||||||
title={channel.name}
|
title={channel.name}
|
||||||
className={`flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm ${
|
className={`relative flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
|
||||||
channel.id === channelId
|
channel.id === channelId
|
||||||
? 'bg-surface-3 text-fg'
|
? 'gl-indicator bg-surface-3 text-fg'
|
||||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
|
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil
|
|||||||
aria-label={t('guilds.railLabel')}
|
aria-label={t('guilds.railLabel')}
|
||||||
className="flex w-[72px] shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border/40 bg-surface-2/60 py-3"
|
className="flex w-[72px] shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border/40 bg-surface-2/60 py-3"
|
||||||
>
|
>
|
||||||
<ul className="flex flex-col items-center gap-2" data-testid="guild-rail">
|
<ul className="gl-stagger flex flex-col items-center gap-2" data-testid="guild-rail">
|
||||||
{guilds.map((guild) => {
|
{guilds.map((guild) => {
|
||||||
const active = guild.id === selectedGuildId;
|
const active = guild.id === selectedGuildId;
|
||||||
return (
|
return (
|
||||||
@@ -29,10 +29,10 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil
|
|||||||
title={guild.name}
|
title={guild.name}
|
||||||
aria-label={t('guilds.switchTo', { name: guild.name })}
|
aria-label={t('guilds.switchTo', { name: guild.name })}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
className={`block rounded-[var(--radius-md)] transition-[border-radius,background-color] duration-[var(--duration-fast)] ${
|
className={`relative block rounded-[var(--radius-md)] transition-[border-radius,background-color,transform] duration-[var(--duration-fast)] ${
|
||||||
active
|
active
|
||||||
? 'rounded-[var(--radius-lg)] ring-2 ring-accent'
|
? 'gl-ring rounded-[var(--radius-lg)]'
|
||||||
: 'hover:rounded-[var(--radius-lg)]'
|
: 'hover:-translate-y-0.5 hover:rounded-[var(--radius-lg)] active:translate-y-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Avatar name={guild.name} seed={guild.id} size="lg" shape="square" />
|
<Avatar name={guild.name} seed={guild.id} size="lg" shape="square" />
|
||||||
@@ -48,7 +48,7 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil
|
|||||||
onClick={onCreate}
|
onClick={onCreate}
|
||||||
aria-label={t('guilds.add')}
|
aria-label={t('guilds.add')}
|
||||||
title={t('guilds.add')}
|
title={t('guilds.add')}
|
||||||
className="flex h-14 w-14 items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-surface-1 text-2xl text-success hover:rounded-[var(--radius-lg)] hover:border-success"
|
className="flex h-14 w-14 items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-surface-1 text-2xl text-success transition-[border-radius,border-color,transform] duration-[var(--duration-fast)] hover:-translate-y-0.5 hover:rounded-[var(--radius-lg)] hover:border-success active:translate-y-0"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { deleteGuild, guildQueryKey, leaveGuild } from '@/api/guilds';
|
||||||
|
import { instanceQueryKey } from '@/api/instance';
|
||||||
|
import { myGuildsQueryKey } from '@/api/users';
|
||||||
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
|
interface GuildDangerSectionProps {
|
||||||
|
guildId: string;
|
||||||
|
guildName: string;
|
||||||
|
/** Владелец не может покинуть сервер — только удалить его. */
|
||||||
|
isOwner: boolean;
|
||||||
|
/** Администратор инстанса может удалить любой сервер. */
|
||||||
|
isInstanceAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmAction = 'leave' | 'delete';
|
||||||
|
|
||||||
|
/** «Опасная зона»: выход с сервера и удаление сервера с подтверждением. */
|
||||||
|
export function GuildDangerSection({
|
||||||
|
guildId,
|
||||||
|
guildName,
|
||||||
|
isOwner,
|
||||||
|
isInstanceAdmin,
|
||||||
|
}: GuildDangerSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [confirm, setConfirm] = useState<ConfirmAction | null>(null);
|
||||||
|
// Последнее действие: пока окно закрывается, тексты не должны «прыгать».
|
||||||
|
const [lastAction, setLastAction] = useState<ConfirmAction>('leave');
|
||||||
|
|
||||||
|
const ask = (action: ConfirmAction): void => {
|
||||||
|
setLastAction(action);
|
||||||
|
setConfirm(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** После выхода/удаления возвращаемся в приложение и обновляем снапшот. */
|
||||||
|
const afterExit = (): void => {
|
||||||
|
setConfirm(null);
|
||||||
|
useSessionStore.getState().removeGuild(guildId);
|
||||||
|
useSessionStore.getState().selectSettingsGuild(null);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
|
||||||
|
void queryClient.removeQueries({ queryKey: guildQueryKey(guildId) });
|
||||||
|
void navigate('/app', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const leave = useMutation({
|
||||||
|
mutationFn: () => leaveGuild(guildId),
|
||||||
|
onSuccess: afterExit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: () => deleteGuild(guildId),
|
||||||
|
onSuccess: afterExit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const canDelete = isOwner || isInstanceAdmin;
|
||||||
|
const pending = leave.isPending || remove.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
title={t('settings.servers.danger.title')}
|
||||||
|
description={t('settings.servers.danger.description')}
|
||||||
|
className="border-danger/40"
|
||||||
|
>
|
||||||
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
{isOwner ? (
|
||||||
|
<p className="text-sm text-fg-muted">{t('settings.servers.danger.leaveOwnerHint')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="max-w-md text-sm text-fg-muted">
|
||||||
|
{t('settings.servers.danger.leaveHint')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="border-danger/50 text-danger"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
leave.reset();
|
||||||
|
ask('leave');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.servers.danger.leave')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canDelete ? (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/40 pt-4">
|
||||||
|
<p className="max-w-md text-sm text-fg-muted">
|
||||||
|
{t('settings.servers.danger.deleteHint')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="border-danger/50 text-danger"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
remove.reset();
|
||||||
|
ask('delete');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.servers.danger.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{leave.isError ? <ErrorNotice className="mt-3" error={leave.error} /> : null}
|
||||||
|
{remove.isError ? <ErrorNotice className="mt-3" error={remove.error} /> : null}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={confirm !== null}
|
||||||
|
title={t(
|
||||||
|
lastAction === 'delete'
|
||||||
|
? 'settings.servers.danger.deleteConfirmTitle'
|
||||||
|
: 'settings.servers.danger.leaveConfirmTitle',
|
||||||
|
)}
|
||||||
|
onClose={() => setConfirm(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setConfirm(null)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
if (lastAction === 'delete') {
|
||||||
|
remove.mutate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
leave.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
lastAction === 'delete'
|
||||||
|
? 'settings.servers.danger.deleteConfirm'
|
||||||
|
: 'settings.servers.danger.leaveConfirm',
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-fg-muted">
|
||||||
|
{t(
|
||||||
|
lastAction === 'delete'
|
||||||
|
? 'settings.servers.danger.deleteConfirmBody'
|
||||||
|
: 'settings.servers.danger.leaveConfirmBody',
|
||||||
|
{ name: guildName },
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useEffect, useState, type FormEvent } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -30,8 +30,17 @@ export function GuildNicknameSection({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [nickname, setNickname] = useState(currentNickname);
|
const [nickname, setNickname] = useState(currentNickname);
|
||||||
|
const [edited, setEdited] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
// Ник может прийти из списка участников уже после монтирования секции:
|
||||||
|
// подтягиваем его, пока пользователь не начал править поле сам.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!edited) {
|
||||||
|
setNickname(currentNickname);
|
||||||
|
}
|
||||||
|
}, [currentNickname, edited]);
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: (value: string) => updateGuildMember(guildId, userId ?? '', { nickname: value }),
|
mutationFn: (value: string) => updateGuildMember(guildId, userId ?? '', { nickname: value }),
|
||||||
onSuccess: (_result, value) => {
|
onSuccess: (_result, value) => {
|
||||||
@@ -72,7 +81,10 @@ export function GuildNicknameSection({
|
|||||||
value={nickname}
|
value={nickname}
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
disabled={userId === undefined}
|
disabled={userId === undefined}
|
||||||
onChange={(event) => setNickname(event.target.value)}
|
onChange={(event) => {
|
||||||
|
setEdited(true);
|
||||||
|
setNickname(event.target.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{save.isError ? <ErrorNotice error={save.error} /> : null}
|
{save.isError ? <ErrorNotice error={save.error} /> : null}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users';
|
import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users';
|
||||||
import { userStatuses, type User } from '@/api/types';
|
import { userStatuses, type User } from '@/api/types';
|
||||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
|
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
|
||||||
import { Button, Card } from '@/components/ui/primitives';
|
import { Button, Card } from '@/components/ui/primitives';
|
||||||
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
@@ -155,7 +156,7 @@ export default function ProfileSettingsPage() {
|
|||||||
if (currentUser.isPending) {
|
if (currentUser.isPending) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<LoadingNotice />
|
<SkeletonLines rows={5} />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { NavLink, useNavigate, useParams } from 'react-router';
|
||||||
|
|
||||||
|
import { fetchGuild, fetchMembers, guildMembersQueryKey, guildQueryKey } from '@/api/guilds';
|
||||||
|
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Card } from '@/components/ui/primitives';
|
||||||
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
|
import { canManageGuildSettings } from '@/lib/identity';
|
||||||
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
||||||
|
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
|
||||||
|
import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
|
||||||
|
import { GuildNicknameSection } from '@/pages/settings/GuildNicknameSection';
|
||||||
|
import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
|
||||||
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Настройки пользовательских серверов (`/settings/servers/:guildId`).
|
||||||
|
*
|
||||||
|
* Список серверов берём из READY-снапшота, а если шлюз ещё не подключился —
|
||||||
|
* из `GET /users/@me/guilds` (как в `AppLayout`). Выбранный сервер хранится
|
||||||
|
* в URL и дублируется в сторе, поэтому прямая ссылка на настройки работает.
|
||||||
|
*/
|
||||||
|
export default function ServersSettingsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const params = useParams<{ guildId?: string }>();
|
||||||
|
const routeGuildId = params.guildId;
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
|
||||||
|
const sessionGuilds = useSessionStore((state) => state.guilds);
|
||||||
|
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
||||||
|
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
|
||||||
|
|
||||||
|
const myGuilds = useQuery({
|
||||||
|
queryKey: myGuildsQueryKey,
|
||||||
|
queryFn: ({ signal }) => fetchMyGuilds(signal),
|
||||||
|
retry: 0,
|
||||||
|
enabled: sessionGuilds.length === 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Подстраховка снапшота: без неё выбранный сервер не найти в сторе.
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionGuilds.length > 0 || myGuilds.data === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const guild of myGuilds.data) {
|
||||||
|
upsertGuild(guild);
|
||||||
|
}
|
||||||
|
}, [sessionGuilds.length, myGuilds.data, upsertGuild]);
|
||||||
|
|
||||||
|
const guilds = sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? []);
|
||||||
|
const active = useMemo(
|
||||||
|
() => (routeGuildId === undefined ? null : (guilds.find((g) => g.id === routeGuildId) ?? null)),
|
||||||
|
[guilds, routeGuildId],
|
||||||
|
);
|
||||||
|
const activeId = active?.id ?? null;
|
||||||
|
|
||||||
|
// URL — источник правды; стор запоминает последний открытый сервер.
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeId !== null) {
|
||||||
|
selectSettingsGuild(activeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (routeGuildId === undefined && guilds.length > 0) {
|
||||||
|
const first = guilds[0];
|
||||||
|
if (first !== undefined) {
|
||||||
|
void navigate(`/settings/servers/${first.id}`, { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeId, routeGuildId, guilds, navigate, selectSettingsGuild]);
|
||||||
|
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: guildQueryKey(activeId ?? ''),
|
||||||
|
queryFn: ({ signal }) => fetchGuild(activeId ?? '', signal),
|
||||||
|
enabled: activeId !== null,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const members = useQuery({
|
||||||
|
queryKey: guildMembersQueryKey(activeId ?? ''),
|
||||||
|
queryFn: ({ signal }) => fetchMembers(activeId ?? '', signal),
|
||||||
|
enabled: activeId !== null,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const guild = detail.data;
|
||||||
|
const myPermissions = guild?.my_permissions ?? active?.my_permissions ?? [];
|
||||||
|
const ownerId = guild?.owner_id ?? active?.owner_id ?? '';
|
||||||
|
const canManage = canManageGuildSettings(myPermissions, ownerId, currentUser.data?.id);
|
||||||
|
const currentNickname = useMemo(() => {
|
||||||
|
const fromSnapshot = active?.my_nickname;
|
||||||
|
if (fromSnapshot !== undefined && fromSnapshot !== '') {
|
||||||
|
return fromSnapshot;
|
||||||
|
}
|
||||||
|
const mine = members.data?.find((member) => member.user_id === currentUser.data?.id);
|
||||||
|
return mine?.nickname ?? '';
|
||||||
|
}, [active, members.data, currentUser.data?.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Card className="p-3">
|
||||||
|
<h2 className="px-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
||||||
|
{t('settings.servers.pickerLabel')}
|
||||||
|
</h2>
|
||||||
|
{guilds.length === 0 ? (
|
||||||
|
myGuilds.isPending ? (
|
||||||
|
<SkeletonLines className="mt-3" rows={1} />
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 px-1 text-sm text-fg-muted">{t('settings.servers.empty')}</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<nav
|
||||||
|
aria-label={t('settings.servers.pickerLabel')}
|
||||||
|
className="gl-stagger mt-2 flex flex-wrap gap-1.5"
|
||||||
|
data-testid="guild-picker"
|
||||||
|
>
|
||||||
|
{guilds.map((guildItem) => {
|
||||||
|
const selected = guildItem.id === activeId;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={guildItem.id}
|
||||||
|
to={`/settings/servers/${guildItem.id}`}
|
||||||
|
aria-current={selected ? 'page' : undefined}
|
||||||
|
aria-label={t('settings.servers.select', { name: guildItem.name })}
|
||||||
|
className={`flex max-w-56 items-center gap-2 rounded-[var(--radius-md)] border px-2 py-1.5 text-sm transition-colors duration-[var(--duration-fast)] ${
|
||||||
|
selected
|
||||||
|
? 'border-accent bg-surface-3 text-fg'
|
||||||
|
: 'border-border/60 bg-surface-1 text-fg-muted hover:bg-surface-2 hover:text-fg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
name={guildItem.name}
|
||||||
|
seed={guildItem.id}
|
||||||
|
fileId={guildItem.icon_file_id}
|
||||||
|
size="sm"
|
||||||
|
shape="square"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{guildItem.name}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{guilds.length === 0 ? (
|
||||||
|
myGuilds.isPending ? (
|
||||||
|
<Card>
|
||||||
|
<SkeletonLines rows={4} />
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<SettingsSection
|
||||||
|
title={t('settings.servers.emptyTitle')}
|
||||||
|
description={t('settings.servers.emptyHint')}
|
||||||
|
>
|
||||||
|
<div className="mt-4">
|
||||||
|
<NavLink className="text-sm text-accent underline" to="/app">
|
||||||
|
{t('settings.back')}
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
)
|
||||||
|
) : activeId === null ? (
|
||||||
|
// Сервер из URL не найден среди доступных пользователю.
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-fg-muted">{t('settings.servers.notFound')}</p>
|
||||||
|
</Card>
|
||||||
|
) : detail.isPending ? (
|
||||||
|
<Card>
|
||||||
|
<SkeletonLines rows={5} />
|
||||||
|
</Card>
|
||||||
|
) : guild === undefined ? (
|
||||||
|
<Card>
|
||||||
|
<ErrorNotice error={detail.error} onRetry={() => void detail.refetch()} />
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
||||||
|
<GuildNicknameSection
|
||||||
|
guildId={guild.id}
|
||||||
|
userId={currentUser.data?.id}
|
||||||
|
currentNickname={currentNickname}
|
||||||
|
members={members.data}
|
||||||
|
/>
|
||||||
|
<GuildMembersSection
|
||||||
|
members={members.data}
|
||||||
|
isPending={members.isPending}
|
||||||
|
error={members.error}
|
||||||
|
onRetry={() => void members.refetch()}
|
||||||
|
/>
|
||||||
|
<GuildRolesSection key={`${guild.id}-roles`} guildId={guild.id} />
|
||||||
|
<GuildDangerSection
|
||||||
|
key={`${guild.id}-danger`}
|
||||||
|
guildId={guild.id}
|
||||||
|
guildName={guild.name}
|
||||||
|
isOwner={currentUser.data?.id !== undefined && currentUser.data.id === guild.owner_id}
|
||||||
|
isInstanceAdmin={currentUser.data?.is_instance_admin === true}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { fetchSessions, logoutAll, sessionsQueryKey } from '@/api/auth';
|
import { fetchSessions, logoutAll, sessionsQueryKey } from '@/api/auth';
|
||||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Button, Card } from '@/components/ui/primitives';
|
import { Button, Card } from '@/components/ui/primitives';
|
||||||
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
import { formatDateTime } from '@/lib/identity';
|
import { formatDateTime } from '@/lib/identity';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
@@ -54,7 +55,7 @@ export function SessionsSection() {
|
|||||||
<ErrorNotice className="mt-3" error={logoutAllMutation.error} />
|
<ErrorNotice className="mt-3" error={logoutAllMutation.error} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{sessions.isPending ? <LoadingNotice /> : null}
|
{sessions.isPending ? <SkeletonLines className="mt-2" rows={2} /> : null}
|
||||||
{sessions.isError ? (
|
{sessions.isError ? (
|
||||||
<ErrorNotice
|
<ErrorNotice
|
||||||
className="mt-3"
|
className="mt-3"
|
||||||
|
|||||||
Reference in New Issue
Block a user