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:
@@ -42,6 +42,8 @@ export const messageTypes = [
|
||||
'scream',
|
||||
'private',
|
||||
'system',
|
||||
/** Сообщение вебхука: автор — вебхук, а не пользователь (AGENT.md 7.11). */
|
||||
'webhook',
|
||||
] as const;
|
||||
export type MessageType = (typeof messageTypes)[number];
|
||||
|
||||
@@ -56,8 +58,13 @@ export interface Message {
|
||||
author_id?: string;
|
||||
content: string;
|
||||
reply_to_id?: string;
|
||||
/** Вид сообщения: `default`, `action`, `whisper`, `scream`, `private`, `system`. */
|
||||
/** Вид сообщения: `default`, `action`, `whisper`, `scream`, `private`, `system`, `webhook`. */
|
||||
type: string;
|
||||
/** Поля вебхука: у таких сообщений нет `author_id` (AGENT.md 7.11). */
|
||||
webhook_id?: string;
|
||||
webhook_name?: string;
|
||||
/** Ссылка на аватар вебхука: `/files/{id}` или внешний http(s) URL. */
|
||||
webhook_avatar?: string;
|
||||
edited_at?: string;
|
||||
pinned: boolean;
|
||||
attachments: MessageAttachment[];
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { ApiError, isApiErrorPayload, parsePayload, request } from './client';
|
||||
|
||||
/**
|
||||
* Вебхуки комнат (AGENT.md §7.11): управление требует MANAGE_WEBHOOKS,
|
||||
* исполнение идёт по ссылке с токеном и не требует сессии. Ссылку собирает
|
||||
* сервер — в ней правильный домен инстанса.
|
||||
*/
|
||||
|
||||
export interface Webhook {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
name: string;
|
||||
avatar_file_id?: string;
|
||||
token: string;
|
||||
created_by?: string;
|
||||
created_at: string;
|
||||
/** Готовая ссылка исполнения. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const channelWebhooksQueryKey = (channelId: string) =>
|
||||
['channels', channelId, 'webhooks'] as const;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
/** Разбирает вебхук из ответа сервера, отбрасывая лишнее. */
|
||||
function parseWebhook(value: unknown): Webhook | null {
|
||||
const record = asRecord(value);
|
||||
const id = asString(record?.['id']);
|
||||
const channelId = asString(record?.['channel_id']);
|
||||
const url = asString(record?.['url']);
|
||||
if (record === null || id === undefined || channelId === undefined || url === undefined) {
|
||||
return null;
|
||||
}
|
||||
const webhook: Webhook = {
|
||||
id,
|
||||
channel_id: channelId,
|
||||
guild_id: asString(record['guild_id']) ?? '',
|
||||
name: asString(record['name']) ?? '',
|
||||
token: asString(record['token']) ?? '',
|
||||
created_at: asString(record['created_at']) ?? '',
|
||||
url,
|
||||
};
|
||||
const avatar = asString(record['avatar_file_id']);
|
||||
const createdBy = asString(record['created_by']);
|
||||
if (avatar !== undefined) {
|
||||
webhook.avatar_file_id = avatar;
|
||||
}
|
||||
if (createdBy !== undefined) {
|
||||
webhook.created_by = createdBy;
|
||||
}
|
||||
return webhook;
|
||||
}
|
||||
|
||||
function parseWebhookList(payload: unknown): Webhook[] {
|
||||
const record = asRecord(payload);
|
||||
const list = record?.['webhooks'];
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
}
|
||||
return list.map((item) => parseWebhook(item)).filter((item): item is Webhook => item !== null);
|
||||
}
|
||||
|
||||
/** `GET /channels/{id}/webhooks` — вебхуки комнаты (право MANAGE_WEBHOOKS). */
|
||||
export async function fetchChannelWebhooks(
|
||||
channelId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Webhook[]> {
|
||||
const payload = await request<unknown>(
|
||||
`/channels/${encodeURIComponent(channelId)}/webhooks`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return parseWebhookList(payload);
|
||||
}
|
||||
|
||||
export interface CreateWebhookInput {
|
||||
name: string;
|
||||
avatar_file_id?: string;
|
||||
/** Step-up: создание вебхука требует свежего подтверждения (AGENT.md 9.3). */
|
||||
step_up_password?: string;
|
||||
step_up_totp?: string;
|
||||
}
|
||||
|
||||
/** `POST /channels/{id}/webhooks` — создать вебхук. */
|
||||
export async function createWebhook(
|
||||
channelId: string,
|
||||
input: CreateWebhookInput,
|
||||
): Promise<Webhook> {
|
||||
const payload = await request<{ webhook?: unknown }>(
|
||||
`/channels/${encodeURIComponent(channelId)}/webhooks`,
|
||||
{ method: 'POST', body: input },
|
||||
);
|
||||
const webhook = parseWebhook(payload.webhook);
|
||||
if (webhook === null) {
|
||||
throw new Error('malformed webhook payload');
|
||||
}
|
||||
return webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /channels/{id}/webhooks/avatar` — картинка аватара (multipart, право
|
||||
* MANAGE_WEBHOOKS). Тело — форма, поэтому запрос собирается напрямую.
|
||||
*/
|
||||
export async function uploadWebhookAvatar(channelId: string, file: File): Promise<string> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const response = await fetch(
|
||||
`/api/v1/channels/${encodeURIComponent(channelId)}/webhooks/avatar`,
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' },
|
||||
body: form,
|
||||
},
|
||||
);
|
||||
const payload = parsePayload(await response.text(), response.headers.get('Content-Type'));
|
||||
if (!response.ok) {
|
||||
const envelope =
|
||||
typeof payload === 'object' && payload !== null
|
||||
? (payload as { error?: unknown }).error
|
||||
: undefined;
|
||||
if (isApiErrorPayload(envelope)) {
|
||||
throw new ApiError(response.status, envelope.code, envelope.message, envelope.details);
|
||||
}
|
||||
throw new ApiError(response.status, 'internal.error', 'unexpected response');
|
||||
}
|
||||
const uploaded = (payload as { file?: { file_id?: unknown } } | null)?.file;
|
||||
const fileId = uploaded?.file_id;
|
||||
if (typeof fileId !== 'string' || fileId === '') {
|
||||
throw new ApiError(response.status, 'internal.error', 'unexpected response');
|
||||
}
|
||||
return fileId;
|
||||
}
|
||||
|
||||
export interface UpdateWebhookInput {
|
||||
name?: string;
|
||||
avatar_file_id?: string;
|
||||
clear_avatar?: boolean;
|
||||
regenerate_token?: boolean;
|
||||
}
|
||||
|
||||
/** `PATCH /webhooks/{id}` — переименовать, сменить аватар или токен. */
|
||||
export async function updateWebhook(
|
||||
webhookId: string,
|
||||
input: UpdateWebhookInput,
|
||||
): Promise<Webhook> {
|
||||
const payload = await request<{ webhook?: unknown }>(
|
||||
`/webhooks/${encodeURIComponent(webhookId)}`,
|
||||
{ method: 'PATCH', body: input },
|
||||
);
|
||||
const webhook = parseWebhook(payload.webhook);
|
||||
if (webhook === null) {
|
||||
throw new Error('malformed webhook payload');
|
||||
}
|
||||
return webhook;
|
||||
}
|
||||
|
||||
/** `DELETE /webhooks/{id}` — удалить вебхук. */
|
||||
export async function deleteWebhook(webhookId: string): Promise<void> {
|
||||
await request(`/webhooks/${encodeURIComponent(webhookId)}`, { method: 'DELETE' });
|
||||
}
|
||||
@@ -239,7 +239,11 @@ export function MessageItem({
|
||||
const menuAnchor = useRef<HTMLButtonElement | null>(null);
|
||||
const editorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const author = authors.resolve(message.author_id);
|
||||
// Сообщение вебхука (AGENT.md 7.11): автора-пользователя у него нет.
|
||||
const author =
|
||||
message.type === 'webhook'
|
||||
? authors.resolveWebhook(message)
|
||||
: authors.resolve(message.author_id);
|
||||
const isMine = currentUserId !== null && message.author_id === currentUserId;
|
||||
const canEdit = isMine && canSend && !pending;
|
||||
const canDelete = !pending && (isMine || canManageMessages);
|
||||
|
||||
@@ -219,7 +219,10 @@ export function MessageList({
|
||||
return null;
|
||||
}
|
||||
const headType = messageTypeOf(first.type);
|
||||
const author = authors.resolve(row.group.authorId ?? undefined);
|
||||
const author =
|
||||
headType === 'webhook'
|
||||
? authors.resolveWebhook(first)
|
||||
: authors.resolve(row.group.authorId ?? undefined);
|
||||
// У системных сообщений нет ни аватара, ни шапки автора, а действие
|
||||
// `/me` рисует имя внутри строки — шапка ему тоже не нужна.
|
||||
const showHeader = headType !== 'system' && headType !== 'action';
|
||||
@@ -275,6 +278,7 @@ export function MessageList({
|
||||
name={author.name}
|
||||
seed={author.id === '' ? row.key : author.id}
|
||||
fileId={author.avatarFileId}
|
||||
url={author.avatarUrl}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
@@ -289,6 +293,14 @@ export function MessageList({
|
||||
>
|
||||
{author.name}
|
||||
</span>
|
||||
{headType === 'webhook' ? (
|
||||
<span
|
||||
data-testid="webhook-badge"
|
||||
className="rounded-full border border-accent/40 px-1.5 text-[10px] uppercase text-accent"
|
||||
>
|
||||
{t('chat.webhookBadge')}
|
||||
</span>
|
||||
) : null}
|
||||
{author.isInstanceAdmin ? (
|
||||
<span className="rounded-full border border-accent/40 px-1.5 text-[10px] uppercase text-accent">
|
||||
{t('chat.adminBadge')}
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface ChatAuthor {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarFileId: string | undefined;
|
||||
/** Готовая ссылка на аватар: у вебхуков нет файла в профиле (AGM 7.11). */
|
||||
avatarUrl?: string | undefined;
|
||||
colorHex: string | null;
|
||||
isInstanceAdmin: boolean;
|
||||
isMember: boolean;
|
||||
@@ -27,6 +29,15 @@ export interface ChatAuthor {
|
||||
|
||||
export interface AuthorDirectory {
|
||||
resolve: (userId: string | undefined) => ChatAuthor;
|
||||
/**
|
||||
* Автор сообщения вебхука (AGENT.md 7.11): имя и аватар лежат в самом
|
||||
* сообщении, поэтому справочник участников для них не нужен.
|
||||
*/
|
||||
resolveWebhook: (message: {
|
||||
webhook_id?: string | undefined;
|
||||
webhook_name?: string | undefined;
|
||||
webhook_avatar?: string | undefined;
|
||||
}) => ChatAuthor;
|
||||
resolveMention: (userId: string) => { name: string; isMe: boolean };
|
||||
/**
|
||||
* Имя участника или `null`, если он неизвестен (например, вышел с сервера
|
||||
@@ -161,8 +172,26 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
};
|
||||
};
|
||||
|
||||
const resolveWebhook = (message: {
|
||||
webhook_id?: string | undefined;
|
||||
webhook_name?: string | undefined;
|
||||
webhook_avatar?: string | undefined;
|
||||
}): ChatAuthor => ({
|
||||
id: message.webhook_id ?? '',
|
||||
name:
|
||||
message.webhook_name === undefined || message.webhook_name === ''
|
||||
? t('chat.webhookAuthor')
|
||||
: message.webhook_name,
|
||||
avatarFileId: undefined,
|
||||
avatarUrl: message.webhook_avatar,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isMember: false,
|
||||
});
|
||||
|
||||
return {
|
||||
resolve,
|
||||
resolveWebhook,
|
||||
resolveMention: (userId) => {
|
||||
const author = resolve(userId);
|
||||
return { name: author.name, isMe: userId === currentUserId };
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { avatarUrl, hueFromId, initials } from '@/lib/identity';
|
||||
|
||||
/**
|
||||
* Безопасная ссылка на картинку аватара: файл инстанса или внешний http(s).
|
||||
* Всё остальное (в том числе `javascript:`) игнорируется.
|
||||
*/
|
||||
function safeImageUrl(url: string | undefined): string | null {
|
||||
if (url === undefined || url === '') {
|
||||
return null;
|
||||
}
|
||||
return url.startsWith('/files/') || url.startsWith('https://') || url.startsWith('http://')
|
||||
? url
|
||||
: null;
|
||||
}
|
||||
|
||||
interface AvatarProps {
|
||||
name: string;
|
||||
/** Стабильный id для оттенка заглушки. */
|
||||
seed: string;
|
||||
fileId?: string | undefined;
|
||||
/** Готовая ссылка (аватар вебхука) вместо `fileId`. */
|
||||
url?: string | undefined;
|
||||
/** Локальный предпросмотр (свежевыбранный файл) вместо `fileId`. */
|
||||
preview?: string | undefined;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
@@ -28,11 +43,12 @@ export function Avatar({
|
||||
name,
|
||||
seed,
|
||||
fileId,
|
||||
url,
|
||||
preview,
|
||||
size = 'md',
|
||||
shape = 'circle',
|
||||
}: AvatarProps) {
|
||||
const src = preview ?? avatarUrl(fileId);
|
||||
const src = preview ?? safeImageUrl(url) ?? avatarUrl(fileId);
|
||||
const hue = hueFromId(seed);
|
||||
const radius = shape === 'circle' ? 'rounded-full' : 'rounded-[var(--radius-lg)]';
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"back": "Back",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"delete": "Delete",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"optional": "optional",
|
||||
@@ -247,7 +248,8 @@
|
||||
"inviteLink": "Invite link",
|
||||
"inviteCopy": "Copy",
|
||||
"inviteCopied": "Copied",
|
||||
"inviteNew": "New link"
|
||||
"inviteNew": "New link",
|
||||
"settingsChannel": "Channel settings"
|
||||
},
|
||||
"voice": {
|
||||
"title": "Voice room",
|
||||
@@ -558,7 +560,9 @@
|
||||
"channelCreated": "Room #{{name}} created",
|
||||
"voiceChannelCreated": "Voice room {{name}} created",
|
||||
"memberLeave": "{{name}} left the server"
|
||||
}
|
||||
},
|
||||
"webhookBadge": "webhook",
|
||||
"webhookAuthor": "webhook"
|
||||
},
|
||||
"user": {
|
||||
"panelLabel": "User panel",
|
||||
@@ -633,7 +637,8 @@
|
||||
"server": "Server",
|
||||
"voice": "Audio & video",
|
||||
"language": "Language & region",
|
||||
"instance": "Instance"
|
||||
"instance": "Instance",
|
||||
"channel": "Channel"
|
||||
},
|
||||
"profile": {
|
||||
"avatar.change": "Change avatar",
|
||||
@@ -1032,6 +1037,29 @@
|
||||
"locale": "Interface language",
|
||||
"localeHint": "Applies immediately.",
|
||||
"saved": "Region settings saved."
|
||||
},
|
||||
"channel": {
|
||||
"noGuild": "Pick a server to configure its channel.",
|
||||
"noChannels": "This server has no text channels.",
|
||||
"pick": "Channel",
|
||||
"noPermission": "Only the server owner and members with MANAGE_WEBHOOKS can manage the channel.",
|
||||
"webhooks": {
|
||||
"title": "Webhooks",
|
||||
"description": "A webhook posts messages to the channel without a user session: requests go to the tokenised URL.",
|
||||
"empty": "No webhooks yet.",
|
||||
"create": "Create webhook",
|
||||
"name": "Webhook name",
|
||||
"avatar": "Avatar",
|
||||
"avatarHint": "PNG, JPEG, WebP or GIF up to 2 MB.",
|
||||
"submit": "Create",
|
||||
"rename": "Rename",
|
||||
"rotate": "New link",
|
||||
"url": "Webhook URL",
|
||||
"createdAt": "created {{date}}",
|
||||
"limit": "{{count}} used",
|
||||
"deleteConfirm": "Delete the webhook? The link stops working, messages already sent stay.",
|
||||
"stepUpBody": "Creating a webhook requires confirming your identity: enter your password{{totp}}."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"back": "Назад",
|
||||
"copy": "Скопировать",
|
||||
"copied": "Скопировано",
|
||||
"delete": "Удалить",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"optional": "необязательно",
|
||||
@@ -247,7 +248,8 @@
|
||||
"inviteLink": "Ссылка приглашения",
|
||||
"inviteCopy": "Скопировать",
|
||||
"inviteCopied": "Скопировано",
|
||||
"inviteNew": "Новая ссылка"
|
||||
"inviteNew": "Новая ссылка",
|
||||
"settingsChannel": "Настройки комнаты"
|
||||
},
|
||||
"voice": {
|
||||
"title": "Голосовая комната",
|
||||
@@ -558,7 +560,9 @@
|
||||
"channelCreated": "Комната #{{name}} создана",
|
||||
"voiceChannelCreated": "Голосовая комната {{name}} создана",
|
||||
"memberLeave": "{{name}} покинул сервер"
|
||||
}
|
||||
},
|
||||
"webhookBadge": "вебхук",
|
||||
"webhookAuthor": "вебхук"
|
||||
},
|
||||
"user": {
|
||||
"panelLabel": "Панель пользователя",
|
||||
@@ -633,7 +637,8 @@
|
||||
"server": "Сервер",
|
||||
"voice": "Аудио-видео",
|
||||
"language": "Язык и регион",
|
||||
"instance": "Инстанс"
|
||||
"instance": "Инстанс",
|
||||
"channel": "Комната"
|
||||
},
|
||||
"profile": {
|
||||
"avatar.change": "Сменить аватар",
|
||||
@@ -1032,6 +1037,29 @@
|
||||
"locale": "Язык интерфейса",
|
||||
"localeHint": "Переключается сразу.",
|
||||
"saved": "Настройки региона сохранены."
|
||||
},
|
||||
"channel": {
|
||||
"noGuild": "Выберите сервер, чтобы настроить комнату.",
|
||||
"noChannels": "В этом сервере нет текстовых комнат.",
|
||||
"pick": "Комната",
|
||||
"noPermission": "Управлять комнатой может владелец сервера и участники с правом MANAGE_WEBHOOKS.",
|
||||
"webhooks": {
|
||||
"title": "Вебхуки",
|
||||
"description": "Вебхук отправляет сообщения в комнату без пользовательской сессии: запрос идёт на ссылку с токеном.",
|
||||
"empty": "Вебхуков пока нет.",
|
||||
"create": "Создать вебхук",
|
||||
"name": "Имя вебхука",
|
||||
"avatar": "Аватар",
|
||||
"avatarHint": "PNG, JPEG, WebP или GIF до 2 МБ.",
|
||||
"submit": "Создать",
|
||||
"rename": "Переименовать",
|
||||
"rotate": "Новая ссылка",
|
||||
"url": "Ссылка вебхука",
|
||||
"createdAt": "создан {{date}}",
|
||||
"limit": "занято {{count}}",
|
||||
"deleteConfirm": "Удалить вебхук? Ссылка перестанет работать, уже отправленные сообщения останутся.",
|
||||
"stepUpBody": "Создание вебхука требует подтверждения личности: введите пароль{{totp}}."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export const PERMISSION_CONNECT_VOICE = 'CONNECT_VOICE';
|
||||
export const PERMISSION_MUTE_MEMBERS = 'MUTE_MEMBERS';
|
||||
export const PERMISSION_DEAFEN_MEMBERS = 'DEAFEN_MEMBERS';
|
||||
export const PERMISSION_MOVE_MEMBERS = 'MOVE_MEMBERS';
|
||||
export const PERMISSION_MANAGE_WEBHOOKS = 'MANAGE_WEBHOOKS';
|
||||
|
||||
/** Есть ли у пользователя право с указанным именем. */
|
||||
export function hasPermission(permissions: readonly string[], name: string): boolean {
|
||||
@@ -190,6 +191,25 @@ export function canManageEmojis(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Может ли пользователь управлять вебхуками комнаты (AGENT.md 7.11):
|
||||
* владелец, ADMINISTRATOR, MANAGE_WEBHOOKS или администратор инстанса.
|
||||
*/
|
||||
export function canManageWebhooks(
|
||||
permissions: readonly string[],
|
||||
ownerId: string,
|
||||
userId: string | undefined,
|
||||
isInstanceAdmin = false,
|
||||
): boolean {
|
||||
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
|
||||
hasPermission(permissions, PERMISSION_MANAGE_WEBHOOKS)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Может ли пользователь банить участников (AGENT.md 7.17): владелец,
|
||||
* ADMINISTRATOR, BAN_MEMBERS или администратор инстанса.
|
||||
|
||||
@@ -134,6 +134,11 @@ export default function AppLayout() {
|
||||
if (guild === undefined || hydrated.current === guild.id) {
|
||||
return;
|
||||
}
|
||||
// Пока запрос летел, мог прийти снапшот: READY — источник правды, и
|
||||
// перезаписывать его ответом REST нельзя (права и оформление сервера).
|
||||
if (useSessionStore.getState().guilds.some((item) => item.id === guild.id)) {
|
||||
return;
|
||||
}
|
||||
hydrated.current = guild.id;
|
||||
upsertGuild({
|
||||
id: guild.id,
|
||||
|
||||
@@ -9,9 +9,10 @@ import { SearchPanel } from '@/components/chat/SearchPanel';
|
||||
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel';
|
||||
import { canManageMessages, canSendMessages } from '@/lib/identity';
|
||||
import { canManageMessages, canManageWebhooks, canSendMessages } from '@/lib/identity';
|
||||
import { useMessagesStore } from '@/stores/messages';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
|
||||
/**
|
||||
* Область контента комнаты: для текстовых комнат — чат (лента, поиск, пины),
|
||||
@@ -29,6 +30,7 @@ export default function GuildView() {
|
||||
const channel = guild?.channels.find((item) => item.id === params.channelId) ?? null;
|
||||
const channelId = channel?.id ?? null;
|
||||
|
||||
const openSettings = useUiStore((state) => state.openSettings);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [pinsOpen, setPinsOpen] = useState(false);
|
||||
const [jump, setJump] = useState<{ id: string; token: number } | null>(null);
|
||||
@@ -73,6 +75,13 @@ export default function GuildView() {
|
||||
const permissions = guild?.my_permissions ?? [];
|
||||
const isAdmin = sessionUser?.is_instance_admin === true;
|
||||
const canPin = canManageMessages(permissions, isAdmin) && canSendMessages(permissions, isAdmin);
|
||||
// Настройки комнаты (вебхуки, AGENT.md 7.11) — владельцу и MANAGE_WEBHOOKS.
|
||||
const canConfigure = canManageWebhooks(
|
||||
permissions,
|
||||
guild?.owner_id ?? '',
|
||||
sessionUser?.id,
|
||||
isAdmin,
|
||||
);
|
||||
// Счётчик пинов: пока панель не открывали, ориентируемся на загруженную ленту.
|
||||
const pinCount = pinsLoaded
|
||||
? (pins?.length ?? 0)
|
||||
@@ -80,7 +89,10 @@ export default function GuildView() {
|
||||
|
||||
return (
|
||||
<section id="app-content" className="absolute inset-0 flex flex-col">
|
||||
<header className="flex items-center gap-2 border-b border-border/40 px-4 py-3">
|
||||
<header
|
||||
data-testid="channel-header"
|
||||
className="flex items-center gap-2 border-b border-border/40 px-4 py-3"
|
||||
>
|
||||
<span aria-hidden="true" className="text-fg-muted">
|
||||
{isVoice ? '🔊' : '#'}
|
||||
</span>
|
||||
@@ -92,6 +104,24 @@ export default function GuildView() {
|
||||
)}
|
||||
{isVoice ? null : (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{canConfigure ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('channels.settingsChannel')}
|
||||
title={t('channels.settingsChannel')}
|
||||
data-testid="channel-settings"
|
||||
className="rounded-[var(--radius-sm)] px-1.5 text-base leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
|
||||
onClick={() =>
|
||||
openSettings({
|
||||
section: 'channel',
|
||||
guildId: guild?.id ?? null,
|
||||
channelId: channel.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('chat.search.open')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -57,6 +57,8 @@ interface SessionState {
|
||||
selectedChannelId: string | null;
|
||||
/** Последний сервер, открытый в настройках (`/settings/servers/:guildId`). */
|
||||
settingsGuildId: string | null;
|
||||
/** Последняя комната, открытая в настройках комнаты (вебхуки, AGENT.md 7.11). */
|
||||
settingsChannelId: string | null;
|
||||
|
||||
applyReady: (snapshot: GatewaySnapshot) => void;
|
||||
/** RESUMED приходит без снапшота — обновляем только то, что пришло. */
|
||||
@@ -117,6 +119,8 @@ interface SessionState {
|
||||
selectChannel: (guildId: string | null, channelId: string | null) => void;
|
||||
/** Выбор сервера в настройках — отдельно от выбора в приложении. */
|
||||
selectSettingsGuild: (guildId: string | null) => void;
|
||||
/** Выбор комнаты в настройках комнаты. */
|
||||
selectSettingsChannel: (channelId: string | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -129,6 +133,7 @@ interface SessionDefaults {
|
||||
selectedGuildId: null;
|
||||
selectedChannelId: null;
|
||||
settingsGuildId: null;
|
||||
settingsChannelId: null;
|
||||
}
|
||||
|
||||
const defaults: SessionDefaults = {
|
||||
@@ -140,8 +145,20 @@ const defaults: SessionDefaults = {
|
||||
selectedGuildId: null,
|
||||
selectedChannelId: null,
|
||||
settingsGuildId: null,
|
||||
settingsChannelId: null,
|
||||
};
|
||||
|
||||
/** Убирает свойства со значением `undefined`: они не должны затирать данные. */
|
||||
function withoutUndefined<T extends object>(value: T): Partial<T> {
|
||||
const result: Partial<T> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (item !== undefined) {
|
||||
result[key as keyof T] = item as T[keyof T];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toSessionGuild(guild: GatewayGuild): SessionGuild {
|
||||
const summary: SessionGuild = {
|
||||
id: guild.id,
|
||||
@@ -254,9 +271,15 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
|
||||
upsertGuild: (guild) => {
|
||||
const guilds = get().guilds;
|
||||
const index = guilds.findIndex((item) => item.id === guild.id);
|
||||
// Неполный ответ REST не должен затирать данные снапшота: свойства со
|
||||
// значением `undefined` в слияние не попадают.
|
||||
const patch = withoutUndefined(guild);
|
||||
const index = guilds.findIndex((item) => item.id === patch.id);
|
||||
if (index === -1) {
|
||||
set({ guilds: [...guilds, { ...guild, channels: [], emojis: [], sounds: [] }] });
|
||||
// Незнакомый сервер: сводки достаточно, комнаты догрузит сайдбар.
|
||||
set({
|
||||
guilds: [...guilds, { ...patch, channels: [], emojis: [], sounds: [] } as SessionGuild],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const existing = guilds[index];
|
||||
@@ -266,7 +289,7 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
// Сводка из REST не знает ни комнат, ни эмодзи, ни звуков — накопленное не теряем.
|
||||
const merged: SessionGuild = {
|
||||
...existing,
|
||||
...guild,
|
||||
...patch,
|
||||
channels: existing.channels,
|
||||
emojis: existing.emojis,
|
||||
sounds: existing.sounds,
|
||||
@@ -529,6 +552,10 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
set({ settingsGuildId: guildId });
|
||||
},
|
||||
|
||||
selectSettingsChannel: (channelId) => {
|
||||
set({ settingsChannelId: channelId });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({ ...defaults });
|
||||
},
|
||||
|
||||
@@ -14,6 +14,8 @@ export const settingsSections = [
|
||||
'language',
|
||||
/** Администрирование выбранного сервера. */
|
||||
'server',
|
||||
/** Настройки выбранной комнаты: вебхуки (AGENT.md 7.11). */
|
||||
'channel',
|
||||
'instance',
|
||||
] as const;
|
||||
export type SettingsSection = (typeof settingsSections)[number];
|
||||
@@ -28,6 +30,8 @@ export interface SettingsTarget {
|
||||
tab?: ProfileTab;
|
||||
/** Сервер для пункта «Сервер» и вкладки «Профиль → Сервер». */
|
||||
guildId?: string | null;
|
||||
/** Комната для пункта «Комната». */
|
||||
channelId?: string | null;
|
||||
}
|
||||
|
||||
interface UiState {
|
||||
@@ -116,6 +120,9 @@ export const useUiStore = create<UiState>((set, get) => ({
|
||||
if (target?.guildId !== undefined) {
|
||||
useSessionStore.getState().selectSettingsGuild(target.guildId);
|
||||
}
|
||||
if (target?.channelId !== undefined) {
|
||||
useSessionStore.getState().selectSettingsChannel(target.channelId);
|
||||
}
|
||||
set({
|
||||
settingsOpen: true,
|
||||
...(target?.section === undefined ? {} : { settingsSection: target.section }),
|
||||
|
||||
Reference in New Issue
Block a user