61a84bfeae
- 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 владельцу сессии;
- тесты: полный жизненный цикл приглашения, исчерпание, отзыв, права,
публичный каталог и вход в публичный сервер.
164 lines
5.6 KiB
TypeScript
164 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|