feat(webhooks): вебхуки комнат — бэкенд, клиент и настройки комнаты

- миграция 00012: таблица webhooks, снимок имени и аватара в messages (AGENT.md 7.11)
- API: список/создание/правка/удаление и пересоздание токена (MANAGE_WEBHOOKS,
  step-up при создании, аудит), загрузка аватара отдельной multipart-ручкой
- исполнение POST /webhooks/{id}/{token} без сессии: content, username,
  avatar_url, файлы создателя вебхука, лимит 30/мин на вебхук
- клиент: пункт настроек «Комната» со списком вебхуков, копированием ссылки,
  пересозданием токена и удалением
- сообщения вебхуков: имя, аватар и значок в ленте вместо «неизвестного автора»
- fix: неполный ответ REST больше не затирает данные READY-снапшота
This commit is contained in:
2026-09-21 00:26:50 +03:00
parent 4ea2965892
commit 2e038a1d3f
32 changed files with 2494 additions and 23 deletions
@@ -0,0 +1,163 @@
import { useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchChannels, guildChannelsQueryKey } from '@/api/guilds';
import type { Channel } from '@/api/types';
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { SelectField } from '@/components/ui/Field';
import { Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { canManageWebhooks } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { ChannelWebhooksSection } from '@/pages/settings/ChannelWebhooksSection';
import { GuildPicker } from '@/pages/settings/GuildPicker';
/**
* Пункт «Комната» окна настроек: управление одной комнатой — пока это вебхуки
* (AGENT.md 7.11). Сервер выбирается тем же списком, что и в пункте «Сервер»,
* комната — выпадающим списком текстовых комнат.
*/
export function ChannelSettingsPage() {
const sessionGuilds = useSessionStore((state) => state.guilds);
const upsertGuild = useSessionStore((state) => state.upsertGuild);
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
const settingsGuildId = useSessionStore((state) => state.settingsGuildId);
// Подстраховка снапшота: без неё список серверов пуст до подключения шлюза.
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 = useMemo(
() => (sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? [])),
[sessionGuilds, myGuilds.data],
);
return (
<div className="flex flex-col gap-5">
<GuildPicker
guilds={guilds}
isPending={myGuilds.isPending && guilds.length === 0}
onSelect={selectSettingsGuild}
/>
<ChannelSettingsBody guildId={settingsGuildId} />
</div>
);
}
/** Тело пункта: выбор комнаты и её секции. */
function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const settingsChannelId = useSessionStore((state) => state.settingsChannelId);
const selectSettingsChannel = useSessionStore((state) => state.selectSettingsChannel);
const guild = useSessionStore((state) =>
guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null),
);
// Комнаты сервера: в снапшоте они могут быть пустыми (сервер только открыли),
// поэтому тянем их по REST.
const channels = useQuery({
queryKey: guildChannelsQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchChannels(guildId ?? '', signal),
enabled: guildId !== null,
retry: 0,
});
const textChannels = useMemo(
() => (channels.data ?? []).filter((channel: Channel) => channel.type === 'text'),
[channels.data],
);
// Первую текстовую комнату выбираем сами: список только что загрузился.
useEffect(() => {
if (settingsChannelId !== null || textChannels.length === 0) {
return;
}
const first = textChannels[0];
if (first !== undefined) {
selectSettingsChannel(first.id);
}
}, [settingsChannelId, textChannels, selectSettingsChannel]);
if (guildId === null || guild === null) {
return (
<Card>
<p className="text-sm text-fg-muted">{t('settings.channel.noGuild')}</p>
</Card>
);
}
const isInstanceAdmin = currentUser.data?.is_instance_admin === true;
const canManage = canManageWebhooks(
guild.my_permissions ?? [],
guild.owner_id,
currentUser.data?.id,
isInstanceAdmin,
);
const active = textChannels.find((channel) => channel.id === settingsChannelId) ?? null;
return (
<>
{channels.isPending ? (
<Card>
<SkeletonLines rows={3} />
</Card>
) : null}
{channels.isError ? (
<Card>
<ErrorNotice error={channels.error} onRetry={() => void channels.refetch()} />
</Card>
) : null}
{channels.isSuccess && textChannels.length === 0 ? (
<Card>
<p className="text-sm text-fg-muted">{t('settings.channel.noChannels')}</p>
</Card>
) : null}
{textChannels.length === 0 ? null : (
<Card>
<SelectField
label={t('settings.channel.pick')}
value={active?.id ?? ''}
onChange={(value) => selectSettingsChannel(value)}
options={textChannels.map((channel) => ({
value: channel.id,
label: `#${channel.name}`,
}))}
/>
</Card>
)}
{active === null ? null : (
<>
<ChannelWebhooksSection
key={`${active.id}-webhooks`}
channelId={active.id}
canManage={canManage}
/>
{canManage ? null : (
<Card>
<p className="text-sm text-fg-muted">{t('settings.channel.noPermission')}</p>
</Card>
)}
</>
)}
</>
);
}
@@ -0,0 +1,381 @@
import { useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
channelWebhooksQueryKey,
createWebhook,
deleteWebhook,
fetchChannelWebhooks,
updateWebhook,
uploadWebhookAvatar,
type Webhook,
} from '@/api/webhooks';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Avatar } from '@/components/ui/Avatar';
import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { stepUp } from '@/api/auth';
import { errorCode } from '@/lib/format';
import { useCurrentUser } from '@/lib/hooks';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/** Сколько держится подсказка «ссылка скопирована», мс. */
const COPIED_HINT_MS = 2_000;
interface ChannelWebhooksSectionProps {
channelId: string;
/** Право MANAGE_WEBHOOKS: без него секция не показывается. */
canManage: boolean;
}
/**
* «Вебхуки» комнаты (AGENT.md 7.11): создание, смена имени и аватара,
* пересоздание токена и удаление. Ссылку собирает сервер — она и есть секрет
* исполнения, поэтому копируется целиком.
*
* Создание вебхука требует step-up (AGENT.md 9.3): если сервер ответил
* `auth.step_up_required`, показываем форму подтверждения и повторяем запрос.
*/
export function ChannelWebhooksSection({ channelId, canManage }: ChannelWebhooksSectionProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const nameInput = useRef<HTMLInputElement | null>(null);
const avatarInput = useRef<HTMLInputElement | null>(null);
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [createError, setCreateError] = useState<unknown>(null);
const [stepUpOpen, setStepUpOpen] = useState(false);
const [stepUpPassword, setStepUpPassword] = useState('');
const [stepUpCode, setStepUpCode] = useState('');
const [renaming, setRenaming] = useState<Webhook | null>(null);
const [renameValue, setRenameValue] = useState('');
const [copiedId, setCopiedId] = useState<string | null>(null);
const webhooks = useQuery({
queryKey: channelWebhooksQueryKey(channelId),
queryFn: ({ signal }) => fetchChannelWebhooks(channelId, signal),
enabled: canManage,
retry: 0,
});
const invalidate = (): void => {
void queryClient.invalidateQueries({ queryKey: channelWebhooksQueryKey(channelId) });
};
/** Загрузка аватара: файл уходит в общее хранилище, вебхук получает его id. */
const uploadAvatar = async (): Promise<string | undefined> => {
if (avatarFile === null) {
return undefined;
}
return uploadWebhookAvatar(channelId, avatarFile);
};
const create = useMutation({
mutationFn: async (stepUpFields: { password?: string; totp?: string }) => {
const avatarFileId = await uploadAvatar();
return createWebhook(channelId, {
name: name.trim(),
...(avatarFileId === undefined ? {} : { avatar_file_id: avatarFileId }),
...(stepUpFields.password === undefined ? {} : { step_up_password: stepUpFields.password }),
...(stepUpFields.totp === undefined ? {} : { step_up_totp: stepUpFields.totp }),
});
},
onSuccess: () => {
setCreateError(null);
setStepUpOpen(false);
setStepUpPassword('');
setStepUpCode('');
setName('');
setAvatarFile(null);
if (avatarInput.current !== null) {
avatarInput.current.value = '';
}
invalidate();
},
onError: (cause: unknown) => {
setCreateError(cause);
if (errorCode(cause) === 'auth.step_up_required') {
setStepUpOpen(true);
}
},
});
const confirmIdentity = useMutation({
mutationFn: () => stepUp(stepUpPassword, stepUpCode === '' ? undefined : stepUpCode),
onSuccess: () => {
setStepUpOpen(false);
create.mutate({});
},
});
const rename = useMutation({
mutationFn: (webhook: Webhook) => updateWebhook(webhook.id, { name: renameValue.trim() }),
onSuccess: () => {
setRenaming(null);
invalidate();
},
});
const rotate = useMutation({
mutationFn: (webhook: Webhook) => updateWebhook(webhook.id, { regenerate_token: true }),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (webhook: Webhook) => deleteWebhook(webhook.id),
onSuccess: invalidate,
});
if (!canManage) {
return null;
}
const copy = async (webhook: Webhook): Promise<void> => {
try {
await navigator.clipboard.writeText(webhook.url);
setCopiedId(webhook.id);
window.setTimeout(
() => setCopiedId((current) => (current === webhook.id ? null : current)),
COPIED_HINT_MS,
);
} catch {
// Буфер обмена недоступен: ссылку видно в поле.
setCopiedId(null);
}
};
return (
<SettingsSection
title={t('settings.channel.webhooks.title')}
description={t('settings.channel.webhooks.description')}
>
<div className="mt-4 flex flex-col gap-3" data-testid="channel-webhooks">
{webhooks.isPending ? <SkeletonLines rows={2} /> : null}
{webhooks.isError ? (
<ErrorNotice error={webhooks.error} onRetry={() => void webhooks.refetch()} />
) : null}
{webhooks.data?.length === 0 ? (
<p className="text-sm text-fg-muted" data-testid="channel-webhooks-empty">
{t('settings.channel.webhooks.empty')}
</p>
) : null}
<ul className="flex flex-col gap-2">
{(webhooks.data ?? []).map((webhook) => (
<li
key={webhook.id}
data-testid={`webhook-${webhook.id}`}
className="flex flex-col gap-2 rounded-[var(--radius-md)] border border-border/50 bg-surface-2 p-3"
>
<div className="flex items-center gap-3">
<Avatar
name={webhook.name}
seed={webhook.id}
fileId={webhook.avatar_file_id}
size="md"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{webhook.name}</p>
<p className="text-xs text-fg-muted">
{t('settings.channel.webhooks.createdAt', {
date: webhook.created_at.slice(0, 10),
})}
</p>
</div>
<Button variant="ghost" onClick={() => void copy(webhook)}>
{t(copiedId === webhook.id ? 'common.copied' : 'common.copy')}
</Button>
<Button
variant="ghost"
data-testid={`webhook-rotate-${webhook.id}`}
disabled={rotate.isPending}
onClick={() => rotate.mutate(webhook)}
>
{t('settings.channel.webhooks.rotate')}
</Button>
<Button
variant="ghost"
data-testid={`webhook-edit-${webhook.id}`}
onClick={() => {
setRenaming(webhook);
setRenameValue(webhook.name);
}}
>
{t('settings.channel.webhooks.rename')}
</Button>
<Button
variant="ghost"
className="border border-danger/50 text-danger"
data-testid={`webhook-delete-${webhook.id}`}
disabled={remove.isPending}
onClick={() => {
if (window.confirm(t('settings.channel.webhooks.deleteConfirm'))) {
remove.mutate(webhook);
}
}}
>
{t('common.delete')}
</Button>
</div>
<input
readOnly
value={webhook.url}
aria-label={t('settings.channel.webhooks.url')}
data-testid={`webhook-url-${webhook.id}`}
onFocus={(event) => event.target.select()}
className="w-full rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1 font-mono text-xs text-fg"
/>
</li>
))}
</ul>
{rotate.isError ? <ErrorNotice error={rotate.error} /> : null}
{remove.isError ? <ErrorNotice error={remove.error} /> : null}
<div className="flex items-center gap-2">
<Button data-testid="webhook-create-open" onClick={() => setOpen(true)}>
{t('settings.channel.webhooks.create')}
</Button>
<span className="text-xs text-fg-muted">
{t('settings.channel.webhooks.limit', { count: webhooks.data?.length ?? 0 })}
</span>
</div>
</div>
<Modal
open={open}
title={t('settings.channel.webhooks.create')}
onClose={() => setOpen(false)}
>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
setCreateError(null);
if (name.trim() === '') {
nameInput.current?.focus();
return;
}
create.mutate({});
}}
>
<Field
label={t('settings.channel.webhooks.name')}
name="webhook_name"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={80}
required
/>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">{t('settings.channel.webhooks.avatar')}</span>
<input
ref={avatarInput}
type="file"
accept="image/*"
data-testid="webhook-avatar-input"
onChange={(event) => setAvatarFile(event.target.files?.[0] ?? null)}
/>
<span className="text-xs text-fg-muted">
{t('settings.channel.webhooks.avatarHint')}
</span>
</label>
{createError === null || stepUpOpen ? null : <ErrorNotice error={createError} />}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={create.isPending}>
{t(create.isPending ? 'common.saving' : 'settings.channel.webhooks.submit')}
</Button>
</div>
</form>
{stepUpOpen ? (
<div className="mt-4 rounded-[var(--radius-md)] border border-warning/50 bg-warning/10 p-3">
<h4 className="text-sm font-semibold">{t('settings.security.stepUpTitle')}</h4>
<p className="mt-1 text-xs text-fg-muted">
{t('settings.channel.webhooks.stepUpBody', {
totp:
currentUser.data?.totp_enabled === true ? t('settings.security.stepUpTotp') : '',
})}
</p>
<form
className="mt-3 flex flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
confirmIdentity.mutate();
}}
>
<Field
label={t('settings.security.stepUpPassword')}
type="password"
name="step_up_password"
autoComplete="current-password"
value={stepUpPassword}
onChange={(event) => setStepUpPassword(event.target.value)}
required
/>
{currentUser.data?.totp_enabled === true ? (
<Field
label={t('auth.login.totp')}
name="step_up_totp"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={8}
value={stepUpCode}
onChange={(event) => setStepUpCode(event.target.value)}
/>
) : null}
{confirmIdentity.isError ? <ErrorNotice error={confirmIdentity.error} /> : null}
<div className="flex justify-end">
<Button type="submit" disabled={confirmIdentity.isPending}>
{t('settings.security.stepUpSubmit')}
</Button>
</div>
</form>
</div>
) : null}
</Modal>
<Modal
open={renaming !== null}
title={t('settings.channel.webhooks.rename')}
onClose={() => setRenaming(null)}
>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
if (renaming !== null && renameValue.trim() !== '') {
rename.mutate(renaming);
}
}}
>
<Field
label={t('settings.channel.webhooks.name')}
name="webhook_rename"
value={renameValue}
onChange={(event) => setRenameValue(event.target.value)}
maxLength={80}
required
/>
{rename.isError ? <ErrorNotice error={rename.error} /> : null}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setRenaming(null)}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={rename.isPending}>
{t('common.save')}
</Button>
</div>
</form>
</Modal>
</SettingsSection>
);
}
+3
View File
@@ -6,6 +6,7 @@ import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { useUiStore, type SettingsSection } from '@/stores/ui';
import AppearanceSettingsPage from '@/pages/settings/AppearanceSettingsPage';
import { ChannelSettingsPage } from '@/pages/settings/ChannelSettingsPage';
import GuildSettingsPage from '@/pages/settings/GuildSettingsPage';
import InstanceSettingsPage from '@/pages/settings/InstanceSettingsPage';
import { LanguageRegionSection } from '@/pages/settings/LanguageRegionSection';
@@ -100,6 +101,7 @@ export function SettingsModal() {
{ id: 'voice', label: t('settings.tabs.voice') },
{ id: 'language', label: t('settings.tabs.language') },
...(canManageGuilds ? [{ id: 'server' as const, label: t('settings.tabs.server') }] : []),
...(canManageGuilds ? [{ id: 'channel' as const, label: t('settings.tabs.channel') }] : []),
...(isAdmin ? [{ id: 'instance' as const, label: t('settings.tabs.instance') }] : []),
];
@@ -155,6 +157,7 @@ export function SettingsModal() {
{section === 'voice' ? <VoiceSettingsSection /> : null}
{section === 'language' ? <LanguageRegionSection /> : null}
{section === 'server' ? <GuildSettingsPage key={selectedGuildId ?? 'none'} /> : null}
{section === 'channel' ? <ChannelSettingsPage key={selectedGuildId ?? 'none'} /> : null}
{section === 'instance' ? <InstanceSettingsPage /> : null}
{section === 'profile' ? <ProfileTabs /> : null}
</div>