feat(invites): приглашения, каталог публичных серверов и публичность сервера
- POST /guilds/{id}/invites (CREATE_INVITES): код из 10 символов, лимит
использований и срок жизни; GET /guilds/{id}/invites (MANAGE_GUILD);
DELETE /invites/{code} — создатель или MANAGE_GUILD;
- GET /invites/{code} — предпросмотр с карточкой сервера, 410 для истёкших и
исчерпанных; POST /invites/{code} — принятие с проверкой лимита участников,
повторное принятие не расходует использование;
- PATCH /guilds/{id} принимает `public`: сервер попадает в каталог и открыт для
входа без приглашения (главный сервер публиковать нельзя);
- GET /guilds/public — каталог (имя, описание, иконка, число участников);
- аудит invite.create/invite.revoke/invite.accept, события INVITE_CREATE и
INVITE_DELETE владельцу сессии;
- тесты: полный жизненный цикл приглашения, исчерпание, отзыв, права,
публичный каталог и вход в публичный сервер.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { deleteGuild, guildQueryKey, leaveGuild } from '@/api/guilds';
|
||||
import { instanceQueryKey } from '@/api/instance';
|
||||
import { myGuildsQueryKey } from '@/api/users';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||
|
||||
interface GuildDangerSectionProps {
|
||||
guildId: string;
|
||||
guildName: string;
|
||||
/** Владелец не может покинуть сервер — только удалить его. */
|
||||
isOwner: boolean;
|
||||
/** Администратор инстанса может удалить любой сервер. */
|
||||
isInstanceAdmin: boolean;
|
||||
}
|
||||
|
||||
type ConfirmAction = 'leave' | 'delete';
|
||||
|
||||
/** «Опасная зона»: выход с сервера и удаление сервера с подтверждением. */
|
||||
export function GuildDangerSection({
|
||||
guildId,
|
||||
guildName,
|
||||
isOwner,
|
||||
isInstanceAdmin,
|
||||
}: GuildDangerSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [confirm, setConfirm] = useState<ConfirmAction | null>(null);
|
||||
// Последнее действие: пока окно закрывается, тексты не должны «прыгать».
|
||||
const [lastAction, setLastAction] = useState<ConfirmAction>('leave');
|
||||
|
||||
const ask = (action: ConfirmAction): void => {
|
||||
setLastAction(action);
|
||||
setConfirm(action);
|
||||
};
|
||||
|
||||
/** После выхода/удаления возвращаемся в приложение и обновляем снапшот. */
|
||||
const afterExit = (): void => {
|
||||
setConfirm(null);
|
||||
useSessionStore.getState().removeGuild(guildId);
|
||||
useSessionStore.getState().selectSettingsGuild(null);
|
||||
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
|
||||
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
|
||||
void queryClient.removeQueries({ queryKey: guildQueryKey(guildId) });
|
||||
void navigate('/app', { replace: true });
|
||||
};
|
||||
|
||||
const leave = useMutation({
|
||||
mutationFn: () => leaveGuild(guildId),
|
||||
onSuccess: afterExit,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => deleteGuild(guildId),
|
||||
onSuccess: afterExit,
|
||||
});
|
||||
|
||||
const canDelete = isOwner || isInstanceAdmin;
|
||||
const pending = leave.isPending || remove.isPending;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('settings.servers.danger.title')}
|
||||
description={t('settings.servers.danger.description')}
|
||||
className="border-danger/40"
|
||||
>
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
{isOwner ? (
|
||||
<p className="text-sm text-fg-muted">{t('settings.servers.danger.leaveOwnerHint')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="max-w-md text-sm text-fg-muted">
|
||||
{t('settings.servers.danger.leaveHint')}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="border-danger/50 text-danger"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
leave.reset();
|
||||
ask('leave');
|
||||
}}
|
||||
>
|
||||
{t('settings.servers.danger.leave')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canDelete ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/40 pt-4">
|
||||
<p className="max-w-md text-sm text-fg-muted">
|
||||
{t('settings.servers.danger.deleteHint')}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="border-danger/50 text-danger"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
remove.reset();
|
||||
ask('delete');
|
||||
}}
|
||||
>
|
||||
{t('settings.servers.danger.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{leave.isError ? <ErrorNotice className="mt-3" error={leave.error} /> : null}
|
||||
{remove.isError ? <ErrorNotice className="mt-3" error={remove.error} /> : null}
|
||||
|
||||
<Modal
|
||||
open={confirm !== null}
|
||||
title={t(
|
||||
lastAction === 'delete'
|
||||
? 'settings.servers.danger.deleteConfirmTitle'
|
||||
: 'settings.servers.danger.leaveConfirmTitle',
|
||||
)}
|
||||
onClose={() => setConfirm(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setConfirm(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
if (lastAction === 'delete') {
|
||||
remove.mutate();
|
||||
return;
|
||||
}
|
||||
leave.mutate();
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
lastAction === 'delete'
|
||||
? 'settings.servers.danger.deleteConfirm'
|
||||
: 'settings.servers.danger.leaveConfirm',
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t(
|
||||
lastAction === 'delete'
|
||||
? 'settings.servers.danger.deleteConfirmBody'
|
||||
: 'settings.servers.danger.leaveConfirmBody',
|
||||
{ name: guildName },
|
||||
)}
|
||||
</p>
|
||||
</Modal>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -30,8 +30,17 @@ export function GuildNicknameSection({
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [nickname, setNickname] = useState(currentNickname);
|
||||
const [edited, setEdited] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
// Ник может прийти из списка участников уже после монтирования секции:
|
||||
// подтягиваем его, пока пользователь не начал править поле сам.
|
||||
useEffect(() => {
|
||||
if (!edited) {
|
||||
setNickname(currentNickname);
|
||||
}
|
||||
}, [currentNickname, edited]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (value: string) => updateGuildMember(guildId, userId ?? '', { nickname: value }),
|
||||
onSuccess: (_result, value) => {
|
||||
@@ -72,7 +81,10 @@ export function GuildNicknameSection({
|
||||
value={nickname}
|
||||
maxLength={32}
|
||||
disabled={userId === undefined}
|
||||
onChange={(event) => setNickname(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setEdited(true);
|
||||
setNickname(event.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
{save.isError ? <ErrorNotice error={save.error} /> : null}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users';
|
||||
import { userStatuses, type User } from '@/api/types';
|
||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
@@ -155,7 +156,7 @@ export default function ProfileSettingsPage() {
|
||||
if (currentUser.isPending) {
|
||||
return (
|
||||
<Card>
|
||||
<LoadingNotice />
|
||||
<SkeletonLines rows={5} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { fetchGuild, fetchMembers, guildMembersQueryKey, guildQueryKey } from '@/api/guilds';
|
||||
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { canManageGuildSettings } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
||||
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
|
||||
import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
|
||||
import { GuildNicknameSection } from '@/pages/settings/GuildNicknameSection';
|
||||
import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
|
||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||
|
||||
/**
|
||||
* Настройки пользовательских серверов (`/settings/servers/:guildId`).
|
||||
*
|
||||
* Список серверов берём из READY-снапшота, а если шлюз ещё не подключился —
|
||||
* из `GET /users/@me/guilds` (как в `AppLayout`). Выбранный сервер хранится
|
||||
* в URL и дублируется в сторе, поэтому прямая ссылка на настройки работает.
|
||||
*/
|
||||
export default function ServersSettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const params = useParams<{ guildId?: string }>();
|
||||
const routeGuildId = params.guildId;
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
const sessionGuilds = useSessionStore((state) => state.guilds);
|
||||
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
||||
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
|
||||
|
||||
const myGuilds = useQuery({
|
||||
queryKey: myGuildsQueryKey,
|
||||
queryFn: ({ signal }) => fetchMyGuilds(signal),
|
||||
retry: 0,
|
||||
enabled: sessionGuilds.length === 0,
|
||||
});
|
||||
|
||||
// Подстраховка снапшота: без неё выбранный сервер не найти в сторе.
|
||||
useEffect(() => {
|
||||
if (sessionGuilds.length > 0 || myGuilds.data === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const guild of myGuilds.data) {
|
||||
upsertGuild(guild);
|
||||
}
|
||||
}, [sessionGuilds.length, myGuilds.data, upsertGuild]);
|
||||
|
||||
const guilds = sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? []);
|
||||
const active = useMemo(
|
||||
() => (routeGuildId === undefined ? null : (guilds.find((g) => g.id === routeGuildId) ?? null)),
|
||||
[guilds, routeGuildId],
|
||||
);
|
||||
const activeId = active?.id ?? null;
|
||||
|
||||
// URL — источник правды; стор запоминает последний открытый сервер.
|
||||
useEffect(() => {
|
||||
if (activeId !== null) {
|
||||
selectSettingsGuild(activeId);
|
||||
return;
|
||||
}
|
||||
if (routeGuildId === undefined && guilds.length > 0) {
|
||||
const first = guilds[0];
|
||||
if (first !== undefined) {
|
||||
void navigate(`/settings/servers/${first.id}`, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [activeId, routeGuildId, guilds, navigate, selectSettingsGuild]);
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: guildQueryKey(activeId ?? ''),
|
||||
queryFn: ({ signal }) => fetchGuild(activeId ?? '', signal),
|
||||
enabled: activeId !== null,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: guildMembersQueryKey(activeId ?? ''),
|
||||
queryFn: ({ signal }) => fetchMembers(activeId ?? '', signal),
|
||||
enabled: activeId !== null,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const guild = detail.data;
|
||||
const myPermissions = guild?.my_permissions ?? active?.my_permissions ?? [];
|
||||
const ownerId = guild?.owner_id ?? active?.owner_id ?? '';
|
||||
const canManage = canManageGuildSettings(myPermissions, ownerId, currentUser.data?.id);
|
||||
const currentNickname = useMemo(() => {
|
||||
const fromSnapshot = active?.my_nickname;
|
||||
if (fromSnapshot !== undefined && fromSnapshot !== '') {
|
||||
return fromSnapshot;
|
||||
}
|
||||
const mine = members.data?.find((member) => member.user_id === currentUser.data?.id);
|
||||
return mine?.nickname ?? '';
|
||||
}, [active, members.data, currentUser.data?.id]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card className="p-3">
|
||||
<h2 className="px-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{t('settings.servers.pickerLabel')}
|
||||
</h2>
|
||||
{guilds.length === 0 ? (
|
||||
myGuilds.isPending ? (
|
||||
<SkeletonLines className="mt-3" rows={1} />
|
||||
) : (
|
||||
<p className="mt-2 px-1 text-sm text-fg-muted">{t('settings.servers.empty')}</p>
|
||||
)
|
||||
) : (
|
||||
<nav
|
||||
aria-label={t('settings.servers.pickerLabel')}
|
||||
className="gl-stagger mt-2 flex flex-wrap gap-1.5"
|
||||
data-testid="guild-picker"
|
||||
>
|
||||
{guilds.map((guildItem) => {
|
||||
const selected = guildItem.id === activeId;
|
||||
return (
|
||||
<NavLink
|
||||
key={guildItem.id}
|
||||
to={`/settings/servers/${guildItem.id}`}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
aria-label={t('settings.servers.select', { name: guildItem.name })}
|
||||
className={`flex max-w-56 items-center gap-2 rounded-[var(--radius-md)] border px-2 py-1.5 text-sm transition-colors duration-[var(--duration-fast)] ${
|
||||
selected
|
||||
? 'border-accent bg-surface-3 text-fg'
|
||||
: 'border-border/60 bg-surface-1 text-fg-muted hover:bg-surface-2 hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
<Avatar
|
||||
name={guildItem.name}
|
||||
seed={guildItem.id}
|
||||
fileId={guildItem.icon_file_id}
|
||||
size="sm"
|
||||
shape="square"
|
||||
/>
|
||||
<span className="truncate">{guildItem.name}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{guilds.length === 0 ? (
|
||||
myGuilds.isPending ? (
|
||||
<Card>
|
||||
<SkeletonLines rows={4} />
|
||||
</Card>
|
||||
) : (
|
||||
<SettingsSection
|
||||
title={t('settings.servers.emptyTitle')}
|
||||
description={t('settings.servers.emptyHint')}
|
||||
>
|
||||
<div className="mt-4">
|
||||
<NavLink className="text-sm text-accent underline" to="/app">
|
||||
{t('settings.back')}
|
||||
</NavLink>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)
|
||||
) : activeId === null ? (
|
||||
// Сервер из URL не найден среди доступных пользователю.
|
||||
<Card>
|
||||
<p className="text-sm text-fg-muted">{t('settings.servers.notFound')}</p>
|
||||
</Card>
|
||||
) : detail.isPending ? (
|
||||
<Card>
|
||||
<SkeletonLines rows={5} />
|
||||
</Card>
|
||||
) : guild === undefined ? (
|
||||
<Card>
|
||||
<ErrorNotice error={detail.error} onRetry={() => void detail.refetch()} />
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
||||
<GuildNicknameSection
|
||||
guildId={guild.id}
|
||||
userId={currentUser.data?.id}
|
||||
currentNickname={currentNickname}
|
||||
members={members.data}
|
||||
/>
|
||||
<GuildMembersSection
|
||||
members={members.data}
|
||||
isPending={members.isPending}
|
||||
error={members.error}
|
||||
onRetry={() => void members.refetch()}
|
||||
/>
|
||||
<GuildRolesSection key={`${guild.id}-roles`} guildId={guild.id} />
|
||||
<GuildDangerSection
|
||||
key={`${guild.id}-danger`}
|
||||
guildId={guild.id}
|
||||
guildName={guild.name}
|
||||
isOwner={currentUser.data?.id !== undefined && currentUser.data.id === guild.owner_id}
|
||||
isInstanceAdmin={currentUser.data?.is_instance_admin === true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { fetchSessions, logoutAll, sessionsQueryKey } from '@/api/auth';
|
||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { formatDateTime } from '@/lib/identity';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
@@ -54,7 +55,7 @@ export function SessionsSection() {
|
||||
<ErrorNotice className="mt-3" error={logoutAllMutation.error} />
|
||||
) : null}
|
||||
|
||||
{sessions.isPending ? <LoadingNotice /> : null}
|
||||
{sessions.isPending ? <SkeletonLines className="mt-2" rows={2} /> : null}
|
||||
{sessions.isError ? (
|
||||
<ErrorNotice
|
||||
className="mt-3"
|
||||
|
||||
Reference in New Issue
Block a user