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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user