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:
2026-09-19 23:33:34 +03:00
parent e4ffbb05a5
commit 61a84bfeae
16 changed files with 1233 additions and 32 deletions
@@ -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>
);
}