feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):
- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
не участник не получает прав роли @user; калькулятор прав общий для API и
Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.
Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
This commit is contained in:
+8
-2
@@ -17,7 +17,9 @@ import (
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/gateway"
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/server"
|
||||
"glchat/internal/source"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
@@ -146,8 +148,12 @@ func run() error {
|
||||
return fmt.Errorf("initialize authentication: %w", err)
|
||||
}
|
||||
|
||||
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st), logger, cfg.AllowedOrigins())
|
||||
srv := server.New(cfg, db, logger, server.Deps{Store: st, Auth: authService, Gateway: gatewayService})
|
||||
// Один калькулятор прав на процесс: HTTP-ручки и Gateway видят общий кэш.
|
||||
calculator := permissions.NewCalculator(source.New(st))
|
||||
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins())
|
||||
srv := server.New(cfg, db, logger, server.Deps{
|
||||
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
|
||||
})
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("http server listening",
|
||||
|
||||
@@ -171,12 +171,20 @@ func (r *Runner) findOrCreateAdmin(ctx context.Context, opts Options, result *Re
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ensureMainGuildSetup гарантирует роли по умолчанию, участие владельца и
|
||||
// выдачу роли «Администратор» (AGENT.md 6.3).
|
||||
func (r *Runner) ensureMainGuildSetup(ctx context.Context, guild *store.Guild, owner *store.User, result *Result) error {
|
||||
roles, err := r.store.ListGuildRoles(ctx, guild.ID)
|
||||
// SeedResult — роли, созданные при настройке сервера.
|
||||
type SeedResult struct {
|
||||
AdminRoleID uint64
|
||||
MemberRoleID uint64
|
||||
}
|
||||
|
||||
// SeedGuildDefaults создаёт роли по умолчанию, добавляет владельца в сервер и
|
||||
// выдаёт ему роль «Администратор» (AGENT.md 6.3). Функция идемпотентна: её
|
||||
// используют и установщик, и ручка создания сервера.
|
||||
func SeedGuildDefaults(ctx context.Context, st *store.Store, guild *store.Guild, owner *store.User) (*SeedResult, error) {
|
||||
result := &SeedResult{}
|
||||
roles, err := st.ListGuildRoles(ctx, guild.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
var adminRole, defaultRole *store.Role
|
||||
for i := range roles {
|
||||
@@ -188,35 +196,47 @@ func (r *Runner) ensureMainGuildSetup(ctx context.Context, guild *store.Guild, o
|
||||
}
|
||||
}
|
||||
if adminRole == nil {
|
||||
created, err := r.store.CreateRole(ctx, store.CreateRoleParams{
|
||||
created, err := st.CreateRole(ctx, store.CreateRoleParams{
|
||||
GuildID: guild.ID, Name: "Администратор",
|
||||
Permissions: uint64(permissions.AllPermissions), Position: 100, Mentionable: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create administrator role: %w", err)
|
||||
return nil, fmt.Errorf("create administrator role: %w", err)
|
||||
}
|
||||
adminRole = created
|
||||
}
|
||||
result.AdminRoleID = adminRole.ID
|
||||
if defaultRole == nil {
|
||||
created, err := r.store.CreateRole(ctx, store.CreateRoleParams{
|
||||
created, err := st.CreateRole(ctx, store.CreateRoleParams{
|
||||
GuildID: guild.ID, Name: "Пользователь",
|
||||
Permissions: uint64(permissions.DefaultUserPermissions), Position: 0,
|
||||
IsDefault: true, Mentionable: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create default role: %w", err)
|
||||
return nil, fmt.Errorf("create default role: %w", err)
|
||||
}
|
||||
defaultRole = created
|
||||
}
|
||||
result.MemberRoleID = defaultRole.ID
|
||||
|
||||
if _, err := r.store.AddGuildMember(ctx, guild.ID, owner.ID, ""); err != nil {
|
||||
return fmt.Errorf("add owner to main guild: %w", err)
|
||||
if _, err := st.AddGuildMember(ctx, guild.ID, owner.ID, ""); err != nil {
|
||||
return nil, fmt.Errorf("add owner to guild: %w", err)
|
||||
}
|
||||
if err := r.store.AssignRole(ctx, guild.ID, owner.ID, adminRole.ID); err != nil {
|
||||
return fmt.Errorf("assign administrator role: %w", err)
|
||||
if err := st.AssignRole(ctx, guild.ID, owner.ID, adminRole.ID); err != nil {
|
||||
return nil, fmt.Errorf("assign administrator role: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ensureMainGuildSetup гарантирует роли по умолчанию, участие владельца и
|
||||
// выдачу роли «Администратор» (AGENT.md 6.3).
|
||||
func (r *Runner) ensureMainGuildSetup(ctx context.Context, guild *store.Guild, owner *store.User, result *Result) error {
|
||||
seeded, err := SeedGuildDefaults(ctx, r.store, guild, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.AdminRoleID = seeded.AdminRoleID
|
||||
result.MemberRoleID = seeded.MemberRoleID
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
-- Фаза 1: отметка о завершении первичной настройки пользователя (AGENT.md 7.2).
|
||||
ALTER TABLE users ADD COLUMN onboarding_completed_at TEXT;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE users DROP COLUMN onboarding_completed_at;
|
||||
@@ -60,7 +60,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
service := gateway.New(st, authService, gateway.NewSnapshot(st), logger, []string{"https://gl.test"})
|
||||
service := gateway.New(st, authService, gateway.NewSnapshot(st, nil), logger, []string{"https://gl.test"})
|
||||
server := httptest.NewServer(service.Handler())
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
@@ -242,6 +242,62 @@ func TestIdentifyWithInvalidToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentifyWithSessionCookie проверяет браузерный вход: токен в IDENTIFY
|
||||
// не передаётся, сессия берётся из cookie рукопожатия (AGENT.md 8.1).
|
||||
func TestIdentifyWithSessionCookie(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
_, token, session, err := f.auth.Register(context.Background(), auth.RegisterInput{
|
||||
Username: "cookie_user", Email: "cookie@example.com", Password: "correct-horse-battery",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if session == nil {
|
||||
t.Fatal("register returned no session")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
url := "ws" + strings.TrimPrefix(f.server.URL, "http")
|
||||
conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{
|
||||
HTTPHeader: map[string][]string{
|
||||
"Origin": {"https://gl.test"},
|
||||
"Cookie": {gateway.SessionCookieName + "=" + token},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dial with cookie: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.CloseNow() })
|
||||
|
||||
if hello := readEnvelope(t, conn); hello.Op != gateway.OpHello {
|
||||
t.Fatalf("first frame op = %d, want HELLO", hello.Op)
|
||||
}
|
||||
// IDENTIFY без токена: сервер доверяет cookie.
|
||||
send(t, conn, gateway.OpIdentify, map[string]any{})
|
||||
ready := readEnvelope(t, conn)
|
||||
if ready.T != "READY" {
|
||||
t.Fatalf("frame = %q, want READY", ready.T)
|
||||
}
|
||||
var snapshot gateway.Ready
|
||||
if err := json.Unmarshal(ready.D, &snapshot); err != nil {
|
||||
t.Fatalf("decode READY: %v", err)
|
||||
}
|
||||
if snapshot.User.Username != "cookie_user" {
|
||||
t.Fatalf("READY user = %q, want cookie_user", snapshot.User.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentifyWithoutTokenOrCookie(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
conn, _ := f.dial(t)
|
||||
send(t, conn, gateway.OpIdentify, map[string]any{})
|
||||
response := readEnvelope(t, conn)
|
||||
if response.Op != gateway.OpInvalidSess {
|
||||
t.Fatalf("op = %d, want INVALID_SESSION", response.Op)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatAck(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
token := registerUser(t, f, "heartbeat_user", "heartbeat@example.com")
|
||||
|
||||
@@ -2,6 +2,7 @@ package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -17,8 +18,13 @@ type Snapshot struct {
|
||||
calculator *permissions.Calculator
|
||||
}
|
||||
|
||||
func NewSnapshot(st *store.Store) *Snapshot {
|
||||
return &Snapshot{store: st, calculator: permissions.NewCalculator(newPermissionSource(st))}
|
||||
// NewSnapshot собирает READY через переданный калькулятор прав: сервер и
|
||||
// Gateway должны использовать один кэш, иначе инвалидация не видна обоим.
|
||||
func NewSnapshot(st *store.Store, calculator *permissions.Calculator) *Snapshot {
|
||||
if calculator == nil {
|
||||
calculator = permissions.NewCalculator(newPermissionSource(st))
|
||||
}
|
||||
return &Snapshot{store: st, calculator: calculator}
|
||||
}
|
||||
|
||||
var _ SnapshotBuilder = (*Snapshot)(nil)
|
||||
@@ -182,6 +188,16 @@ func (p *permissionSource) MemberRoleIDs(ctx context.Context, guildID, userID ui
|
||||
return p.store.MemberRoleIDs(ctx, guildID, userID)
|
||||
}
|
||||
|
||||
func (p *permissionSource) IsMember(ctx context.Context, guildID, userID uint64) (bool, error) {
|
||||
if _, err := p.store.GetGuildMember(ctx, guildID, userID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (p *permissionSource) ChannelOverrides(ctx context.Context, channelID uint64) ([]permissions.OverrideData, error) {
|
||||
overrides, err := p.store.ListChannelOverrides(ctx, channelID)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// clientSession — одно подключение клиента (AGENT.md 8.3: сессий может быть
|
||||
@@ -24,8 +26,16 @@ type clientSession struct {
|
||||
closeOnce sync.Once
|
||||
closed chan struct{}
|
||||
buffer *resumeBuffer
|
||||
// preAuth — сессия, восстановленная из cookie на рукопожатии: браузерный
|
||||
// клиент не имеет доступа к токену (AGENT.md 8.1, 8.3).
|
||||
preAuth *store.Session
|
||||
}
|
||||
|
||||
// SessionCookieName — имя cookie сессии. Значение должно совпадать с
|
||||
// server.sessionCookieName: браузерный Gateway-клиент аутентифицируется
|
||||
// cookie, а не токеном в IDENTIFY (AGENT.md 8.1).
|
||||
const SessionCookieName = "__Host-session"
|
||||
|
||||
// outbound — кадр в очереди на отправку. done используется, когда отправитель
|
||||
// ждёт фактической записи в сокет (например, перед закрытием соединения).
|
||||
type outbound struct {
|
||||
@@ -97,6 +107,12 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
send: make(chan outbound, 128),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
// Cookie сессии проверяем до HELLO: ошибку вернёт IDENTIFY.
|
||||
if cookie, err := r.Cookie(SessionCookieName); err == nil && cookie.Value != "" {
|
||||
if _, authSession, err := s.auth.ResolveSession(ctx, cookie.Value); err == nil {
|
||||
session.preAuth = authSession
|
||||
}
|
||||
}
|
||||
conn.SetReadLimit(MaxIncomingFrame)
|
||||
|
||||
// HELLO с интервалом heartbeat и идентификатором сессии.
|
||||
@@ -215,7 +231,21 @@ func (s *Service) sendInvalidSession(session *clientSession, reason string) {
|
||||
// handleIdentify проверяет токен, регистрирует сессию и отправляет READY
|
||||
// либо догоняет пропущенные события при RESUME.
|
||||
func (s *Service) handleIdentify(ctx context.Context, session *clientSession, payload identifyPayload, resume bool) error {
|
||||
user, authSession, err := s.auth.ResolveSession(ctx, payload.Token)
|
||||
var (
|
||||
user *store.User
|
||||
authSession *store.Session
|
||||
err error
|
||||
)
|
||||
switch {
|
||||
case payload.Token != "":
|
||||
user, authSession, err = s.auth.ResolveSession(ctx, payload.Token)
|
||||
case session.preAuth != nil:
|
||||
// Токен не передан: доверяем cookie, проверенной на рукопожатии.
|
||||
user, err = s.store.GetUser(ctx, session.preAuth.UserID)
|
||||
authSession = session.preAuth
|
||||
default:
|
||||
err = errors.New("identify without token and without session cookie")
|
||||
}
|
||||
if err != nil {
|
||||
s.sendInvalidSession(session, "invalid token")
|
||||
return err
|
||||
@@ -340,14 +370,12 @@ func writeDirect(ctx context.Context, conn *websocket.Conn, payload []byte) erro
|
||||
func decodeIdentify(raw json.RawMessage) (identifyPayload, error) {
|
||||
var payload identifyPayload
|
||||
if len(raw) == 0 {
|
||||
return payload, errors.New("empty identify payload")
|
||||
// Пустой payload допустим: браузер аутентифицируется cookie.
|
||||
return payload, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return payload, err
|
||||
}
|
||||
if payload.Token == "" {
|
||||
return payload, errors.New("token is required")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ func (c *Calculator) Channel(ctx context.Context, guildID, channelID, userID uin
|
||||
return Resolved{}, err
|
||||
}
|
||||
resolved.Channel = resolved.Guild
|
||||
if !resolved.IsMember && !resolved.IsInstanceAdmin {
|
||||
// Не участник не видит ничего, даже если есть персональный оверрайд.
|
||||
c.cache.PutChannel(channelID, userID, resolved)
|
||||
return resolved, nil
|
||||
}
|
||||
if resolved.IsInstanceAdmin || resolved.Guild.Has(Administrator) {
|
||||
c.cache.PutChannel(channelID, userID, resolved)
|
||||
return resolved, nil
|
||||
|
||||
@@ -158,6 +158,9 @@ type Resolved struct {
|
||||
IsOwner bool
|
||||
// IsInstanceAdmin — глобальная роль «Администратор сервера» (AGENT.md 7.19).
|
||||
IsInstanceAdmin bool
|
||||
// IsMember — состоит ли пользователь в сервере. Не участник не получает
|
||||
// прав роли @user: сервер для него невидим (AGENT.md 6.2).
|
||||
IsMember bool
|
||||
// TimedOut — участник в тайм-ауте: отправка сообщений запрещена (AGENT.md 7.10).
|
||||
TimedOut bool
|
||||
}
|
||||
@@ -167,6 +170,9 @@ func (r Resolved) Has(permission Permission) bool {
|
||||
if r.IsInstanceAdmin {
|
||||
return true
|
||||
}
|
||||
if !r.IsMember {
|
||||
return false
|
||||
}
|
||||
if r.Guild.Has(Administrator) {
|
||||
return true
|
||||
}
|
||||
@@ -178,6 +184,9 @@ func (r Resolved) Can(permission Permission) bool {
|
||||
if r.IsInstanceAdmin {
|
||||
return true
|
||||
}
|
||||
if !r.IsMember {
|
||||
return false
|
||||
}
|
||||
if r.Channel.Has(Administrator) {
|
||||
return true
|
||||
}
|
||||
@@ -197,16 +206,48 @@ func (r Resolved) CanViewChannel() bool {
|
||||
if r.IsInstanceAdmin {
|
||||
return true
|
||||
}
|
||||
if !r.IsMember {
|
||||
return false
|
||||
}
|
||||
if r.Channel.Has(Administrator) {
|
||||
return true
|
||||
}
|
||||
return r.Channel.Has(ViewChannel)
|
||||
}
|
||||
|
||||
// Names возвращает список названий установленных прав: клиенту удобнее
|
||||
// работать со списком, чем с битовой маской (AGENT.md 8.2).
|
||||
func Names(permission Permission) []string {
|
||||
names := make([]string, 0, 8)
|
||||
for bit := Permission(1); bit != 0 && bit <= Administrator; bit <<= 1 {
|
||||
if permission&bit != 0 {
|
||||
names = append(names, permissionNames[bit])
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Parse разбирает строку с названиями прав: "VIEW_CHANNEL|SEND_MESSAGES",
|
||||
// "VIEW_CHANNEL,SEND_MESSAGES" или через пробел. Пустая строка — нет прав.
|
||||
func Parse(value string) (Permission, error) {
|
||||
normalized := strings.NewReplacer(",", " ", "|", " ").Replace(value)
|
||||
var result Permission
|
||||
for _, name := range strings.Fields(normalized) {
|
||||
permission, ok := ByName(name)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown permission %q", name)
|
||||
}
|
||||
result |= permission
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Source — данные для расчёта прав: роли сервера и роли участника.
|
||||
type Source interface {
|
||||
GuildRoles(ctx context.Context, guildID uint64) ([]RoleData, error)
|
||||
MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]uint64, error)
|
||||
// IsMember отвечает, состоит ли пользователь в сервере.
|
||||
IsMember(ctx context.Context, guildID, userID uint64) (bool, error)
|
||||
ChannelOverrides(ctx context.Context, channelID uint64) ([]OverrideData, error)
|
||||
GuildOwnerID(ctx context.Context, guildID uint64) (uint64, error)
|
||||
MemberTimeout(ctx context.Context, guildID, userID uint64) (bool, error)
|
||||
@@ -268,6 +309,19 @@ func (c *Calculator) Guild(ctx context.Context, guildID, userID uint64, instance
|
||||
}
|
||||
|
||||
resolved := Resolved{IsOwner: ownerID == userID, IsInstanceAdmin: instanceAdmin}
|
||||
resolved.IsMember = resolved.IsOwner
|
||||
if !resolved.IsMember {
|
||||
resolved.IsMember, err = c.source.IsMember(ctx, guildID, userID)
|
||||
if err != nil {
|
||||
return Resolved{}, fmt.Errorf("load guild membership: %w", err)
|
||||
}
|
||||
}
|
||||
if !resolved.IsMember {
|
||||
// Не участник: прав нет вовсе, роль @user на него не действует.
|
||||
c.cache.PutGuild(guildID, userID, resolved)
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
assigned := make(map[uint64]bool, len(memberRoleIDs))
|
||||
for _, roleID := range memberRoleIDs {
|
||||
assigned[roleID] = true
|
||||
|
||||
@@ -36,6 +36,15 @@ func (f *fakeSource) MemberRoleIDs(_ context.Context, guildID, userID uint64) ([
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) IsMember(_ context.Context, guildID, userID uint64) (bool, error) {
|
||||
if byUser, ok := f.memberRoles[guildID]; ok {
|
||||
if _, found := byUser[userID]; found {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) ChannelOverrides(_ context.Context, channelID uint64) ([]OverrideData, error) {
|
||||
return f.overrides[channelID], nil
|
||||
}
|
||||
@@ -92,6 +101,39 @@ func TestDefaultRoleProvidesBasePermissions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonMemberHasNoPermissions(t *testing.T) {
|
||||
source := newFakeSource()
|
||||
source.roles[guildID] = []RoleData{
|
||||
{ID: defaultID, Permissions: DefaultUserPermissions, IsDefault: true, Position: 0},
|
||||
}
|
||||
source.owners[guildID] = ownerID
|
||||
source.overrides[channelID] = []OverrideData{
|
||||
{TargetType: "user", TargetID: memberID, Allow: ViewChannel | SendMessages},
|
||||
}
|
||||
calculator := NewCalculator(source)
|
||||
|
||||
// Пользователь вне сервера: роль @user и персональный оверрайд не действуют.
|
||||
resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Channel: %v", err)
|
||||
}
|
||||
if resolved.IsMember {
|
||||
t.Fatal("user without membership must not be a member")
|
||||
}
|
||||
if resolved.Has(ViewGuild) || resolved.CanViewChannel() || resolved.Can(SendMessages) {
|
||||
t.Fatalf("non-member received permissions: guild=%s channel=%s", resolved.Guild, resolved.Channel)
|
||||
}
|
||||
|
||||
// Инстанс-админ обходит отсутствие участия (AGENT.md 7.19).
|
||||
admin, err := calculator.Channel(context.Background(), guildID, channelID, memberID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Channel as instance admin: %v", err)
|
||||
}
|
||||
if !admin.CanViewChannel() || !admin.Has(ManageGuild) {
|
||||
t.Fatal("instance admin must bypass membership checks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignedRoleAddsPermissions(t *testing.T) {
|
||||
source, calculator := fixture()
|
||||
source.memberRoles[guildID][memberID] = []uint64{moderatorRole}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// sessionContextValue — сессия, восстановленная из cookie или Bearer-токена.
|
||||
type sessionContextValue struct {
|
||||
User *store.User
|
||||
Session *store.Session
|
||||
}
|
||||
|
||||
type sessionContextKey struct{}
|
||||
|
||||
// sessionContext резолвит сессию один раз на запрос и кладёт её в контекст.
|
||||
// Отсутствие сессии не ошибка: часть ручек публичная (мета, OpenAPI).
|
||||
func (s *Server) sessionContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
token := sessionToken(r)
|
||||
if token == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
user, session, err := s.auth.ResolveSession(r.Context(), token)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), sessionContextKey{}, &sessionContextValue{User: user, Session: session})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// sessionToken читает токен сессии из cookie (браузер) или Bearer (desktop).
|
||||
func sessionToken(r *http.Request) string {
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
|
||||
return cookie.Value
|
||||
}
|
||||
return normalizeBearer(r.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func sessionFromContext(ctx context.Context) (*store.User, *store.Session, bool) {
|
||||
value, ok := ctx.Value(sessionContextKey{}).(*sessionContextValue)
|
||||
if !ok || value.User == nil || value.Session == nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
return value.User, value.Session, true
|
||||
}
|
||||
|
||||
// requireUser достаёт текущего пользователя или возвращает 401 в конверте API.
|
||||
func requireUser(ctx context.Context) (*store.User, *store.Session, error) {
|
||||
user, session, ok := sessionFromContext(ctx)
|
||||
if !ok {
|
||||
return nil, nil, humaErrorStatus(http.StatusUnauthorized, "auth.session_expired", "authentication required")
|
||||
}
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
// requireInstanceAdmin проверяет права администратора инстанса (AGENT.md 6.5).
|
||||
func requireInstanceAdmin(ctx context.Context) (*store.User, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !user.IsInstanceAdmin {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// humaAPIError — ошибка huma в едином конверте {"error":{"code","message"}}
|
||||
// (AGENT.md 8.5): клиент разбирает код, а не текст.
|
||||
type humaAPIError struct {
|
||||
status int
|
||||
code string
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *humaAPIError) Error() string { return e.code + ": " + e.message }
|
||||
func (e *humaAPIError) GetStatus() int { return e.status }
|
||||
|
||||
func (e *humaAPIError) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(map[string]any{
|
||||
"error": map[string]any{"code": e.code, "message": e.message},
|
||||
})
|
||||
}
|
||||
|
||||
// humaErrorStatus создаёт ошибку с явным кодом.
|
||||
func humaErrorStatus(status int, code, message string) huma.StatusError {
|
||||
return &humaAPIError{status: status, code: code, message: message}
|
||||
}
|
||||
|
||||
// humaError переводит доменную ошибку в ответ API с тем же кодом, что и
|
||||
// chi-ручки (единая таблица в errors.go).
|
||||
func humaError(err error) huma.StatusError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
converted := newAPIError(err)
|
||||
return &humaAPIError{status: converted.Status, code: converted.Code, message: converted.Message}
|
||||
}
|
||||
|
||||
// parseID разбирает Snowflake из пути: идентификаторы в API — строки (AGENT.md 6.4).
|
||||
func parseID(name, value string) (uint64, error) {
|
||||
if value == "" {
|
||||
return 0, humaErrorStatus(http.StatusBadRequest, "request.bad", name+" is required")
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil {
|
||||
return 0, humaErrorStatus(http.StatusBadRequest, "request.bad", name+" must be a snowflake id")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// guildPermissions считает права пользователя на сервере.
|
||||
func (s *Server) guildPermissions(ctx context.Context, guildID uint64, user *store.User) (permissions.Resolved, error) {
|
||||
resolved, err := s.perms.Guild(ctx, guildID, user.ID, user.IsInstanceAdmin)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return permissions.Resolved{}, humaErrorStatus(http.StatusNotFound, "not_found", "guild not found")
|
||||
}
|
||||
return permissions.Resolved{}, humaError(err)
|
||||
}
|
||||
if !resolved.Has(permissions.ViewGuild) {
|
||||
// Существование сервера не подтверждаем: 404 вместо 403 (AGENT.md 9.7).
|
||||
return permissions.Resolved{}, humaErrorStatus(http.StatusNotFound, "not_found", "guild not found")
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// requireGuildPermission считает права и требует конкретное разрешение.
|
||||
func (s *Server) requireGuildPermission(ctx context.Context, guildID string, user *store.User, permission permissions.Permission) (uint64, permissions.Resolved, error) {
|
||||
id, err := parseID("guild_id", guildID)
|
||||
if err != nil {
|
||||
return 0, permissions.Resolved{}, err
|
||||
}
|
||||
resolved, err := s.guildPermissions(ctx, id, user)
|
||||
if err != nil {
|
||||
return 0, permissions.Resolved{}, err
|
||||
}
|
||||
if !resolved.Has(permission) {
|
||||
return 0, permissions.Resolved{}, humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied")
|
||||
}
|
||||
return id, resolved, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,483 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"glchat/internal/bootstrap"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// instancePayload — публичная информация об инстансе (AGENT.md 6.5).
|
||||
type instancePayload struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
AllowGuildCreation bool `json:"allow_guild_creation"`
|
||||
VoiceEnabled bool `json:"voice_enabled"`
|
||||
MaxGuildsPerUser int `json:"max_guilds_per_user"`
|
||||
MaxMembersPerGuild int `json:"max_members_per_guild"`
|
||||
MaxMessageLength int `json:"max_message_length"`
|
||||
MainGuildID string `json:"main_guild_id,omitempty"`
|
||||
UserCount int `json:"user_count"`
|
||||
GuildCount int `json:"guild_count"`
|
||||
}
|
||||
|
||||
type instanceOutput struct {
|
||||
Body struct {
|
||||
Instance instancePayload `json:"instance"`
|
||||
}
|
||||
}
|
||||
|
||||
type instanceGuildPayload struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
OwnerName string `json:"owner_name,omitempty"`
|
||||
IsMain bool `json:"is_main"`
|
||||
MemberCount int `json:"member_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type instanceGuildListOutput struct {
|
||||
Body struct {
|
||||
Guilds []instanceGuildPayload `json:"guilds"`
|
||||
}
|
||||
}
|
||||
|
||||
type instanceUserPayload struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsInstanceAdmin bool `json:"is_instance_admin"`
|
||||
Badges []string `json:"badges"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type instanceUserListOutput struct {
|
||||
Body struct {
|
||||
Users []instanceUserPayload `json:"users"`
|
||||
}
|
||||
}
|
||||
|
||||
type instanceSettingsPayload struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
AllowGuildCreation bool `json:"allow_guild_creation"`
|
||||
MaxGuildsPerUser int `json:"max_guilds_per_user"`
|
||||
MaxMembersPerGuild int `json:"max_members_per_guild"`
|
||||
MaxMessageLength int `json:"max_message_length"`
|
||||
}
|
||||
|
||||
type instanceSettingsOutput struct {
|
||||
Body struct {
|
||||
Settings instanceSettingsPayload `json:"settings"`
|
||||
}
|
||||
}
|
||||
|
||||
// registerInstanceRoutes описывает публичную информацию об инстансе и
|
||||
// админ-панель администратора инстанса (AGENT.md 6.5, 7.19).
|
||||
func (s *Server) registerInstanceRoutes(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getInstance",
|
||||
Method: http.MethodGet,
|
||||
Path: "/instance",
|
||||
Summary: "Публичная информация об инстансе",
|
||||
Tags: []string{"Instance"},
|
||||
}, func(ctx context.Context, _ *struct{}) (*instanceOutput, error) {
|
||||
payload, err := s.instancePayload(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Лимиты и число пользователей видны только администратору инстанса.
|
||||
if user, _, ok := sessionFromContext(ctx); !ok || !user.IsInstanceAdmin {
|
||||
payload.MaxGuildsPerUser = 0
|
||||
payload.MaxMembersPerGuild = 0
|
||||
payload.UserCount = 0
|
||||
payload.GuildCount = 0
|
||||
}
|
||||
output := &instanceOutput{}
|
||||
output.Body.Instance = payload
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getInstanceSettings",
|
||||
Method: http.MethodGet,
|
||||
Path: "/instance/settings",
|
||||
Summary: "Настройки инстанса (только администратор)",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, _ *struct{}) (*instanceSettingsOutput, error) {
|
||||
if _, err := requireInstanceAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings, err := s.store.InstanceSettings(ctx)
|
||||
if err != nil {
|
||||
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,
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "updateInstanceSettings",
|
||||
Method: http.MethodPatch,
|
||||
Path: "/instance/settings",
|
||||
Summary: "Изменить настройки инстанса",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Body struct {
|
||||
RegistrationEnabled *bool `json:"registration_enabled,omitempty"`
|
||||
AllowGuildCreation *bool `json:"allow_guild_creation,omitempty"`
|
||||
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"`
|
||||
}
|
||||
},
|
||||
) (*instanceSettingsOutput, error) {
|
||||
user, err := requireInstanceAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates := map[string]string{}
|
||||
if input.Body.RegistrationEnabled != nil {
|
||||
updates["registration_enabled"] = strconv.FormatBool(*input.Body.RegistrationEnabled)
|
||||
}
|
||||
if input.Body.AllowGuildCreation != nil {
|
||||
updates["allow_guild_creation"] = strconv.FormatBool(*input.Body.AllowGuildCreation)
|
||||
}
|
||||
if input.Body.MaxGuildsPerUser != nil {
|
||||
updates["max_guilds_per_user"] = strconv.Itoa(*input.Body.MaxGuildsPerUser)
|
||||
}
|
||||
if input.Body.MaxMembersPerGuild != nil {
|
||||
updates["max_members_per_guild"] = strconv.Itoa(*input.Body.MaxMembersPerGuild)
|
||||
}
|
||||
if input.Body.MaxMessageLength != nil {
|
||||
updates["max_message_length"] = strconv.Itoa(*input.Body.MaxMessageLength)
|
||||
}
|
||||
for key, value := range updates {
|
||||
if err := s.store.SetInstanceSetting(ctx, key, value); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
}
|
||||
settings, err := s.store.InstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
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,
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listInstanceGuilds",
|
||||
Method: http.MethodGet,
|
||||
Path: "/instance/guilds",
|
||||
Summary: "Все серверы инстанса",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, _ *struct{}) (*instanceGuildListOutput, error) {
|
||||
if _, err := requireInstanceAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
guilds, err := s.store.ListAllGuilds(ctx)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &instanceGuildListOutput{}
|
||||
output.Body.Guilds = make([]instanceGuildPayload, 0, len(guilds))
|
||||
for _, guild := range guilds {
|
||||
payload := instanceGuildPayload{
|
||||
ID: formatSnowflake(guild.ID),
|
||||
Name: guild.Name,
|
||||
OwnerID: formatSnowflake(guild.OwnerID),
|
||||
IsMain: guild.IsMain,
|
||||
CreatedAt: guild.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if owner, err := s.store.GetUser(ctx, guild.OwnerID); err == nil {
|
||||
payload.OwnerName = owner.Username
|
||||
}
|
||||
if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil {
|
||||
payload.MemberCount = count
|
||||
}
|
||||
output.Body.Guilds = append(output.Body.Guilds, payload)
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "adminCreateGuild",
|
||||
Method: http.MethodPost,
|
||||
Path: "/instance/guilds",
|
||||
Summary: "Создать сервер от имени администратора (лимиты обходятся)",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Body struct {
|
||||
Name string `json:"name" minLength:"1" maxLength:"64"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
}
|
||||
},
|
||||
) (*instanceGuildListOutput, error) {
|
||||
admin, err := requireInstanceAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner := admin
|
||||
if input.Body.OwnerID != "" {
|
||||
ownerID, err := parseID("owner_id", input.Body.OwnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner, err = s.store.GetUser(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
}
|
||||
name := strings.TrimSpace(input.Body.Name)
|
||||
if name == "" {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty")
|
||||
}
|
||||
// Администратор инстанса создаёт сервер в обход лимитов: причина
|
||||
// фиксируется в аудите отдельной записью (AGENT.md 6.5).
|
||||
guild, err := s.createGuildAsAdmin(ctx, admin, owner, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output := &instanceGuildListOutput{}
|
||||
output.Body.Guilds = []instanceGuildPayload{{
|
||||
ID: formatSnowflake(guild.ID),
|
||||
Name: guild.Name,
|
||||
OwnerID: formatSnowflake(guild.OwnerID),
|
||||
IsMain: guild.IsMain,
|
||||
CreatedAt: guild.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "adminDeleteGuild",
|
||||
Method: http.MethodDelete,
|
||||
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"`
|
||||
},
|
||||
) (*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
|
||||
}
|
||||
if err := s.deleteGuild(ctx, admin, guildID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listInstanceUsers",
|
||||
Method: http.MethodGet,
|
||||
Path: "/instance/users",
|
||||
Summary: "Пользователи инстанса",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
|
||||
Offset int `query:"offset" default:"0" minimum:"0"`
|
||||
},
|
||||
) (*instanceUserListOutput, error) {
|
||||
if _, err := requireInstanceAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users, err := s.store.ListUsers(ctx, input.Limit, input.Offset)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &instanceUserListOutput{}
|
||||
output.Body.Users = make([]instanceUserPayload, 0, len(users))
|
||||
for _, user := range users {
|
||||
badges := user.Badges
|
||||
if badges == nil {
|
||||
badges = []string{}
|
||||
}
|
||||
output.Body.Users = append(output.Body.Users, instanceUserPayload{
|
||||
ID: formatSnowflake(user.ID),
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
IsInstanceAdmin: user.IsInstanceAdmin,
|
||||
Badges: badges,
|
||||
CreatedAt: user.CreatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "setInstanceAdmin",
|
||||
Method: http.MethodPost,
|
||||
Path: "/instance/users/{user_id}/admin",
|
||||
Summary: "Выдать или снять права администратора инстанса",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, input *struct {
|
||||
UserID string `path:"user_id"`
|
||||
Body struct {
|
||||
Admin bool `json:"admin"`
|
||||
// StepUpPassword подтверждает действие: смена администраторов —
|
||||
// чувствительная операция (AGENT.md 7.1).
|
||||
StepUpPassword string `json:"step_up_password,omitempty"`
|
||||
StepUpTOTP string `json:"step_up_totp,omitempty"`
|
||||
}
|
||||
},
|
||||
) (*userOutput, error) {
|
||||
admin, session, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !admin.IsInstanceAdmin {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
|
||||
}
|
||||
if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
userID, err := parseID("user_id", input.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID == admin.ID && !input.Body.Admin {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot revoke your own administrator rights")
|
||||
}
|
||||
if err := s.store.SetInstanceAdmin(ctx, userID, input.Body.Admin); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
updated, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
action := "instance.admin_grant"
|
||||
if !input.Body.Admin {
|
||||
action = "instance.admin_revoke"
|
||||
}
|
||||
s.recordAudit(ctx, admin, 0, action, "user", &userID, "")
|
||||
s.dispatchUserUpdate(updated)
|
||||
output := &userOutput{}
|
||||
output.Body.User = profileFromUser(updated, false)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listInstanceAudit",
|
||||
Method: http.MethodGet,
|
||||
Path: "/instance/audit",
|
||||
Summary: "Журнал действий администраторов инстанса",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
|
||||
},
|
||||
) (*auditListOutput, error) {
|
||||
if _, err := requireInstanceAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := s.store.ListInstanceAudit(ctx, input.Limit)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &auditListOutput{}
|
||||
output.Body.Entries = auditPayloads(entries)
|
||||
return output, nil
|
||||
})
|
||||
}
|
||||
|
||||
// instancePayload собирает публичные сведения об инстансе.
|
||||
func (s *Server) instancePayload(ctx context.Context) (instancePayload, error) {
|
||||
settings, err := s.store.InstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return instancePayload{}, humaError(err)
|
||||
}
|
||||
users, err := s.store.CountUsers(ctx)
|
||||
if err != nil {
|
||||
return instancePayload{}, humaError(err)
|
||||
}
|
||||
guilds, err := s.store.ListAllGuilds(ctx)
|
||||
if err != nil {
|
||||
return instancePayload{}, humaError(err)
|
||||
}
|
||||
payload := instancePayload{
|
||||
Name: s.cfg.InstanceName,
|
||||
Version: s.cfg.Version,
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
AllowGuildCreation: settings.AllowGuildCreation,
|
||||
MaxGuildsPerUser: settings.MaxGuildsPerUser,
|
||||
MaxMembersPerGuild: settings.MaxMembersPerGuild,
|
||||
MaxMessageLength: settings.MaxMessageLength,
|
||||
UserCount: users,
|
||||
GuildCount: len(guilds),
|
||||
}
|
||||
if settings.MainGuildID != 0 {
|
||||
payload.MainGuildID = formatSnowflake(settings.MainGuildID)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// createGuildAsAdmin создаёт сервер в обход лимитов и фиксирует это в аудите.
|
||||
func (s *Server) createGuildAsAdmin(ctx context.Context, admin, owner *store.User, name string) (*store.Guild, error) {
|
||||
settings, err := s.store.InstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
guild, err := s.store.CreateGuild(ctx, store.CreateGuildParams{
|
||||
Name: name,
|
||||
OwnerID: owner.ID,
|
||||
IsMain: settings.MainGuildID == 0,
|
||||
IsDiscoverable: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if _, err := bootstrap.SeedGuildDefaults(ctx, s.store, guild, owner); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if _, err := s.store.CreateChannel(ctx, store.CreateChannelParams{
|
||||
GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0,
|
||||
}); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if guild.IsMain {
|
||||
if err := s.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
}
|
||||
s.recordAudit(ctx, admin, guild.ID, "guild.create", "guild", &guild.ID, "created by instance admin")
|
||||
s.recordAudit(ctx, admin, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limits")
|
||||
if s.gateway != nil {
|
||||
s.gateway.SendToUser(owner.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(guild.ID)})
|
||||
}
|
||||
return guild, nil
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// permissionViewChannel — сокращение для оверрайдов в тестах.
|
||||
const permissionViewChannel = permissions.ViewChannel
|
||||
|
||||
// registerAndLogin регистрирует пользователя и возвращает cookie сессии.
|
||||
func registerAndLogin(t *testing.T, srv *Server, username, email string) *http.Cookie {
|
||||
t.Helper()
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
|
||||
`{"username":"`+username+`","email":"`+email+`","password":"correct-horse-battery"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("register %s = %d, body = %s", username, rec.Code, rec.Body.String())
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatalf("register %s did not return a session cookie", username)
|
||||
}
|
||||
return cookies[0]
|
||||
}
|
||||
|
||||
func decodeResponse[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
|
||||
t.Helper()
|
||||
var payload T
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode body: %v (raw: %s)", err, rec.Body.String())
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func errorCodeOf(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
payload := decodeResponse[struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}](t, rec)
|
||||
if payload.Error.Code == "" {
|
||||
t.Fatalf("response has no error code: %s", rec.Body.String())
|
||||
}
|
||||
return payload.Error.Code
|
||||
}
|
||||
|
||||
// promoteAdmin делает пользователя администратором инстанса напрямую в БД.
|
||||
func promoteAdmin(t *testing.T, srv *Server, email string) {
|
||||
t.Helper()
|
||||
user, err := srv.auth.UserByEmail(t.Context(), email)
|
||||
if err != nil {
|
||||
t.Fatalf("find user %s: %v", email, err)
|
||||
}
|
||||
if err := srv.store.SetInstanceAdmin(t.Context(), user.ID, true); err != nil {
|
||||
t.Fatalf("promote %s: %v", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumaErrorsUseAPIEnvelope(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
|
||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != "auth.session_expired" {
|
||||
t.Fatalf("error code = %q, want auth.session_expired", code)
|
||||
}
|
||||
|
||||
rec = doJSON(t, srv, http.MethodGet, "/api/v1/instance/settings", "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("instance settings without session = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUpdateFlow(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
cookie := registerAndLogin(t, srv, "profile_user", "profile@example.com")
|
||||
|
||||
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie)
|
||||
if me.Code != http.StatusOK {
|
||||
t.Fatalf("GET /users/@me = %d, body = %s", me.Code, me.Body.String())
|
||||
}
|
||||
profile := decodeResponse[struct {
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
OnboardingCompleted bool `json:"onboarding_completed"`
|
||||
Locale string `json:"locale"`
|
||||
} `json:"user"`
|
||||
}](t, me)
|
||||
if profile.User.Username != "profile_user" {
|
||||
t.Fatalf("username = %q", profile.User.Username)
|
||||
}
|
||||
if profile.User.OnboardingCompleted {
|
||||
t.Fatal("new user must not have onboarding completed")
|
||||
}
|
||||
if profile.User.Locale != "ru" {
|
||||
t.Fatalf("default locale = %q, want ru", profile.User.Locale)
|
||||
}
|
||||
|
||||
updated := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me",
|
||||
`{"display_name":"Профиль","bio":"о себе","status":"idle","locale":"en"}`, cookie)
|
||||
if updated.Code != http.StatusOK {
|
||||
t.Fatalf("PATCH /users/@me = %d, body = %s", updated.Code, updated.Body.String())
|
||||
}
|
||||
payload := decodeResponse[struct {
|
||||
User struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio"`
|
||||
Status string `json:"status"`
|
||||
Locale string `json:"locale"`
|
||||
} `json:"user"`
|
||||
}](t, updated)
|
||||
if payload.User.DisplayName != "Профиль" || payload.User.Bio != "о себе" || payload.User.Status != "idle" {
|
||||
t.Fatalf("unexpected profile: %+v", payload.User)
|
||||
}
|
||||
|
||||
// Онбординг завершается отдельной ручкой и выставляет флаг.
|
||||
onboarding := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/onboarding/complete",
|
||||
`{"display_name":"Новый ник","bio":"привет"}`, cookie)
|
||||
if onboarding.Code != http.StatusOK {
|
||||
t.Fatalf("onboarding = %d, body = %s", onboarding.Code, onboarding.Body.String())
|
||||
}
|
||||
after := decodeResponse[struct {
|
||||
User struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
OnboardingCompleted bool `json:"onboarding_completed"`
|
||||
} `json:"user"`
|
||||
}](t, onboarding)
|
||||
if !after.User.OnboardingCompleted || after.User.DisplayName != "Новый ник" {
|
||||
t.Fatalf("unexpected onboarding result: %+v", after.User)
|
||||
}
|
||||
|
||||
// Публичный профиль доступен другому пользователю и не содержит приватных полей.
|
||||
otherCookie := registerAndLogin(t, srv, "other_user", "other@example.com")
|
||||
user, err := srv.auth.UserByEmail(t.Context(), "profile@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
public := doJSON(t, srv, http.MethodGet, "/api/v1/users/"+formatSnowflake(user.ID), "", otherCookie)
|
||||
if public.Code != http.StatusOK {
|
||||
t.Fatalf("public profile = %d, body = %s", public.Code, public.Body.String())
|
||||
}
|
||||
if strings.Contains(public.Body.String(), "@example.com") {
|
||||
t.Fatalf("public profile must not leak email: %s", public.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuildLifecycleAndPermissions(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
ownerCookie := registerAndLogin(t, srv, "guild_owner", "owner@example.com")
|
||||
memberCookie := registerAndLogin(t, srv, "guild_member", "member@example.com")
|
||||
|
||||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Тестовый сервер"}`, ownerCookie)
|
||||
if created.Code != http.StatusOK {
|
||||
t.Fatalf("create guild = %d, body = %s", created.Code, created.Body.String())
|
||||
}
|
||||
guild := decodeResponse[struct {
|
||||
Guild struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsMain bool `json:"is_main"`
|
||||
Roles []struct {
|
||||
ID string `json:"id"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
} `json:"roles"`
|
||||
Channels []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"channels"`
|
||||
MyPermissions []string `json:"my_permissions"`
|
||||
} `json:"guild"`
|
||||
}](t, created)
|
||||
if guild.Guild.Name != "Тестовый сервер" {
|
||||
t.Fatalf("guild name = %q", guild.Guild.Name)
|
||||
}
|
||||
if len(guild.Guild.Roles) != 2 {
|
||||
t.Fatalf("default roles = %d, want 2", len(guild.Guild.Roles))
|
||||
}
|
||||
if len(guild.Guild.Channels) != 1 || guild.Guild.Channels[0].Name != "общий" {
|
||||
t.Fatalf("default channels = %+v", guild.Guild.Channels)
|
||||
}
|
||||
if !containsString(guild.Guild.MyPermissions, "ADMINISTRATOR") {
|
||||
t.Fatalf("owner must have ADMINISTRATOR, got %v", guild.Guild.MyPermissions)
|
||||
}
|
||||
|
||||
// Посторонний не видит сервер: 404 вместо 403 (AGENT.md 9.7).
|
||||
stranger := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
|
||||
if stranger.Code != http.StatusNotFound {
|
||||
t.Fatalf("stranger channels = %d, want 404", stranger.Code)
|
||||
}
|
||||
|
||||
// Главный сервер открыт для присоединения.
|
||||
join := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie)
|
||||
if join.Code != http.StatusOK {
|
||||
t.Fatalf("join main guild = %d, body = %s", join.Code, join.Body.String())
|
||||
}
|
||||
|
||||
// Участник видит комнату, но не может её создать: нет MANAGE_CHANNELS.
|
||||
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
|
||||
if channels.Code != http.StatusOK {
|
||||
t.Fatalf("member channels = %d", channels.Code)
|
||||
}
|
||||
list := decodeResponse[struct {
|
||||
Channels []struct {
|
||||
Name string `json:"name"`
|
||||
CanView bool `json:"can_view"`
|
||||
CanSend bool `json:"can_send"`
|
||||
} `json:"channels"`
|
||||
}](t, channels)
|
||||
if len(list.Channels) != 1 || !list.Channels[0].CanView || !list.Channels[0].CanSend {
|
||||
t.Fatalf("unexpected member channels: %+v", list.Channels)
|
||||
}
|
||||
denied := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
|
||||
`{"name":"секретная","type":"text"}`, memberCookie)
|
||||
if denied.Code != http.StatusForbidden {
|
||||
t.Fatalf("member create channel = %d, want 403", denied.Code)
|
||||
}
|
||||
if code := errorCodeOf(t, denied); code != "perm.denied" {
|
||||
t.Fatalf("error code = %q, want perm.denied", code)
|
||||
}
|
||||
|
||||
// Владелец создаёт комнату и роль.
|
||||
newChannel := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
|
||||
`{"name":"флудилка","type":"voice"}`, ownerCookie)
|
||||
if newChannel.Code != http.StatusOK {
|
||||
t.Fatalf("owner create channel = %d, body = %s", newChannel.Code, newChannel.Body.String())
|
||||
}
|
||||
|
||||
roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/roles",
|
||||
`{"name":"Модератор","permissions":"VIEW_CHANNEL|KICK_MEMBERS","color":16711680}`, ownerCookie)
|
||||
if roleRec.Code != http.StatusOK {
|
||||
t.Fatalf("create role = %d, body = %s", roleRec.Code, roleRec.Body.String())
|
||||
}
|
||||
role := decodeResponse[struct {
|
||||
Role struct {
|
||||
ID string `json:"id"`
|
||||
Permissions string `json:"permissions"`
|
||||
} `json:"role"`
|
||||
}](t, roleRec)
|
||||
if !strings.Contains(role.Role.Permissions, "KICK_MEMBERS") {
|
||||
t.Fatalf("role permissions = %q", role.Role.Permissions)
|
||||
}
|
||||
|
||||
// Роль можно выдать участнику.
|
||||
member, err := srv.auth.UserByEmail(t.Context(), "member@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
assign := doJSON(t, srv, http.MethodPut,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID)+"/roles/"+role.Role.ID, "", ownerCookie)
|
||||
if assign.Code != http.StatusOK {
|
||||
t.Fatalf("assign role = %d, body = %s", assign.Code, assign.Body.String())
|
||||
}
|
||||
members := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/members", "", ownerCookie)
|
||||
roster := decodeResponse[struct {
|
||||
Members []struct {
|
||||
UserID string `json:"user_id"`
|
||||
RoleIDs []string `json:"role_ids"`
|
||||
} `json:"members"`
|
||||
}](t, members)
|
||||
if len(roster.Members) != 2 {
|
||||
t.Fatalf("members = %d, want 2", len(roster.Members))
|
||||
}
|
||||
found := false
|
||||
for _, entry := range roster.Members {
|
||||
if entry.UserID == formatSnowflake(member.ID) && containsString(entry.RoleIDs, role.Role.ID) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("role was not assigned: %+v", roster.Members)
|
||||
}
|
||||
|
||||
// Участник не может исключить владельца даже с правом KICK_MEMBERS.
|
||||
owner, err := srv.auth.UserByEmail(t.Context(), "owner@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
kickOwner := doJSON(t, srv, http.MethodDelete,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(owner.ID), "", memberCookie)
|
||||
if kickOwner.Code != http.StatusForbidden {
|
||||
t.Fatalf("kick owner = %d, want 403", kickOwner.Code)
|
||||
}
|
||||
|
||||
// Владелец исключает участника.
|
||||
kick := doJSON(t, srv, http.MethodDelete,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID), "", ownerCookie)
|
||||
if kick.Code != http.StatusOK {
|
||||
t.Fatalf("kick member = %d, body = %s", kick.Code, kick.Body.String())
|
||||
}
|
||||
|
||||
// Журнал аудита содержит действие исключения.
|
||||
audit := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/audit-log", "", ownerCookie)
|
||||
entries := decodeResponse[struct {
|
||||
Entries []struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"entries"`
|
||||
}](t, audit)
|
||||
if !hasAction(entries.Entries, "member.kick") {
|
||||
t.Fatalf("audit log has no member.kick: %+v", entries.Entries)
|
||||
}
|
||||
|
||||
// Главный сервер удалить нельзя.
|
||||
deleteMain := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guild.Guild.ID, "", ownerCookie)
|
||||
if deleteMain.Code != http.StatusForbidden {
|
||||
t.Fatalf("delete main guild = %d, want 403", deleteMain.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceAdminEndpointsAndLimits(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
adminCookie := registerAndLogin(t, srv, "instance_admin", "admin@example.com")
|
||||
promoteAdmin(t, srv, "admin@example.com")
|
||||
userCookie := registerAndLogin(t, srv, "plain_user", "plain@example.com")
|
||||
|
||||
// Публичная информация об инстансе доступна без сессии.
|
||||
public := doJSON(t, srv, http.MethodGet, "/api/v1/instance", "")
|
||||
if public.Code != http.StatusOK {
|
||||
t.Fatalf("GET /instance = %d", public.Code)
|
||||
}
|
||||
publicPayload := decodeResponse[struct {
|
||||
Instance struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
UserCount int `json:"user_count"`
|
||||
} `json:"instance"`
|
||||
}](t, public)
|
||||
if !publicPayload.Instance.RegistrationEnabled {
|
||||
t.Fatal("registration must be enabled by default")
|
||||
}
|
||||
if publicPayload.Instance.UserCount != 0 {
|
||||
t.Fatalf("public payload must not expose user count, got %d", publicPayload.Instance.UserCount)
|
||||
}
|
||||
|
||||
// Обычный пользователь не имеет доступа к админ-ручкам.
|
||||
for _, path := range []string{"/api/v1/instance/settings", "/api/v1/instance/guilds", "/api/v1/instance/users", "/api/v1/instance/audit"} {
|
||||
rec := doJSON(t, srv, http.MethodGet, path, "", userCookie)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("GET %s as user = %d, want 403", path, rec.Code)
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != "instance.admin_required" {
|
||||
t.Fatalf("GET %s code = %q", path, code)
|
||||
}
|
||||
}
|
||||
|
||||
// Лимит в один сервер на пользователя.
|
||||
patch := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings",
|
||||
`{"max_guilds_per_user":1}`, adminCookie)
|
||||
if patch.Code != http.StatusOK {
|
||||
t.Fatalf("patch settings = %d, body = %s", patch.Code, patch.Body.String())
|
||||
}
|
||||
|
||||
first := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Первый"}`, userCookie)
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("first guild = %d, body = %s", first.Code, first.Body.String())
|
||||
}
|
||||
second := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Второй"}`, userCookie)
|
||||
if second.Code != http.StatusForbidden {
|
||||
t.Fatalf("second guild = %d, want 403", second.Code)
|
||||
}
|
||||
if code := errorCodeOf(t, second); code != "limits.guilds_reached" {
|
||||
t.Fatalf("limit error code = %q, want limits.guilds_reached", code)
|
||||
}
|
||||
|
||||
// Администратор инстанса обходит лимит, и это попадает в аудит.
|
||||
adminGuild := doJSON(t, srv, http.MethodPost, "/api/v1/instance/guilds", `{"name":"Админский"}`, adminCookie)
|
||||
if adminGuild.Code != http.StatusOK {
|
||||
t.Fatalf("admin guild = %d, body = %s", adminGuild.Code, adminGuild.Body.String())
|
||||
}
|
||||
|
||||
guilds := doJSON(t, srv, http.MethodGet, "/api/v1/instance/guilds", "", adminCookie)
|
||||
guildList := decodeResponse[struct {
|
||||
Guilds []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"guilds"`
|
||||
}](t, guilds)
|
||||
if len(guildList.Guilds) != 2 {
|
||||
t.Fatalf("instance guilds = %d, want 2", len(guildList.Guilds))
|
||||
}
|
||||
|
||||
audit := doJSON(t, srv, http.MethodGet, "/api/v1/instance/audit", "", adminCookie)
|
||||
entries := decodeResponse[struct {
|
||||
Entries []struct {
|
||||
Action string `json:"action"`
|
||||
ActorInstanceAdmin bool `json:"actor_instance_admin"`
|
||||
} `json:"entries"`
|
||||
}](t, audit)
|
||||
if !hasAction(entries.Entries, "limits.bypass") {
|
||||
t.Fatalf("audit has no limits.bypass: %+v", entries.Entries)
|
||||
}
|
||||
for _, entry := range entries.Entries {
|
||||
if !entry.ActorInstanceAdmin {
|
||||
t.Fatalf("instance audit entry without admin flag: %+v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Права администратора выдаются только со step-up.
|
||||
user, err := srv.auth.UserByEmail(t.Context(), "plain@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
noStepUp := doJSON(t, srv, http.MethodPost,
|
||||
"/api/v1/instance/users/"+formatSnowflake(user.ID)+"/admin", `{"admin":true}`, adminCookie)
|
||||
if noStepUp.Code != http.StatusForbidden {
|
||||
t.Fatalf("admin grant without step-up = %d, want 403", noStepUp.Code)
|
||||
}
|
||||
grant := doJSON(t, srv, http.MethodPost,
|
||||
"/api/v1/instance/users/"+formatSnowflake(user.ID)+"/admin",
|
||||
`{"admin":true,"step_up_password":"correct-horse-battery"}`, adminCookie)
|
||||
if grant.Code != http.StatusOK {
|
||||
t.Fatalf("admin grant = %d, body = %s", grant.Code, grant.Body.String())
|
||||
}
|
||||
|
||||
// Смена настроек регистрации закрывает регистрацию.
|
||||
off := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings", `{"registration_enabled":false}`, adminCookie)
|
||||
if off.Code != http.StatusOK {
|
||||
t.Fatalf("disable registration = %d", off.Code)
|
||||
}
|
||||
blocked := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
|
||||
`{"username":"blocked_user","email":"blocked@example.com","password":"correct-horse-battery"}`)
|
||||
if blocked.Code == http.StatusOK {
|
||||
t.Fatal("registration must be rejected when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberTimeoutRequiresPermission(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
ownerCookie := registerAndLogin(t, srv, "timeout_owner", "timeout-owner@example.com")
|
||||
memberCookie := registerAndLogin(t, srv, "timeout_member", "timeout-member@example.com")
|
||||
|
||||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Модерация"}`, ownerCookie)
|
||||
guild := decodeResponse[struct {
|
||||
Guild struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"guild"`
|
||||
}](t, created)
|
||||
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie)
|
||||
|
||||
member, err := srv.auth.UserByEmail(t.Context(), "timeout-member@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
// У участника нет TIMEOUT_MEMBERS: тайм-аут запрещён.
|
||||
denied := doJSON(t, srv, http.MethodPatch,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID),
|
||||
`{"timeout_until":"2030-01-01T00:00:00Z"}`, memberCookie)
|
||||
if denied.Code != http.StatusForbidden {
|
||||
t.Fatalf("member self timeout = %d, want 403", denied.Code)
|
||||
}
|
||||
|
||||
// Владелец выдаёт тайм-аут: права SEND_MESSAGES отключаются.
|
||||
ok := doJSON(t, srv, http.MethodPatch,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID),
|
||||
`{"timeout_until":"2030-01-01T00:00:00Z"}`, ownerCookie)
|
||||
if ok.Code != http.StatusOK {
|
||||
t.Fatalf("owner timeout = %d, body = %s", ok.Code, ok.Body.String())
|
||||
}
|
||||
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
|
||||
list := decodeResponse[struct {
|
||||
Channels []struct {
|
||||
CanSend bool `json:"can_send"`
|
||||
} `json:"channels"`
|
||||
}](t, channels)
|
||||
if len(list.Channels) == 0 || list.Channels[0].CanSend {
|
||||
t.Fatalf("timed out member must not be able to send: %+v", list.Channels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleHierarchyProtectsHigherRoles(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
ownerCookie := registerAndLogin(t, srv, "hier_owner", "hier-owner@example.com")
|
||||
modCookie := registerAndLogin(t, srv, "hier_mod", "hier-mod@example.com")
|
||||
|
||||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Иерархия"}`, ownerCookie)
|
||||
guild := decodeResponse[struct {
|
||||
Guild struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"guild"`
|
||||
}](t, created)
|
||||
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", modCookie)
|
||||
|
||||
mod, err := srv.auth.UserByEmail(t.Context(), "hier-mod@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
// Роль модератора с правом управления ролями.
|
||||
roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/roles",
|
||||
`{"name":"Модератор","permissions":"VIEW_CHANNEL|MANAGE_ROLES|KICK_MEMBERS"}`, ownerCookie)
|
||||
role := decodeResponse[struct {
|
||||
Role struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"role"`
|
||||
}](t, roleRec)
|
||||
assign := doJSON(t, srv, http.MethodPut,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+role.Role.ID, "", ownerCookie)
|
||||
if assign.Code != http.StatusOK {
|
||||
t.Fatalf("assign moderator role = %d", assign.Code)
|
||||
}
|
||||
|
||||
// Модератор пытается снять собственную роль: себе модерировать нельзя.
|
||||
self := doJSON(t, srv, http.MethodDelete,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+role.Role.ID, "", modCookie)
|
||||
if self.Code != http.StatusForbidden {
|
||||
t.Fatalf("self moderation = %d, want 403", self.Code)
|
||||
}
|
||||
|
||||
// Модератор пытается выдать себе роль администратора: она выше его роли.
|
||||
roles := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/roles", "", ownerCookie)
|
||||
roleList := decodeResponse[struct {
|
||||
Roles []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"roles"`
|
||||
}](t, roles)
|
||||
var adminRoleID string
|
||||
for _, candidate := range roleList.Roles {
|
||||
if candidate.Name == "Администратор" {
|
||||
adminRoleID = candidate.ID
|
||||
}
|
||||
}
|
||||
if adminRoleID == "" {
|
||||
t.Fatal("administrator role is missing")
|
||||
}
|
||||
escalate := doJSON(t, srv, http.MethodPut,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+adminRoleID, "", modCookie)
|
||||
if escalate.Code != http.StatusForbidden {
|
||||
t.Fatalf("privilege escalation = %d, want 403", escalate.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelOverridesHideChannel(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
ownerCookie := registerAndLogin(t, srv, "override_owner", "override-owner@example.com")
|
||||
memberCookie := registerAndLogin(t, srv, "override_member", "override-member@example.com")
|
||||
|
||||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Оверрайды"}`, ownerCookie)
|
||||
guild := decodeResponse[struct {
|
||||
Guild struct {
|
||||
ID string `json:"id"`
|
||||
Roles []struct {
|
||||
ID string `json:"id"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
} `json:"roles"`
|
||||
} `json:"guild"`
|
||||
}](t, created)
|
||||
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie)
|
||||
|
||||
secret := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
|
||||
`{"name":"тайная","type":"text"}`, ownerCookie)
|
||||
channel := decodeResponse[struct {
|
||||
Channel struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"channel"`
|
||||
}](t, secret)
|
||||
|
||||
// Оверрайд для роли по умолчанию: VIEW_CHANNEL запрещён.
|
||||
channelID, err := parseID("channel_id", channel.Channel.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("parse channel id: %v", err)
|
||||
}
|
||||
var defaultRoleID uint64
|
||||
for _, role := range guild.Guild.Roles {
|
||||
if role.IsDefault {
|
||||
defaultRoleID, err = parseID("role_id", role.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("parse role id: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if defaultRoleID == 0 {
|
||||
t.Fatal("default role not found")
|
||||
}
|
||||
if err := srv.store.SetChannelOverride(t.Context(), store.ChannelOverride{
|
||||
ChannelID: channelID, TargetType: "role", TargetID: defaultRoleID,
|
||||
Deny: uint64(permissionViewChannel),
|
||||
}); err != nil {
|
||||
t.Fatalf("SetChannelOverride: %v", err)
|
||||
}
|
||||
srv.perms.InvalidateGuild(guildIDOf(t, guild.Guild.ID))
|
||||
|
||||
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
|
||||
list := decodeResponse[struct {
|
||||
Channels []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"channels"`
|
||||
}](t, channels)
|
||||
for _, entry := range list.Channels {
|
||||
if entry.Name == "тайная" {
|
||||
t.Fatalf("channel with denied VIEW_CHANNEL must be hidden: %+v", list.Channels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func guildIDOf(t *testing.T, raw string) uint64 {
|
||||
t.Helper()
|
||||
id, err := parseID("guild_id", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse guild id: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func containsString(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if value == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasAction[T any](entries []T, action string) bool {
|
||||
for _, entry := range entries {
|
||||
encoded, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(encoded), `"`+action+`"`) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// profilePayload — публичный профиль пользователя (AGENT.md 8.2).
|
||||
type profilePayload struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio"`
|
||||
Status string `json:"status"`
|
||||
CustomStatus string `json:"custom_status"`
|
||||
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
||||
BannerFileID string `json:"banner_file_id,omitempty"`
|
||||
IsInstanceAdmin bool `json:"is_instance_admin"`
|
||||
Badges []string `json:"badges"`
|
||||
Locale string `json:"locale"`
|
||||
// OnboardingCompleted — признак пройденной первичной настройки (AGENT.md 7.2).
|
||||
OnboardingCompleted bool `json:"onboarding_completed"`
|
||||
}
|
||||
|
||||
func profileFromUser(user *store.User, includePrivate bool) profilePayload {
|
||||
payload := profilePayload{
|
||||
ID: formatSnowflake(user.ID),
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Bio: user.Bio,
|
||||
Status: user.Status,
|
||||
CustomStatus: user.CustomStatus,
|
||||
IsInstanceAdmin: user.IsInstanceAdmin,
|
||||
Badges: user.Badges,
|
||||
OnboardingCompleted: user.OnboardingCompletedAt != nil,
|
||||
}
|
||||
if payload.Badges == nil {
|
||||
payload.Badges = []string{}
|
||||
}
|
||||
if user.AvatarFileID != nil {
|
||||
payload.AvatarFileID = formatSnowflake(*user.AvatarFileID)
|
||||
}
|
||||
if user.BannerFileID != nil {
|
||||
payload.BannerFileID = formatSnowflake(*user.BannerFileID)
|
||||
}
|
||||
if includePrivate {
|
||||
payload.Locale = user.Locale
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
type meOutput struct {
|
||||
Body struct {
|
||||
User profilePayload `json:"user"`
|
||||
}
|
||||
}
|
||||
|
||||
type userOutput struct {
|
||||
Body struct {
|
||||
User profilePayload `json:"user"`
|
||||
}
|
||||
}
|
||||
|
||||
type okOutput struct {
|
||||
Body struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
}
|
||||
|
||||
func newOKOutput() *okOutput {
|
||||
output := &okOutput{}
|
||||
output.Body.OK = true
|
||||
return output
|
||||
}
|
||||
|
||||
type updateProfileInput struct {
|
||||
Body struct {
|
||||
DisplayName *string `json:"display_name,omitempty" maxLength:"32"`
|
||||
Bio *string `json:"bio,omitempty" maxLength:"500"`
|
||||
Status *string `json:"status,omitempty" enum:"online,idle,dnd,invisible"`
|
||||
CustomStatus *string `json:"custom_status,omitempty" maxLength:"128"`
|
||||
CustomStatusEmoji *string `json:"custom_status_emoji,omitempty" maxLength:"32"`
|
||||
Locale *string `json:"locale,omitempty" enum:"ru,en"`
|
||||
}
|
||||
}
|
||||
|
||||
type changePasswordInput struct {
|
||||
Body struct {
|
||||
CurrentPassword string `json:"current_password" minLength:"1"`
|
||||
NewPassword string `json:"new_password" minLength:"1"`
|
||||
}
|
||||
}
|
||||
|
||||
type onboardingInput struct {
|
||||
Body struct {
|
||||
DisplayName *string `json:"display_name,omitempty" maxLength:"32"`
|
||||
Bio *string `json:"bio,omitempty" maxLength:"500"`
|
||||
Locale *string `json:"locale,omitempty" enum:"ru,en"`
|
||||
}
|
||||
}
|
||||
|
||||
type guildSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IconFileID string `json:"icon_file_id,omitempty"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
IsMain bool `json:"is_main"`
|
||||
MemberCount int `json:"member_count"`
|
||||
MyRoleIDs []string `json:"my_role_ids"`
|
||||
MyPermissions []string `json:"my_permissions"`
|
||||
}
|
||||
|
||||
type guildListOutput struct {
|
||||
Body struct {
|
||||
Guilds []guildSummary `json:"guilds"`
|
||||
}
|
||||
}
|
||||
|
||||
// registerUserRoutes описывает ручки профиля, онбординга и списка серверов.
|
||||
func (s *Server) registerUserRoutes(api huma.API) {
|
||||
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getCurrentUser",
|
||||
Method: http.MethodGet,
|
||||
Path: "/users/@me",
|
||||
Summary: "Текущий пользователь",
|
||||
Tags: []string{"Users"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, _ *struct{}) (*meOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output := &meOutput{}
|
||||
output.Body.User = profileFromUser(user, true)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "updateCurrentUser",
|
||||
Method: http.MethodPatch,
|
||||
Path: "/users/@me",
|
||||
Summary: "Изменить профиль",
|
||||
Tags: []string{"Users"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *updateProfileInput) (*meOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := store.UpdateUserParams{
|
||||
DisplayName: input.Body.DisplayName,
|
||||
Bio: input.Body.Bio,
|
||||
Status: input.Body.Status,
|
||||
CustomStatus: input.Body.CustomStatus,
|
||||
CustomStatusEmoji: input.Body.CustomStatusEmoji,
|
||||
Locale: input.Body.Locale,
|
||||
}
|
||||
if input.Body.DisplayName != nil {
|
||||
trimmed := strings.TrimSpace(*input.Body.DisplayName)
|
||||
if trimmed == "" {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "display name must not be empty")
|
||||
}
|
||||
params.DisplayName = &trimmed
|
||||
}
|
||||
updated, err := s.store.UpdateUser(ctx, user.ID, params)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
// Профиль изменился — остальные клиенты получают событие (AGENT.md 8.3).
|
||||
s.dispatchUserUpdate(updated)
|
||||
output := &meOutput{}
|
||||
output.Body.User = profileFromUser(updated, true)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "changePassword",
|
||||
Method: http.MethodPost,
|
||||
Path: "/users/@me/password",
|
||||
Summary: "Сменить пароль (требует step-up)",
|
||||
Tags: []string{"Users"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *changePasswordInput) (*okOutput, error) {
|
||||
user, session, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "completeOnboarding",
|
||||
Method: http.MethodPost,
|
||||
Path: "/users/@me/onboarding/complete",
|
||||
Summary: "Завершить первичную настройку",
|
||||
Tags: []string{"Users"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *onboardingInput) (*meOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := store.UpdateUserParams{
|
||||
Bio: input.Body.Bio,
|
||||
Locale: input.Body.Locale,
|
||||
}
|
||||
if input.Body.DisplayName != nil {
|
||||
if trimmed := strings.TrimSpace(*input.Body.DisplayName); trimmed != "" {
|
||||
params.DisplayName = &trimmed
|
||||
}
|
||||
}
|
||||
if _, err := s.store.UpdateUser(ctx, user.ID, params); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if err := s.store.MarkOnboardingCompleted(ctx, user.ID); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
updated, err := s.store.GetUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
s.dispatchUserUpdate(updated)
|
||||
output := &meOutput{}
|
||||
output.Body.User = profileFromUser(updated, true)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getUser",
|
||||
Method: http.MethodGet,
|
||||
Path: "/users/{user_id}",
|
||||
Summary: "Публичный профиль пользователя",
|
||||
Tags: []string{"Users"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
UserID string `path:"user_id"`
|
||||
},
|
||||
) (*userOutput, error) {
|
||||
if _, _, err := requireUser(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := parseID("user_id", input.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := s.store.GetUser(ctx, id)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &userOutput{}
|
||||
output.Body.User = profileFromUser(user, false)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listMyGuilds",
|
||||
Method: http.MethodGet,
|
||||
Path: "/users/@me/guilds",
|
||||
Summary: "Серверы текущего пользователя",
|
||||
Tags: []string{"Users"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, _ *struct{}) (*guildListOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
summaries := make([]guildSummary, 0, len(guilds))
|
||||
for _, guild := range guilds {
|
||||
resolved, err := s.guildPermissions(ctx, guild.ID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !resolved.Has(permissions.ViewGuild) {
|
||||
continue
|
||||
}
|
||||
summary, err := s.guildSummary(ctx, guild, user.ID, resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
output := &guildListOutput{}
|
||||
output.Body.Guilds = summaries
|
||||
return output, nil
|
||||
})
|
||||
}
|
||||
|
||||
// guildSummary собирает краткую карточку сервера для списка.
|
||||
func (s *Server) guildSummary(ctx context.Context, guild store.Guild, userID uint64, resolved permissions.Resolved) (guildSummary, error) {
|
||||
summary := guildSummary{
|
||||
ID: formatSnowflake(guild.ID),
|
||||
Name: guild.Name,
|
||||
OwnerID: formatSnowflake(guild.OwnerID),
|
||||
IsMain: guild.IsMain,
|
||||
MyRoleIDs: []string{},
|
||||
MyPermissions: permissions.Names(resolved.Guild),
|
||||
}
|
||||
if guild.IconFileID != nil {
|
||||
summary.IconFileID = formatSnowflake(*guild.IconFileID)
|
||||
}
|
||||
roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, userID)
|
||||
if err != nil {
|
||||
return guildSummary{}, humaError(err)
|
||||
}
|
||||
for _, roleID := range roleIDs {
|
||||
summary.MyRoleIDs = append(summary.MyRoleIDs, formatSnowflake(roleID))
|
||||
}
|
||||
count, err := s.store.CountGuildMembers(ctx, guild.ID)
|
||||
if err != nil {
|
||||
return guildSummary{}, humaError(err)
|
||||
}
|
||||
summary.MemberCount = count
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// dispatchUserUpdate рассылает обновление профиля во все сессии пользователя.
|
||||
func (s *Server) dispatchUserUpdate(user *store.User) {
|
||||
if s.gateway == nil {
|
||||
return
|
||||
}
|
||||
s.gateway.SendToUser(user.ID, "USER_UPDATE", map[string]any{
|
||||
"user": profileFromUser(user, true),
|
||||
})
|
||||
}
|
||||
+6
-10
@@ -7,12 +7,14 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/gateway"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// sessionCookieName — имя cookie сессии (AGENT.md 8.1: префикс __Host-).
|
||||
const sessionCookieName = "__Host-session"
|
||||
// Значение общее с Gateway: браузерный клиент аутентифицируется этой cookie.
|
||||
const sessionCookieName = gateway.SessionCookieName
|
||||
|
||||
const sessionCookiePath = "/"
|
||||
|
||||
@@ -54,7 +56,6 @@ func (s *Server) registerAuthRoutes(router chi.Router) {
|
||||
router.Post("/auth/step-up", s.handleStepUp)
|
||||
router.Post("/auth/2fa/setup", s.handleSetupTOTP)
|
||||
router.Post("/auth/2fa/enable", s.handleEnableTOTP)
|
||||
router.Get("/users/@me", s.handleGetMe)
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
@@ -77,6 +78,8 @@ type currentUserPayload struct {
|
||||
IsInstanceAdmin bool `json:"is_instance_admin"`
|
||||
Badges []string `json:"badges"`
|
||||
Locale string `json:"locale"`
|
||||
// OnboardingCompleted — пройдена ли первичная настройка (AGENT.md 7.2).
|
||||
OnboardingCompleted bool `json:"onboarding_completed"`
|
||||
}
|
||||
|
||||
func userPayload(user *store.User) currentUserPayload {
|
||||
@@ -90,6 +93,7 @@ func userPayload(user *store.User) currentUserPayload {
|
||||
IsInstanceAdmin: user.IsInstanceAdmin,
|
||||
Badges: user.Badges,
|
||||
Locale: user.Locale,
|
||||
OnboardingCompleted: user.OnboardingCompletedAt != nil,
|
||||
}
|
||||
if payload.Badges == nil {
|
||||
payload.Badges = []string{}
|
||||
@@ -251,14 +255,6 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"sessions": payload})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"user": userPayload(user)})
|
||||
}
|
||||
|
||||
type totpEnableRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
@@ -110,7 +110,15 @@ func TestOpenAPIDocumentsAuthEndpoints(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("decode openapi: %v", err)
|
||||
}
|
||||
for _, path := range []string{"/auth/register", "/auth/login", "/auth/logout", "/auth/sessions", "/users/@me", "/auth/2fa/setup", "/meta"} {
|
||||
paths := []string{
|
||||
"/auth/register", "/auth/login", "/auth/logout", "/auth/sessions",
|
||||
"/users/@me", "/auth/2fa/setup", "/meta",
|
||||
// Ручки Фазы 1, сгенерированные huma.
|
||||
"/users/@me/guilds", "/guilds", "/guilds/{guild_id}", "/guilds/{guild_id}/channels",
|
||||
"/guilds/{guild_id}/members", "/guilds/{guild_id}/roles", "/instance",
|
||||
"/instance/settings", "/instance/guilds", "/instance/users", "/instance/audit",
|
||||
}
|
||||
for _, path := range paths {
|
||||
if _, ok := doc.Paths[path]; !ok {
|
||||
t.Errorf("openapi is missing %s", path)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"glchat/internal/gateway"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/meta"
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/source"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
@@ -26,6 +28,9 @@ type Deps struct {
|
||||
Store *store.Store
|
||||
Auth *auth.Service
|
||||
Gateway *gateway.Service
|
||||
// Permissions — общий с Gateway калькулятор прав (необязателен: если не
|
||||
// передан, сервер создаёт собственный).
|
||||
Permissions *permissions.Calculator
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -34,6 +39,8 @@ type Server struct {
|
||||
store *store.Store
|
||||
auth *auth.Service
|
||||
gateway *gateway.Service
|
||||
// perms — движок прав: ручки проверяют права теми же правилами, что Gateway.
|
||||
perms *permissions.Calculator
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
static *staticHandler
|
||||
@@ -50,12 +57,25 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
logger: logger,
|
||||
static: newStaticHandler(cfg.WebRoot),
|
||||
}
|
||||
switch {
|
||||
case deps.Permissions != nil:
|
||||
s.perms = deps.Permissions
|
||||
case deps.Store != nil:
|
||||
s.perms = permissions.NewCalculator(source.New(deps.Store))
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Route("/api/v1", func(apiRouter chi.Router) {
|
||||
// Сессия резолвится один раз на запрос: huma-ручки читают её из контекста.
|
||||
apiRouter.Use(s.sessionContext)
|
||||
s.api = s.registerAPI(apiRouter)
|
||||
s.registerMetaRoutes(s.api)
|
||||
s.registerAuthRoutes(apiRouter)
|
||||
if deps.Store != nil {
|
||||
s.registerUserRoutes(s.api)
|
||||
s.registerGuildRoutes(s.api)
|
||||
s.registerInstanceRoutes(s.api)
|
||||
}
|
||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||
})
|
||||
s.registerRoutes(router)
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/gateway"
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/source"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
@@ -62,8 +64,11 @@ func newTestServer(t *testing.T) (*Server, *database.DB) {
|
||||
if err != nil {
|
||||
t.Fatalf("initialize authentication: %v", err)
|
||||
}
|
||||
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st), logger, cfg.AllowedOrigins())
|
||||
return New(cfg, db, logger, Deps{Store: st, Auth: authService, Gateway: gatewayService}), db
|
||||
calculator := permissions.NewCalculator(source.New(st))
|
||||
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins())
|
||||
return New(cfg, db, logger, Deps{
|
||||
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
|
||||
}), db
|
||||
}
|
||||
|
||||
// TestGatewayRouteUpgrades проверяет связку: маршрут /gateway доступен через
|
||||
|
||||
@@ -41,6 +41,17 @@ func (a *Adapter) MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]
|
||||
return a.store.MemberRoleIDs(ctx, guildID, userID)
|
||||
}
|
||||
|
||||
// IsMember отвечает, состоит ли пользователь в сервере.
|
||||
func (a *Adapter) IsMember(ctx context.Context, guildID, userID uint64) (bool, error) {
|
||||
if _, err := a.store.GetGuildMember(ctx, guildID, userID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) ChannelOverrides(ctx context.Context, channelID uint64) ([]permissions.OverrideData, error) {
|
||||
overrides, err := a.store.ListChannelOverrides(ctx, channelID)
|
||||
if err != nil {
|
||||
|
||||
@@ -357,3 +357,103 @@ func prefixColumns(prefix, columns string) string {
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// GuildMemberProfile — участник сервера вместе с публичными полями профиля:
|
||||
// нужен списку участников в клиенте (AGENT.md 8.2).
|
||||
type GuildMemberProfile struct {
|
||||
UserID uint64
|
||||
Username string
|
||||
DisplayName string
|
||||
Nickname string
|
||||
AvatarFileID *uint64
|
||||
Status string
|
||||
CustomStatus string
|
||||
IsInstanceAdmin bool
|
||||
JoinedAt time.Time
|
||||
TimeoutUntil *time.Time
|
||||
RoleIDs []uint64
|
||||
}
|
||||
|
||||
// ListGuildMemberProfiles возвращает участников сервера с профилями и ролями.
|
||||
func (s *Store) ListGuildMemberProfiles(ctx context.Context, guildID uint64) ([]GuildMemberProfile, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT m.user_id, u.username, u.display_name, m.nickname, u.avatar_file_id,
|
||||
u.status, u.custom_status, u.is_instance_admin, m.joined_at, m.timeout_until
|
||||
FROM guild_members m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE m.guild_id = ? AND u.deleted_at IS NULL
|
||||
ORDER BY m.joined_at, m.user_id`, int64(guildID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
profiles := make([]GuildMemberProfile, 0, 16)
|
||||
for rows.Next() {
|
||||
var (
|
||||
profile GuildMemberProfile
|
||||
nickname sql.NullString
|
||||
avatarID sql.NullInt64
|
||||
isAdmin int
|
||||
joinedAt string
|
||||
timeoutUntil sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&profile.UserID, &profile.Username, &profile.DisplayName, &nickname,
|
||||
&avatarID, &profile.Status, &profile.CustomStatus, &isAdmin, &joinedAt, &timeoutUntil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nickname.Valid {
|
||||
profile.Nickname = nickname.String
|
||||
}
|
||||
if avatarID.Valid {
|
||||
value := uint64(avatarID.Int64)
|
||||
profile.AvatarFileID = &value
|
||||
}
|
||||
profile.IsInstanceAdmin = isAdmin == 1
|
||||
profile.JoinedAt = parseTimestamp(joinedAt)
|
||||
if timeoutUntil.Valid {
|
||||
value := parseTimestamp(timeoutUntil.String)
|
||||
profile.TimeoutUntil = &value
|
||||
}
|
||||
profiles = append(profiles, profile)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Роли участников читаем одним запросом и раскладываем по участникам.
|
||||
roleRows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT mr.user_id, mr.role_id
|
||||
FROM member_roles mr
|
||||
JOIN guild_members m ON m.guild_id = mr.guild_id AND m.user_id = mr.user_id
|
||||
WHERE mr.guild_id = ?
|
||||
ORDER BY mr.role_id`, int64(guildID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer roleRows.Close()
|
||||
|
||||
index := make(map[uint64]int, len(profiles))
|
||||
for i, profile := range profiles {
|
||||
index[profile.UserID] = i
|
||||
}
|
||||
for roleRows.Next() {
|
||||
var userID, roleID uint64
|
||||
if err := roleRows.Scan(&userID, &roleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if position, ok := index[userID]; ok {
|
||||
profiles[position].RoleIDs = append(profiles[position].RoleIDs, roleID)
|
||||
}
|
||||
}
|
||||
return profiles, roleRows.Err()
|
||||
}
|
||||
|
||||
// CountGuildMemberships возвращает число серверов, которыми владеет пользователь
|
||||
// и в которых состоит: используется для лимитов (AGENT.md 6.5).
|
||||
func (s *Store) CountGuildMemberships(ctx context.Context, userID uint64) (int, error) {
|
||||
var count int
|
||||
err := s.reader.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM guild_members WHERE user_id = ?`, int64(userID)).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
@@ -127,6 +127,10 @@ func (s *Store) ListAuditLog(ctx context.Context, guildID uint64, limit int) ([]
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanAuditEntries(rows, limit)
|
||||
}
|
||||
|
||||
func scanAuditEntries(rows *sql.Rows, limit int) ([]AuditEntry, error) {
|
||||
defer rows.Close()
|
||||
|
||||
entries := make([]AuditEntry, 0, limit)
|
||||
@@ -191,3 +195,19 @@ func parseUint(value string) (uint64, bool) {
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
// ListInstanceAudit отдаёт общий журнал инстанса: действия администраторов
|
||||
// инстанса (в том числе внутри серверов) и записи без привязки к серверу,
|
||||
// которые переживают удаление сервера (AGENT.md 7.18).
|
||||
func (s *Store) ListInstanceAudit(ctx context.Context, limit int) ([]AuditEntry, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at
|
||||
FROM audit_log WHERE actor_instance_admin = 1 OR guild_id IS NULL ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanAuditEntries(rows, limit)
|
||||
}
|
||||
|
||||
+51
-1
@@ -29,6 +29,8 @@ type User struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
// OnboardingCompletedAt заполняется после первичной настройки (AGENT.md 7.2).
|
||||
OnboardingCompletedAt *time.Time
|
||||
}
|
||||
|
||||
// CreateUserParams — данные новой учётной записи: шифрование и blind index
|
||||
@@ -45,7 +47,8 @@ type CreateUserParams struct {
|
||||
|
||||
const userColumns = `id, username, display_name, email_enc, password_hash, avatar_file_id,
|
||||
banner_file_id, bio, status, custom_status, custom_status_emoji, flags,
|
||||
is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at`
|
||||
is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at,
|
||||
onboarding_completed_at`
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User, error) {
|
||||
if params.ID == 0 {
|
||||
@@ -218,6 +221,47 @@ func (s *Store) SetUserBadges(ctx context.Context, id uint64, badges []string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkOnboardingCompleted фиксирует завершение первичной настройки (AGENT.md 7.2).
|
||||
func (s *Store) MarkOnboardingCompleted(ctx context.Context, id uint64) error {
|
||||
result, err := s.writer.ExecContext(ctx,
|
||||
`UPDATE users SET onboarding_completed_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||
s.Now(), s.Now(), int64(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListUsers отдаёт страницу пользователей для админ-панели инстанса (AGENT.md 6.5).
|
||||
func (s *Store) ListUsers(ctx context.Context, limit, offset int) ([]User, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := s.reader.QueryContext(ctx,
|
||||
`SELECT `+userColumns+` FROM users WHERE deleted_at IS NULL ORDER BY id LIMIT ? OFFSET ?`,
|
||||
limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := make([]User, 0, limit)
|
||||
for rows.Next() {
|
||||
user, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, *user)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
||||
var (
|
||||
user User
|
||||
@@ -228,12 +272,14 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
||||
createdAt string
|
||||
updatedAt string
|
||||
deletedAt sql.NullString
|
||||
onboardingAt sql.NullString
|
||||
emailEncrypted string
|
||||
)
|
||||
err := scanner.Scan(
|
||||
&user.ID, &user.Username, &user.DisplayName, &emailEncrypted, &user.PasswordHash,
|
||||
&avatarID, &bannerID, &user.Bio, &user.Status, &user.CustomStatus, &user.CustomStatusEmoji,
|
||||
&user.Flags, &isAdmin, &badges, &user.Locale, &createdAt, &updatedAt, &deletedAt,
|
||||
&onboardingAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
@@ -256,6 +302,10 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
||||
value := parseTimestamp(deletedAt.String)
|
||||
user.DeletedAt = &value
|
||||
}
|
||||
if onboardingAt.Valid {
|
||||
value := parseTimestamp(onboardingAt.String)
|
||||
user.OnboardingCompletedAt = &value
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
|
||||
+93
-118
@@ -1,125 +1,100 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router';
|
||||
|
||||
import { fetchReadiness, readyQueryKey } from '@/api/health';
|
||||
import { fetchMeta, metaQueryKey } from '@/api/meta';
|
||||
import { Badge, Button, Card } from '@/components/ui/primitives';
|
||||
import { StatBytes, StatRow } from '@/components/ui/StatRow';
|
||||
import { errorCode } from '@/lib/format';
|
||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import '@/i18n';
|
||||
|
||||
export function App() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const theme = useUiStore((state) => state.theme);
|
||||
const toggleTheme = useUiStore((state) => state.toggleTheme);
|
||||
const locale = i18n.resolvedLanguage ?? 'ru';
|
||||
|
||||
const meta = useQuery({
|
||||
queryKey: metaQueryKey,
|
||||
queryFn: ({ signal }) => fetchMeta(signal),
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const readiness = useQuery({
|
||||
queryKey: readyQueryKey,
|
||||
queryFn: ({ signal }) => fetchReadiness(signal),
|
||||
retry: 1,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const online = readiness.isSuccess || meta.isSuccess;
|
||||
const schemaReady = readiness.data?.checks['database'] === 'ok';
|
||||
|
||||
const errorMessage = (error: unknown): string => {
|
||||
const code = errorCode(error);
|
||||
const key = `errors.${code}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? t('errors.unknown', { code }) : translated;
|
||||
};
|
||||
|
||||
const changeLanguage = (language: SupportedLanguage): void => {
|
||||
window.localStorage.setItem('glchat.language', language);
|
||||
void i18n.changeLanguage(language);
|
||||
};
|
||||
const LoginPage = lazy(() => import('@/pages/LoginPage'));
|
||||
const RegisterPage = lazy(() => import('@/pages/RegisterPage'));
|
||||
const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
|
||||
const StatusPage = lazy(() => import('@/pages/StatusPage'));
|
||||
const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
|
||||
const GuildView = lazy(() => import('@/pages/app/GuildView'));
|
||||
const NoGuildView = lazy(() => import('@/pages/app/NoGuildView'));
|
||||
const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout'));
|
||||
const ProfileSettingsPage = lazy(() => import('@/pages/settings/ProfileSettingsPage'));
|
||||
const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage'));
|
||||
const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage'));
|
||||
const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage'));
|
||||
|
||||
/** `/` — редирект по состоянию сессии. */
|
||||
function IndexRedirect() {
|
||||
const currentUser = useCurrentUser();
|
||||
if (currentUser.isPending) {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-4 py-10">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('app.name')}</h1>
|
||||
<p className="mt-1 text-fg-muted">{t('app.tagline')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge aria-live="polite" data-testid="connection-state">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-2 w-2 rounded-full ${online ? 'bg-success' : 'bg-danger'}`}
|
||||
/>
|
||||
{t(online ? 'health.online' : 'health.offline')}
|
||||
</Badge>
|
||||
<Button variant="ghost" onClick={toggleTheme}>
|
||||
{t(theme === 'dark' ? 'theme.switchToLight' : 'theme.switchToDark')}
|
||||
</Button>
|
||||
<label className="flex items-center gap-1 text-sm text-fg-muted">
|
||||
<span className="sr-only">{t('language.label')}</span>
|
||||
<select
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-fg"
|
||||
value={locale.startsWith('en') ? 'en' : 'ru'}
|
||||
onChange={(event) => changeLanguage(event.target.value === 'en' ? 'en' : 'ru')}
|
||||
>
|
||||
{supportedLanguages.map((language) => (
|
||||
<option key={language} value={language}>
|
||||
{t(`language.${language}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold">{t('status.title')}</h2>
|
||||
<p className="mt-1 text-fg-muted">{t('status.description')}</p>
|
||||
|
||||
{meta.isPending ? (
|
||||
<p className="mt-4 text-fg-muted" role="status">
|
||||
{t('status.connecting')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{meta.isError ? (
|
||||
<div className="mt-4 flex items-center justify-between gap-4" role="alert">
|
||||
<p className="text-danger">{errorMessage(meta.error)}</p>
|
||||
<Button variant="ghost" onClick={() => void meta.refetch()}>
|
||||
{t('status.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{meta.data ? (
|
||||
<dl className="mt-4 text-sm">
|
||||
<StatRow label={t('status.instance')} value={meta.data.name} />
|
||||
<StatRow label={t('status.version')} value={meta.data.version} />
|
||||
<StatRow label={t('status.apiVersion')} value={meta.data.api_version} />
|
||||
<StatRow
|
||||
label={t('status.schemaReady')}
|
||||
value={schemaReady ? t('status.schemaOk') : (readiness.data?.status ?? '…')}
|
||||
/>
|
||||
<StatRow
|
||||
label={t('status.maxUpload')}
|
||||
value={<StatBytes bytes={meta.data.max_upload_size} locale={locale} />}
|
||||
/>
|
||||
</dl>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<footer className="mt-auto flex items-center justify-between text-xs text-fg-muted">
|
||||
<span>
|
||||
{t('app.name')} · {t('footer.license')}
|
||||
</span>
|
||||
<span className="tabular-nums">{meta.data?.commit ?? ''}</span>
|
||||
</footer>
|
||||
<div className="flex min-h-full items-center justify-center p-6">
|
||||
<LoadingNotice />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Navigate to={currentUser.isSuccess ? '/app' : '/login'} replace />;
|
||||
}
|
||||
|
||||
/** Общая заглушка на время загрузки ленивых страниц. */
|
||||
export function RouteFallback() {
|
||||
return (
|
||||
<div className="flex min-h-full items-center justify-center p-6">
|
||||
<LoadingNotice />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: '/',
|
||||
children: [
|
||||
{ index: true, element: <IndexRedirect /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'register', element: <RegisterPage /> },
|
||||
{ path: 'status', element: <StatusPage /> },
|
||||
{
|
||||
element: <AuthGuard allowIncompleteOnboarding />,
|
||||
children: [{ path: 'onboarding', element: <OnboardingPage /> }],
|
||||
},
|
||||
{
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{
|
||||
path: 'app',
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="empty" replace /> },
|
||||
{ path: 'empty', element: <NoGuildView /> },
|
||||
{ path: ':guildId', element: <GuildView /> },
|
||||
{ path: ':guildId/:channelId', element: <GuildView /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <AuthGuard allowIncompleteOnboarding />,
|
||||
children: [
|
||||
{
|
||||
path: 'settings',
|
||||
element: <SettingsLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="profile" replace /> },
|
||||
{ path: 'profile', element: <ProfileSettingsPage /> },
|
||||
{ path: 'security', element: <SecuritySettingsPage /> },
|
||||
{ path: 'appearance', element: <AppearanceSettingsPage /> },
|
||||
{ path: 'instance', element: <InstanceSettingsPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const router = createBrowserRouter(routes);
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { request } from './client';
|
||||
import type { SessionInfo, User } from './types';
|
||||
|
||||
export interface RegisterInput {
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export interface LoginInput {
|
||||
email: string;
|
||||
password: string;
|
||||
totp_code?: string;
|
||||
}
|
||||
|
||||
export interface LogoutAllResponse {
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export const sessionsQueryKey = ['auth', 'sessions'] as const;
|
||||
|
||||
export function register(input: RegisterInput): Promise<{ user: User }> {
|
||||
return request<{ user: User }>('/auth/register', { method: 'POST', body: input });
|
||||
}
|
||||
|
||||
export function login(input: LoginInput): Promise<{ user: User }> {
|
||||
return request<{ user: User }>('/auth/login', { method: 'POST', body: input });
|
||||
}
|
||||
|
||||
export function logout(): Promise<{ ok: true }> {
|
||||
return request<{ ok: true }>('/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function logoutAll(): Promise<LogoutAllResponse> {
|
||||
return request<LogoutAllResponse>('/auth/logout-all', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function fetchSessions(signal?: AbortSignal): Promise<SessionInfo[]> {
|
||||
const payload = await request<{ sessions: SessionInfo[] }>(
|
||||
'/auth/sessions',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.sessions;
|
||||
}
|
||||
|
||||
/** Подтверждение личности перед сменой пароля и включением 2FA. */
|
||||
export function stepUp(password: string, totpCode?: string): Promise<{ ok: true }> {
|
||||
const body: { password: string; totp_code?: string } = { password };
|
||||
if (totpCode !== undefined && totpCode !== '') {
|
||||
body.totp_code = totpCode;
|
||||
}
|
||||
return request<{ ok: true }>('/auth/step-up', { method: 'POST', body });
|
||||
}
|
||||
|
||||
export function setupTotp(): Promise<{ secret: string; otpauth_url: string }> {
|
||||
return request<{ secret: string; otpauth_url: string }>('/auth/2fa/setup', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function enableTotp(code: string): Promise<string[]> {
|
||||
const payload = await request<{ recovery_codes: string[] }>('/auth/2fa/enable', {
|
||||
method: 'POST',
|
||||
body: { code },
|
||||
});
|
||||
return payload.recovery_codes;
|
||||
}
|
||||
|
||||
/** Смена пароля: требует предварительного step-up. */
|
||||
export function changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<{ ok: true }> {
|
||||
return request<{ ok: true }>('/users/@me/password', {
|
||||
method: 'POST',
|
||||
body: { current_password: currentPassword, new_password: newPassword },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
import type { Channel, Role, UserStatus } from './types';
|
||||
|
||||
/**
|
||||
* Клиент шлюза (WebSocket) Фазы 1.
|
||||
*
|
||||
* Контракт: сервер присылает HELLO (op 10) с интервалом heartbeat, клиент
|
||||
* отвечает IDENTIFY (op 2, пустой токен — браузер аутентифицируется cookie
|
||||
* на рукопожатии), затем получает READY/RESUMED. Разрыв соединения лечится
|
||||
* экспоненциальной задержкой и RESUME (op 3) с последним полученным `s`.
|
||||
*
|
||||
* Модуль намеренно не зависит от React и Zustand: он принимает колбэки,
|
||||
* поэтому его можно проверить юнит-тестом с подставным WebSocket.
|
||||
*/
|
||||
|
||||
export const GatewayOp = {
|
||||
DISPATCH: 0,
|
||||
HEARTBEAT: 1,
|
||||
IDENTIFY: 2,
|
||||
RESUME: 3,
|
||||
INVALID_SESSION: 4,
|
||||
HELLO: 10,
|
||||
HEARTBEAT_ACK: 11,
|
||||
RECONNECT: 12,
|
||||
} as const;
|
||||
|
||||
export const GATEWAY_CLOSE_INVALID_SESSION = 4000;
|
||||
|
||||
export interface GatewayHello {
|
||||
heartbeat_interval_ms: number;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
/** Сервер в снапшоте READY: каналы/роли/права приходят сразу. */
|
||||
export interface GatewayGuild {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
is_main: boolean;
|
||||
icon_file_id?: string;
|
||||
channels: Channel[];
|
||||
roles: Role[];
|
||||
member_ids: string[];
|
||||
my_role_ids: string[];
|
||||
my_nickname?: string;
|
||||
my_permissions?: string[];
|
||||
}
|
||||
|
||||
export interface GatewayUser {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_file_id?: string;
|
||||
is_instance_admin: boolean;
|
||||
badges: string[];
|
||||
status?: UserStatus;
|
||||
custom_status?: string;
|
||||
custom_status_emoji?: string;
|
||||
}
|
||||
|
||||
export interface GatewaySnapshot {
|
||||
user: GatewayUser;
|
||||
guilds: GatewayGuild[];
|
||||
dm_channels: Channel[];
|
||||
read_states: unknown[];
|
||||
session_id: string;
|
||||
heartbeat_interval_ms: number;
|
||||
}
|
||||
|
||||
export interface GatewayDispatch {
|
||||
op: 0;
|
||||
t: string;
|
||||
s: number;
|
||||
d: unknown;
|
||||
}
|
||||
|
||||
export type GatewayStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
|
||||
|
||||
export interface GatewayHandlers {
|
||||
/** Любое событие DISPATCH (READY, RESUMED и события гильдий/каналов). */
|
||||
onDispatch?: (event: GatewayDispatch) => void;
|
||||
onStatus?: (status: GatewayStatus) => void;
|
||||
/** Сессия недействительна — нужно разлогинить пользователя. */
|
||||
onInvalidSession?: () => void;
|
||||
/** RESUMED без снапшота: данные нужно перечитать по REST. */
|
||||
onResumeIncomplete?: () => void;
|
||||
}
|
||||
|
||||
export interface GatewayLogger {
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface GatewayConnectionOptions {
|
||||
url: string;
|
||||
handlers?: GatewayHandlers;
|
||||
/** Инъекция для тестов; по умолчанию берётся глобальный WebSocket. */
|
||||
socketFactory?: (url: string) => WebSocket;
|
||||
logger?: GatewayLogger;
|
||||
}
|
||||
|
||||
const DEFAULT_HEARTBEAT_MS = 45_000;
|
||||
const MAX_BACKOFF_MS = 30_000;
|
||||
const BASE_BACKOFF_MS = 1_000;
|
||||
|
||||
interface GatewayPacket {
|
||||
op: number;
|
||||
t?: string;
|
||||
s?: number;
|
||||
d?: unknown;
|
||||
}
|
||||
|
||||
function isGatewayPacket(value: unknown): value is GatewayPacket {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as { op?: unknown }).op === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
}
|
||||
|
||||
function parseChannel(value: unknown): Channel | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const id = asString(record['id']);
|
||||
const name = asString(record['name']);
|
||||
const type = asString(record['type']);
|
||||
if (id === undefined || name === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (type !== 'text' && type !== 'voice' && type !== 'category') {
|
||||
return null;
|
||||
}
|
||||
const channel: Channel = {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position: asNumber(record['position']) ?? 0,
|
||||
};
|
||||
const guildId = asString(record['guild_id']);
|
||||
const parentId = asString(record['parent_id']);
|
||||
const userLimit = asNumber(record['user_limit']);
|
||||
const slowmode = asNumber(record['slowmode_seconds']);
|
||||
if (guildId !== undefined) {
|
||||
channel.guild_id = guildId;
|
||||
}
|
||||
if (parentId !== undefined) {
|
||||
channel.parent_id = parentId;
|
||||
}
|
||||
if (userLimit !== undefined) {
|
||||
channel.user_limit = userLimit;
|
||||
}
|
||||
if (slowmode !== undefined) {
|
||||
channel.slowmode_seconds = slowmode;
|
||||
}
|
||||
if (typeof record['can_send'] === 'boolean') {
|
||||
channel.can_send = record['can_send'];
|
||||
}
|
||||
if (typeof record['can_connect'] === 'boolean') {
|
||||
channel.can_connect = record['can_connect'];
|
||||
}
|
||||
if (typeof record['can_view'] === 'boolean') {
|
||||
channel.can_view = record['can_view'];
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
function parseRole(value: unknown): Role | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const id = asString(record['id']);
|
||||
const name = asString(record['name']);
|
||||
if (id === undefined || name === undefined) {
|
||||
return null;
|
||||
}
|
||||
const permissions = record['permissions'];
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
color: asNumber(record['color']) ?? 0,
|
||||
position: asNumber(record['position']) ?? 0,
|
||||
permissions:
|
||||
typeof permissions === 'string'
|
||||
? permissions
|
||||
: typeof permissions === 'number'
|
||||
? String(permissions)
|
||||
: '0',
|
||||
is_default: record['is_default'] === true,
|
||||
hoist: record['hoist'] === true,
|
||||
mentionable: record['mentionable'] === true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGatewayUser(value: unknown): GatewayUser | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const id = asString(record['id']);
|
||||
const username = asString(record['username']);
|
||||
if (id === undefined || username === undefined) {
|
||||
return null;
|
||||
}
|
||||
const user: GatewayUser = {
|
||||
id,
|
||||
username,
|
||||
display_name: asString(record['display_name']) ?? username,
|
||||
is_instance_admin: record['is_instance_admin'] === true,
|
||||
badges: asStringArray(record['badges']),
|
||||
};
|
||||
const avatar = asString(record['avatar_file_id']);
|
||||
const status = asString(record['status']);
|
||||
const customStatus = asString(record['custom_status']);
|
||||
const customEmoji = asString(record['custom_status_emoji']);
|
||||
if (avatar !== undefined) {
|
||||
user.avatar_file_id = avatar;
|
||||
}
|
||||
if (status !== undefined) {
|
||||
user.status = status as UserStatus;
|
||||
}
|
||||
if (customStatus !== undefined) {
|
||||
user.custom_status = customStatus;
|
||||
}
|
||||
if (customEmoji !== undefined) {
|
||||
user.custom_status_emoji = customEmoji;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
function parseGuild(value: unknown): GatewayGuild | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const id = asString(record['id']);
|
||||
const name = asString(record['name']);
|
||||
if (id === undefined || name === undefined) {
|
||||
return null;
|
||||
}
|
||||
const channels = Array.isArray(record['channels'])
|
||||
? record['channels'].map(parseChannel).filter((item): item is Channel => item !== null)
|
||||
: [];
|
||||
const roles = Array.isArray(record['roles'])
|
||||
? record['roles'].map(parseRole).filter((item): item is Role => item !== null)
|
||||
: [];
|
||||
const guild: GatewayGuild = {
|
||||
id,
|
||||
name,
|
||||
owner_id: asString(record['owner_id']) ?? '',
|
||||
is_main: record['is_main'] === true,
|
||||
channels,
|
||||
roles,
|
||||
member_ids: asStringArray(record['member_ids']),
|
||||
my_role_ids: asStringArray(record['my_role_ids']),
|
||||
my_permissions: asStringArray(record['my_permissions']),
|
||||
};
|
||||
const icon = asString(record['icon_file_id']);
|
||||
const nickname = asString(record['my_nickname']);
|
||||
if (icon !== undefined) {
|
||||
guild.icon_file_id = icon;
|
||||
}
|
||||
if (nickname !== undefined) {
|
||||
guild.my_nickname = nickname;
|
||||
}
|
||||
return guild;
|
||||
}
|
||||
|
||||
/** Разбирает снапшот READY, отбрасывая всё, что не совпало с контрактом. */
|
||||
export function parseGatewaySnapshot(value: unknown): GatewaySnapshot | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const user = parseGatewayUser(record['user']);
|
||||
if (user === null) {
|
||||
return null;
|
||||
}
|
||||
const guilds = Array.isArray(record['guilds'])
|
||||
? record['guilds'].map(parseGuild).filter((item): item is GatewayGuild => item !== null)
|
||||
: [];
|
||||
const dmChannels = Array.isArray(record['dm_channels'])
|
||||
? record['dm_channels'].map(parseChannel).filter((item): item is Channel => item !== null)
|
||||
: [];
|
||||
return {
|
||||
user,
|
||||
guilds,
|
||||
dm_channels: dmChannels,
|
||||
read_states: Array.isArray(record['read_states']) ? record['read_states'] : [],
|
||||
session_id: asString(record['session_id']) ?? '',
|
||||
heartbeat_interval_ms: asNumber(record['heartbeat_interval_ms']) ?? DEFAULT_HEARTBEAT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export function gatewayUrl(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '/gateway';
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${window.location.host}/gateway`;
|
||||
}
|
||||
|
||||
export type GatewaySend = (packet: { op: number; d: unknown }) => void;
|
||||
|
||||
export class GatewayConnection {
|
||||
private readonly url: string;
|
||||
private readonly handlers: GatewayHandlers;
|
||||
private readonly socketFactory: (url: string) => WebSocket;
|
||||
private readonly logger: GatewayLogger;
|
||||
|
||||
private socket: WebSocket | null = null;
|
||||
private status: GatewayStatus = 'idle';
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private helloTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS;
|
||||
private awaitingAck = false;
|
||||
private attempt = 0;
|
||||
private sessionId: string | null = null;
|
||||
private lastSeq = 0;
|
||||
private closedByUser = false;
|
||||
|
||||
constructor(options: GatewayConnectionOptions) {
|
||||
this.url = options.url;
|
||||
this.handlers = options.handlers ?? {};
|
||||
this.logger = options.logger ?? { warn: () => undefined, error: () => undefined };
|
||||
this.socketFactory =
|
||||
options.socketFactory ??
|
||||
((url) => {
|
||||
if (typeof WebSocket === 'undefined') {
|
||||
throw new Error('WebSocket is not available in this environment');
|
||||
}
|
||||
return new WebSocket(url);
|
||||
});
|
||||
}
|
||||
|
||||
getStatus(): GatewayStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getSessionId(): string | null {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
getLastSeq(): number {
|
||||
return this.lastSeq;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
this.closedByUser = false;
|
||||
if (this.socket !== null) {
|
||||
return;
|
||||
}
|
||||
this.open(undefined);
|
||||
}
|
||||
|
||||
/** Плановое закрытие: без переподключения. */
|
||||
close(): void {
|
||||
this.closedByUser = true;
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.stopHeartbeat();
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
if (socket !== null) {
|
||||
socket.onopen = null;
|
||||
socket.onmessage = null;
|
||||
socket.onclose = null;
|
||||
socket.onerror = null;
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Сокет мог уже закрыться — это не ошибка.
|
||||
}
|
||||
}
|
||||
this.setStatus('disconnected');
|
||||
}
|
||||
|
||||
private open(reason: unknown): void {
|
||||
this.setStatus(this.attempt === 0 ? 'connecting' : 'reconnecting');
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
socket = this.socketFactory(this.url);
|
||||
} catch (error) {
|
||||
this.logger.warn(`gateway: cannot open socket (${String(error)})`);
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.socket = socket;
|
||||
if (reason !== undefined) {
|
||||
this.logger.warn(`gateway: reconnecting (${String(reason)})`);
|
||||
}
|
||||
|
||||
socket.onopen = () => {
|
||||
// Ждём HELLO; если его нет — соединение считаем нерабочим.
|
||||
this.helloTimeout = setTimeout(() => {
|
||||
this.logger.warn('gateway: HELLO timeout');
|
||||
this.dropSocket();
|
||||
}, 15_000);
|
||||
};
|
||||
|
||||
socket.onmessage = (event: MessageEvent) => {
|
||||
this.handleMessage(event.data);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
this.logger.warn('gateway: socket error');
|
||||
};
|
||||
|
||||
socket.onclose = (event: CloseEvent) => {
|
||||
const wasCurrent = this.socket === socket;
|
||||
this.socket = null;
|
||||
this.stopHeartbeat();
|
||||
if (!wasCurrent || this.closedByUser) {
|
||||
return;
|
||||
}
|
||||
if (event.code === GATEWAY_CLOSE_INVALID_SESSION) {
|
||||
this.setStatus('disconnected');
|
||||
this.handlers.onInvalidSession?.();
|
||||
return;
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
private dropSocket(): void {
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
this.stopHeartbeat();
|
||||
if (socket !== null) {
|
||||
socket.onclose = null;
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Игнорируем: сокет уже не нужен.
|
||||
}
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.closedByUser || this.reconnectTimer !== null) {
|
||||
return;
|
||||
}
|
||||
this.attempt += 1;
|
||||
const backoff = Math.min(BASE_BACKOFF_MS * 2 ** (this.attempt - 1), MAX_BACKOFF_MS);
|
||||
const jitter = Math.floor(Math.random() * 250);
|
||||
this.setStatus('reconnecting');
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.open('backoff');
|
||||
}, backoff + jitter);
|
||||
}
|
||||
|
||||
private setStatus(status: GatewayStatus): void {
|
||||
if (this.status === status) {
|
||||
return;
|
||||
}
|
||||
this.status = status;
|
||||
this.handlers.onStatus?.(status);
|
||||
}
|
||||
|
||||
private send(op: number, d: unknown): void {
|
||||
const socket = this.socket;
|
||||
if (socket === null || socket.readyState !== 1) {
|
||||
return;
|
||||
}
|
||||
socket.send(JSON.stringify({ op, d }));
|
||||
}
|
||||
|
||||
private handleMessage(raw: unknown): void {
|
||||
if (typeof raw !== 'string') {
|
||||
return;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
this.logger.warn('gateway: cannot parse frame');
|
||||
return;
|
||||
}
|
||||
if (!isGatewayPacket(parsed)) {
|
||||
return;
|
||||
}
|
||||
switch (parsed.op) {
|
||||
case GatewayOp.HELLO: {
|
||||
const data = asRecord(parsed.d);
|
||||
this.heartbeatIntervalMs =
|
||||
asNumber(data?.['heartbeat_interval_ms']) ?? DEFAULT_HEARTBEAT_MS;
|
||||
this.sessionId = asString(data?.['session_id']) ?? this.sessionId;
|
||||
if (this.helloTimeout !== null) {
|
||||
clearTimeout(this.helloTimeout);
|
||||
this.helloTimeout = null;
|
||||
}
|
||||
this.identify();
|
||||
this.startHeartbeat();
|
||||
return;
|
||||
}
|
||||
case GatewayOp.HEARTBEAT_ACK: {
|
||||
this.awaitingAck = false;
|
||||
return;
|
||||
}
|
||||
case GatewayOp.DISPATCH: {
|
||||
const seq = asNumber(parsed.s) ?? 0;
|
||||
if (seq > this.lastSeq) {
|
||||
this.lastSeq = seq;
|
||||
}
|
||||
const type = asString(parsed.t) ?? '';
|
||||
if (type === 'READY') {
|
||||
this.attempt = 0;
|
||||
}
|
||||
if (type === 'RESUMED' && parsed.d === null) {
|
||||
this.handlers.onResumeIncomplete?.();
|
||||
}
|
||||
this.setStatus('connected');
|
||||
this.handlers.onDispatch?.({
|
||||
op: 0,
|
||||
t: type,
|
||||
s: seq,
|
||||
d: parsed.d,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case GatewayOp.INVALID_SESSION: {
|
||||
const resumable = parsed.d === true;
|
||||
if (resumable) {
|
||||
this.logger.warn('gateway: invalid session, retrying resume');
|
||||
this.dropSocket();
|
||||
return;
|
||||
}
|
||||
this.sessionId = null;
|
||||
this.lastSeq = 0;
|
||||
this.closedByUser = true;
|
||||
this.setStatus('disconnected');
|
||||
this.handlers.onInvalidSession?.();
|
||||
return;
|
||||
}
|
||||
case GatewayOp.RECONNECT: {
|
||||
this.dropSocket();
|
||||
return;
|
||||
}
|
||||
default:
|
||||
// Неизвестные opcode игнорируем: контракт может расширяться.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private identify(): void {
|
||||
// Пустой токен допустим: браузер аутентифицируется cookie на рукопожатии.
|
||||
const token = '';
|
||||
if (this.sessionId !== null && this.lastSeq > 0) {
|
||||
this.send(GatewayOp.RESUME, { token, resume_seq: this.lastSeq });
|
||||
return;
|
||||
}
|
||||
this.send(GatewayOp.IDENTIFY, { token, resume_seq: 0 });
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
this.awaitingAck = false;
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.awaitingAck) {
|
||||
this.logger.warn('gateway: heartbeat was not acknowledged');
|
||||
this.dropSocket();
|
||||
return;
|
||||
}
|
||||
this.awaitingAck = true;
|
||||
this.send(GatewayOp.HEARTBEAT, null);
|
||||
}, this.heartbeatIntervalMs);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer !== null) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
if (this.helloTimeout !== null) {
|
||||
clearTimeout(this.helloTimeout);
|
||||
this.helloTimeout = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sharedConnection: GatewayConnection | null = null;
|
||||
|
||||
export function getGatewayConnection(): GatewayConnection | null {
|
||||
return sharedConnection;
|
||||
}
|
||||
|
||||
/** Создаёт (или переиспользует) единственное соединение с шлюзом. */
|
||||
export function connectGateway(options: {
|
||||
url?: string;
|
||||
handlers: GatewayHandlers;
|
||||
socketFactory?: (url: string) => WebSocket;
|
||||
}): GatewayConnection {
|
||||
if (sharedConnection !== null) {
|
||||
sharedConnection.close();
|
||||
}
|
||||
const socketFactory = options.socketFactory;
|
||||
sharedConnection = new GatewayConnection({
|
||||
url: options.url ?? gatewayUrl(),
|
||||
handlers: options.handlers,
|
||||
...(socketFactory === undefined ? {} : { socketFactory }),
|
||||
});
|
||||
sharedConnection.connect();
|
||||
return sharedConnection;
|
||||
}
|
||||
|
||||
export function disconnectGateway(): void {
|
||||
sharedConnection?.close();
|
||||
sharedConnection = null;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { request } from './client';
|
||||
import type { Channel, ChannelType, Guild, GuildMember, GuildSummary, Role } from './types';
|
||||
|
||||
export interface CreateChannelInput {
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
parent_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateGuildInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const guildQueryKey = (guildId: string) => ['guilds', guildId] as const;
|
||||
export const guildChannelsQueryKey = (guildId: string) => ['guilds', guildId, 'channels'] as const;
|
||||
export const guildMembersQueryKey = (guildId: string) => ['guilds', guildId, 'members'] as const;
|
||||
export const guildRolesQueryKey = (guildId: string) => ['guilds', guildId, 'roles'] as const;
|
||||
|
||||
export async function createGuild(name: string): Promise<GuildSummary> {
|
||||
const payload = await request<{ guild: GuildSummary }>('/guilds', {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
});
|
||||
return payload.guild;
|
||||
}
|
||||
|
||||
export async function fetchGuild(guildId: string, signal?: AbortSignal): Promise<Guild> {
|
||||
const payload = await request<{ guild: Guild }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.guild;
|
||||
}
|
||||
|
||||
export async function updateGuild(guildId: string, input: UpdateGuildInput): Promise<Guild> {
|
||||
const payload = await request<{ guild: Guild }>(`/guilds/${encodeURIComponent(guildId)}`, {
|
||||
method: 'PATCH',
|
||||
body: input,
|
||||
});
|
||||
return payload.guild;
|
||||
}
|
||||
|
||||
export async function fetchChannels(guildId: string, signal?: AbortSignal): Promise<Channel[]> {
|
||||
const payload = await request<{ channels: Channel[] }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/channels`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.channels;
|
||||
}
|
||||
|
||||
export async function createChannel(guildId: string, input: CreateChannelInput): Promise<Channel> {
|
||||
const payload = await request<{ channel: Channel }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/channels`,
|
||||
{ method: 'POST', body: input },
|
||||
);
|
||||
return payload.channel;
|
||||
}
|
||||
|
||||
export async function fetchMembers(guildId: string, signal?: AbortSignal): Promise<GuildMember[]> {
|
||||
const payload = await request<{ members: GuildMember[] }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/members`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.members;
|
||||
}
|
||||
|
||||
export async function fetchRoles(guildId: string, signal?: AbortSignal): Promise<Role[]> {
|
||||
const payload = await request<{ roles: Role[] }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/roles`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.roles;
|
||||
}
|
||||
|
||||
export function joinGuild(guildId: string): Promise<{ ok: true }> {
|
||||
return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/join`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function leaveGuild(guildId: string): Promise<{ ok: true }> {
|
||||
return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/leave`, { method: 'POST' });
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { request } from './client';
|
||||
import type { AuditEntry, InstanceGuild, InstanceInfo, InstanceUser } from './types';
|
||||
|
||||
export interface InstanceSettings {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const instanceQueryKey = ['instance'] as const;
|
||||
export const instanceSettingsQueryKey = ['instance', 'settings'] as const;
|
||||
export const instanceGuildsQueryKey = ['instance', 'guilds'] as const;
|
||||
export const instanceUsersQueryKey = ['instance', 'users'] as const;
|
||||
export const instanceAuditQueryKey = (limit: number) => ['instance', 'audit', limit] as const;
|
||||
|
||||
/** Публичная информация об инстансе: нужна на логине/регистрации и в /app. */
|
||||
export async function fetchInstance(signal?: AbortSignal): Promise<InstanceInfo> {
|
||||
const payload = await request<{ instance: InstanceInfo }>(
|
||||
'/instance',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.instance;
|
||||
}
|
||||
|
||||
export async function fetchInstanceSettings(signal?: AbortSignal): Promise<InstanceSettings> {
|
||||
const payload = await request<{ settings: InstanceSettings }>(
|
||||
'/instance/settings',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.settings;
|
||||
}
|
||||
|
||||
export async function updateInstanceSettings(
|
||||
settings: Partial<InstanceSettings>,
|
||||
): Promise<InstanceSettings> {
|
||||
const payload = await request<{ settings: InstanceSettings }>('/instance/settings', {
|
||||
method: 'PATCH',
|
||||
body: settings,
|
||||
});
|
||||
return payload.settings;
|
||||
}
|
||||
|
||||
export async function fetchInstanceGuilds(signal?: AbortSignal): Promise<InstanceGuild[]> {
|
||||
const payload = await request<{ guilds: InstanceGuild[] }>(
|
||||
'/instance/guilds',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.guilds;
|
||||
}
|
||||
|
||||
export async function fetchInstanceUsers(signal?: AbortSignal): Promise<InstanceUser[]> {
|
||||
const payload = await request<{ users: InstanceUser[] }>(
|
||||
'/instance/users',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.users;
|
||||
}
|
||||
|
||||
export async function fetchAudit(limit = 50, signal?: AbortSignal): Promise<AuditEntry[]> {
|
||||
const payload = await request<{ entries: AuditEntry[] }>(
|
||||
`/instance/audit?limit=${String(limit)}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.entries;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Общие типы API Фазы 1. Все структуры строгие: поля, которых нет в контракте,
|
||||
* не выдумываем, а необязательные помечаем `?` (на клиенте их может не быть
|
||||
* в зависимости от прав и состояния сервера).
|
||||
*/
|
||||
|
||||
/** Виды присутствия, которые сервер принимает в PATCH /users/@me. */
|
||||
export const userStatuses = ['online', 'idle', 'dnd', 'invisible', 'offline'] as const;
|
||||
export type UserStatus = (typeof userStatuses)[number];
|
||||
|
||||
/** Тип комнаты: текст, голосовой канал или категория. */
|
||||
export type ChannelType = 'text' | 'voice' | 'category';
|
||||
|
||||
/** Пользователь в объёме, достаточном для интерфейса (GET /users/{id}). */
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
bio: string;
|
||||
status: UserStatus;
|
||||
custom_status: string;
|
||||
custom_status_emoji: string;
|
||||
avatar_file_id?: string;
|
||||
banner_file_id?: string;
|
||||
is_instance_admin: boolean;
|
||||
badges: string[];
|
||||
locale: string;
|
||||
onboarding_completed: boolean;
|
||||
}
|
||||
|
||||
/** Урезанное представление пользователя (участники сервера, READY). */
|
||||
export interface UserSummary {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_file_id?: string;
|
||||
is_instance_admin: boolean;
|
||||
badges: string[];
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
user_agent: string;
|
||||
ip: string;
|
||||
created_at: string;
|
||||
last_seen: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
color: number;
|
||||
position: number;
|
||||
/** Битовая маска прав (строка — чтобы не терять старшие биты в JSON). */
|
||||
permissions: string;
|
||||
is_default: boolean;
|
||||
hoist: boolean;
|
||||
mentionable: boolean;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
guild_id?: string;
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
position: number;
|
||||
parent_id?: string;
|
||||
user_limit?: number;
|
||||
slowmode_seconds?: number;
|
||||
can_view?: boolean;
|
||||
can_send?: boolean;
|
||||
can_connect?: boolean;
|
||||
}
|
||||
|
||||
export interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon_file_id?: string;
|
||||
owner_id: string;
|
||||
is_main: boolean;
|
||||
description: string;
|
||||
member_count: number;
|
||||
roles: Role[];
|
||||
my_role_ids: string[];
|
||||
my_permissions: string[];
|
||||
}
|
||||
|
||||
/** Сервер в списке GET /users/@me/guilds. */
|
||||
export interface GuildSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
icon_file_id?: string;
|
||||
owner_id: string;
|
||||
is_main: boolean;
|
||||
member_count: number;
|
||||
my_role_ids: string[];
|
||||
my_permissions: string[];
|
||||
}
|
||||
|
||||
export interface GuildMember {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
nickname?: string;
|
||||
avatar_file_id?: string;
|
||||
status: UserStatus;
|
||||
is_instance_admin: boolean;
|
||||
joined_at: string;
|
||||
role_ids: string[];
|
||||
}
|
||||
|
||||
export interface InstanceInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
registration_enabled: boolean;
|
||||
allow_guild_creation: boolean;
|
||||
max_guilds_per_user: number;
|
||||
max_members_per_guild: number;
|
||||
max_message_length: number;
|
||||
main_guild_id?: string;
|
||||
user_count: number;
|
||||
guild_count: number;
|
||||
}
|
||||
|
||||
/** Сервер из админского списка GET /instance/guilds. */
|
||||
export interface InstanceGuild {
|
||||
id: string;
|
||||
name: string;
|
||||
member_count: number;
|
||||
owner_id: string;
|
||||
is_main: boolean;
|
||||
}
|
||||
|
||||
/** Пользователь из админского списка GET /instance/users. */
|
||||
export interface InstanceUser {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
is_instance_admin: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
actor_id: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
created_at: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function parsePermission(value: string): bigint | null {
|
||||
try {
|
||||
return BigInt(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { request } from './client';
|
||||
import type { GuildSummary, User, UserSummary } from './types';
|
||||
|
||||
export interface UpdateProfileInput {
|
||||
display_name?: string;
|
||||
bio?: string;
|
||||
status?: string;
|
||||
custom_status?: string;
|
||||
custom_status_emoji?: string;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export interface OnboardingInput {
|
||||
display_name?: string;
|
||||
bio?: string;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export const currentUserQueryKey = ['users', '@me'] as const;
|
||||
export const myGuildsQueryKey = ['users', '@me', 'guilds'] as const;
|
||||
|
||||
export function userQueryKey(userId: string) {
|
||||
return ['users', userId] as const;
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(signal?: AbortSignal): Promise<User> {
|
||||
const payload = await request<{ user: User }>(
|
||||
'/users/@me',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.user;
|
||||
}
|
||||
|
||||
export async function updateProfile(input: UpdateProfileInput): Promise<User> {
|
||||
const payload = await request<{ user: User }>('/users/@me', { method: 'PATCH', body: input });
|
||||
return payload.user;
|
||||
}
|
||||
|
||||
export async function completeOnboarding(input: OnboardingInput): Promise<User> {
|
||||
const payload = await request<{ user: User }>('/users/@me/onboarding/complete', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
return payload.user;
|
||||
}
|
||||
|
||||
export async function fetchMyGuilds(signal?: AbortSignal): Promise<GuildSummary[]> {
|
||||
const payload = await request<{ guilds: GuildSummary[] }>(
|
||||
'/users/@me/guilds',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.guilds;
|
||||
}
|
||||
|
||||
export async function fetchUser(userId: string, signal?: AbortSignal): Promise<UserSummary> {
|
||||
const payload = await request<{ user: UserSummary }>(
|
||||
`/users/${encodeURIComponent(userId)}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.user;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router';
|
||||
|
||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
|
||||
interface AuthGuardProps {
|
||||
/**
|
||||
* Если `true`, пользователей с незавершённым онбордингом пускаем внутрь
|
||||
* (нужно для /onboarding и /settings).
|
||||
*/
|
||||
allowIncompleteOnboarding?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Защита маршрутов: проверяет GET /users/@me.
|
||||
* Неавторизованных отправляет на /login, не прошедших онбординг — на /onboarding.
|
||||
*/
|
||||
export function AuthGuard({ allowIncompleteOnboarding = false }: AuthGuardProps) {
|
||||
const location = useLocation();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
if (currentUser.isPending) {
|
||||
return (
|
||||
<div className="flex min-h-full items-center justify-center p-6">
|
||||
<LoadingNotice />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentUser.isError) {
|
||||
if (isUnauthorized(currentUser.error)) {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center p-6">
|
||||
<ErrorNotice error={currentUser.error} onRetry={() => void currentUser.refetch()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const user = currentUser.data;
|
||||
if (!allowIncompleteOnboarding && !user.onboarding_completed) {
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
|
||||
return <Outlet context={{ user }} />;
|
||||
}
|
||||
|
||||
/** Коды, которые означают именно отсутствие сессии. */
|
||||
const unauthorizedCodes = new Set(['auth.unauthorized', 'auth.session_invalid', 'auth.required']);
|
||||
|
||||
/** 401/403 или один из «сессионных» кодов; остальное — сеть или ошибка сервера. */
|
||||
export function isUnauthorized(error: unknown): boolean {
|
||||
if (typeof error !== 'object' || error === null) {
|
||||
return false;
|
||||
}
|
||||
const candidate = error as { status?: unknown; code?: unknown };
|
||||
if (candidate.status === 401) {
|
||||
return true;
|
||||
}
|
||||
return typeof candidate.code === 'string' && unauthorizedCodes.has(candidate.code);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
|
||||
/**
|
||||
* Поднимает соединение со шлюзом, когда известно, кто вошёл в систему.
|
||||
* Живёт в корне приложения и переживает переходы между страницами:
|
||||
* состояние подключения хранится в zustand-сторе, а не в компоненте.
|
||||
*/
|
||||
export function GatewayBridge({ userId }: { userId: string | null }) {
|
||||
const status = useGatewayStore((state) => state.status);
|
||||
const connect = useGatewayStore((state) => state.connect);
|
||||
const disconnect = useGatewayStore((state) => state.disconnect);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId === null) {
|
||||
if (status === 'idle') {
|
||||
return;
|
||||
}
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
if (status !== 'idle') {
|
||||
return;
|
||||
}
|
||||
connect(userId);
|
||||
}, [userId, status, connect, disconnect]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { avatarUrl, hueFromId, initials } from '@/lib/identity';
|
||||
|
||||
interface AvatarProps {
|
||||
name: string;
|
||||
/** Стабильный id для оттенка заглушки. */
|
||||
seed: string;
|
||||
fileId?: string | undefined;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Квадратная заглушка для серверов, круглая — для пользователей. */
|
||||
shape?: 'circle' | 'square';
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'h-7 w-7 text-[11px]',
|
||||
md: 'h-9 w-9 text-xs',
|
||||
lg: 'h-14 w-14 text-lg',
|
||||
} as const;
|
||||
|
||||
/** Аватар: картинка файлового сервиса либо инициалы на цветной заглушке. */
|
||||
export function Avatar({ name, seed, fileId, size = 'md', shape = 'circle' }: AvatarProps) {
|
||||
const src = avatarUrl(fileId);
|
||||
const hue = hueFromId(seed);
|
||||
const radius = shape === 'circle' ? 'rounded-full' : 'rounded-[var(--radius-md)]';
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-flex shrink-0 items-center justify-center overflow-hidden font-semibold text-white ${radius} ${sizes[size]}`}
|
||||
style={src === null ? { backgroundColor: `oklch(0.55 0.13 ${String(hue)})` } : undefined}
|
||||
>
|
||||
{src === null ? (
|
||||
initials(name)
|
||||
) : (
|
||||
<img src={src} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
|
||||
interface ErrorNoticeProps {
|
||||
error: unknown;
|
||||
onRetry?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Сообщение об ошибке API с необязательной кнопкой повтора. */
|
||||
export function ErrorNotice({ error, onRetry, className = '' }: ErrorNoticeProps) {
|
||||
const { t } = useTranslation();
|
||||
const describe = useApiErrorMessage();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={`flex items-start justify-between gap-3 rounded-[var(--radius-md)] border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger ${className}`}
|
||||
>
|
||||
<p>{describe(error)}</p>
|
||||
{onRetry === undefined ? null : (
|
||||
<Button variant="ghost" onClick={onRetry}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Компактная строка состояния загрузки. */
|
||||
export function LoadingNotice({ label }: { label?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<p className="text-sm text-fg-muted" role="status">
|
||||
{label ?? t('common.loading')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
useId,
|
||||
type InputHTMLAttributes,
|
||||
type ReactNode,
|
||||
type TextareaHTMLAttributes,
|
||||
} from 'react';
|
||||
|
||||
interface FieldShellProps {
|
||||
label: string;
|
||||
hint?: string | undefined;
|
||||
error?: string | null | undefined;
|
||||
children: (ids: { id: string; describedBy: string | undefined }) => ReactNode;
|
||||
}
|
||||
|
||||
/** Общая обёртка поля: label, подсказка и сообщение об ошибке. */
|
||||
function FieldShell({ label, hint, error, children }: FieldShellProps) {
|
||||
const id = useId();
|
||||
const hintId = `${id}-hint`;
|
||||
const errorId = `${id}-error`;
|
||||
const describedBy =
|
||||
[hint === undefined ? null : hintId, error === null || error === undefined ? null : errorId]
|
||||
.filter((value): value is string => value !== null)
|
||||
.join(' ') || undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-fg" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
{children({ id, describedBy })}
|
||||
{hint === undefined ? null : (
|
||||
<p id={hintId} className="text-xs text-fg-muted">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
{error === null || error === undefined ? null : (
|
||||
<p id={errorId} className="text-xs text-danger" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const controlClass =
|
||||
'w-full rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-2 text-fg placeholder:text-fg-muted/70 disabled:opacity-60';
|
||||
|
||||
export type InputFieldProps = {
|
||||
label: string;
|
||||
hint?: string | undefined;
|
||||
error?: string | null | undefined;
|
||||
} & InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export function Field({ label, hint, error, className = '', ...rest }: InputFieldProps) {
|
||||
return (
|
||||
<FieldShell label={label} hint={hint} error={error}>
|
||||
{({ id, describedBy }) => (
|
||||
<input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={error === null || error === undefined ? undefined : true}
|
||||
className={`${controlClass} ${className}`}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
export type TextAreaFieldProps = {
|
||||
label: string;
|
||||
hint?: string | undefined;
|
||||
error?: string | null | undefined;
|
||||
} & TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
export function TextAreaField({ label, hint, error, className = '', ...rest }: TextAreaFieldProps) {
|
||||
return (
|
||||
<FieldShell label={label} hint={hint} error={error}>
|
||||
{({ id, describedBy }) => (
|
||||
<textarea
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={error === null || error === undefined ? undefined : true}
|
||||
className={`${controlClass} ${className}`}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectFieldProps {
|
||||
label: string;
|
||||
hint?: string | undefined;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SelectField({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className = '',
|
||||
}: SelectFieldProps) {
|
||||
return (
|
||||
<FieldShell label={label} hint={hint}>
|
||||
{({ id, describedBy }) => (
|
||||
<select
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
className={`${controlClass} ${className}`}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Простое модальное окно: закрывается по Escape и по клику на подложку,
|
||||
* focus-lock обеспечивается браузером за счёт role="dialog" и автофокуса.
|
||||
*/
|
||||
export function Modal({ open, title, onClose, children, footer }: ModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
const firstField = panelRef.current?.querySelector<HTMLElement>(
|
||||
'input, textarea, select, button',
|
||||
);
|
||||
firstField?.focus();
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="w-full max-w-md rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 p-5 shadow-[var(--shadow-3)]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className="rounded-[var(--radius-sm)] px-2 text-lg leading-none text-fg-muted hover:text-fg"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4">{children}</div>
|
||||
{footer === undefined ? null : <div className="mt-5 flex justify-end gap-2">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "glchat",
|
||||
"tagline": "Self-hosted communication: text, voice, video"
|
||||
"tagline": "Self-hosted communication: text, voice, video",
|
||||
"skipToContent": "Skip to content"
|
||||
},
|
||||
"status": {
|
||||
"title": "Foundation deployed",
|
||||
@@ -14,7 +15,11 @@
|
||||
"schemaOk": "ready",
|
||||
"maxUpload": "Maximum upload size",
|
||||
"checks": "Checks",
|
||||
"retry": "Retry"
|
||||
"retry": "Retry",
|
||||
"back": "Back",
|
||||
"users": "Users",
|
||||
"guilds": "Servers",
|
||||
"registration": "Registration"
|
||||
},
|
||||
"health": {
|
||||
"online": "Server is reachable",
|
||||
@@ -26,19 +31,305 @@
|
||||
"instance.not_ready": "The instance is still starting. Try again in a few seconds.",
|
||||
"internal.error": "Internal server error.",
|
||||
"not_found": "The requested resource was not found.",
|
||||
"unknown": "Unknown error ({{code}})."
|
||||
"unknown": "Unknown error ({{code}}).",
|
||||
"auth.2fa_required": "Enter the two-factor code from your authenticator app.",
|
||||
"auth.totp_invalid": "The two-factor code is incorrect.",
|
||||
"auth.invalid_credentials": "Wrong email or password.",
|
||||
"auth.step_up_required": "Confirm your identity: enter your password again.",
|
||||
"auth.guild_limit_reached": "You have reached the server limit on this instance.",
|
||||
"auth.session_invalid": "Your session has expired. Sign in again.",
|
||||
"auth.unauthorized": "Sign-in is required.",
|
||||
"auth.registration_disabled": "Registration is disabled on this instance.",
|
||||
"auth.username_taken": "This username is already taken.",
|
||||
"auth.email_taken": "This email is already registered.",
|
||||
"auth.weak_password": "The password is too weak. Use at least 8 characters.",
|
||||
"auth.too_many_attempts": "Too many attempts. Try again later.",
|
||||
"permissions.denied": "You do not have permission for this action.",
|
||||
"instance.admin_required": "This action is available to the instance admin only.",
|
||||
"guild.not_found": "Server not found.",
|
||||
"guild.member_limit_reached": "This server has reached its member limit.",
|
||||
"channel.not_found": "Channel not found.",
|
||||
"validation.failed": "Check the fields you filled in.",
|
||||
"rate.limited": "Too many requests. Please wait a moment."
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Light theme",
|
||||
"switchToDark": "Dark theme"
|
||||
"switchToDark": "Dark theme",
|
||||
"label": "Theme",
|
||||
"dark": "Dark",
|
||||
"light": "Light"
|
||||
},
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"ru": "Русский",
|
||||
"en": "English"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading…",
|
||||
"retry": "Retry",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"create": "Create",
|
||||
"creating": "Creating…",
|
||||
"join": "Join",
|
||||
"leave": "Leave",
|
||||
"back": "Back",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"optional": "optional",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"footer": {
|
||||
"source": "Source code",
|
||||
"license": "AGPL-3.0"
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"subtitle": "Sign in to keep talking",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"password": "Password",
|
||||
"totp": "2FA code",
|
||||
"totpHint": "Six digits from your authenticator app.",
|
||||
"submit": "Sign in",
|
||||
"submitting": "Signing in…",
|
||||
"noAccount": "No account yet?",
|
||||
"registerLink": "Create one",
|
||||
"forgot": "Forgot your password? Ask the instance admin.",
|
||||
"instanceStatus": "Instance status",
|
||||
"registrationDisabled": "Registration on this instance is disabled by the admin."
|
||||
},
|
||||
"register": {
|
||||
"title": "Create account",
|
||||
"subtitle": "Create an account on {{instance}}",
|
||||
"username": "Username",
|
||||
"usernameHint": "3–32 characters: latin letters, digits, “_” and “-”.",
|
||||
"displayName": "Display name",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"passwordHint": "At least 8 characters.",
|
||||
"requirements": "Password requirements: {{list}}",
|
||||
"requirementLength": "at least 8 characters",
|
||||
"requirementLetter": "at least one letter",
|
||||
"requirementDigit": "at least one digit",
|
||||
"submit": "Create account",
|
||||
"submitting": "Creating account…",
|
||||
"haveAccount": "Already have an account?",
|
||||
"loginLink": "Sign in",
|
||||
"disabledTitle": "Registration is closed",
|
||||
"disabledBody": "The instance admin disabled self-service registration. Ask for an invite or contact the admin."
|
||||
},
|
||||
"validation": {
|
||||
"usernameFormat": "Username: 3–32 characters, latin letters, digits, “_” or “-”.",
|
||||
"usernameRequired": "Enter a username.",
|
||||
"displayNameRequired": "Enter a display name.",
|
||||
"emailInvalid": "Enter a valid email address.",
|
||||
"passwordShort": "The password must be at least 8 characters long.",
|
||||
"passwordLetter": "Add at least one letter to the password.",
|
||||
"passwordDigit": "Add at least one digit to the password.",
|
||||
"passwordMismatch": "The passwords do not match."
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "Welcome to glchat",
|
||||
"greeting": "Hi, {{name}}!",
|
||||
"subtitle": "A couple of steps and you are ready.",
|
||||
"displayName": "How should we show you?",
|
||||
"displayNameHint": "Other members see this name.",
|
||||
"bio": "Tell us about yourself",
|
||||
"bioPlaceholder": "What you do, what you like…",
|
||||
"language": "Interface language",
|
||||
"theme": "Theme",
|
||||
"start": "Get started",
|
||||
"starting": "Preparing your workspace…",
|
||||
"skip": "Skip and start",
|
||||
"note": "You can change all of this later in settings."
|
||||
},
|
||||
"guilds": {
|
||||
"railLabel": "Servers",
|
||||
"listLabel": "Server list",
|
||||
"add": "Create a server",
|
||||
"addShort": "Create a server",
|
||||
"create": {
|
||||
"title": "Create a server",
|
||||
"name": "Server name",
|
||||
"namePlaceholder": "For example, “Friends”",
|
||||
"hint": "You can rename it later in the server settings.",
|
||||
"submit": "Create",
|
||||
"limit": "You can create at most {{count}} servers on this instance."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No servers yet",
|
||||
"body": "Join the instance main server or create your own.",
|
||||
"joinMain": "Join the main server",
|
||||
"joining": "Joining…",
|
||||
"noMain": "This instance has no main server configured — create your own."
|
||||
},
|
||||
"leave": "Leave server",
|
||||
"leaveConfirm": "Leave the server “{{name}}”?",
|
||||
"switchTo": "Open server {{name}}"
|
||||
},
|
||||
"channels": {
|
||||
"title": "Channels",
|
||||
"text": "Text channels",
|
||||
"voice": "Voice channels",
|
||||
"category": "Category",
|
||||
"empty": "This server has no visible channels yet.",
|
||||
"select": "Open channel {{name}}",
|
||||
"add": "Create a channel",
|
||||
"create": {
|
||||
"title": "New channel",
|
||||
"name": "Channel name",
|
||||
"namePlaceholder": "for example, general",
|
||||
"type": "Channel type",
|
||||
"typeText": "Text",
|
||||
"typeVoice": "Voice",
|
||||
"parent": "Category",
|
||||
"noParent": "No category",
|
||||
"submit": "Create"
|
||||
},
|
||||
"chatPlaceholder": {
|
||||
"title": "Chat arrives in Phase 2",
|
||||
"body": "The channel “{{name}}” is ready. Messages and voice arrive in the next phase."
|
||||
},
|
||||
"voicePlaceholder": {
|
||||
"title": "Voice channel",
|
||||
"body": "Connecting to voice arrives in the next phases."
|
||||
},
|
||||
"noSelection": {
|
||||
"title": "Pick a channel",
|
||||
"body": "The server and channel lists are on the left. Pick a channel to open it."
|
||||
},
|
||||
"notInGuild": {
|
||||
"title": "Server unavailable",
|
||||
"body": "Looks like you are not a member of this server."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"panelLabel": "User panel",
|
||||
"settings": "User settings",
|
||||
"logout": "Sign out",
|
||||
"loggingOut": "Signing out…",
|
||||
"statusLabel": "Status: {{status}}",
|
||||
"copyId": "Copy ID",
|
||||
"status": {
|
||||
"online": "Online",
|
||||
"idle": "Idle",
|
||||
"dnd": "Do not disturb",
|
||||
"invisible": "Invisible",
|
||||
"offline": "Offline"
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"label": "Connection",
|
||||
"status": {
|
||||
"idle": "Not connected",
|
||||
"connecting": "Connecting…",
|
||||
"connected": "Connected",
|
||||
"reconnecting": "Reconnecting…",
|
||||
"disconnected": "Connection lost"
|
||||
},
|
||||
"invalidated": "The server closed the session. Sign in again."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"back": "Back to app",
|
||||
"tabs": {
|
||||
"label": "Settings sections",
|
||||
"profile": "Profile",
|
||||
"security": "Security",
|
||||
"appearance": "Appearance",
|
||||
"instance": "Instance"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
"description": "How other members see you.",
|
||||
"displayName": "Display name",
|
||||
"bio": "About me",
|
||||
"bioPlaceholder": "A few words about you",
|
||||
"customStatus": "Custom status",
|
||||
"customStatusPlaceholder": "For example, “Writing code”",
|
||||
"customStatusEmoji": "Status emoji",
|
||||
"customStatusEmojiPlaceholder": "🚀",
|
||||
"status": "Presence status",
|
||||
"locale": "Interface language",
|
||||
"saved": "Profile saved.",
|
||||
"username": "Username",
|
||||
"userId": "User ID",
|
||||
"badges": "Badges",
|
||||
"noBadges": "none"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"description": "Password, active sessions and two-factor authentication.",
|
||||
"passwordTitle": "Change password",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"confirmPassword": "Repeat the new password",
|
||||
"submitPassword": "Change password",
|
||||
"passwordChanged": "Password changed.",
|
||||
"stepUpTitle": "Confirm your identity",
|
||||
"stepUpBody": "The server requires your password again for this action{{totp}}.",
|
||||
"stepUpTotp": " and a 2FA code",
|
||||
"stepUpSubmit": "Confirm",
|
||||
"stepUpSuccess": "Identity confirmed.",
|
||||
"stepUpExpired": "The confirmation expired, enter your password again.",
|
||||
"stepUpPassword": "Your password",
|
||||
"sessionsTitle": "Active sessions",
|
||||
"sessionsEmpty": "No active sessions.",
|
||||
"sessionCurrent": "current",
|
||||
"sessionCreated": "Created",
|
||||
"sessionSeen": "Last seen",
|
||||
"sessionIp": "IP",
|
||||
"sessionAgent": "Device",
|
||||
"unknownAgent": "Unknown device",
|
||||
"logoutAll": "Sign out everywhere",
|
||||
"logoutAllConfirm": "End all sessions, including this one?",
|
||||
"logoutAllDone": "All sessions have been ended.",
|
||||
"twoFactorTitle": "Two-factor authentication",
|
||||
"twoFactorEnabled": "2FA is on",
|
||||
"twoFactorDisabled": "2FA is off",
|
||||
"twoFactorEnable": "Enable 2FA",
|
||||
"twoFactorSetupHint": "Add the secret to your authenticator app, then enter the six-digit code.",
|
||||
"twoFactorSecret": "Secret",
|
||||
"twoFactorUri": "otpauth link",
|
||||
"twoFactorQrAlt": "QR code for 2FA setup",
|
||||
"twoFactorQrUnavailable": "The QR code is unavailable — add the secret manually.",
|
||||
"twoFactorCode": "Code from the app",
|
||||
"twoFactorConfirm": "Enable",
|
||||
"twoFactorEnabledDone": "2FA is enabled. Save your recovery codes.",
|
||||
"recoveryTitle": "Recovery codes",
|
||||
"recoveryHint": "Every code works once. They are shown only once.",
|
||||
"recoveryCopyAll": "Copy all codes"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"description": "Interface theme and language. Stored locally in your browser.",
|
||||
"theme": "Theme",
|
||||
"themeHint": "The dark theme is on by default.",
|
||||
"language": "Language",
|
||||
"languageHint": "Russian is the primary interface language."
|
||||
},
|
||||
"instance": {
|
||||
"title": "Instance",
|
||||
"description": "Instance admin only. Read-only data.",
|
||||
"notAdmin": "This section is available to the instance admin only.",
|
||||
"settings": "Instance settings",
|
||||
"settingsEmpty": "No settings configured.",
|
||||
"guilds": "Servers ({{count}})",
|
||||
"guildsEmpty": "No servers.",
|
||||
"users": "Users ({{count}})",
|
||||
"usersEmpty": "No users.",
|
||||
"audit": "Audit log (last {{count}})",
|
||||
"auditEmpty": "No entries.",
|
||||
"memberCount": "{{count}} members",
|
||||
"adminBadge": "admin",
|
||||
"mainBadge": "main"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "glchat",
|
||||
"tagline": "Self-hosted общение: текст, голос, видео"
|
||||
"tagline": "Self-hosted общение: текст, голос, видео",
|
||||
"skipToContent": "Перейти к содержимому"
|
||||
},
|
||||
"status": {
|
||||
"title": "Фундамент развёрнут",
|
||||
@@ -14,7 +15,11 @@
|
||||
"schemaOk": "готова",
|
||||
"maxUpload": "Максимальный размер загрузки",
|
||||
"checks": "Проверки",
|
||||
"retry": "Повторить"
|
||||
"retry": "Повторить",
|
||||
"back": "Назад",
|
||||
"users": "Пользователей",
|
||||
"guilds": "Серверов",
|
||||
"registration": "Регистрация"
|
||||
},
|
||||
"health": {
|
||||
"online": "Сервер доступен",
|
||||
@@ -26,19 +31,304 @@
|
||||
"instance.not_ready": "Инстанс ещё запускается. Повторите через несколько секунд.",
|
||||
"internal.error": "Внутренняя ошибка сервера.",
|
||||
"not_found": "Запрашиваемый ресурс не найден.",
|
||||
"unknown": "Неизвестная ошибка ({{code}})."
|
||||
"unknown": "Неизвестная ошибка ({{code}}).",
|
||||
"auth.2fa_required": "Введите код двухфакторной аутентификации из приложения.",
|
||||
"auth.totp_invalid": "Неверный код двухфакторной аутентификации.",
|
||||
"auth.invalid_credentials": "Неверная почта или пароль.",
|
||||
"auth.step_up_required": "Подтвердите личность: введите пароль заново.",
|
||||
"auth.guild_limit_reached": "Достигнут лимит серверов на этом инстансе.",
|
||||
"auth.session_invalid": "Сессия истекла. Войдите снова.",
|
||||
"auth.unauthorized": "Требуется вход в систему.",
|
||||
"auth.registration_disabled": "Регистрация на этом инстансе выключена.",
|
||||
"auth.username_taken": "Такое имя пользователя уже занято.",
|
||||
"auth.email_taken": "Такая почта уже зарегистрирована.",
|
||||
"auth.weak_password": "Пароль слишком простой. Используйте не менее 8 символов.",
|
||||
"auth.too_many_attempts": "Слишком много попыток. Попробуйте позже.",
|
||||
"permissions.denied": "Недостаточно прав для этого действия.",
|
||||
"instance.admin_required": "Действие доступно только администратору инстанса.",
|
||||
"guild.not_found": "Сервер не найден.",
|
||||
"guild.member_limit_reached": "На сервере достигнут лимит участников.",
|
||||
"channel.not_found": "Комната не найдена.",
|
||||
"validation.failed": "Проверьте заполненные поля.",
|
||||
"rate.limited": "Слишком много запросов. Подождите немного."
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Светлая тема",
|
||||
"switchToDark": "Тёмная тема"
|
||||
"switchToDark": "Тёмная тема",
|
||||
"label": "Тема",
|
||||
"dark": "Тёмная",
|
||||
"light": "Светлая"
|
||||
},
|
||||
"language": {
|
||||
"label": "Язык",
|
||||
"ru": "Русский",
|
||||
"en": "English"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка…",
|
||||
"retry": "Повторить",
|
||||
"cancel": "Отмена",
|
||||
"close": "Закрыть",
|
||||
"save": "Сохранить",
|
||||
"saving": "Сохраняем…",
|
||||
"create": "Создать",
|
||||
"creating": "Создаём…",
|
||||
"join": "Присоединиться",
|
||||
"leave": "Покинуть",
|
||||
"back": "Назад",
|
||||
"copy": "Скопировать",
|
||||
"copied": "Скопировано",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"optional": "необязательно",
|
||||
"dismiss": "Скрыть"
|
||||
},
|
||||
"footer": {
|
||||
"source": "Исходный код",
|
||||
"license": "AGPL-3.0"
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "Вход",
|
||||
"subtitle": "Войдите, чтобы продолжить общение",
|
||||
"email": "Почта",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"password": "Пароль",
|
||||
"totp": "Код 2FA",
|
||||
"totpHint": "Шесть цифр из приложения-аутентификатора.",
|
||||
"submit": "Войти",
|
||||
"submitting": "Входим…",
|
||||
"noAccount": "Ещё нет аккаунта?",
|
||||
"registerLink": "Зарегистрироваться",
|
||||
"forgot": "Пароль забыт? Обратитесь к администратору инстанса.",
|
||||
"instanceStatus": "Состояние инстанса",
|
||||
"registrationDisabled": "Регистрация на этом инстансе выключена администратором."
|
||||
},
|
||||
"register": {
|
||||
"title": "Регистрация",
|
||||
"subtitle": "Создайте аккаунт на инстансе {{instance}}",
|
||||
"username": "Имя пользователя",
|
||||
"usernameHint": "3–32 символа: латиница, цифры, «_» и «-».",
|
||||
"displayName": "Отображаемое имя",
|
||||
"email": "Почта",
|
||||
"password": "Пароль",
|
||||
"passwordHint": "Не менее 8 символов.",
|
||||
"requirements": "Требования к паролю: {{list}}",
|
||||
"requirementLength": "не менее 8 символов",
|
||||
"requirementLetter": "хотя бы одна буква",
|
||||
"requirementDigit": "хотя бы одна цифра",
|
||||
"submit": "Создать аккаунт",
|
||||
"submitting": "Создаём аккаунт…",
|
||||
"haveAccount": "Уже есть аккаунт?",
|
||||
"loginLink": "Войти",
|
||||
"disabledTitle": "Регистрация закрыта",
|
||||
"disabledBody": "Администратор инстанса выключил самостоятельную регистрацию. Попросите приглашение или обратитесь к администратору."
|
||||
},
|
||||
"validation": {
|
||||
"usernameFormat": "Имя пользователя: 3–32 символа, латиница, цифры, «_» или «-».",
|
||||
"usernameRequired": "Укажите имя пользователя.",
|
||||
"displayNameRequired": "Укажите отображаемое имя.",
|
||||
"emailInvalid": "Укажите корректный адрес почты.",
|
||||
"passwordShort": "Пароль должен быть не короче 8 символов.",
|
||||
"passwordLetter": "Добавьте в пароль хотя бы одну букву.",
|
||||
"passwordDigit": "Добавьте в пароль хотя бы одну цифру.",
|
||||
"passwordMismatch": "Пароли не совпадают."
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "Добро пожаловать в glchat",
|
||||
"greeting": "Привет, {{name}}!",
|
||||
"subtitle": "Пара шагов — и можно общаться.",
|
||||
"displayName": "Как вас показывать?",
|
||||
"displayNameHint": "Это имя видят другие участники.",
|
||||
"bio": "Расскажи о себе",
|
||||
"bioPlaceholder": "Чем занимаетесь, что интересно…",
|
||||
"language": "Язык интерфейса",
|
||||
"theme": "Тема",
|
||||
"start": "Начать",
|
||||
"starting": "Готовим рабочее пространство…",
|
||||
"skip": "Пропустить и начать",
|
||||
"note": "Позже всё это можно изменить в настройках."
|
||||
},
|
||||
"guilds": {
|
||||
"railLabel": "Серверы",
|
||||
"listLabel": "Список серверов",
|
||||
"add": "Создать сервер",
|
||||
"addShort": "Создать сервер",
|
||||
"create": {
|
||||
"title": "Создать сервер",
|
||||
"name": "Название сервера",
|
||||
"namePlaceholder": "Например, «Друзья»",
|
||||
"hint": "Название можно изменить позже в настройках сервера.",
|
||||
"submit": "Создать",
|
||||
"limit": "На инстансе можно создать не более {{count}} серверов."
|
||||
},
|
||||
"empty": {
|
||||
"title": "Пока нет ни одного сервера",
|
||||
"body": "Присоединитесь к главному серверу инстанса или создайте свой.",
|
||||
"joinMain": "Присоединиться к главному серверу",
|
||||
"joining": "Присоединяемся…",
|
||||
"noMain": "Главный сервер на инстансе не настроен — создайте свой."
|
||||
},
|
||||
"leave": "Покинуть сервер",
|
||||
"leaveConfirm": "Покинуть сервер «{{name}}»?",
|
||||
"switchTo": "Открыть сервер {{name}}"
|
||||
},
|
||||
"channels": {
|
||||
"title": "Комнаты",
|
||||
"text": "Текстовые комнаты",
|
||||
"voice": "Голосовые комнаты",
|
||||
"category": "Категория",
|
||||
"empty": "В этом сервере пока нет видимых комнат.",
|
||||
"select": "Открыть комнату {{name}}",
|
||||
"add": "Создать комнату",
|
||||
"create": {
|
||||
"title": "Новая комната",
|
||||
"name": "Название комнаты",
|
||||
"namePlaceholder": "например, общий-чат",
|
||||
"type": "Тип комнаты",
|
||||
"typeText": "Текстовая",
|
||||
"typeVoice": "Голосовая",
|
||||
"parent": "Категория",
|
||||
"noParent": "Без категории",
|
||||
"submit": "Создать"
|
||||
},
|
||||
"chatPlaceholder": {
|
||||
"title": "Чат появится в Фазе 2",
|
||||
"body": "Комната «{{name}}» уже доступна. Отправка сообщений и голосовые каналы появятся в следующей фазе."
|
||||
},
|
||||
"voicePlaceholder": {
|
||||
"title": "Голосовая комната",
|
||||
"body": "Подключение к голосу появится в следующих фазах."
|
||||
},
|
||||
"noSelection": {
|
||||
"title": "Выберите комнату",
|
||||
"body": "Слева — список серверов и комнат. Выберите комнату, чтобы открыть её."
|
||||
},
|
||||
"notInGuild": {
|
||||
"title": "Сервер недоступен",
|
||||
"body": "Похоже, вы не состоите в этом сервере."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"panelLabel": "Панель пользователя",
|
||||
"settings": "Настройки пользователя",
|
||||
"logout": "Выйти",
|
||||
"loggingOut": "Выходим…",
|
||||
"statusLabel": "Статус: {{status}}",
|
||||
"copyId": "Скопировать ID",
|
||||
"status": {
|
||||
"online": "В сети",
|
||||
"idle": "Неактивен",
|
||||
"dnd": "Не беспокоить",
|
||||
"invisible": "Невидимый",
|
||||
"offline": "Не в сети"
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"label": "Соединение",
|
||||
"status": {
|
||||
"idle": "Не подключено",
|
||||
"connecting": "Подключаемся…",
|
||||
"connected": "Подключено",
|
||||
"reconnecting": "Переподключение…",
|
||||
"disconnected": "Соединение потеряно"
|
||||
},
|
||||
"invalidated": "Сессия закрыта сервером. Войдите снова."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки",
|
||||
"back": "В приложение",
|
||||
"tabs": {
|
||||
"label": "Разделы настроек",
|
||||
"profile": "Профиль",
|
||||
"security": "Безопасность",
|
||||
"appearance": "Внешний вид",
|
||||
"instance": "Инстанс"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Профиль",
|
||||
"description": "Как вас видят другие участники.",
|
||||
"displayName": "Отображаемое имя",
|
||||
"bio": "О себе",
|
||||
"bioPlaceholder": "Пара слов о себе",
|
||||
"customStatus": "Своё состояние",
|
||||
"customStatusPlaceholder": "Например, «Пишу код»",
|
||||
"customStatusEmoji": "Эмодзи состояния",
|
||||
"customStatusEmojiPlaceholder": "🚀",
|
||||
"status": "Статус присутствия",
|
||||
"locale": "Язык интерфейса",
|
||||
"saved": "Профиль сохранён.",
|
||||
"username": "Имя пользователя",
|
||||
"userId": "ID пользователя",
|
||||
"badges": "Значки",
|
||||
"noBadges": "нет"
|
||||
},
|
||||
"security": {
|
||||
"title": "Безопасность",
|
||||
"description": "Пароль, активные сессии и двухфакторная аутентификация.",
|
||||
"passwordTitle": "Смена пароля",
|
||||
"currentPassword": "Текущий пароль",
|
||||
"newPassword": "Новый пароль",
|
||||
"confirmPassword": "Повторите новый пароль",
|
||||
"submitPassword": "Сменить пароль",
|
||||
"passwordChanged": "Пароль изменён.",
|
||||
"stepUpTitle": "Подтвердите личность",
|
||||
"stepUpBody": "Для этого действия сервер требует повторного ввода пароля{{totp}}.",
|
||||
"stepUpTotp": " и кода 2FA",
|
||||
"stepUpSubmit": "Подтвердить",
|
||||
"stepUpSuccess": "Личность подтверждена.",
|
||||
"stepUpExpired": "Подтверждение устарело, введите пароль заново.",
|
||||
"stepUpPassword": "Ваш пароль",
|
||||
"sessionsTitle": "Активные сессии",
|
||||
"sessionsEmpty": "Активных сессий нет.",
|
||||
"sessionCurrent": "текущая",
|
||||
"sessionCreated": "Создана",
|
||||
"sessionSeen": "Активность",
|
||||
"sessionIp": "IP",
|
||||
"sessionAgent": "Устройство",
|
||||
"unknownAgent": "Неизвестное устройство",
|
||||
"logoutAll": "Выйти на всех устройствах",
|
||||
"logoutAllConfirm": "Завершить все сессии, включая текущую?",
|
||||
"logoutAllDone": "Все сессии завершены.",
|
||||
"twoFactorTitle": "Двухфакторная аутентификация",
|
||||
"twoFactorEnabled": "2FA включена",
|
||||
"twoFactorDisabled": "2FA выключена",
|
||||
"twoFactorEnable": "Включить 2FA",
|
||||
"twoFactorSetupHint": "Добавьте секрет в приложение-аутентификатор, затем введите шестизначный код.",
|
||||
"twoFactorSecret": "Секрет",
|
||||
"twoFactorUri": "Ссылка otpauth",
|
||||
"twoFactorQrAlt": "QR-код для настройки 2FA",
|
||||
"twoFactorQrUnavailable": "QR-код недоступен — добавьте секрет вручную.",
|
||||
"twoFactorCode": "Код из приложения",
|
||||
"twoFactorConfirm": "Включить",
|
||||
"twoFactorEnabledDone": "2FA включена. Сохраните коды восстановления.",
|
||||
"recoveryTitle": "Коды восстановления",
|
||||
"recoveryHint": "Каждый код одноразовый. Они показываются только один раз.",
|
||||
"recoveryCopyAll": "Скопировать все коды"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Внешний вид",
|
||||
"description": "Тема и язык интерфейса. Хранятся локально в браузере.",
|
||||
"theme": "Тема",
|
||||
"themeHint": "Тёмная тема включена по умолчанию.",
|
||||
"language": "Язык",
|
||||
"languageHint": "Русский — основной язык интерфейса."
|
||||
},
|
||||
"instance": {
|
||||
"title": "Инстанс",
|
||||
"description": "Только для администратора инстанса. Данные только для чтения.",
|
||||
"notAdmin": "Раздел доступен только администратору инстанса.",
|
||||
"settings": "Настройки инстанса",
|
||||
"settingsEmpty": "Настройки не заданы.",
|
||||
"guilds": "Серверы ({{count}})",
|
||||
"guildsEmpty": "Серверов нет.",
|
||||
"users": "Пользователи ({{count}})",
|
||||
"usersEmpty": "Пользователей нет.",
|
||||
"audit": "Журнал аудита (последние {{count}})",
|
||||
"auditEmpty": "Записей нет.",
|
||||
"memberCount": "{{count}} участн.", "adminBadge": "админ",
|
||||
"mainBadge": "главный"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import { fetchInstance, instanceQueryKey } from '@/api/instance';
|
||||
import { fetchCurrentUser, currentUserQueryKey } from '@/api/users';
|
||||
import type { InstanceInfo, User } from '@/api/types';
|
||||
|
||||
/** Текущий пользователь: единственный источник правды о сессии (cookie). */
|
||||
export function useCurrentUser(): UseQueryResult<User> {
|
||||
return useQuery({
|
||||
queryKey: currentUserQueryKey,
|
||||
queryFn: ({ signal }) => fetchCurrentUser(signal),
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstance(): UseQueryResult<InstanceInfo> {
|
||||
return useQuery({
|
||||
queryKey: instanceQueryKey,
|
||||
queryFn: ({ signal }) => fetchInstance(signal),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Инициалы для аватара-заглушки: до двух первых букв значимых слов. */
|
||||
export function initials(name: string): string {
|
||||
const parts = name
|
||||
.trim()
|
||||
.split(/\s+/u)
|
||||
.filter((part) => part.length > 0);
|
||||
if (parts.length === 0) {
|
||||
return '?';
|
||||
}
|
||||
const first = parts[0] ?? '';
|
||||
if (parts.length === 1) {
|
||||
return [...first].slice(0, 2).join('').toLocaleUpperCase();
|
||||
}
|
||||
const second = parts[1] ?? '';
|
||||
return `${[...first][0] ?? ''}${[...second][0] ?? ''}`.toLocaleUpperCase();
|
||||
}
|
||||
|
||||
/** Стабильный оттенок для заглушки сервера/пользователя. */
|
||||
export function hueFromId(id: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < id.length; index += 1) {
|
||||
hash = (hash * 31 + id.charCodeAt(index)) % 360;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ссылка на файл аватара. Файловый сервис отдаётся тем же origin
|
||||
* (`/files/{id}`), поэтому отдельный хост не нужен.
|
||||
*/
|
||||
export function avatarUrl(fileId: string | undefined): string | null {
|
||||
return fileId === undefined || fileId === '' ? null : `/files/${encodeURIComponent(fileId)}`;
|
||||
}
|
||||
|
||||
/** Разбирает число прав из строковой битовой маски, не падая на мусоре. */
|
||||
export function parseBigInt(value: string | number | undefined): bigint {
|
||||
if (value === undefined) {
|
||||
return 0n;
|
||||
}
|
||||
try {
|
||||
return BigInt(value);
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
|
||||
/** Бит ADMINISTRATOR в маске прав (совпадает с серверной константой). */
|
||||
export const PERMISSION_ADMINISTRATOR = 1n << 3n;
|
||||
export const PERMISSION_MANAGE_GUILD = 1n << 5n;
|
||||
export const PERMISSION_MANAGE_CHANNELS = 1n << 6n;
|
||||
export const PERMISSION_OWNER = 1n << 62n;
|
||||
|
||||
/** Проверяет право в списке масок прав текущего пользователя. */
|
||||
export function hasPermission(permissions: readonly string[], mask: bigint): boolean {
|
||||
for (const value of permissions) {
|
||||
const parsed = parseBigInt(value);
|
||||
if (parsed === PERMISSION_OWNER || (parsed & mask) !== 0n) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Может ли пользователь управлять сервером (создавать комнаты и т.п.). */
|
||||
export function canManageGuild(
|
||||
permissions: readonly string[],
|
||||
ownerId: string,
|
||||
userId: string | undefined,
|
||||
): boolean {
|
||||
if (userId !== undefined && userId === ownerId) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
|
||||
hasPermission(permissions, PERMISSION_MANAGE_GUILD) ||
|
||||
hasPermission(permissions, PERMISSION_MANAGE_CHANNELS)
|
||||
);
|
||||
}
|
||||
|
||||
/** Человекочитаемая дата/время для списков (сессии, аудит). */
|
||||
export function formatDateTime(value: string, locale: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
/**
|
||||
* Единый QueryClient приложения. Живёт в модуле (а не в main.tsx), чтобы
|
||||
* не-React код — например стор шлюза — мог инвалидировать кэш REST-запросов.
|
||||
*/
|
||||
let client: QueryClient | null = null;
|
||||
|
||||
export function getQueryClient(): QueryClient {
|
||||
client ??= new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Только для тестов: подменяет общий клиент изолированным. */
|
||||
export function setQueryClient(next: QueryClient | null): void {
|
||||
client = next;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { errorCode } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* Переводит ошибку API по коду (`errors.<code>`), а неизвестные коды
|
||||
* показывает через `errors.unknown` — единый механизм для всего клиента.
|
||||
*/
|
||||
export function useApiErrorMessage(): (error: unknown) => string {
|
||||
const { t } = useTranslation();
|
||||
return (error: unknown): string => {
|
||||
const code = errorCode(error);
|
||||
const key = `errors.${code}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? t('errors.unknown', { code }) : translated;
|
||||
};
|
||||
}
|
||||
+3
-11
@@ -1,21 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { App } from '@/App';
|
||||
import { getQueryClient } from '@/lib/queryClient';
|
||||
import { applyTheme, useUiStore } from '@/stores/ui';
|
||||
import '@/i18n';
|
||||
import '@/index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
applyTheme(useUiStore.getState().theme);
|
||||
|
||||
const container = document.getElementById('root');
|
||||
@@ -25,7 +17,7 @@ if (!container) {
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={getQueryClient()}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import { login } from '@/api/auth';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field } from '@/components/ui/Field';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { getQueryClient } from '@/lib/queryClient';
|
||||
import { useInstance } from '@/lib/hooks';
|
||||
import { errorCode } from '@/lib/format';
|
||||
|
||||
interface LocationState {
|
||||
from?: string;
|
||||
}
|
||||
|
||||
/** Форма входа: поле кода 2FA появляется при ответе `auth.2fa_required`. */
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const instance = useInstance();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [needsTotp, setNeedsTotp] = useState(false);
|
||||
|
||||
const from = (location.state as LocationState | null)?.from ?? '/app';
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: () =>
|
||||
login(needsTotp ? { email, password, totp_code: totpCode } : { email, password }),
|
||||
onSuccess: async () => {
|
||||
// Профиль перечитываем заново: cookie уже выставлена сервером.
|
||||
await getQueryClient().invalidateQueries();
|
||||
void navigate(from === '/login' ? '/app' : from, { replace: true });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (errorCode(error) === 'auth.2fa_required') {
|
||||
setNeedsTotp(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
submit.mutate();
|
||||
};
|
||||
|
||||
const registrationEnabled = instance.data?.registration_enabled === true;
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('auth.login.title')}</h1>
|
||||
<p className="mt-1 text-fg-muted">{t('auth.login.subtitle')}</p>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
|
||||
<Field
|
||||
label={t('auth.login.email')}
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="username"
|
||||
placeholder={t('auth.login.emailPlaceholder')}
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={t('auth.login.password')}
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
{needsTotp ? (
|
||||
<Field
|
||||
label={t('auth.login.totp')}
|
||||
hint={t('auth.login.totpHint')}
|
||||
name="totp_code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]*"
|
||||
maxLength={8}
|
||||
value={totpCode}
|
||||
onChange={(event) => setTotpCode(event.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{submit.isError ? <ErrorNotice error={submit.error} /> : null}
|
||||
|
||||
<Button type="submit" disabled={submit.isPending}>
|
||||
{t(submit.isPending ? 'auth.login.submitting' : 'auth.login.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-sm text-fg-muted">
|
||||
{t('auth.login.noAccount')}{' '}
|
||||
{registrationEnabled ? (
|
||||
<Link className="text-accent underline" to="/register">
|
||||
{t('auth.login.registerLink')}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{t('auth.login.registrationDisabled')}</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-fg-muted">{t('auth.login.forgot')}</p>
|
||||
</Card>
|
||||
|
||||
<footer className="flex items-center justify-between text-xs text-fg-muted">
|
||||
<Link className="underline" to="/status">
|
||||
{t('auth.login.instanceStatus')}
|
||||
</Link>
|
||||
<span>{instance.data?.version ?? ''}</span>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { completeOnboarding, currentUserQueryKey } from '@/api/users';
|
||||
import type { OnboardingInput } from '@/api/users';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useUiStore, type Theme } from '@/stores/ui';
|
||||
|
||||
/** Первичная настройка: имя, «о себе», язык и тема, затем POST onboarding/complete. */
|
||||
export default function OnboardingPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const currentUser = useCurrentUser();
|
||||
const theme = useUiStore((state) => state.theme);
|
||||
const setTheme = useUiStore((state) => state.setTheme);
|
||||
const sessionUser = useSessionStore((state) => state.user);
|
||||
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [bio, setBio] = useState('');
|
||||
const [locale, setLocale] = useState<SupportedLanguage>(
|
||||
i18n.resolvedLanguage === 'en' ? 'en' : 'ru',
|
||||
);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
const user = currentUser.data;
|
||||
const suggestedName = user?.display_name ?? sessionUser?.display_name ?? user?.username ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (suggestedName !== '' && !touched) {
|
||||
setDisplayName((current) => (current === '' ? suggestedName : current));
|
||||
}
|
||||
}, [suggestedName, touched]);
|
||||
|
||||
useEffect(() => {
|
||||
const preferred = user?.locale;
|
||||
if (preferred === 'en' || preferred === 'ru') {
|
||||
setLocale(preferred);
|
||||
}
|
||||
}, [user?.locale]);
|
||||
|
||||
const complete = useMutation({
|
||||
mutationFn: (input: OnboardingInput) => completeOnboarding(input),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(currentUserQueryKey, updated);
|
||||
useSessionStore.getState().patchUser(updated);
|
||||
void navigate('/app', { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const changeLanguage = (language: SupportedLanguage): void => {
|
||||
setLocale(language);
|
||||
window.localStorage.setItem('glchat.language', language);
|
||||
void i18n.changeLanguage(language);
|
||||
};
|
||||
|
||||
const changeTheme = (next: Theme): void => {
|
||||
setTheme(next);
|
||||
};
|
||||
|
||||
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
setTouched(true);
|
||||
const input: OnboardingInput = { locale };
|
||||
const trimmedName = displayName.trim();
|
||||
const trimmedBio = bio.trim();
|
||||
if (trimmedName !== '') {
|
||||
input.display_name = trimmedName;
|
||||
}
|
||||
if (trimmedBio !== '') {
|
||||
input.bio = trimmedBio;
|
||||
}
|
||||
complete.mutate(input);
|
||||
};
|
||||
|
||||
const unknownUser = currentUser.isError && !complete.isSuccess;
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col justify-center gap-5 px-4 py-10">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{suggestedName === ''
|
||||
? t('onboarding.title')
|
||||
: t('onboarding.greeting', { name: suggestedName })}
|
||||
</h1>
|
||||
<p className="mt-1 text-fg-muted">{t('onboarding.subtitle')}</p>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
|
||||
<Field
|
||||
label={t('onboarding.displayName')}
|
||||
hint={t('onboarding.displayNameHint')}
|
||||
name="display_name"
|
||||
value={displayName}
|
||||
onChange={(event) => {
|
||||
setTouched(true);
|
||||
setDisplayName(event.target.value);
|
||||
}}
|
||||
maxLength={64}
|
||||
/>
|
||||
<TextAreaField
|
||||
label={t('onboarding.bio')}
|
||||
placeholder={t('onboarding.bioPlaceholder')}
|
||||
name="bio"
|
||||
rows={4}
|
||||
maxLength={512}
|
||||
value={bio}
|
||||
onChange={(event) => setBio(event.target.value)}
|
||||
/>
|
||||
<SelectField
|
||||
label={t('onboarding.language')}
|
||||
value={locale}
|
||||
onChange={(value) => changeLanguage(value === 'en' ? 'en' : 'ru')}
|
||||
options={supportedLanguages.map((language) => ({
|
||||
value: language,
|
||||
label: t(`language.${language}`),
|
||||
}))}
|
||||
/>
|
||||
<SelectField
|
||||
label={t('onboarding.theme')}
|
||||
value={theme}
|
||||
onChange={(value) => changeTheme(value === 'light' ? 'light' : 'dark')}
|
||||
options={[
|
||||
{ value: 'dark', label: t('theme.dark') },
|
||||
{ value: 'light', label: t('theme.light') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{unknownUser ? <ErrorNotice error={currentUser.error} /> : null}
|
||||
{complete.isError ? <ErrorNotice error={complete.error} /> : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-fg-muted">{t('onboarding.note')}</p>
|
||||
<Button type="submit" disabled={complete.isPending}>
|
||||
{t(complete.isPending ? 'onboarding.starting' : 'onboarding.start')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import { register } from '@/api/auth';
|
||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field, SelectField } from '@/components/ui/Field';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||
import { useInstance } from '@/lib/hooks';
|
||||
|
||||
const usernamePattern = /^[A-Za-z0-9_-]{3,32}$/u;
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
|
||||
|
||||
interface FormErrors {
|
||||
username?: string;
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/** Регистрация: клиентские проверки, требования к паролю и учёт флага инстанса. */
|
||||
export default function RegisterPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const instance = useInstance();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [locale, setLocale] = useState<SupportedLanguage>(
|
||||
i18n.resolvedLanguage === 'en' ? 'en' : 'ru',
|
||||
);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: () =>
|
||||
register({
|
||||
username,
|
||||
display_name: displayName,
|
||||
email,
|
||||
password,
|
||||
locale,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void navigate('/onboarding', { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const validate = (): FormErrors => {
|
||||
const next: FormErrors = {};
|
||||
if (!usernamePattern.test(username)) {
|
||||
next.username = t('auth.validation.usernameFormat');
|
||||
}
|
||||
if (displayName.trim().length === 0) {
|
||||
next.display_name = t('auth.validation.displayNameRequired');
|
||||
}
|
||||
if (!emailPattern.test(email)) {
|
||||
next.email = t('auth.validation.emailInvalid');
|
||||
}
|
||||
if (password.length < 8) {
|
||||
next.password = t('auth.validation.passwordShort');
|
||||
} else if (!/\p{L}/u.test(password)) {
|
||||
next.password = t('auth.validation.passwordLetter');
|
||||
} else if (!/\d/u.test(password)) {
|
||||
next.password = t('auth.validation.passwordDigit');
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
const next = validate();
|
||||
setErrors(next);
|
||||
if (Object.keys(next).length > 0) {
|
||||
return;
|
||||
}
|
||||
submit.mutate();
|
||||
};
|
||||
|
||||
if (instance.isPending) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center px-4 py-10">
|
||||
<LoadingNotice />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (instance.isError) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-4 px-4 py-10">
|
||||
<ErrorNotice error={instance.error} onRetry={() => void instance.refetch()} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!instance.data.registration_enabled) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
|
||||
<Card>
|
||||
<h1 className="text-xl font-semibold">{t('auth.register.disabledTitle')}</h1>
|
||||
<p className="mt-2 text-fg-muted">{t('auth.register.disabledBody')}</p>
|
||||
</Card>
|
||||
<Link className="text-sm text-accent underline" to="/login">
|
||||
{t('auth.register.loginLink')}
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('auth.register.title')}</h1>
|
||||
<p className="mt-1 text-fg-muted">
|
||||
{t('auth.register.subtitle', { instance: instance.data.name })}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
|
||||
<Field
|
||||
label={t('auth.register.username')}
|
||||
hint={t('auth.register.usernameHint')}
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
error={errors.username ?? null}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={t('auth.register.displayName')}
|
||||
name="display_name"
|
||||
autoComplete="nickname"
|
||||
value={displayName}
|
||||
error={errors.display_name ?? null}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={t('auth.register.email')}
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
error={errors.email ?? null}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={t('auth.register.password')}
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
error={errors.password ?? null}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('auth.register.requirements', {
|
||||
list: [
|
||||
t('auth.register.requirementLength'),
|
||||
t('auth.register.requirementLetter'),
|
||||
t('auth.register.requirementDigit'),
|
||||
].join(', '),
|
||||
})}
|
||||
</p>
|
||||
<SelectField
|
||||
label={t('language.label')}
|
||||
value={locale}
|
||||
onChange={(value) => setLocale(value === 'en' ? 'en' : 'ru')}
|
||||
options={supportedLanguages.map((language) => ({
|
||||
value: language,
|
||||
label: t(`language.${language}`),
|
||||
}))}
|
||||
/>
|
||||
|
||||
{submit.isError ? <ErrorNotice error={submit.error} /> : null}
|
||||
|
||||
<Button type="submit" disabled={submit.isPending}>
|
||||
{t(submit.isPending ? 'auth.register.submitting' : 'auth.register.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-sm text-fg-muted">
|
||||
{t('auth.register.haveAccount')}{' '}
|
||||
<Link className="text-accent underline" to="/login">
|
||||
{t('auth.register.loginLink')}
|
||||
</Link>
|
||||
</p>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { fetchReadiness, readyQueryKey } from '@/api/health';
|
||||
import { fetchMeta, metaQueryKey } from '@/api/meta';
|
||||
import { Badge, Button, Card } from '@/components/ui/primitives';
|
||||
import { StatBytes, StatRow } from '@/components/ui/StatRow';
|
||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
|
||||
/** Публичная страница состояния инстанса: мета, healthz/readyz, тема и язык. */
|
||||
export default function StatusPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const describeError = useApiErrorMessage();
|
||||
const theme = useUiStore((state) => state.theme);
|
||||
const toggleTheme = useUiStore((state) => state.toggleTheme);
|
||||
const locale = i18n.resolvedLanguage ?? 'ru';
|
||||
|
||||
const meta = useQuery({
|
||||
queryKey: metaQueryKey,
|
||||
queryFn: ({ signal }) => fetchMeta(signal),
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const readiness = useQuery({
|
||||
queryKey: readyQueryKey,
|
||||
queryFn: ({ signal }) => fetchReadiness(signal),
|
||||
retry: 1,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const online = readiness.isSuccess || meta.isSuccess;
|
||||
const schemaReady = readiness.data?.checks['database'] === 'ok';
|
||||
|
||||
const changeLanguage = (language: SupportedLanguage): void => {
|
||||
window.localStorage.setItem('glchat.language', language);
|
||||
void i18n.changeLanguage(language);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-4 py-10">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('app.name')}</h1>
|
||||
<p className="mt-1 text-fg-muted">{t('app.tagline')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge aria-live="polite" data-testid="connection-state">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-2 w-2 rounded-full ${online ? 'bg-success' : 'bg-danger'}`}
|
||||
/>
|
||||
{t(online ? 'health.online' : 'health.offline')}
|
||||
</Badge>
|
||||
<Button variant="ghost" onClick={toggleTheme}>
|
||||
{t(theme === 'dark' ? 'theme.switchToLight' : 'theme.switchToDark')}
|
||||
</Button>
|
||||
<label className="flex items-center gap-1 text-sm text-fg-muted">
|
||||
<span className="sr-only">{t('language.label')}</span>
|
||||
<select
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-fg"
|
||||
value={locale.startsWith('en') ? 'en' : 'ru'}
|
||||
onChange={(event) => changeLanguage(event.target.value === 'en' ? 'en' : 'ru')}
|
||||
>
|
||||
{supportedLanguages.map((language) => (
|
||||
<option key={language} value={language}>
|
||||
{t(`language.${language}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold">{t('status.title')}</h2>
|
||||
<p className="mt-1 text-fg-muted">{t('status.description')}</p>
|
||||
|
||||
{meta.isPending ? (
|
||||
<p className="mt-4 text-fg-muted" role="status">
|
||||
{t('status.connecting')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{meta.isError ? (
|
||||
<div className="mt-4 flex items-center justify-between gap-4" role="alert">
|
||||
<p className="text-danger">{describeError(meta.error)}</p>
|
||||
<Button variant="ghost" onClick={() => void meta.refetch()}>
|
||||
{t('status.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{meta.data ? (
|
||||
<dl className="mt-4 text-sm">
|
||||
<StatRow label={t('status.instance')} value={meta.data.name} />
|
||||
<StatRow label={t('status.version')} value={meta.data.version} />
|
||||
<StatRow label={t('status.apiVersion')} value={meta.data.api_version} />
|
||||
<StatRow
|
||||
label={t('status.schemaReady')}
|
||||
value={schemaReady ? t('status.schemaOk') : (readiness.data?.status ?? '…')}
|
||||
/>
|
||||
<StatRow
|
||||
label={t('status.maxUpload')}
|
||||
value={<StatBytes bytes={meta.data.max_upload_size} locale={locale} />}
|
||||
/>
|
||||
</dl>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link className="text-sm text-accent underline" to="/login">
|
||||
{t('auth.login.title')}
|
||||
</Link>
|
||||
<Link className="text-sm text-accent underline" to="/register">
|
||||
{t('auth.register.title')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<footer className="mt-auto flex items-center justify-between text-xs text-fg-muted">
|
||||
<span>
|
||||
{t('app.name')} · {t('footer.license')}
|
||||
</span>
|
||||
<span className="tabular-nums">{meta.data?.commit ?? ''}</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import { createGuild, fetchGuild, guildQueryKey } from '@/api/guilds';
|
||||
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||
import { instanceQueryKey } from '@/api/instance';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field } from '@/components/ui/Field';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import { useInstance } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { ChannelSidebar } from '@/pages/app/ChannelSidebar';
|
||||
import { GuildRail } from '@/pages/app/GuildRail';
|
||||
import { UserPanel } from '@/pages/app/UserPanel';
|
||||
import { errorCode } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* Оболочка приложения: рейка серверов, сайдбар комнат, контент и панель
|
||||
* пользователя. Данные серверов приходят из снапшота шлюза, а если шлюз ещё
|
||||
* не подключился — подстраховываемся REST-запросом `GET /users/@me/guilds`.
|
||||
*/
|
||||
export default function AppLayout() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const instance = useInstance();
|
||||
|
||||
const guilds = useSessionStore((state) => state.guilds);
|
||||
const selectedGuildId = useSessionStore((state) => state.selectedGuildId);
|
||||
const selectChannel = useSessionStore((state) => state.selectChannel);
|
||||
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [guildName, setGuildName] = useState('');
|
||||
|
||||
const segments = location.pathname.split('/').filter((segment) => segment !== '');
|
||||
const guildId = segments[1] === undefined ? null : decodeURIComponent(segments[1]);
|
||||
const channelId = segments[2] === undefined ? null : decodeURIComponent(segments[2]);
|
||||
|
||||
// URL — источник правды для выбранного сервера и комнаты.
|
||||
useEffect(() => {
|
||||
if (guildId === selectedGuildId) {
|
||||
return;
|
||||
}
|
||||
selectChannel(guildId, channelId);
|
||||
}, [guildId, channelId, selectedGuildId, selectChannel]);
|
||||
|
||||
const myGuilds = useQuery({
|
||||
queryKey: myGuildsQueryKey,
|
||||
queryFn: ({ signal }) => fetchMyGuilds(signal),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
// Список из снапшота шлюза; если он пуст — берём REST-ответ.
|
||||
useEffect(() => {
|
||||
if (guilds.length > 0 || myGuilds.data === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const guild of myGuilds.data) {
|
||||
upsertGuild(guild);
|
||||
}
|
||||
}, [guilds.length, myGuilds.data, upsertGuild]);
|
||||
|
||||
const guildInSnapshot = guildId !== null && guilds.some((guild) => guild.id === guildId);
|
||||
const fallbackGuild = useQuery({
|
||||
queryKey: guildQueryKey(guildId ?? ''),
|
||||
queryFn: ({ signal }) => fetchGuild(guildId ?? '', signal),
|
||||
enabled: guildId !== null && !guildInSnapshot,
|
||||
retry: 0,
|
||||
});
|
||||
const hydrated = useRef<string | null>(null);
|
||||
|
||||
// Сервер есть в URL, но отсутствует в снапшоте (например, после RESUME).
|
||||
useEffect(() => {
|
||||
const guild = fallbackGuild.data;
|
||||
if (guild === undefined || hydrated.current === guild.id) {
|
||||
return;
|
||||
}
|
||||
hydrated.current = guild.id;
|
||||
upsertGuild({
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
owner_id: guild.owner_id,
|
||||
is_main: guild.is_main,
|
||||
member_count: guild.member_count,
|
||||
my_role_ids: guild.my_role_ids,
|
||||
my_permissions: guild.my_permissions,
|
||||
...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }),
|
||||
});
|
||||
}, [fallbackGuild.data, upsertGuild]);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (name: string) => createGuild(name),
|
||||
onSuccess: (guild) => {
|
||||
setCreateOpen(false);
|
||||
setGuildName('');
|
||||
upsertGuild(guild);
|
||||
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
|
||||
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
|
||||
void navigate(`/app/${guild.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const closeCreate = useCallback(() => {
|
||||
setCreateOpen(false);
|
||||
}, []);
|
||||
|
||||
const guildList = useMemo(
|
||||
() => guilds.map((guild) => ({ id: guild.id, name: guild.name })),
|
||||
[guilds],
|
||||
);
|
||||
|
||||
const canCreate = instance.data?.allow_guild_creation !== false;
|
||||
const limitReached =
|
||||
instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-full flex-col">
|
||||
<a
|
||||
href="#app-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-2 focus:top-2 focus:z-50 focus:rounded-[var(--radius-sm)] focus:bg-surface-2 focus:px-3 focus:py-1"
|
||||
>
|
||||
{t('app.skipToContent')}
|
||||
</a>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<GuildRail
|
||||
guilds={guildList}
|
||||
selectedGuildId={guildId}
|
||||
canCreate={canCreate}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
/>
|
||||
<ChannelSidebar guildId={guildId} channelId={channelId} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
<UserPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title={t('guilds.create.title')}
|
||||
onClose={closeCreate}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={closeCreate}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => create.mutate(guildName.trim())}
|
||||
disabled={create.isPending || guildName.trim().length === 0}
|
||||
>
|
||||
{t(create.isPending ? 'common.creating' : 'guilds.create.submit')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (guildName.trim() !== '') {
|
||||
create.mutate(guildName.trim());
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label={t('guilds.create.name')}
|
||||
placeholder={t('guilds.create.namePlaceholder')}
|
||||
value={guildName}
|
||||
onChange={(event) => setGuildName(event.target.value)}
|
||||
maxLength={64}
|
||||
hint={t('guilds.create.hint')}
|
||||
/>
|
||||
{instance.data === undefined ? null : (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}
|
||||
</p>
|
||||
)}
|
||||
{limitReached && errorCode(create.error) === null ? (
|
||||
<p className="text-xs text-warning">{t('errors.auth.guild_limit_reached')}</p>
|
||||
) : null}
|
||||
{create.isError ? <ErrorNotice error={create.error} /> : null}
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useNavigate } from 'react-router';
|
||||
|
||||
import { createChannel, fetchChannels, guildChannelsQueryKey, leaveGuild } from '@/api/guilds';
|
||||
import { myGuildsQueryKey } from '@/api/users';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field, SelectField } from '@/components/ui/Field';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import { canManageGuild } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import type { Channel } from '@/api/types';
|
||||
|
||||
interface ChannelSidebarProps {
|
||||
guildId: string | null;
|
||||
channelId: string | null;
|
||||
}
|
||||
|
||||
interface ChannelGroup {
|
||||
category: Channel | null;
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
/** Группирует комнаты по категориям, сохраняя порядок `position`. */
|
||||
export function groupChannels(channels: Channel[]): ChannelGroup[] {
|
||||
const visible = channels
|
||||
.filter((channel) => channel.can_view !== false && channel.type !== 'category')
|
||||
.slice()
|
||||
.sort((left, right) => left.position - right.position);
|
||||
const categories = channels
|
||||
.filter((channel) => channel.type === 'category')
|
||||
.slice()
|
||||
.sort((left, right) => left.position - right.position);
|
||||
|
||||
const groups: ChannelGroup[] = [];
|
||||
const uncategorized = visible.filter((channel) => channel.parent_id === undefined);
|
||||
if (uncategorized.length > 0) {
|
||||
groups.push({ category: null, channels: uncategorized });
|
||||
}
|
||||
for (const category of categories) {
|
||||
const children = visible.filter((channel) => channel.parent_id === category.id);
|
||||
if (children.length > 0) {
|
||||
groups.push({ category, channels: children });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Сайдбар комнат выбранного сервера: категории, текстовые и голосовые комнаты. */
|
||||
export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
const guild = useSessionStore(
|
||||
useCallback((state) => state.guilds.find((item) => item.id === guildId) ?? null, [guildId]),
|
||||
);
|
||||
const setGuildChannels = useSessionStore((state) => state.setGuildChannels);
|
||||
const upsertChannel = useSessionStore((state) => state.upsertChannel);
|
||||
const removeGuild = useSessionStore((state) => state.removeGuild);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [channelName, setChannelName] = useState('');
|
||||
const [channelType, setChannelType] = useState<'text' | 'voice'>('text');
|
||||
const [parentId, setParentId] = useState('');
|
||||
|
||||
const channels = guild?.channels ?? [];
|
||||
const needsChannels = guildId !== null && guild !== null && channels.length === 0;
|
||||
|
||||
const remoteChannels = useQuery({
|
||||
queryKey: guildChannelsQueryKey(guildId ?? ''),
|
||||
queryFn: ({ signal }) => fetchChannels(guildId ?? '', signal),
|
||||
enabled: needsChannels,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (guildId === null || remoteChannels.data === undefined) {
|
||||
return;
|
||||
}
|
||||
setGuildChannels(guildId, remoteChannels.data);
|
||||
}, [guildId, remoteChannels.data, setGuildChannels]);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
createChannel(guildId ?? '', {
|
||||
name: channelName.trim(),
|
||||
type: channelType,
|
||||
...(parentId === '' ? {} : { parent_id: parentId }),
|
||||
}),
|
||||
onSuccess: (channel) => {
|
||||
if (guildId !== null) {
|
||||
upsertChannel(guildId, channel);
|
||||
}
|
||||
setCreateOpen(false);
|
||||
setChannelName('');
|
||||
void queryClient.invalidateQueries({ queryKey: guildChannelsQueryKey(guildId ?? '') });
|
||||
void navigate(`/app/${guildId ?? ''}/${channel.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const leave = useMutation({
|
||||
mutationFn: () => leaveGuild(guildId ?? ''),
|
||||
onSuccess: () => {
|
||||
if (guildId !== null) {
|
||||
removeGuild(guildId);
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
|
||||
void navigate('/app/empty', { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const groups = useMemo(() => groupChannels(channels), [channels]);
|
||||
const categories = channels.filter((channel) => channel.type === 'category');
|
||||
const canManage =
|
||||
guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id);
|
||||
|
||||
if (guildId === null) {
|
||||
return (
|
||||
<aside className="hidden w-60 shrink-0 border-r border-border/40 bg-surface-1 p-3 md:block" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label={t('channels.title')}
|
||||
className="flex w-60 shrink-0 flex-col border-r border-border/40 bg-surface-1"
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2 border-b border-border/40 px-3 py-3">
|
||||
<h1 className="truncate text-sm font-semibold" title={guild?.name}>
|
||||
{guild?.name ?? t('channels.notInGuild.title')}
|
||||
</h1>
|
||||
{canManage ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('channels.add')}
|
||||
title={t('channels.add')}
|
||||
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted hover:text-fg"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
||||
{guild === null ? (
|
||||
<p className="px-2 text-sm text-fg-muted">{t('channels.notInGuild.body')}</p>
|
||||
) : groups.length === 0 ? (
|
||||
<p className="px-2 text-sm text-fg-muted">{t('channels.empty')}</p>
|
||||
) : (
|
||||
<nav aria-label={t('channels.title')}>
|
||||
{groups.map((group) => (
|
||||
<section key={group.category?.id ?? 'uncategorized'} className="mb-3">
|
||||
{group.category === null ? null : (
|
||||
<h2 className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{group.category.name}
|
||||
</h2>
|
||||
)}
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{group.channels.map((channel) => (
|
||||
<li key={channel.id}>
|
||||
<NavLink
|
||||
to={`/app/${guildId}/${channel.id}`}
|
||||
aria-current={channel.id === channelId ? 'page' : undefined}
|
||||
title={channel.name}
|
||||
className={`flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm ${
|
||||
channel.id === channelId
|
||||
? 'bg-surface-3 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">{channel.type === 'voice' ? '🔊' : '#'}</span>
|
||||
<span className="truncate">{channel.name}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{remoteChannels.isError ? (
|
||||
<ErrorNotice
|
||||
className="mt-2"
|
||||
error={remoteChannels.error}
|
||||
onRetry={() => void remoteChannels.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-border/40 px-2 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
disabled={leave.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t('guilds.leaveConfirm', { name: guild?.name ?? '' }))) {
|
||||
leave.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('guilds.leave')}
|
||||
</Button>
|
||||
{leave.isError ? <ErrorNotice className="mt-2" error={leave.error} /> : null}
|
||||
</footer>
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title={t('channels.create.title')}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setCreateOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => create.mutate()}
|
||||
disabled={create.isPending || channelName.trim().length === 0}
|
||||
>
|
||||
{t(create.isPending ? 'common.creating' : 'channels.create.submit')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (channelName.trim() !== '') {
|
||||
create.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label={t('channels.create.name')}
|
||||
placeholder={t('channels.create.namePlaceholder')}
|
||||
value={channelName}
|
||||
onChange={(event) => setChannelName(event.target.value)}
|
||||
maxLength={64}
|
||||
/>
|
||||
<SelectField
|
||||
label={t('channels.create.type')}
|
||||
value={channelType}
|
||||
onChange={(value) => setChannelType(value === 'voice' ? 'voice' : 'text')}
|
||||
options={[
|
||||
{ value: 'text', label: t('channels.create.typeText') },
|
||||
{ value: 'voice', label: t('channels.create.typeVoice') },
|
||||
]}
|
||||
/>
|
||||
{categories.length === 0 ? null : (
|
||||
<SelectField
|
||||
label={t('channels.create.parent')}
|
||||
value={parentId}
|
||||
onChange={setParentId}
|
||||
options={[
|
||||
{ value: '', label: t('channels.create.noParent') },
|
||||
...categories.map((category) => ({
|
||||
value: category.id,
|
||||
label: category.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{create.isError ? <ErrorNotice error={create.error} /> : null}
|
||||
</form>
|
||||
</Modal>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink } from 'react-router';
|
||||
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
|
||||
interface GuildRailProps {
|
||||
guilds: { id: string; name: string }[];
|
||||
selectedGuildId: string | null;
|
||||
canCreate: boolean;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
/** Левая рейка: иконки серверов-инициалов и кнопка создания сервера. */
|
||||
export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: GuildRailProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<nav
|
||||
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"
|
||||
>
|
||||
<ul className="flex flex-col items-center gap-2" data-testid="guild-rail">
|
||||
{guilds.map((guild) => {
|
||||
const active = guild.id === selectedGuildId;
|
||||
return (
|
||||
<li key={guild.id}>
|
||||
<NavLink
|
||||
to={`/app/${guild.id}`}
|
||||
title={guild.name}
|
||||
aria-label={t('guilds.switchTo', { name: guild.name })}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`block rounded-[var(--radius-md)] transition-[border-radius,background-color] duration-[var(--duration-fast)] ${
|
||||
active
|
||||
? 'rounded-[var(--radius-lg)] ring-2 ring-accent'
|
||||
: 'hover:rounded-[var(--radius-lg)]'
|
||||
}`}
|
||||
>
|
||||
<Avatar name={guild.name} seed={guild.id} size="lg" shape="square" />
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{canCreate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
aria-label={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"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Область контента комнаты. В Фазе 1 — заглушка: сообщения, голос и видео
|
||||
* появятся в следующих фазах, но маршрутизация и выбор комнаты уже работают.
|
||||
*/
|
||||
export default function GuildView() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ guildId?: string; channelId?: string }>();
|
||||
const guild = useSessionStore((state) =>
|
||||
params.guildId === undefined
|
||||
? null
|
||||
: (state.guilds.find((item) => item.id === params.guildId) ?? null),
|
||||
);
|
||||
const channel = guild?.channels.find((item) => item.id === params.channelId) ?? null;
|
||||
|
||||
if (params.channelId === undefined || channel === null) {
|
||||
return (
|
||||
<section id="app-content" className="flex h-full items-center justify-center p-6">
|
||||
<Card className="max-w-md text-center">
|
||||
<h2 className="text-lg font-semibold">{t('channels.noSelection.title')}</h2>
|
||||
<p className="mt-2 text-sm text-fg-muted">{t('channels.noSelection.body')}</p>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const isVoice = channel.type === 'voice';
|
||||
|
||||
return (
|
||||
<section id="app-content" className="flex h-full flex-col">
|
||||
<header className="flex items-center gap-2 border-b border-border/40 px-4 py-3">
|
||||
<span aria-hidden="true" className="text-fg-muted">
|
||||
{isVoice ? '🔊' : '#'}
|
||||
</span>
|
||||
<h2 className="text-sm font-semibold">{channel.name}</h2>
|
||||
{channel.slowmode_seconds === undefined || channel.slowmode_seconds === 0 ? null : (
|
||||
<span className="text-xs text-fg-muted">slowmode {channel.slowmode_seconds}s</span>
|
||||
)}
|
||||
</header>
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<Card className="max-w-md text-center">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t(isVoice ? 'channels.voicePlaceholder.title' : 'channels.chatPlaceholder.title')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-fg-muted">
|
||||
{t(isVoice ? 'channels.voicePlaceholder.body' : 'channels.chatPlaceholder.body', {
|
||||
name: channel.name,
|
||||
})}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { joinGuild } from '@/api/guilds';
|
||||
import { instanceQueryKey } from '@/api/instance';
|
||||
import { myGuildsQueryKey } from '@/api/users';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { useInstance } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Экран пустого списка серверов: присоединиться к главному серверу инстанса
|
||||
* (id берём из `GET /instance`) или создать свой (кнопка есть в рейке).
|
||||
*/
|
||||
export default function NoGuildView() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const instance = useInstance();
|
||||
const guilds = useSessionStore((state) => state.guilds);
|
||||
|
||||
const mainGuildId = instance.data?.main_guild_id;
|
||||
const alreadyMember =
|
||||
mainGuildId !== undefined && guilds.some((guild) => guild.id === mainGuildId);
|
||||
|
||||
const join = useMutation({
|
||||
mutationFn: (guildId: string) => joinGuild(guildId),
|
||||
onSuccess: () => {
|
||||
// Список серверов обновится снапшотом шлюза; REST — подстраховка.
|
||||
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
|
||||
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<section id="app-content" className="flex h-full items-center justify-center p-6">
|
||||
<Card className="max-w-lg text-center">
|
||||
<h2 className="text-lg font-semibold">{t('guilds.empty.title')}</h2>
|
||||
<p className="mt-2 text-sm text-fg-muted">{t('guilds.empty.body')}</p>
|
||||
|
||||
{instance.isPending ? (
|
||||
<p className="mt-4 text-sm text-fg-muted" role="status">
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{mainGuildId !== undefined && !alreadyMember ? (
|
||||
<Button
|
||||
className="mt-4"
|
||||
disabled={join.isPending}
|
||||
onClick={() => join.mutate(mainGuildId)}
|
||||
>
|
||||
{t(join.isPending ? 'guilds.empty.joining' : 'guilds.empty.joinMain')}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{mainGuildId === undefined && !instance.isPending ? (
|
||||
<p className="mt-4 text-sm text-fg-muted">{t('guilds.empty.noMain')}</p>
|
||||
) : null}
|
||||
|
||||
{join.isError ? <ErrorNotice className="mt-3" error={join.error} /> : null}
|
||||
{instance.isError ? (
|
||||
<ErrorNotice
|
||||
className="mt-3"
|
||||
error={instance.error}
|
||||
onRetry={() => void instance.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import { logout } from '@/api/auth';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { userStatuses, type UserStatus } from '@/api/types';
|
||||
|
||||
/** Панель пользователя внизу: аватар, имя, статус, настройки и выход. */
|
||||
export function UserPanel() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const currentUser = useCurrentUser();
|
||||
const describeError = useApiErrorMessage();
|
||||
const sessionUser = useSessionStore((state) => state.user);
|
||||
const disconnect = useGatewayStore((state) => state.disconnect);
|
||||
|
||||
const name = sessionUser?.display_name ?? currentUser.data?.display_name ?? '';
|
||||
const username = sessionUser?.username ?? currentUser.data?.username ?? '';
|
||||
const status: UserStatus = sessionUser?.status ?? currentUser.data?.status ?? 'online';
|
||||
const customStatus = sessionUser?.custom_status ?? '';
|
||||
const emoji = sessionUser?.custom_status_emoji ?? '';
|
||||
const seed = currentUser.data?.id ?? sessionUser?.id ?? username;
|
||||
const avatarFileId = sessionUser?.avatar_file_id ?? currentUser.data?.avatar_file_id;
|
||||
|
||||
const signOut = useMutation({
|
||||
mutationFn: () => logout(),
|
||||
onSuccess: () => {
|
||||
// Сессия в cookie уже погашена — сбрасываем локальное состояние.
|
||||
disconnect();
|
||||
useSessionStore.getState().reset();
|
||||
queryClient.clear();
|
||||
void navigate('/login', { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const statusLabel = userStatuses.includes(status)
|
||||
? t(`user.status.${status}`)
|
||||
: t('user.status.offline');
|
||||
|
||||
const onCopyId = useCallback(() => {
|
||||
if (currentUser.data === undefined) {
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard?.writeText(currentUser.data.id);
|
||||
}, [currentUser.data]);
|
||||
|
||||
return (
|
||||
<footer
|
||||
aria-label={t('user.panelLabel')}
|
||||
className="flex items-center gap-2 border-t border-border/40 bg-surface-2/80 px-2 py-2"
|
||||
>
|
||||
<Avatar name={name === '' ? username : name} seed={seed} fileId={avatarFileId} size="md" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium" title={name}>
|
||||
{name === '' ? username : name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-fg-muted" title={statusLabel}>
|
||||
<span aria-hidden="true" className="mr-1">
|
||||
{emoji}
|
||||
</span>
|
||||
{customStatus === '' ? statusLabel : `${statusLabel} · ${customStatus}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('user.copyId')}
|
||||
title={t('user.copyId')}
|
||||
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
onClick={onCopyId}
|
||||
>
|
||||
#
|
||||
</button>
|
||||
<Link
|
||||
to="/settings"
|
||||
aria-label={t('user.settings')}
|
||||
title={t('user.settings')}
|
||||
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
⚙
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('user.logout')}
|
||||
title={t('user.logout')}
|
||||
disabled={signOut.isPending}
|
||||
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted hover:bg-surface-3 hover:text-fg disabled:opacity-50"
|
||||
onClick={() => signOut.mutate()}
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
{signOut.isError ? (
|
||||
<span role="alert" className="max-w-40 truncate text-xs text-danger">
|
||||
{describeError(signOut.error)}
|
||||
</span>
|
||||
) : null}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SelectField } from '@/components/ui/Field';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||
import { useUiStore, type Theme } from '@/stores/ui';
|
||||
|
||||
/** Внешний вид: тема и язык. Хранятся локально, без запросов к серверу. */
|
||||
export default function AppearanceSettingsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const theme = useUiStore((state) => state.theme);
|
||||
const setTheme = useUiStore((state) => state.setTheme);
|
||||
const locale = i18n.resolvedLanguage === 'en' ? 'en' : 'ru';
|
||||
|
||||
const changeLanguage = (language: SupportedLanguage): void => {
|
||||
window.localStorage.setItem('glchat.language', language);
|
||||
void i18n.changeLanguage(language);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold">{t('settings.appearance.title')}</h2>
|
||||
<p className="mt-1 text-sm text-fg-muted">{t('settings.appearance.description')}</p>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
<SelectField
|
||||
label={t('settings.appearance.theme')}
|
||||
hint={t('settings.appearance.themeHint')}
|
||||
value={theme}
|
||||
onChange={(value) => setTheme(value === 'light' ? 'light' : ('dark' as Theme))}
|
||||
options={[
|
||||
{ value: 'dark', label: t('theme.dark') },
|
||||
{ value: 'light', label: t('theme.light') },
|
||||
]}
|
||||
/>
|
||||
<SelectField
|
||||
label={t('settings.appearance.language')}
|
||||
hint={t('settings.appearance.languageHint')}
|
||||
value={locale}
|
||||
onChange={(value) => changeLanguage(value === 'en' ? 'en' : 'ru')}
|
||||
options={supportedLanguages.map((language) => ({
|
||||
value: language,
|
||||
label: t(`language.${language}`),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
fetchAudit,
|
||||
fetchInstanceGuilds,
|
||||
fetchInstanceSettings,
|
||||
fetchInstanceUsers,
|
||||
instanceAuditQueryKey,
|
||||
instanceGuildsQueryKey,
|
||||
instanceSettingsQueryKey,
|
||||
instanceUsersQueryKey,
|
||||
} from '@/api/instance';
|
||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { formatDateTime } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
|
||||
const AUDIT_LIMIT = 50;
|
||||
|
||||
/** Админский раздел инстанса: настройки, серверы, пользователи, аудит (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 settings = useQuery({
|
||||
queryKey: instanceSettingsQueryKey,
|
||||
queryFn: ({ signal }) => fetchInstanceSettings(signal),
|
||||
enabled: isAdmin,
|
||||
retry: 0,
|
||||
});
|
||||
const guilds = useQuery({
|
||||
queryKey: instanceGuildsQueryKey,
|
||||
queryFn: ({ signal }) => fetchInstanceGuilds(signal),
|
||||
enabled: isAdmin,
|
||||
retry: 0,
|
||||
});
|
||||
const users = useQuery({
|
||||
queryKey: instanceUsersQueryKey,
|
||||
queryFn: ({ signal }) => fetchInstanceUsers(signal),
|
||||
enabled: isAdmin,
|
||||
retry: 0,
|
||||
});
|
||||
const audit = useQuery({
|
||||
queryKey: instanceAuditQueryKey(AUDIT_LIMIT),
|
||||
queryFn: ({ signal }) => fetchAudit(AUDIT_LIMIT, signal),
|
||||
enabled: isAdmin,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold">{t('settings.instance.title')}</h2>
|
||||
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.notAdmin')}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const settingsEntries =
|
||||
settings.data === undefined ? [] : Object.entries(settings.data).slice(0, 30);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<header>
|
||||
<h2 className="text-lg font-semibold">{t('settings.instance.title')}</h2>
|
||||
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.description')}</p>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-base font-semibold">{t('settings.instance.settings')}</h3>
|
||||
{settings.isPending ? <LoadingNotice /> : null}
|
||||
{settings.isError ? (
|
||||
<ErrorNotice
|
||||
className="mt-3"
|
||||
error={settings.error}
|
||||
onRetry={() => void settings.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{!settings.isPending && !settings.isError && settingsEntries.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.settingsEmpty')}</p>
|
||||
) : null}
|
||||
{settingsEntries.length === 0 ? null : (
|
||||
<dl className="mt-3 text-sm">
|
||||
{settingsEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex justify-between gap-4 border-b border-border/40 py-2 last:border-b-0"
|
||||
>
|
||||
<dt className="text-fg-muted">{key}</dt>
|
||||
<dd className="max-w-[60%] truncate text-right font-medium" title={format(value)}>
|
||||
{format(value)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-base font-semibold">
|
||||
{t('settings.instance.guilds', { count: guilds.data?.length ?? 0 })}
|
||||
</h3>
|
||||
{guilds.isPending ? <LoadingNotice /> : null}
|
||||
{guilds.isError ? (
|
||||
<ErrorNotice
|
||||
className="mt-3"
|
||||
error={guilds.error}
|
||||
onRetry={() => void guilds.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{guilds.data !== undefined && guilds.data.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.guildsEmpty')}</p>
|
||||
) : null}
|
||||
{guilds.data === undefined || guilds.data.length === 0 ? null : (
|
||||
<ul className="mt-3 flex flex-col gap-1 text-sm" data-testid="instance-guilds">
|
||||
{guilds.data.map((guild) => (
|
||||
<li
|
||||
key={guild.id}
|
||||
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
|
||||
>
|
||||
<span className="truncate">{guild.name}</span>
|
||||
<span className="shrink-0 text-xs text-fg-muted">
|
||||
{guild.is_main ? `${t('settings.instance.mainBadge')} · ` : ''}
|
||||
{t('settings.instance.memberCount', { count: guild.member_count })}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-base font-semibold">
|
||||
{t('settings.instance.users', { count: users.data?.length ?? 0 })}
|
||||
</h3>
|
||||
{users.isPending ? <LoadingNotice /> : null}
|
||||
{users.isError ? (
|
||||
<ErrorNotice className="mt-3" error={users.error} onRetry={() => void users.refetch()} />
|
||||
) : null}
|
||||
{users.data !== undefined && users.data.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.usersEmpty')}</p>
|
||||
) : null}
|
||||
{users.data === undefined || users.data.length === 0 ? null : (
|
||||
<ul className="mt-3 flex flex-col gap-1 text-sm" data-testid="instance-users">
|
||||
{users.data.map((user) => (
|
||||
<li
|
||||
key={user.id}
|
||||
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
|
||||
>
|
||||
<span className="truncate">
|
||||
{user.display_name} <span className="text-fg-muted">@{user.username}</span>
|
||||
</span>
|
||||
{user.is_instance_admin ? (
|
||||
<span className="shrink-0 rounded-full border border-accent/50 px-2 text-xs text-accent">
|
||||
{t('settings.instance.adminBadge')}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-base font-semibold">
|
||||
{t('settings.instance.audit', { count: audit.data?.length ?? 0 })}
|
||||
</h3>
|
||||
{audit.isPending ? <LoadingNotice /> : null}
|
||||
{audit.isError ? (
|
||||
<ErrorNotice className="mt-3" error={audit.error} onRetry={() => void audit.refetch()} />
|
||||
) : null}
|
||||
{audit.data !== undefined && audit.data.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.auditEmpty')}</p>
|
||||
) : null}
|
||||
{audit.data === undefined || audit.data.length === 0 ? null : (
|
||||
<ul className="mt-3 flex flex-col gap-1 text-sm" data-testid="instance-audit">
|
||||
{audit.data.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
|
||||
>
|
||||
<span className="truncate">
|
||||
{entry.action}
|
||||
{entry.target === undefined ? '' : ` → ${entry.target}`}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-fg-muted">
|
||||
{formatDateTime(entry.created_at, locale)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function format(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useState, type FormEvent } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { changePassword, stepUp } from '@/api/auth';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field } from '@/components/ui/Field';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { errorCode } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* Смена пароля. Если сервер отвечает `auth.step_up_required`, показываем
|
||||
* форму подтверждения личности (пароль + код 2FA при необходимости) и после
|
||||
* успешного step-up повторяем смену пароля один раз.
|
||||
*/
|
||||
export function PasswordSection({ twoFactorEnabled }: { twoFactorEnabled: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [stepUpOpen, setStepUpOpen] = useState(false);
|
||||
const [stepUpPassword, setStepUpPassword] = useState('');
|
||||
const [stepUpCode, setStepUpCode] = useState('');
|
||||
const [changed, setChanged] = useState(false);
|
||||
|
||||
const change = useMutation({
|
||||
mutationFn: () => changePassword(currentPassword, newPassword),
|
||||
onSuccess: () => {
|
||||
setChanged(true);
|
||||
setStepUpOpen(false);
|
||||
setStepUpPassword('');
|
||||
setStepUpCode('');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (errorCode(error) === 'auth.step_up_required') {
|
||||
setStepUpOpen(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const confirmIdentity = useMutation({
|
||||
mutationFn: () => stepUp(stepUpPassword, twoFactorEnabled ? stepUpCode : undefined),
|
||||
onSuccess: () => {
|
||||
// Пароль подтверждён — повторяем исходное действие.
|
||||
change.mutate();
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
setChanged(false);
|
||||
if (newPassword.length < 8) {
|
||||
setFormError(t('auth.validation.passwordShort'));
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setFormError(t('auth.validation.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
setFormError(null);
|
||||
change.mutate();
|
||||
};
|
||||
|
||||
const closeStepUp = useCallback(() => {
|
||||
setStepUpOpen(false);
|
||||
}, []);
|
||||
|
||||
const stepUpRequired = errorCode(change.error) === 'auth.step_up_required';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold">{t('settings.security.passwordTitle')}</h2>
|
||||
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit} noValidate>
|
||||
<Field
|
||||
label={t('settings.security.currentPassword')}
|
||||
type="password"
|
||||
name="current_password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={t('settings.security.newPassword')}
|
||||
type="password"
|
||||
name="new_password"
|
||||
autoComplete="new-password"
|
||||
hint={t('auth.register.passwordHint')}
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={t('settings.security.confirmPassword')}
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
{formError === null ? null : (
|
||||
<p className="text-xs text-danger" role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
{change.isError && !stepUpRequired ? <ErrorNotice error={change.error} /> : null}
|
||||
{changed ? (
|
||||
<p className="text-sm text-success" role="status">
|
||||
{t('settings.security.passwordChanged')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={change.isPending}>
|
||||
{t(change.isPending ? 'common.saving' : 'settings.security.submitPassword')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{stepUpOpen ? (
|
||||
<div className="mt-5 rounded-[var(--radius-md)] border border-warning/50 bg-warning/10 p-4">
|
||||
<h3 className="text-base font-semibold">{t('settings.security.stepUpTitle')}</h3>
|
||||
<p className="mt-1 text-sm text-fg-muted">
|
||||
{t('settings.security.stepUpBody', {
|
||||
totp: twoFactorEnabled ? t('settings.security.stepUpTotp') : '',
|
||||
})}
|
||||
</p>
|
||||
<form
|
||||
className="mt-3 flex flex-col gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
confirmIdentity.mutate();
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label={t('settings.security.stepUpPassword')}
|
||||
type="password"
|
||||
name="step_up_password"
|
||||
autoComplete="current-password"
|
||||
value={stepUpPassword}
|
||||
onChange={(event) => setStepUpPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
{twoFactorEnabled ? (
|
||||
<Field
|
||||
label={t('auth.login.totp')}
|
||||
hint={t('auth.login.totpHint')}
|
||||
name="step_up_totp"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={8}
|
||||
value={stepUpCode}
|
||||
onChange={(event) => setStepUpCode(event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
{confirmIdentity.isError ? <ErrorNotice error={confirmIdentity.error} /> : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={closeStepUp}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={confirmIdentity.isPending || change.isPending}>
|
||||
{t(confirmIdentity.isPending ? 'common.saving' : 'settings.security.stepUpSubmit')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users';
|
||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { userStatuses } from '@/api/types';
|
||||
|
||||
/** Профиль: отображаемое имя, «о себе», своё состояние, статус и язык. */
|
||||
export default function ProfileSettingsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [bio, setBio] = useState('');
|
||||
const [customStatus, setCustomStatus] = useState('');
|
||||
const [customEmoji, setCustomEmoji] = useState('');
|
||||
const [status, setStatus] = useState<string>('online');
|
||||
const [locale, setLocale] = useState<SupportedLanguage>('ru');
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const user = currentUser.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (user === undefined) {
|
||||
return;
|
||||
}
|
||||
setDisplayName(user.display_name);
|
||||
setBio(user.bio);
|
||||
setCustomStatus(user.custom_status);
|
||||
setCustomEmoji(user.custom_status_emoji);
|
||||
setStatus(user.status);
|
||||
setLocale(user.locale === 'en' ? 'en' : 'ru');
|
||||
}, [user]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (input: UpdateProfileInput) => updateProfile(input),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(currentUserQueryKey, updated);
|
||||
useSessionStore.getState().patchUser(updated);
|
||||
setSaved(true);
|
||||
if (updated.locale === 'en' || updated.locale === 'ru') {
|
||||
window.localStorage.setItem('glchat.language', updated.locale);
|
||||
void i18n.changeLanguage(updated.locale);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
setSaved(false);
|
||||
save.mutate({
|
||||
display_name: displayName.trim(),
|
||||
bio: bio.trim(),
|
||||
custom_status: customStatus.trim(),
|
||||
custom_status_emoji: customEmoji.trim(),
|
||||
status,
|
||||
locale,
|
||||
});
|
||||
};
|
||||
|
||||
if (currentUser.isPending) {
|
||||
return (
|
||||
<Card>
|
||||
<LoadingNotice />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentUser.isError || user === undefined) {
|
||||
return (
|
||||
<Card>
|
||||
<ErrorNotice error={currentUser.error} onRetry={() => void currentUser.refetch()} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold">{t('settings.profile.title')}</h2>
|
||||
<p className="mt-1 text-sm text-fg-muted">{t('settings.profile.description')}</p>
|
||||
|
||||
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit} noValidate>
|
||||
<Field
|
||||
label={t('settings.profile.displayName')}
|
||||
name="display_name"
|
||||
value={displayName}
|
||||
maxLength={64}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
<TextAreaField
|
||||
label={t('settings.profile.bio')}
|
||||
placeholder={t('settings.profile.bioPlaceholder')}
|
||||
name="bio"
|
||||
rows={4}
|
||||
maxLength={512}
|
||||
value={bio}
|
||||
onChange={(event) => setBio(event.target.value)}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_6rem]">
|
||||
<Field
|
||||
label={t('settings.profile.customStatus')}
|
||||
placeholder={t('settings.profile.customStatusPlaceholder')}
|
||||
name="custom_status"
|
||||
maxLength={128}
|
||||
value={customStatus}
|
||||
onChange={(event) => setCustomStatus(event.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label={t('settings.profile.customStatusEmoji')}
|
||||
placeholder={t('settings.profile.customStatusEmojiPlaceholder')}
|
||||
name="custom_status_emoji"
|
||||
maxLength={8}
|
||||
value={customEmoji}
|
||||
onChange={(event) => setCustomEmoji(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
label={t('settings.profile.status')}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
options={userStatuses.map((value) => ({
|
||||
value,
|
||||
label: t(`user.status.${value}`),
|
||||
}))}
|
||||
/>
|
||||
<SelectField
|
||||
label={t('settings.profile.locale')}
|
||||
value={locale}
|
||||
onChange={(value) => setLocale(value === 'en' ? 'en' : 'ru')}
|
||||
options={supportedLanguages.map((language) => ({
|
||||
value: language,
|
||||
label: t(`language.${language}`),
|
||||
}))}
|
||||
/>
|
||||
|
||||
<dl className="text-sm text-fg-muted">
|
||||
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
|
||||
<dt>{t('settings.profile.username')}</dt>
|
||||
<dd className="font-medium text-fg">{user.username}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
|
||||
<dt>{t('settings.profile.userId')}</dt>
|
||||
<dd className="font-mono text-xs text-fg">{user.id}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 py-2">
|
||||
<dt>{t('settings.profile.badges')}</dt>
|
||||
<dd className="text-fg">
|
||||
{user.badges.length === 0 ? t('settings.profile.noBadges') : user.badges.join(', ')}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{save.isError ? <ErrorNotice error={save.error} /> : null}
|
||||
{saved && !save.isError ? (
|
||||
<p className="text-sm text-success" role="status">
|
||||
{t('settings.profile.saved')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={save.isPending}>
|
||||
{t(save.isPending ? 'common.saving' : 'common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PasswordSection } from '@/pages/settings/PasswordSection';
|
||||
import { SessionsSection } from '@/pages/settings/SessionsSection';
|
||||
import { TwoFactorSection } from '@/pages/settings/TwoFactorSection';
|
||||
|
||||
/**
|
||||
* Безопасность: смена пароля со step-up, список сессий с выходом на всех
|
||||
* устройствах и включение 2FA с показом секрета и кодов восстановления.
|
||||
*
|
||||
* Контракт Фазы 1 не отдаёт флаг включённой 2FA в `GET /users/@me`, поэтому
|
||||
* состояние «2FA включена» ведём локально: после успешного `2fa/enable`
|
||||
* формы запрашивают код подтверждения.
|
||||
*/
|
||||
export default function SecuritySettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<header>
|
||||
<h2 className="text-lg font-semibold">{t('settings.security.title')}</h2>
|
||||
<p className="mt-1 text-sm text-fg-muted">{t('settings.security.description')}</p>
|
||||
</header>
|
||||
<PasswordSection twoFactorEnabled={twoFactorEnabled} />
|
||||
<SessionsSection />
|
||||
<TwoFactorSection enabled={twoFactorEnabled} onEnabled={() => setTwoFactorEnabled(true)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { fetchSessions, logoutAll, sessionsQueryKey } from '@/api/auth';
|
||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { formatDateTime } from '@/lib/identity';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { getQueryClient } from '@/lib/queryClient';
|
||||
|
||||
/** Список активных сессий и выход на всех устройствах. */
|
||||
export function SessionsSection() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const locale = i18n.resolvedLanguage ?? 'ru';
|
||||
const disconnect = useGatewayStore((state) => state.disconnect);
|
||||
|
||||
const sessions = useQuery({
|
||||
queryKey: sessionsQueryKey,
|
||||
queryFn: ({ signal }) => fetchSessions(signal),
|
||||
});
|
||||
|
||||
const logoutAllMutation = useMutation({
|
||||
mutationFn: () => logoutAll(),
|
||||
onSuccess: () => {
|
||||
// Все сессии погашены, включая текущую: чистим состояние и уходим на вход.
|
||||
disconnect();
|
||||
useSessionStore.getState().reset();
|
||||
getQueryClient().clear();
|
||||
void navigate('/login', { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">{t('settings.security.sessionsTitle')}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={logoutAllMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t('settings.security.logoutAllConfirm'))) {
|
||||
logoutAllMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('settings.security.logoutAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{logoutAllMutation.isError ? (
|
||||
<ErrorNotice className="mt-3" error={logoutAllMutation.error} />
|
||||
) : null}
|
||||
|
||||
{sessions.isPending ? <LoadingNotice /> : null}
|
||||
{sessions.isError ? (
|
||||
<ErrorNotice
|
||||
className="mt-3"
|
||||
error={sessions.error}
|
||||
onRetry={() => void sessions.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{sessions.data !== undefined && sessions.data.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-fg-muted">{t('settings.security.sessionsEmpty')}</p>
|
||||
) : null}
|
||||
|
||||
{sessions.data !== undefined && sessions.data.length > 0 ? (
|
||||
<ul className="mt-3 flex flex-col gap-2" data-testid="sessions-list">
|
||||
{sessions.data.map((session) => (
|
||||
<li
|
||||
key={session.id}
|
||||
className="rounded-[var(--radius-md)] border border-border/60 bg-surface-2/60 p-3 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate font-medium" title={session.user_agent}>
|
||||
{session.user_agent === ''
|
||||
? t('settings.security.unknownAgent')
|
||||
: session.user_agent}
|
||||
</span>
|
||||
{session.current ? (
|
||||
<span className="shrink-0 rounded-full border border-success/50 px-2 text-xs text-success">
|
||||
{t('settings.security.sessionCurrent')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<dl className="mt-2 grid gap-1 text-xs text-fg-muted sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="inline">{t('settings.security.sessionIp')}: </dt>
|
||||
<dd className="inline">{session.ip}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline">{t('settings.security.sessionCreated')}: </dt>
|
||||
<dd className="inline">{formatDateTime(session.created_at, locale)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline">{t('settings.security.sessionSeen')}: </dt>
|
||||
<dd className="inline">{formatDateTime(session.last_seen, locale)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Suspense } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, Outlet } from 'react-router';
|
||||
|
||||
import { RouteFallback } from '@/App';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
|
||||
/** Каркас настроек: вкладки профиля, безопасности, внешнего вида и инстанса. */
|
||||
export default function SettingsLayout() {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
const tabs = [
|
||||
{ to: 'profile', label: t('settings.tabs.profile') },
|
||||
{ to: 'security', label: t('settings.tabs.security') },
|
||||
{ to: 'appearance', label: t('settings.tabs.appearance') },
|
||||
...(currentUser.data?.is_instance_admin === true
|
||||
? [{ to: 'instance', label: t('settings.tabs.instance') }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col gap-5 px-4 py-8">
|
||||
<header className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
|
||||
<NavLink className="text-sm text-accent underline" to="/app">
|
||||
{t('settings.back')}
|
||||
</NavLink>
|
||||
</header>
|
||||
|
||||
<nav aria-label={t('settings.tabs.label')} className="flex flex-wrap gap-2">
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className={({ isActive }) =>
|
||||
`rounded-[var(--radius-md)] border px-3 py-1.5 text-sm ${
|
||||
isActive
|
||||
? 'border-accent bg-surface-3 text-fg'
|
||||
: 'border-border/60 bg-surface-1 text-fg-muted hover:text-fg'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { enableTotp, setupTotp } from '@/api/auth';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field } from '@/components/ui/Field';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
|
||||
/**
|
||||
* Включение двухфакторной аутентификации: setup → секрет (и QR) → код →
|
||||
* одноразовые коды восстановления, которые показываются ровно один раз.
|
||||
*/
|
||||
export function TwoFactorSection({
|
||||
enabled,
|
||||
onEnabled,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
onEnabled: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [otpauthUrl, setOtpauthUrl] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||
const [qrFailed, setQrFailed] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const setup = useMutation({
|
||||
mutationFn: () => setupTotp(),
|
||||
onSuccess: (payload) => {
|
||||
setSecret(payload.secret);
|
||||
setOtpauthUrl(payload.otpauth_url);
|
||||
setRecoveryCodes(null);
|
||||
setQrFailed(false);
|
||||
},
|
||||
});
|
||||
|
||||
const enable = useMutation({
|
||||
mutationFn: () => enableTotp(code.trim()),
|
||||
onSuccess: (codes) => {
|
||||
setRecoveryCodes(codes);
|
||||
setSecret(null);
|
||||
setCode('');
|
||||
onEnabled();
|
||||
},
|
||||
});
|
||||
|
||||
const copyRecovery = (): void => {
|
||||
if (recoveryCodes === null) {
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard?.writeText(recoveryCodes.join('\n'));
|
||||
setCopied(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t('settings.security.twoFactorTitle')}</h2>
|
||||
<p className="mt-1 text-sm text-fg-muted">
|
||||
{t(
|
||||
enabled
|
||||
? 'settings.security.twoFactorEnabled'
|
||||
: 'settings.security.twoFactorDisabled',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{enabled || secret !== null ? null : (
|
||||
<Button disabled={setup.isPending} onClick={() => setup.mutate()}>
|
||||
{t('settings.security.twoFactorEnable')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{setup.isError ? <ErrorNotice className="mt-3" error={setup.error} /> : null}
|
||||
|
||||
{secret !== null ? (
|
||||
<div className="mt-4 flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-surface-2/60 p-3">
|
||||
<p className="text-sm text-fg-muted">{t('settings.security.twoFactorSetupHint')}</p>
|
||||
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
{qrFailed || otpauthUrl === '' ? (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('settings.security.twoFactorQrUnavailable')}
|
||||
</p>
|
||||
) : (
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(
|
||||
otpauthUrl,
|
||||
)}`}
|
||||
alt={t('settings.security.twoFactorQrAlt')}
|
||||
width={160}
|
||||
height={160}
|
||||
className="rounded-[var(--radius-sm)] bg-white p-1"
|
||||
onError={() => setQrFailed(true)}
|
||||
/>
|
||||
)}
|
||||
<dl className="text-sm">
|
||||
<dt className="text-fg-muted">{t('settings.security.twoFactorSecret')}</dt>
|
||||
<dd className="mt-1 font-mono text-xs break-all" data-testid="totp-secret">
|
||||
{secret}
|
||||
</dd>
|
||||
<dt className="mt-3 text-fg-muted">{t('settings.security.twoFactorUri')}</dt>
|
||||
<dd className="mt-1 font-mono text-xs break-all">{otpauthUrl}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (code.trim() !== '') {
|
||||
enable.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label={t('settings.security.twoFactorCode')}
|
||||
name="totp_code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]*"
|
||||
maxLength={8}
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
required
|
||||
/>
|
||||
{enable.isError ? <ErrorNotice error={enable.error} /> : null}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={enable.isPending || code.trim() === ''}>
|
||||
{t(enable.isPending ? 'common.saving' : 'settings.security.twoFactorConfirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{recoveryCodes !== null ? (
|
||||
<div className="mt-4 rounded-[var(--radius-md)] border border-success/50 bg-success/10 p-3">
|
||||
<p className="text-sm text-success" role="status">
|
||||
{t('settings.security.twoFactorEnabledDone')}
|
||||
</p>
|
||||
<h3 className="mt-2 text-sm font-semibold">{t('settings.security.recoveryTitle')}</h3>
|
||||
<p className="text-xs text-fg-muted">{t('settings.security.recoveryHint')}</p>
|
||||
<ul
|
||||
className="mt-2 grid grid-cols-2 gap-1 font-mono text-xs"
|
||||
data-testid="recovery-codes"
|
||||
>
|
||||
{recoveryCodes.map((recoveryCode) => (
|
||||
<li key={recoveryCode}>{recoveryCode}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button variant="ghost" onClick={copyRecovery}>
|
||||
{t(copied ? 'common.copied' : 'settings.security.recoveryCopyAll')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import {
|
||||
connectGateway,
|
||||
disconnectGateway,
|
||||
parseGatewaySnapshot,
|
||||
type GatewayDispatch,
|
||||
type GatewayStatus,
|
||||
type GatewayUser,
|
||||
} from '@/api/gateway';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Состояние соединения со шлюзом. Данные приходят в session-store,
|
||||
* здесь — только транспорт: статус, heartbeat, resume и переподключение.
|
||||
*/
|
||||
interface GatewayState {
|
||||
status: GatewayStatus;
|
||||
heartbeatIntervalMs: number | null;
|
||||
sessionId: string | null;
|
||||
attempts: number;
|
||||
/** Признак того, что после INVALID_SESSION нужно уйти на /login. */
|
||||
invalidated: boolean;
|
||||
connect: (userId: string) => void;
|
||||
disconnect: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const defaults = {
|
||||
status: 'idle' as GatewayStatus,
|
||||
heartbeatIntervalMs: null,
|
||||
sessionId: null,
|
||||
attempts: 0,
|
||||
invalidated: false,
|
||||
};
|
||||
|
||||
export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
...defaults,
|
||||
|
||||
connect: (userId) => {
|
||||
if (get().status !== 'idle') {
|
||||
return;
|
||||
}
|
||||
// userId пока не уходит на сервер (аутентификация — cookie на рукопожатии),
|
||||
// но фиксирует, что снапшот принадлежит именно этому пользователю.
|
||||
void userId;
|
||||
connectGateway({
|
||||
handlers: {
|
||||
onStatus: (status) => {
|
||||
set({
|
||||
status,
|
||||
attempts: status === 'reconnecting' ? get().attempts + 1 : get().attempts,
|
||||
});
|
||||
},
|
||||
onInvalidSession: () => {
|
||||
set({ invalidated: true, status: 'disconnected' });
|
||||
useSessionStore.getState().reset();
|
||||
void invalidateCurrentUser();
|
||||
},
|
||||
onResumeIncomplete: () => {
|
||||
// Сервер не прислал снапшот — перечитываем профиль по REST.
|
||||
void refetchCurrentUser();
|
||||
},
|
||||
onDispatch: (event) => {
|
||||
dispatchGatewayEvent(event);
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
disconnect: () => {
|
||||
disconnectGateway();
|
||||
set({ ...defaults });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
disconnectGateway();
|
||||
set({ ...defaults });
|
||||
},
|
||||
}));
|
||||
|
||||
/** Разбор события DISPATCH: в Фазе 1 обрабатываем READY/RESUMED, остальные — в диспетчер. */
|
||||
export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
const session = useSessionStore.getState();
|
||||
switch (event.t) {
|
||||
case 'READY': {
|
||||
const snapshot = parseGatewaySnapshot(event.d);
|
||||
if (snapshot === null) {
|
||||
return;
|
||||
}
|
||||
session.applyReady(snapshot);
|
||||
useGatewayStore.setState({
|
||||
status: 'connected',
|
||||
heartbeatIntervalMs: snapshot.heartbeat_interval_ms,
|
||||
sessionId: snapshot.session_id,
|
||||
attempts: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'RESUMED': {
|
||||
const user = parseResumedUser(event.d);
|
||||
session.applyResumed(user);
|
||||
useGatewayStore.setState({ status: 'connected', attempts: 0 });
|
||||
return;
|
||||
}
|
||||
// Заготовка диспетчера Фазы 2: события приходят, но пока не меняют UI.
|
||||
case 'GUILD_CREATE':
|
||||
case 'GUILD_UPDATE':
|
||||
case 'GUILD_DELETE':
|
||||
case 'CHANNEL_CREATE':
|
||||
case 'CHANNEL_UPDATE':
|
||||
case 'CHANNEL_DELETE':
|
||||
case 'MEMBER_UPDATE':
|
||||
case 'PRESENCE_UPDATE':
|
||||
return;
|
||||
default:
|
||||
// Неизвестные события игнорируем: контракт может расширяться.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function parseResumedUser(payload: unknown): GatewayUser | null {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return null;
|
||||
}
|
||||
const user = (payload as { user?: unknown }).user;
|
||||
const snapshot = parseGatewaySnapshot({ user, guilds: [] });
|
||||
return snapshot === null ? null : snapshot.user;
|
||||
}
|
||||
|
||||
/** Ленивые импорты разрывают цикл «стор → query-client → стор». */
|
||||
async function invalidateCurrentUser(): Promise<void> {
|
||||
const [{ getQueryClient }, { currentUserQueryKey }] = await Promise.all([
|
||||
import('@/lib/queryClient'),
|
||||
import('@/api/users'),
|
||||
]);
|
||||
// removeQueries вместо invalidate: сессия мертва, повторный запрос не нужен.
|
||||
getQueryClient().removeQueries({ queryKey: currentUserQueryKey });
|
||||
}
|
||||
|
||||
async function refetchCurrentUser(): Promise<void> {
|
||||
const [{ getQueryClient }, { currentUserQueryKey }] = await Promise.all([
|
||||
import('@/lib/queryClient'),
|
||||
import('@/api/users'),
|
||||
]);
|
||||
await getQueryClient().invalidateQueries({ queryKey: currentUserQueryKey });
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
|
||||
import type { Channel, GuildSummary, User } from '@/api/types';
|
||||
|
||||
/**
|
||||
* Снапшот реального времени (READY/RESUMED) живёт здесь, а не в TanStack
|
||||
* Query: Query отвечает за REST-данные (профиль, настройки, сессии), Zustand —
|
||||
* за состояние, которое приходит событиями шлюза.
|
||||
*/
|
||||
|
||||
/** Сервер снапшота: сводка для рейки + каналы/роли для сайдбара. */
|
||||
export interface SessionGuild extends GuildSummary {
|
||||
channels: Channel[];
|
||||
my_nickname?: string;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
user: GatewayUser | null;
|
||||
guilds: SessionGuild[];
|
||||
dmChannels: Channel[];
|
||||
sessionId: string | null;
|
||||
selectedGuildId: string | null;
|
||||
selectedChannelId: string | null;
|
||||
|
||||
applyReady: (snapshot: GatewaySnapshot) => void;
|
||||
/** RESUMED приходит без снапшота — обновляем только то, что пришло. */
|
||||
applyResumed: (user: GatewayUser | null) => void;
|
||||
setUser: (user: GatewayUser | null) => void;
|
||||
/** Синхронизация с REST-профилем (PATCH /users/@me возвращает полного user). */
|
||||
patchUser: (user: User) => void;
|
||||
upsertGuild: (guild: GuildSummary) => void;
|
||||
removeGuild: (guildId: string) => void;
|
||||
setGuildChannels: (guildId: string, channels: Channel[]) => void;
|
||||
upsertChannel: (guildId: string, channel: Channel) => void;
|
||||
selectGuild: (guildId: string | null) => void;
|
||||
selectChannel: (guildId: string | null, channelId: string | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
interface SessionDefaults {
|
||||
user: null;
|
||||
guilds: SessionGuild[];
|
||||
dmChannels: Channel[];
|
||||
sessionId: null;
|
||||
selectedGuildId: null;
|
||||
selectedChannelId: null;
|
||||
}
|
||||
|
||||
const defaults: SessionDefaults = {
|
||||
user: null,
|
||||
guilds: [],
|
||||
dmChannels: [],
|
||||
sessionId: null,
|
||||
selectedGuildId: null,
|
||||
selectedChannelId: null,
|
||||
};
|
||||
|
||||
function toSessionGuild(guild: GatewayGuild): SessionGuild {
|
||||
const summary: SessionGuild = {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
owner_id: guild.owner_id,
|
||||
is_main: guild.is_main,
|
||||
member_count: guild.member_ids.length,
|
||||
my_role_ids: guild.my_role_ids,
|
||||
my_permissions: guild.my_permissions ?? [],
|
||||
channels: guild.channels,
|
||||
};
|
||||
if (guild.icon_file_id !== undefined) {
|
||||
summary.icon_file_id = guild.icon_file_id;
|
||||
}
|
||||
if (guild.my_nickname !== undefined) {
|
||||
summary.my_nickname = guild.my_nickname;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
...defaults,
|
||||
|
||||
applyReady: (snapshot) => {
|
||||
const guilds = snapshot.guilds.map(toSessionGuild);
|
||||
const previous = get();
|
||||
// Сохраняем выбор пользователя, если он ещё существует в снапшоте.
|
||||
const selectedGuildId =
|
||||
previous.selectedGuildId !== null && guilds.some((g) => g.id === previous.selectedGuildId)
|
||||
? previous.selectedGuildId
|
||||
: null;
|
||||
const channels =
|
||||
selectedGuildId === null
|
||||
? []
|
||||
: (guilds.find((g) => g.id === selectedGuildId)?.channels ?? []);
|
||||
const selectedChannelId =
|
||||
previous.selectedChannelId !== null &&
|
||||
channels.some((c) => c.id === previous.selectedChannelId)
|
||||
? previous.selectedChannelId
|
||||
: null;
|
||||
set({
|
||||
user: snapshot.user,
|
||||
guilds,
|
||||
dmChannels: snapshot.dm_channels,
|
||||
sessionId: snapshot.session_id,
|
||||
selectedGuildId,
|
||||
selectedChannelId,
|
||||
});
|
||||
},
|
||||
|
||||
applyResumed: (user) => {
|
||||
if (user !== null) {
|
||||
set({ user });
|
||||
}
|
||||
},
|
||||
|
||||
setUser: (user) => {
|
||||
set({ user });
|
||||
},
|
||||
|
||||
patchUser: (user) => {
|
||||
// PATCH /users/@me возвращает полный профиль — переносим его в снапшот,
|
||||
// сохраняя открытые шлюзом поля присутствия, если сервер их не вернул.
|
||||
const current = get().user;
|
||||
const next: GatewayUser = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_instance_admin: user.is_instance_admin,
|
||||
badges: user.badges,
|
||||
status: user.status,
|
||||
custom_status: user.custom_status,
|
||||
custom_status_emoji: user.custom_status_emoji,
|
||||
};
|
||||
const avatar = user.avatar_file_id ?? current?.avatar_file_id;
|
||||
if (avatar !== undefined) {
|
||||
next.avatar_file_id = avatar;
|
||||
}
|
||||
set({ user: next });
|
||||
},
|
||||
|
||||
upsertGuild: (guild) => {
|
||||
const guilds = get().guilds;
|
||||
const index = guilds.findIndex((item) => item.id === guild.id);
|
||||
if (index === -1) {
|
||||
set({ guilds: [...guilds, { ...guild, channels: [] }] });
|
||||
return;
|
||||
}
|
||||
const existing = guilds[index];
|
||||
if (existing === undefined) {
|
||||
return;
|
||||
}
|
||||
const merged: SessionGuild = { ...existing, ...guild, channels: existing.channels };
|
||||
const next = [...guilds];
|
||||
next[index] = merged;
|
||||
set({ guilds: next });
|
||||
},
|
||||
|
||||
removeGuild: (guildId) => {
|
||||
const state = get();
|
||||
set({
|
||||
guilds: state.guilds.filter((guild) => guild.id !== guildId),
|
||||
selectedGuildId: state.selectedGuildId === guildId ? null : state.selectedGuildId,
|
||||
selectedChannelId: state.selectedGuildId === guildId ? null : state.selectedChannelId,
|
||||
});
|
||||
},
|
||||
|
||||
setGuildChannels: (guildId, channels) => {
|
||||
set({
|
||||
guilds: get().guilds.map((guild) => (guild.id === guildId ? { ...guild, channels } : guild)),
|
||||
});
|
||||
},
|
||||
|
||||
upsertChannel: (guildId, channel) => {
|
||||
set({
|
||||
guilds: get().guilds.map((guild) => {
|
||||
if (guild.id !== guildId) {
|
||||
return guild;
|
||||
}
|
||||
const index = guild.channels.findIndex((item) => item.id === channel.id);
|
||||
if (index === -1) {
|
||||
return { ...guild, channels: [...guild.channels, channel] };
|
||||
}
|
||||
const channels = [...guild.channels];
|
||||
channels[index] = channel;
|
||||
return { ...guild, channels };
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
selectGuild: (guildId) => {
|
||||
set({ selectedGuildId: guildId, selectedChannelId: null });
|
||||
},
|
||||
|
||||
selectChannel: (guildId, channelId) => {
|
||||
set({ selectedGuildId: guildId, selectedChannelId: channelId });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({ ...defaults });
|
||||
},
|
||||
}));
|
||||
|
||||
/** Селектор сервера по id (без подписки на весь список). */
|
||||
export function selectGuildById(guildId: string | null) {
|
||||
return (state: SessionState): SessionGuild | null =>
|
||||
guildId === null ? null : (state.guilds.find((guild) => guild.id === guildId) ?? null);
|
||||
}
|
||||
+4
-1
@@ -22,7 +22,10 @@ export default defineConfig({
|
||||
'/api': 'http://127.0.0.1:8080',
|
||||
'/healthz': 'http://127.0.0.1:8080',
|
||||
'/readyz': 'http://127.0.0.1:8080',
|
||||
// Gateway (WS) is added in phase 1 together with the realtime server.
|
||||
// Файлы аватаров/иконок отдаёт тот же инстанс.
|
||||
'/files': 'http://127.0.0.1:8080',
|
||||
// Шлюз Фазы 1: WebSocket на том же origin.
|
||||
'/gateway': { target: 'ws://127.0.0.1:8080', ws: true },
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user