From 566e1cf9810d26f1cb5187c9e84482467d1e6712 Mon Sep 17 00:00:00 2001 From: grendervill Date: Mon, 21 Sep 2026 19:25:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(invites):=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB=D0=B0=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BE=20splash=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BE=D0=BF=D1=80=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - миграция 00015: переопределение splash у приглашения (AGENT.md 7.9) - GET /invites/{code} доступен без сессии: гость по ссылке видит карточку сервера, описание, число участников и пригласившего - POST/DELETE /invites/{code}/background: своя картинка страницы под правом создателя или MANAGE_GUILD, файл удаляется при замене и снятии - клиент: маршрут /invite/:code с фоном (переопределение → splash → баннер), карточкой сервера, кнопками «Присоединиться»/«Открыть сервер» и входом с возвратом на страницу (?next=) - тесты: Go (карточка, загрузка, права, снятие) и Vitest (splash, гость, присоединение, недействительная ссылка) --- .../migrations/00015_invite_background.sql | 7 + internal/server/api_files.go | 3 +- internal/server/api_invites.go | 163 +++++++++++++- internal/server/cosmetics_test.go | 95 ++++++++ internal/server/server.go | 1 + internal/store/invites.go | 32 ++- web/src/api/invites.ts | 29 +++ web/src/i18n/locales/en.json | 12 +- web/src/i18n/locales/ru.json | 12 +- web/src/pages/InvitePage.tsx | 205 ++++++++++++++++++ web/src/pages/LoginPage.tsx | 9 +- web/src/pages/lazyPages.ts | 1 + web/src/router.tsx | 3 + web/tests/invitePage.test.tsx | 153 +++++++++++++ 14 files changed, 701 insertions(+), 24 deletions(-) create mode 100644 internal/database/migrations/00015_invite_background.sql create mode 100644 web/src/pages/InvitePage.tsx create mode 100644 web/tests/invitePage.test.tsx diff --git a/internal/database/migrations/00015_invite_background.sql b/internal/database/migrations/00015_invite_background.sql new file mode 100644 index 0000000..9367b27 --- /dev/null +++ b/internal/database/migrations/00015_invite_background.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- Переопределение splash для конкретного приглашения (AGENT.md 7.9): страница +-- приглашения показывает свою картинку вместо splash сервера. +ALTER TABLE invites ADD COLUMN background_file_id INTEGER REFERENCES files (id) ON DELETE SET NULL; + +-- +goose Down +ALTER TABLE invites DROP COLUMN background_file_id; diff --git a/internal/server/api_files.go b/internal/server/api_files.go index f968dee..6d2b8e3 100644 --- a/internal/server/api_files.go +++ b/internal/server/api_files.go @@ -301,7 +301,8 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user * // Аватары и баннеры видны всем авторизованным: они показываются в списках // участников и друзей (AGENT.md 7.2). if file.Purpose == "avatar" || file.Purpose == "banner" || file.Purpose == "emoji" || - file.Purpose == "sound" || file.Purpose == "webhook_avatar" { + file.Purpose == "sound" || file.Purpose == "webhook_avatar" || + file.Purpose == "invite_background" { return file, nil } // Фон комнаты виден тем, кто видит саму комнату (проверка ниже по каналу). diff --git a/internal/server/api_invites.go b/internal/server/api_invites.go index b8129dd..823c156 100644 --- a/internal/server/api_invites.go +++ b/internal/server/api_invites.go @@ -1,14 +1,17 @@ package server import ( + "bytes" "context" "crypto/rand" "errors" + "io" "net/http" "strings" "time" "github.com/danielgtaylor/huma/v2" + "github.com/go-chi/chi/v5" "glchat/internal/permissions" "glchat/internal/store" @@ -28,6 +31,9 @@ type invitePayload struct { ExpiresAt string `json:"expires_at,omitempty"` CreatedAt string `json:"created_at"` URL string `json:"url"` + // BackgroundFileID — переопределение splash для страницы приглашения + // (AGENT.md 7.9). + BackgroundFileID string `json:"background_file_id,omitempty"` } type inviteListOutput struct { @@ -49,6 +55,13 @@ type inviteOutput struct { Description string `json:"description,omitempty"` MemberCount int `json:"member_count"` IsMember bool `json:"is_member"` + // Inviter — кто позвал: страница приглашения показывает имя. + Inviter struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + AvatarFileID string `json:"avatar_file_id,omitempty"` + } `json:"inviter"` } `json:"guild"` } } @@ -212,24 +225,24 @@ func (s *Server) registerInviteRoutes(api huma.API) { Path: "/invites/{code}", Summary: "Предпросмотр приглашения", Tags: []string{"Invites"}, - Security: security, + // Карточку приглашения видно и без сессии: по ссылке приходят те, у кого + // аккаунта ещё нет (AGENT.md 7.9). Присоединение требует входа. }, func(ctx context.Context, input *struct { Code string `path:"code"` }, ) (*inviteOutput, error) { - user, _, err := requireUser(ctx) - if err != nil { - return nil, err - } + user, _, _ := sessionFromContext(ctx) invite, err := s.store.GetInvite(ctx, input.Code) if err != nil { return nil, humaError(err) } // Бан сервера сильнее приглашения: снять его может только модератор. - if banned, err := s.store.IsGuildBanned(ctx, invite.GuildID, user.ID); err != nil { - return nil, humaError(err) - } else if banned { - return nil, humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild") + if user != nil { + if banned, err := s.store.IsGuildBanned(ctx, invite.GuildID, user.ID); err != nil { + return nil, humaError(err) + } else if banned { + return nil, humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild") + } } if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now().UTC()) { return nil, humaErrorStatus(http.StatusGone, "invite.expired", "invite has expired") @@ -362,11 +375,14 @@ func (s *Server) invitePayload(invite *store.Invite) invitePayload { if invite.ExpiresAt != nil { payload.ExpiresAt = invite.ExpiresAt.UTC().Format(time.RFC3339) } + if invite.BackgroundFileID != nil { + payload.BackgroundFileID = formatSnowflake(*invite.BackgroundFileID) + } return payload } // inviteOutput собирает приглашение вместе с карточкой сервера для страницы -// предпросмотра (AGENT.md 7.9). +// предпросмотра (AGENT.md 7.9). `user` может быть nil — гость по ссылке. func (s *Server) inviteOutput(ctx context.Context, user *store.User, invite *store.Invite) *inviteOutput { output := &inviteOutput{} output.Body.Invite = s.invitePayload(invite) @@ -390,8 +406,21 @@ func (s *Server) inviteOutput(ctx context.Context, user *store.User, invite *sto if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil { output.Body.Guild.MemberCount = count } - if _, err := s.store.GetGuildMember(ctx, guild.ID, user.ID); err == nil { - output.Body.Guild.IsMember = true + if user != nil { + if _, err := s.store.GetGuildMember(ctx, guild.ID, user.ID); err == nil { + output.Body.Guild.IsMember = true + } + } + // Пригласившего показываем по имени: это часть карточки приглашения. + if invite.CreatorID != nil { + if creator, err := s.store.GetUser(ctx, *invite.CreatorID); err == nil { + output.Body.Guild.Inviter.ID = formatSnowflake(creator.ID) + output.Body.Guild.Inviter.Username = creator.Username + output.Body.Guild.Inviter.DisplayName = creator.DisplayName + if creator.AvatarFileID != nil { + output.Body.Guild.Inviter.AvatarFileID = formatSnowflake(*creator.AvatarFileID) + } + } } return output } @@ -412,3 +441,113 @@ func newInviteCode() (string, error) { } return builder.String(), nil } + +// maxInviteBackgroundSize — предел размера переопределения splash (AGENT.md 7.7). +const maxInviteBackgroundSize = 5 << 20 + +// registerInviteBackgroundRoutes описывает картинку приглашения: создатель +// приглашения или MANAGE_GUILD сервера может задать своё оформление страницы +// (AGENT.md 7.9). Ручка multipart, поэтому живёт на роутере. +func (s *Server) registerInviteBackgroundRoutes(router chi.Router) { + router.Post("/invites/{code}/background", s.handleInviteBackgroundUpload) + router.Delete("/invites/{code}/background", s.handleInviteBackgroundDelete) +} + +func (s *Server) handleInviteBackgroundUpload(w http.ResponseWriter, r *http.Request) { + user, _, ok := s.authenticate(w, r) + if !ok { + return + } + ctx := r.Context() + invite, err := s.requireInviteManage(ctx, chi.URLParam(r, "code"), user) + if err != nil { + writeHumaAPIError(w, err) + return + } + file, header, err := r.FormFile("file") + if err != nil { + httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`) + return + } + defer func() { _ = file.Close() }() + if header.Size > maxInviteBackgroundSize { + httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large") + return + } + data, err := io.ReadAll(io.LimitReader(file, maxInviteBackgroundSize+1)) + if err != nil || int64(len(data)) > maxInviteBackgroundSize { + httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large") + return + } + contentType := header.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + contentType = http.DetectContentType(data) + } + if !strings.HasPrefix(contentType, "image/") { + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "invite background must be an image") + return + } + + // Старую картинку удаляем: загрузки не должны копиться (AGENT.md 7.7). + if previous := invite.BackgroundFileID; previous != nil && *previous != 0 { + s.deleteStoredFile(ctx, *previous) + } + stored, err := s.saveUpload(ctx, store.File{ + UploaderID: &user.ID, + GuildID: &invite.GuildID, + Filename: sanitizeFilename(header.Filename), + ContentType: contentType, + Purpose: "invite_background", + }, bytes.NewReader(data)) + if err != nil { + writeHumaAPIError(w, err) + return + } + updated, err := s.store.SetInviteBackground(ctx, invite.Code, &stored.ID) + if err != nil { + s.deleteStoredFile(ctx, stored.ID) + writeHumaAPIError(w, humaError(err)) + return + } + s.recordAudit(ctx, user, invite.GuildID, "invite.background", "invite", nil, invite.Code) + httpxWriteJSON(w, http.StatusOK, map[string]any{"invite": s.invitePayload(updated)}) +} + +func (s *Server) handleInviteBackgroundDelete(w http.ResponseWriter, r *http.Request) { + user, _, ok := s.authenticate(w, r) + if !ok { + return + } + ctx := r.Context() + invite, err := s.requireInviteManage(ctx, chi.URLParam(r, "code"), user) + if err != nil { + writeHumaAPIError(w, err) + return + } + if previous := invite.BackgroundFileID; previous != nil && *previous != 0 { + s.deleteStoredFile(ctx, *previous) + } + updated, err := s.store.SetInviteBackground(ctx, invite.Code, nil) + if err != nil { + writeHumaAPIError(w, humaError(err)) + return + } + s.recordAudit(ctx, user, invite.GuildID, "invite.background_remove", "invite", nil, invite.Code) + httpxWriteJSON(w, http.StatusOK, map[string]any{"invite": s.invitePayload(updated)}) +} + +// requireInviteManage достаёт приглашение и проверяет право менять его +// оформление: создатель или MANAGE_GUILD сервера (AGENT.md 7.9). +func (s *Server) requireInviteManage(ctx context.Context, code string, user *store.User) (*store.Invite, error) { + invite, err := s.store.GetInvite(ctx, code) + if err != nil { + return nil, humaError(err) + } + if invite.CreatorID != nil && *invite.CreatorID == user.ID { + return invite, nil + } + if _, _, err := s.requireGuildPermission(ctx, formatSnowflake(invite.GuildID), user, permissions.ManageGuild); err != nil { + return nil, err + } + return invite, nil +} diff --git a/internal/server/cosmetics_test.go b/internal/server/cosmetics_test.go index 4b2f03d..0f8b28e 100644 --- a/internal/server/cosmetics_test.go +++ b/internal/server/cosmetics_test.go @@ -602,3 +602,98 @@ func TestBlockUserStopsDMsAndRequests(t *testing.T) { } _ = ownerID } + +func TestInviteBackgroundAndPreview(t *testing.T) { + srv, owner, member, guildID, _ := cosmeticsFixture(t) + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/invites", + `{"max_uses":5,"max_age_seconds":600}`, owner) + code := decodeResponse[struct { + Invite struct { + Code string `json:"code"` + } `json:"invite"` + }](t, created).Invite.Code + if code == "" { + t.Fatalf("приглашение не создано: %s", created.Body.String()) + } + + // Предпросмотр: карточка сервера и пригласивший. + preview := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+code, "", member) + body := decodeResponse[struct { + Invite struct { + BackgroundFileID string `json:"background_file_id"` + } `json:"invite"` + Guild struct { + Name string `json:"name"` + IsMember bool `json:"is_member"` + Inviter struct { + Username string `json:"username"` + } `json:"inviter"` + } `json:"guild"` + }](t, preview) + if body.Guild.Name == "" || body.Guild.Inviter.Username == "" { + t.Fatalf("предпросмотр без карточки: %s", preview.Body.String()) + } + + // Загрузка переопределения splash: multipart, картинка. + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, err := writer.CreateFormFile("file", "invite.png") + if err != nil { + t.Fatalf("multipart: %v", err) + } + if _, err := part.Write(pngBytes()); err != nil { + t.Fatalf("multipart write: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("multipart close: %v", err) + } + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, + "/api/v1/invites/"+code+"/background", &buf) + request.Header.Set("Content-Type", writer.FormDataContentType()) + request.AddCookie(owner) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, request) + if rec.Code != http.StatusOK { + t.Fatalf("загрузка картинки приглашения = %d (%s)", rec.Code, rec.Body.String()) + } + uploaded := decodeResponse[struct { + Invite struct { + BackgroundFileID string `json:"background_file_id"` + } `json:"invite"` + }](t, rec) + if uploaded.Invite.BackgroundFileID == "" { + t.Fatalf("переопределение не сохранилось: %s", rec.Body.String()) + } + + // Карточка приглашения отдаёт переопределение и без сессии не работает. + after := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+code, "", member) + fileID := decodeResponse[struct { + Invite struct { + BackgroundFileID string `json:"background_file_id"` + } `json:"invite"` + }](t, after).Invite.BackgroundFileID + if fileID != uploaded.Invite.BackgroundFileID { + t.Fatalf("переопределение не видно в предпросмотре: %s", after.Body.String()) + } + + // Посторонний не может менять оформление чужого приглашения. + outsider := registerAndLogin(t, srv, "invite_guest", "invite-guest@example.com") + denied := doJSON(t, srv, http.MethodDelete, "/api/v1/invites/"+code+"/background", "", outsider) + if denied.Code != http.StatusForbidden && denied.Code != http.StatusNotFound { + t.Fatalf("снятие чужим = %d, ожидался отказ (%s)", denied.Code, denied.Body.String()) + } + + // Снятие переопределения создателем. + cleared := doJSON(t, srv, http.MethodDelete, "/api/v1/invites/"+code+"/background", "", owner) + if cleared.Code != http.StatusOK { + t.Fatalf("снятие переопределения = %d (%s)", cleared.Code, cleared.Body.String()) + } + final := decodeResponse[struct { + Invite struct { + BackgroundFileID string `json:"background_file_id"` + } `json:"invite"` + }](t, cleared) + if final.Invite.BackgroundFileID != "" { + t.Fatalf("переопределение не снято: %s", cleared.Body.String()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 078ce22..b5aaaa0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -155,6 +155,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerWebhookRoutes(s.api, apiRouter) s.registerCosmeticRoutes(s.api, apiRouter) s.registerChannelBackgroundRoutes(apiRouter) + s.registerInviteBackgroundRoutes(apiRouter) s.registerVoiceWebhook(apiRouter) } apiRouter.Get("/openapi.json", s.handleOpenAPI) diff --git a/internal/store/invites.go b/internal/store/invites.go index 339c12a..e20e51e 100644 --- a/internal/store/invites.go +++ b/internal/store/invites.go @@ -17,6 +17,9 @@ type Invite struct { MaxAgeSec int ExpiresAt *time.Time CreatedAt time.Time + // BackgroundFileID — переопределение splash для этого приглашения + // (AGENT.md 7.9): страница приглашения показывает свою картинку. + BackgroundFileID *uint64 } // CreateInviteParams — параметры приглашения. @@ -30,7 +33,7 @@ type CreateInviteParams struct { } const inviteColumns = `code, guild_id, channel_id, creator_id, max_uses, uses, - max_age_sec, expires_at, created_at` + max_age_sec, expires_at, created_at, background_file_id` // CreateInvite сохраняет приглашение с кодом, лимитом использований и сроком. func (s *Store) CreateInvite(ctx context.Context, params CreateInviteParams) (*Invite, error) { @@ -54,6 +57,19 @@ func (s *Store) GetInvite(ctx context.Context, code string) (*Invite, error) { return scanInvite(row) } +// SetInviteBackground задаёт или снимает переопределение splash приглашения. +func (s *Store) SetInviteBackground(ctx context.Context, code string, fileID *uint64) (*Invite, error) { + result, err := s.writer.ExecContext(ctx, + `UPDATE invites SET background_file_id = ? WHERE code = ?`, nullableID(fileID), code) + if err != nil { + return nil, mapError(err) + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return nil, ErrNotFound + } + return s.GetInvite(ctx, code) +} + // ListGuildInvites возвращает приглашения сервера (для панели управления). func (s *Store) ListGuildInvites(ctx context.Context, guildID uint64, limit int) ([]Invite, error) { if limit <= 0 || limit > 200 { @@ -132,19 +148,21 @@ func (s *Store) ListPublicGuilds(ctx context.Context, limit int) ([]Guild, error func scanInvite(scanner interface{ Scan(...any) error }) (*Invite, error) { var ( - invite Invite - channelID sql.NullInt64 - creatorID sql.NullInt64 - expiresAt sql.NullString - createdAt string + invite Invite + channelID sql.NullInt64 + creatorID sql.NullInt64 + expiresAt sql.NullString + createdAt string + background sql.NullInt64 ) err := scanner.Scan(&invite.Code, &invite.GuildID, &channelID, &creatorID, &invite.MaxUses, - &invite.Uses, &invite.MaxAgeSec, &expiresAt, &createdAt) + &invite.Uses, &invite.MaxAgeSec, &expiresAt, &createdAt, &background) if err != nil { return nil, mapError(err) } invite.ChannelID = optionalID(channelID) invite.CreatorID = optionalID(creatorID) + invite.BackgroundFileID = optionalID(background) if expiresAt.Valid { value := parseTimestamp(expiresAt.String) invite.ExpiresAt = &value diff --git a/web/src/api/invites.ts b/web/src/api/invites.ts index 1f4a630..2371474 100644 --- a/web/src/api/invites.ts +++ b/web/src/api/invites.ts @@ -12,6 +12,13 @@ export interface InviteGuildPreview { id: string; name: string; icon_file_id?: string; + /** Кто позвал: имя видно на странице приглашения (AGENT.md 7.9). */ + inviter?: { + id: string; + username: string; + display_name: string; + avatar_file_id?: string; + }; /** Оформление сервера для страницы приглашения (AGENT.md 7.4). */ banner_file_id?: string; splash_file_id?: string; @@ -25,6 +32,8 @@ export interface InviteGuildPreview { export interface Invite { code: string; guild_id: string; + /** Переопределение splash для этого приглашения (AGENT.md 7.9). */ + background_file_id?: string; channel_id?: string; creator_id?: string; max_uses: number; @@ -40,6 +49,8 @@ export interface InvitePreview { code: string; guild_id: string; guild: InviteGuildPreview; + /** Переопределение splash самого приглашения (приоритетнее splash сервера). */ + background_file_id?: string; } export const inviteQueryKey = (code: string) => ['invites', code] as const; @@ -113,6 +124,7 @@ export async function fetchInvite(code: string, signal?: AbortSignal): Promise = { + id: inviterId, + username: asString(inviter['username']) ?? '', + display_name: asString(inviter['display_name']) ?? '', + }; + const inviterAvatar = asString(inviter['avatar_file_id']); + if (inviterAvatar !== undefined) { + inviterUser.avatar_file_id = inviterAvatar; + } + preview.guild.inviter = inviterUser; + } if (description !== undefined) { preview.guild.description = description; } + if (background !== undefined) { + preview.background_file_id = background; + } return preview; } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 40be205..5c32c23 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -434,7 +434,17 @@ "join": "Join", "joining": "Joining…", "members": "Members: {{count}}", - "alreadyMember": "You are already on this server" + "alreadyMember": "You are already on this server", + "invitedTo": "You are invited to a server", + "inviter": "Invited by {{name}}", + "accept": "Join server", + "open": "Open server", + "loginHint": "Log in to join — we will bring you back to this page.", + "login": "Log in", + "register": "Register", + "toLogin": "Log in", + "notFoundTitle": "This invite does not work", + "notFound": "The link is invalid, expired or revoked. Ask for a new one." }, "chat": { "log": "Message log of {{name}}", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 5174818..dd77296 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -434,7 +434,17 @@ "join": "Присоединиться", "joining": "Присоединяемся…", "members": "Участников: {{count}}", - "alreadyMember": "Вы уже на этом сервере" + "alreadyMember": "Вы уже на этом сервере", + "invitedTo": "Вас приглашают на сервер", + "inviter": "Приглашает {{name}}", + "accept": "Присоединиться", + "open": "Открыть сервер", + "loginHint": "Войдите, чтобы присоединиться — вернём вас на эту страницу.", + "login": "Войти", + "register": "Регистрация", + "toLogin": "Войти", + "notFoundTitle": "Приглашение не работает", + "notFound": "Ссылка недействительна, истекла или отозвана. Попросите новую." }, "chat": { "log": "Лента сообщений комнаты {{name}}", diff --git a/web/src/pages/InvitePage.tsx b/web/src/pages/InvitePage.tsx new file mode 100644 index 0000000..6e02ab3 --- /dev/null +++ b/web/src/pages/InvitePage.tsx @@ -0,0 +1,205 @@ +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router'; + +import { acceptInvite, fetchInvite, inviteQueryKey } from '@/api/invites'; +import { instanceQueryKey } from '@/api/instance'; +import { myGuildsQueryKey } from '@/api/users'; +import { Avatar } from '@/components/ui/Avatar'; +import { AnimatedImage } from '@/components/ui/AnimatedImage'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { Button } from '@/components/ui/primitives'; +import { SkeletonLines } from '@/components/ui/Skeleton'; +import { fileContentUrl } from '@/api/files'; +import { roleColorHex } from '@/lib/format'; +import { isUnauthorized } from '@/lib/http'; +import { useCurrentUser } from '@/lib/hooks'; +import { useApiErrorMessage } from '@/lib/useApiErrorMessage'; +import { useSessionStore } from '@/stores/session'; + +/** + * Страница приглашения `/invite/{code}` (AGENT.md 7.9, критерий Фазы 4): + * показывает splash сервера или переопределение приглашения, карточку сервера, + * кто позвал, и присоединяет одним нажатием. Доступна без входа — гость видит + * предпросмотр, а кнопка ведёт на вход с возвратом на эту же страницу. + */ +export default function InvitePage() { + const { t } = useTranslation(); + const params = useParams<{ code?: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const describeError = useApiErrorMessage(); + const currentUser = useCurrentUser(); + const guilds = useSessionStore((state) => state.guilds); + const [joinError, setJoinError] = useState(null); + const code = params.code ?? ''; + + // Гость (401) видит карточку приглашения: сервер отдаёт её без сессии. + const guest = currentUser.isError && isUnauthorized(currentUser.error); + const preview = useQuery({ + queryKey: inviteQueryKey(code), + queryFn: ({ signal }) => fetchInvite(code, signal), + enabled: code !== '' && (currentUser.isSuccess || guest), + retry: 0, + }); + + const join = useMutation({ + mutationFn: () => acceptInvite(code), + onSuccess: (guildId) => { + setJoinError(null); + void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey }); + void queryClient.invalidateQueries({ queryKey: instanceQueryKey }); + void navigate(`/app/${guildId}`); + }, + onError: (cause: unknown) => setJoinError(cause), + }); + + const guild = preview.data?.guild; + const memberGuild = useMemo( + () => (guild === undefined ? undefined : guilds.find((item) => item.id === guild.id)), + [guild, guilds], + ); + + // Фон страницы: переопределение приглашения → splash → баннер сервера. + const backgroundFileId = + preview.data?.background_file_id ?? guild?.splash_file_id ?? guild?.banner_file_id; + const accent = guild?.accent_color; + const accentHex = accent === undefined || accent === 0 ? null : roleColorHex(accent); + + const inviterName = + guild?.inviter === undefined + ? '' + : guild.inviter.display_name === '' + ? guild.inviter.username + : guild.inviter.display_name; + + return ( +
+ {backgroundFileId === undefined ? ( +
+ ); +} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 2235620..5640205 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -1,7 +1,7 @@ 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 { Link, useLocation, useNavigate, useSearchParams } from 'react-router'; import { login } from '@/api/auth'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; @@ -30,8 +30,13 @@ export default function LoginPage() { const [password, setPassword] = useState(''); const [totpCode, setTotpCode] = useState(''); const [needsTotp, setNeedsTotp] = useState(false); + const [searchParams] = useSearchParams(); - const from = (location.state as LocationState | null)?.from ?? '/app'; + // Возврат после входа: `?next=` (страница приглашения) важнее состояния + // перехода из AuthGuard, но только для внутренних путей. + const next = searchParams.get('next'); + const stateFrom = (location.state as LocationState | null)?.from ?? null; + const from = next !== null && next.startsWith('/') ? next : (stateFrom ?? '/app'); const submit = useMutation({ mutationFn: () => { diff --git a/web/src/pages/lazyPages.ts b/web/src/pages/lazyPages.ts index fee68d4..a27d057 100644 --- a/web/src/pages/lazyPages.ts +++ b/web/src/pages/lazyPages.ts @@ -9,6 +9,7 @@ export const LoginPage = lazy(() => import('@/pages/LoginPage')); export const RegisterPage = lazy(() => import('@/pages/RegisterPage')); export const OnboardingPage = lazy(() => import('@/pages/OnboardingPage')); export const StatusPage = lazy(() => import('@/pages/StatusPage')); +export const InvitePage = lazy(() => import('@/pages/InvitePage')); export const AppLayout = lazy(() => import('@/pages/app/AppLayout')); export const GuildView = lazy(() => import('@/pages/app/GuildView')); export const FriendsPage = lazy(() => import('@/pages/app/FriendsPage')); diff --git a/web/src/router.tsx b/web/src/router.tsx index e4265fb..4fabe90 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -7,6 +7,7 @@ import { AppLayout, IndexRedirect, FriendsPage, + InvitePage, GuildView, InstanceSettingsPage, LoginPage, @@ -29,6 +30,8 @@ export const routes = [ { path: 'login', element: }, { path: 'register', element: }, { path: 'status', element: }, + // Страница приглашения: доступна без входа, гость видит предпросмотр. + { path: 'invite/:code', element: }, { element: , children: [{ path: 'onboarding', element: }], diff --git a/web/tests/invitePage.test.tsx b/web/tests/invitePage.test.tsx new file mode 100644 index 0000000..8bc9611 --- /dev/null +++ b/web/tests/invitePage.test.tsx @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { + findRequest, + installFetch, + installFailingFetch, + json, + makeUser, + renderApp, + resetStores, + type FetchRoute, +} from './helpers'; + +/** + * Страница приглашения `/invite/{code}` (AGENT.md 7.9): splash сервера или + * переопределение приглашения, карточка сервера, пригласивший и присоединение. + */ + +const user = makeUser(); + +function guildPreview(overrides: Record = {}) { + return { + id: 'g-9', + name: 'Сообщество', + icon_file_id: 'icon-9', + splash_file_id: 'splash-9', + description: 'Обсуждаем всё подряд', + member_count: 12, + is_member: false, + inviter: { id: 'user-2', username: 'bob', display_name: 'Боб', avatar_file_id: 'av-2' }, + ...overrides, + }; +} + +function routes( + options: { guild?: Record; background?: string } = {}, +): FetchRoute[] { + return [ + { + match: '/api/v1/invites/abc123', + method: 'POST', + response: () => json({ guild_id: 'g-9' }), + }, + { + match: '/api/v1/invites/abc123', + response: () => + json({ + invite: { + code: 'abc123', + guild_id: 'g-9', + ...(options.background === undefined ? {} : { background_file_id: options.background }), + }, + guild: options.guild ?? guildPreview(), + }), + }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + { match: '/api/v1/guilds/g-9/channels', response: () => json({ channels: [] }) }, + { + match: '/api/v1/guilds/g-9', + response: () => json({ guild: { id: 'g-9', name: 'Сообщество' } }), + }, + ]; +} + +beforeEach(() => { + resetStores(); +}); + +describe('страница приглашения', () => { + it('показывает splash сервера, карточку и пригласившего', async () => { + installFetch(routes()); + renderApp('/invite/abc123'); + + const page = await screen.findByTestId('invite-page'); + expect(await screen.findByTestId('invite-guild-name')).toHaveTextContent('Сообщество'); + expect(screen.getByText('Обсуждаем всё подряд')).toBeVisible(); + expect(screen.getByText('Участников: 12')).toBeVisible(); + expect(screen.getByTestId('invite-inviter')).toHaveTextContent('Боб'); + // Splash сервера становится фоном страницы. + const background = screen.getByTestId('invite-background').parentElement; + expect(background?.querySelector('img')?.getAttribute('src')).toBe('/files/splash-9'); + expect(page).toBeInTheDocument(); + }); + + it('переопределение приглашения важнее splash сервера', async () => { + installFetch(routes({ background: 'invite-bg' })); + renderApp('/invite/abc123'); + + await screen.findByTestId('invite-page'); + const background = await screen.findByTestId('invite-background'); + expect(background.parentElement?.querySelector('img')?.getAttribute('src')).toBe( + '/files/invite-bg', + ); + }); + + it('присоединяет по кнопке и открывает сервер', async () => { + const fetchMock = installFetch(routes()); + const { router } = renderApp('/invite/abc123'); + + await userEvent.click(await screen.findByTestId('invite-accept')); + await waitFor(() => { + expect(findRequest(fetchMock, { url: '/invites/abc123', method: 'POST' })).toBeDefined(); + }); + await waitFor(() => { + expect(router.state.location.pathname).toBe('/app/g-9'); + }); + }); + + it('для участника показывает «Открыть сервер» вместо присоединения', async () => { + installFetch(routes({ guild: guildPreview({ is_member: true }) })); + renderApp('/invite/abc123'); + + expect(await screen.findByTestId('invite-open')).toBeVisible(); + expect(screen.queryByTestId('invite-accept')).toBeNull(); + }); + + it('недействительное приглашение объясняет ошибку', async () => { + installFailingFetch(); + renderApp('/invite/broken'); + + expect(await screen.findByTestId('invite-page')).toBeInTheDocument(); + expect(await screen.findByText('Приглашение не работает')).toBeVisible(); + }); +}); + +describe('страница приглашения для гостя', () => { + it('показывает карточку и ведёт на вход с возвратом', async () => { + installFetch([ + { + match: '/api/v1/invites/abc123', + response: () => + json({ + invite: { code: 'abc123', guild_id: 'g-9' }, + guild: guildPreview({ is_member: false }), + }), + }, + { + match: '/api/v1/users/@me', + response: () => json({ error: { code: 'auth.session_expired', message: 'no' } }, 401), + }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + ]); + renderApp('/invite/abc123'); + + expect(await screen.findByTestId('invite-guild-name')).toHaveTextContent('Сообщество'); + await userEvent.click(screen.getByTestId('invite-login')); + expect(await screen.findByLabelText('Почта', { exact: true })).toBeVisible(); + }); +});