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:
2026-09-19 21:50:06 +03:00
parent 86dff94a02
commit 1b1a679827
63 changed files with 8477 additions and 187 deletions
+8 -2
View File
@@ -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",
+33 -13
View File
@@ -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;
+57 -1
View File
@@ -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")
+18 -2
View File
@@ -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 {
+33 -5
View File
@@ -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
}
+5
View File
@@ -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
+54
View File
@@ -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
+42
View File
@@ -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}
+158
View File
@@ -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
+483
View File
@@ -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
}
+631
View File
@@ -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
}
+339
View File
@@ -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
View File
@@ -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"`
}
+9 -1
View File
@@ -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)
}
+20
View File
@@ -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)
+7 -2
View File
@@ -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 доступен через
+11
View File
@@ -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 {
+100
View File
@@ -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
}
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+78
View File
@@ -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 },
});
}
+633
View File
@@ -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;
}
+82
View File
@@ -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' });
}
+63
View File
@@ -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;
}
+159
View File
@@ -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;
}
}
+61
View File
@@ -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;
}
+62
View File
@@ -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);
}
+30
View File
@@ -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;
}
+38
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+128
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+295 -4
View File
@@ -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"
}
}
}
+294 -4
View File
@@ -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": "главный"
}
}
}
+23
View File
@@ -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,
});
}
+90
View File
@@ -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);
}
+25
View File
@@ -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;
}
+17
View File
@@ -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
View File
@@ -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>,
+125
View File
@@ -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>
);
}
+150
View File
@@ -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>
);
}
+198
View File
@@ -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>
);
}
+130
View File
@@ -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>
);
}
+193
View File
@@ -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>
);
}
+275
View File
@@ -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>
);
}
+58
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+72
View File
@@ -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>
);
}
+105
View File
@@ -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 '—';
}
}
+176
View File
@@ -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>
);
}
+109
View File
@@ -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>
);
}
+54
View File
@@ -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>
);
}
+164
View File
@@ -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>
);
}
+147
View File
@@ -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 });
}
+206
View File
@@ -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
View File
@@ -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: {