feat(guilds): приватные комнаты — права доступа поверх серверных

Сервер:
- store: оверрайды всех комнат сервера одним запросом (без N+1) и снятие
  оверрайда с признаком «был ли он»;
- API: PUT/DELETE /guilds/{id}/channels/{cid}/overwrites/{role|user}/{tid}
  (права именами через `|`, как у ролей), step-up на изменение, проверка что
  роль принадлежит серверу, а участник состоит в нём;
- список комнат отдаёт permission_overwrites; после правки сбрасывается кэш
  прав комнаты, пишется аудит (channel.overwrite_set/delete) и уходит событие
  GUILD_CHANNELS_SYNC — видимость комнаты меняется у всех участников.

Клиент:
- редактор «Доступ к комнате»: приватность одним переключателем (запрет
  VIEW_CHANNEL для @everyone), права просмотра/переписки/входа для ролей и
  участников, подтверждение личности по требованию сервера;
- в настройках комнаты теперь и голосовые комнаты (фон и права), вебхуки —
  только у текстовых; событие GUILD_CHANNELS_SYNC перечитывает список комнат.

Тесты: 2 Go-теста (скрытие и открытие комнаты ролями, проверка цели и прав) и
3 web-теста редактора (маски, step-up, скрытие без MANAGE_ROLES).
This commit is contained in:
2026-09-22 19:14:05 +03:00
parent b403f6c894
commit 785af0dc7b
16 changed files with 1569 additions and 219 deletions
+42 -1
View File
@@ -1,6 +1,6 @@
import { parseGuildEmojiList } from './emojis';
import { parseGuildSoundList } from './sounds';
import { userStatuses, type Channel, type UserStatus } from './types';
import { userStatuses, type Channel, type ChannelOverwrite, type UserStatus } from './types';
import type { GatewayUser } from './gateway';
import type { GuildEmoji } from './emojis';
import type { GuildSound } from './sounds';
@@ -284,6 +284,42 @@ export function parseSoundboardPlay(value: unknown): SoundboardPlayEvent | null
}
/** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */
/** `GUILD_CHANNELS_SYNC`: права комнат сервера изменились — перечитать список. */
export function parseGuildChannelsSyncEvent(value: unknown): { guild_id: string } | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const guildId = asString(record['guild_id']);
return guildId === undefined ? null : { guild_id: guildId };
}
/** parseChannelOverwrites читает список оверрайдов прав комнаты. */
export function parseChannelOverwrites(value: unknown): ChannelOverwrite[] {
if (!Array.isArray(value)) {
return [];
}
const result: ChannelOverwrite[] = [];
for (const item of value) {
const record = asRecord(item);
if (record === null) {
continue;
}
const targetType = asString(record['target_type']);
const targetId = asString(record['target_id']);
if ((targetType !== 'role' && targetType !== 'user') || targetId === undefined) {
continue;
}
result.push({
target_type: targetType,
target_id: targetId,
allow: asString(record['allow']) ?? '0',
deny: asString(record['deny']) ?? '0',
});
}
return result;
}
export function parseChannelPayload(value: unknown): Channel | null {
const record = asRecord(value);
if (record === null) {
@@ -327,6 +363,11 @@ export function parseChannelPayload(value: unknown): Channel | null {
if (typeof record['can_view'] === 'boolean') {
channel.can_view = record['can_view'];
}
// Права комнаты: нужны редактору доступа (AGENT.md 6.2).
const overwrites = parseChannelOverwrites(record['permission_overwrites']);
if (overwrites.length > 0) {
channel.permission_overwrites = overwrites;
}
// Фон комнаты: приходит в READY и CHANNEL_UPDATE (AGENT.md 7.5).
const backgroundFileId = asString(record['background_file_id']);
if (backgroundFileId !== undefined) {
+39
View File
@@ -120,6 +120,45 @@ export async function updateRole(
return payload.role;
}
/** Права комнаты в масках (AGENT.md 6.2): тем же способом, что права роли. */
export interface ChannelOverwriteInput {
allow: string;
deny: string;
step_up_password?: string;
step_up_totp?: string;
}
/**
* `PUT /guilds/{id}/channels/{cid}/overwrites/{type}/{tid}` — права комнаты
* для роли или участника поверх серверных.
*/
export async function setChannelOverwrite(
guildId: string,
channelId: string,
targetType: 'role' | 'user',
targetId: string,
body: ChannelOverwriteInput,
): Promise<Channel> {
const payload = await request<{ channel: Channel }>(
`/guilds/${encodeURIComponent(guildId)}/channels/${encodeURIComponent(channelId)}/overwrites/${targetType}/${encodeURIComponent(targetId)}`,
{ method: 'PUT', body },
);
return payload.channel;
}
/** `DELETE .../overwrites/{type}/{tid}` — снять права комнаты (нужен step-up). */
export function deleteChannelOverwrite(
guildId: string,
channelId: string,
targetType: 'role' | 'user',
targetId: string,
): Promise<{ ok: true }> {
return request<{ ok: true }>(
`/guilds/${encodeURIComponent(guildId)}/channels/${encodeURIComponent(channelId)}/overwrites/${targetType}/${encodeURIComponent(targetId)}`,
{ method: 'DELETE' },
);
}
export function joinGuild(guildId: string): Promise<{ ok: true }> {
return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/join`, { method: 'POST' });
}
+10
View File
@@ -75,6 +75,14 @@ export interface Role {
nick_effect?: string;
}
/** Оверрайд прав комнаты для роли или участника (AGENT.md 6.2). */
export interface ChannelOverwrite {
target_type: 'role' | 'user';
target_id: string;
allow: string;
deny: string;
}
export interface Channel {
id: string;
/** Фон комнаты: файл-картинка за лентой сообщений (AGENT.md 7.5). */
@@ -89,6 +97,8 @@ export interface Channel {
can_view?: boolean;
can_send?: boolean;
can_connect?: boolean;
/** Права комнаты поверх серверных (AGENT.md 6.2): маски приходят строками. */
permission_overwrites?: ChannelOverwrite[];
/**
* Поля личной беседы (`type: 'dm'`): id собеседника, его аватар и видимый
* статус. В снапшоте READY аватар приходит как `icon_file_id`, в REST-списке
+13 -1
View File
@@ -1182,7 +1182,19 @@
"upload": "Upload background",
"remove": "Remove background",
"notImage": "An image file is required",
"tooLarge": "File is larger than 5 MB"
"tooLarge": "File is larger than 5 MB",
"permissions": {
"title": "Channel access",
"description": "Channel permissions on top of server ones: a private channel is hidden from @everyone, access is granted to roles and individual members.",
"private": "Private channel (hide from @everyone)",
"everyone": "@everyone",
"view": "View",
"send": "Send",
"connect": "Connect",
"inherit": "Server default",
"allow": "Allow",
"deny": "Deny"
}
}
},
"badges": {
+13 -1
View File
@@ -1182,7 +1182,19 @@
"upload": "Загрузить фон",
"remove": "Убрать фон",
"notImage": "Нужен файл изображения",
"tooLarge": "Файл больше 5 МБ"
"tooLarge": "Файл больше 5 МБ",
"permissions": {
"title": "Доступ к комнате",
"description": "Права комнаты поверх серверных: приватная комната скрыта для @everyone, доступ выдают роли и отдельные участники.",
"private": "Приватная комната (скрыть от @everyone)",
"everyone": "@everyone",
"view": "Просмотр",
"send": "Писать",
"connect": "Вход",
"inherit": "Как на сервере",
"allow": "Разрешено",
"deny": "Запрещено"
}
}
},
"badges": {
@@ -0,0 +1,316 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
deleteChannelOverwrite,
fetchMembers,
fetchRoles,
guildChannelsQueryKey,
guildMembersQueryKey,
guildRolesQueryKey,
setChannelOverwrite,
} from '@/api/guilds';
import type { Channel, ChannelOverwrite, GuildMember, Role } from '@/api/types';
import { stepUp } from '@/api/auth';
import { Button, Card } from '@/components/ui/primitives';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { errorCode } from '@/lib/format';
import { useCurrentUser } from '@/lib/hooks';
/** Имена прав, которыми управляет редактор доступа (AGENT.md 6.2). */
const VIEW_CHANNEL = 'VIEW_CHANNEL';
const SEND_MESSAGES = 'SEND_MESSAGES';
const CONNECT_VOICE = 'CONNECT_VOICE';
/** Три состояния права: как на сервере, разрешено, запрещено. */
type TriState = 'inherit' | 'allow' | 'deny';
/** Действие над оверрайдом: задать права или снять цель целиком. */
type PendingAction =
| { kind: 'set'; targetType: 'role' | 'user'; targetId: string; allow: string; deny: string }
| { kind: 'delete'; targetType: 'role' | 'user'; targetId: string };
/** names разбирает строку прав «A|B» в список, отбрасывая пустые значения. */
function names(value: string | undefined): string[] {
return (value ?? '').split('|').filter((item) => item !== '');
}
interface ChannelPermissionsSectionProps {
guildId: string | null;
channel: Channel;
}
/** stateOf читает состояние одного права из оверрайда. */
function stateOf(overwrite: ChannelOverwrite | undefined, name: string): TriState {
if (overwrite === undefined) {
return 'inherit';
}
if (names(overwrite.allow).includes(name)) {
return 'allow';
}
if (names(overwrite.deny).includes(name)) {
return 'deny';
}
return 'inherit';
}
/** applyState меняет одно право, сохраняя остальные (AGENT.md 6.2). */
function applyState(
overwrite: ChannelOverwrite | undefined,
name: string,
state: TriState,
): { allow: string; deny: string } {
const allow = names(overwrite?.allow).filter((item) => item !== name);
const deny = names(overwrite?.deny).filter((item) => item !== name);
if (state === 'allow') {
allow.push(name);
}
if (state === 'deny') {
deny.push(name);
}
return { allow: allow.join('|'), deny: deny.join('|') };
}
/**
* Редактор доступа к комнате (AGENT.md 6.2, 7.5): приватность для @everyone
* плюс права ролей и участников по каждому из ключевых прав. Изменение прав
* требует подтверждения личности (AGENT.md 9.3) — сервер отвечает
* `auth.step_up_required`, после чего действие повторяется со step-up.
*/
export function ChannelPermissionsSection({ guildId, channel }: ChannelPermissionsSectionProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const [actionError, setActionError] = useState<unknown>(null);
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
const [stepUpPassword, setStepUpPassword] = useState('');
const [stepUpCode, setStepUpCode] = useState('');
const roles = useQuery({
queryKey: guildRolesQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchRoles(guildId ?? '', signal),
enabled: guildId !== null,
retry: 0,
});
const members = useQuery({
queryKey: guildMembersQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchMembers(guildId ?? '', signal),
enabled: guildId !== null,
retry: 0,
});
const overwrites = useMemo(() => channel.permission_overwrites ?? [], [channel]);
const defaultRole = useMemo(
() => (roles.data ?? []).find((role) => role.is_default) ?? null,
[roles.data],
);
const overwriteFor = (
targetType: 'role' | 'user',
targetId: string,
): ChannelOverwrite | undefined =>
overwrites.find((item) => item.target_type === targetType && item.target_id === targetId);
const isPrivate =
stateOf(
defaultRole === null ? undefined : overwriteFor('role', defaultRole.id),
VIEW_CHANNEL,
) === 'deny';
const invalidate = (): void => {
void queryClient.invalidateQueries({ queryKey: guildChannelsQueryKey(guildId ?? '') });
};
const action = useMutation({
mutationFn: async (input: PendingAction) => {
if (guildId === null) {
return;
}
if (input.kind === 'set') {
await setChannelOverwrite(guildId, channel.id, input.targetType, input.targetId, {
allow: input.allow,
deny: input.deny,
});
return;
}
await deleteChannelOverwrite(guildId, channel.id, input.targetType, input.targetId);
},
onSuccess: () => {
setActionError(null);
setPendingAction(null);
setStepUpPassword('');
setStepUpCode('');
invalidate();
},
onError: (cause: unknown, variables) => {
setActionError(cause);
// Сервер требует свежее подтверждение личности (AGENT.md 9.3): показываем
// форму и повторяем то же действие после успешного step-up.
if (errorCode(cause) === 'auth.step_up_required') {
setPendingAction(variables);
}
},
});
const confirmIdentity = useMutation({
mutationFn: () => stepUp(stepUpPassword, stepUpCode === '' ? undefined : stepUpCode),
onSuccess: () => {
const retry = pendingAction;
setStepUpPassword('');
setStepUpCode('');
if (retry !== null) {
action.mutate(retry);
}
},
});
if (guildId === null) {
return null;
}
const permissions: { name: string; label: string }[] =
channel.type === 'voice'
? [
{ name: VIEW_CHANNEL, label: t('settings.channel.permissions.view') },
{ name: CONNECT_VOICE, label: t('settings.channel.permissions.connect') },
]
: [
{ name: VIEW_CHANNEL, label: t('settings.channel.permissions.view') },
{ name: SEND_MESSAGES, label: t('settings.channel.permissions.send') },
];
const rows: { key: string; title: string; targetType: 'role' | 'user'; targetId: string }[] = [
...(roles.data ?? []).map((role: Role) => ({
key: `role-${role.id}`,
title: role.is_default ? t('settings.channel.permissions.everyone') : role.name,
targetType: 'role' as const,
targetId: role.id,
})),
...(members.data ?? []).map((member: GuildMember) => ({
key: `user-${member.user_id}`,
title: `${member.display_name} @${member.username ?? ''}`,
targetType: 'user' as const,
targetId: member.user_id,
})),
];
const togglePrivate = (next: boolean): void => {
if (defaultRole === null) {
return;
}
const existing = overwriteFor('role', defaultRole.id);
if (!next) {
// Снимаем оверрайд @everyone целиком: комната снова как на сервере.
action.mutate({ kind: 'delete', targetType: 'role', targetId: defaultRole.id });
return;
}
const masks = applyState(existing, VIEW_CHANNEL, 'deny');
action.mutate({ kind: 'set', targetType: 'role', targetId: defaultRole.id, ...masks });
};
const pending = action.isPending || confirmIdentity.isPending;
return (
<Card>
<h3 className="text-base font-semibold">{t('settings.channel.permissions.title')}</h3>
<p className="mt-1 text-sm text-fg-muted">{t('settings.channel.permissions.description')}</p>
{actionError === null ? null : <ErrorNotice className="mt-3" error={actionError} />}
{pendingAction === null ? null : (
<form
className="mt-3 flex flex-wrap items-end gap-2 rounded-[var(--radius-md)] border border-warning/50 bg-warning/10 p-2"
data-testid="channel-permissions-step-up"
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}
<Button type="submit" disabled={confirmIdentity.isPending}>
{t('settings.security.stepUpSubmit')}
</Button>
<Button variant="ghost" onClick={() => setPendingAction(null)}>
{t('common.cancel')}
</Button>
</form>
)}
<label className="mt-3 flex items-center gap-2 text-sm">
<input
type="checkbox"
data-testid="channel-private-toggle"
checked={isPrivate}
disabled={pending || defaultRole === null}
onChange={(event) => togglePrivate(event.target.checked)}
/>
{t('settings.channel.permissions.private')}
</label>
<div className="mt-4 flex flex-col gap-2" data-testid="channel-permissions">
{rows.map((row) => {
const overwrite = overwriteFor(row.targetType, row.targetId);
const canEdit = !(row.targetType === 'user' && row.targetId === currentUser.data?.id);
return (
<div
key={row.key}
className="flex flex-wrap items-center gap-3 border-b border-border/40 py-2 last:border-b-0"
data-testid={`channel-permission-row-${row.targetId}`}
>
<span className="min-w-0 flex-1 truncate">{row.title}</span>
{permissions.map((permission) => (
<label key={permission.label} className="flex items-center gap-1 text-xs">
<span className="text-fg-muted">{permission.label}</span>
<select
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-1 py-0.5"
data-testid={`channel-permission-${row.targetId}-${permission.name.toLowerCase()}`}
value={stateOf(overwrite, permission.name)}
disabled={pending || !canEdit}
onChange={(event) => {
const masks = applyState(
overwrite,
permission.name,
event.target.value as TriState,
);
action.mutate({
kind: 'set',
targetType: row.targetType,
targetId: row.targetId,
...masks,
});
}}
>
<option value="inherit">{t('settings.channel.permissions.inherit')}</option>
<option value="allow">{t('settings.channel.permissions.allow')}</option>
<option value="deny">{t('settings.channel.permissions.deny')}</option>
</select>
</label>
))}
</div>
);
})}
</div>
</Card>
);
}
+37 -16
View File
@@ -13,6 +13,7 @@ import { canManageWebhooks } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { ChannelBackgroundSection } from '@/pages/settings/ChannelBackgroundSection';
import { ChannelPermissionsSection } from '@/pages/settings/ChannelPermissionsSection';
import { ChannelWebhooksSection } from '@/pages/settings/ChannelWebhooksSection';
import { GuildPicker } from '@/pages/settings/GuildPicker';
@@ -80,21 +81,26 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
retry: 0,
});
const textChannels = useMemo(
() => (channels.data ?? []).filter((channel: Channel) => channel.type === 'text'),
// В списке и текстовые, и голосовые комнаты: у голосовых настраиваются фон и
// права доступа (AGENT.md 7.5), вебхуки — только у текстовых.
const selectableChannels = useMemo(
() =>
(channels.data ?? []).filter(
(channel: Channel) => channel.type === 'text' || channel.type === 'voice',
),
[channels.data],
);
// Первую текстовую комнату выбираем сами: список только что загрузился.
// Первую комнату выбираем сами: список только что загрузился.
useEffect(() => {
if (settingsChannelId !== null || textChannels.length === 0) {
if (settingsChannelId !== null || selectableChannels.length === 0) {
return;
}
const first = textChannels[0];
const first = selectableChannels[0];
if (first !== undefined) {
selectSettingsChannel(first.id);
}
}, [settingsChannelId, textChannels, selectSettingsChannel]);
}, [settingsChannelId, selectableChannels, selectSettingsChannel]);
if (guildId === null || guild === null) {
return (
@@ -111,13 +117,19 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
currentUser.data?.id,
isInstanceAdmin,
);
// Права комнаты меняет тот, кто управляет ролями (AGENT.md 6.2).
const canManagePermissions =
isInstanceAdmin ||
guild.owner_id === currentUser.data?.id ||
(guild.my_permissions ?? []).includes('ADMINISTRATOR') ||
(guild.my_permissions ?? []).includes('MANAGE_ROLES');
// Фон комнаты — отдельное право MANAGE_CHANNEL_BACKGROUND (AGENT.md 7.5).
const canManageBackground =
isInstanceAdmin ||
guild.owner_id === currentUser.data?.id ||
(guild.my_permissions ?? []).includes('ADMINISTRATOR') ||
(guild.my_permissions ?? []).includes('MANAGE_CHANNEL_BACKGROUND');
const active = textChannels.find((channel) => channel.id === settingsChannelId) ?? null;
const active = selectableChannels.find((channel) => channel.id === settingsChannelId) ?? null;
return (
<>
@@ -131,21 +143,21 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
<ErrorNotice error={channels.error} onRetry={() => void channels.refetch()} />
</Card>
) : null}
{channels.isSuccess && textChannels.length === 0 ? (
{channels.isSuccess && selectableChannels.length === 0 ? (
<Card>
<p className="text-sm text-fg-muted">{t('settings.channel.noChannels')}</p>
</Card>
) : null}
{textChannels.length === 0 ? null : (
{selectableChannels.length === 0 ? null : (
<Card>
<SelectField
label={t('settings.channel.pick')}
value={active?.id ?? ''}
onChange={(value) => selectSettingsChannel(value)}
options={textChannels.map((channel) => ({
options={selectableChannels.map((channel) => ({
value: channel.id,
label: `#${channel.name}`,
label: `${channel.type === 'voice' ? '🔊' : '#'}${channel.name}`,
}))}
/>
</Card>
@@ -159,11 +171,20 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
backgroundFileId={active.background_file_id}
canManage={canManageBackground}
/>
<ChannelWebhooksSection
key={`${active.id}-webhooks`}
channelId={active.id}
canManage={canManage}
/>
{active.type === 'text' ? (
<ChannelWebhooksSection
key={`${active.id}-webhooks`}
channelId={active.id}
canManage={canManage}
/>
) : null}
{canManagePermissions ? (
<ChannelPermissionsSection
key={`${active.id}-permissions`}
guildId={guildId}
channel={active}
/>
) : null}
{canManage ? null : (
<Card>
<p className="text-sm text-fg-muted">{t('settings.channel.noPermission')}</p>
+10
View File
@@ -11,6 +11,7 @@ import {
} from '@/api/gateway';
import {
parseChannelDelete,
parseGuildChannelsSyncEvent,
parseChannelPayload,
parseDmChannelEvent,
parseGuildEmojisUpdate,
@@ -334,6 +335,15 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
}
return;
}
case 'GUILD_CHANNELS_SYNC': {
// Права комнаты изменились: видимость может поменяться у всех
// участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6).
const sync = parseGuildChannelsSyncEvent(event.d);
if (sync !== null) {
void session.reloadGuildChannels(sync.guild_id);
}
return;
}
case 'DM_CHANNEL_CREATE': {
// В событии только channel_id: список бесед забираем по REST.
const direct = parseDmChannelEvent(event.d);
+12
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import type { GuildEmoji } from '@/api/emojis';
import { fetchChannels } from '@/api/guilds';
import { fetchDirectChannels } from '@/api/friends';
import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
import type { GuildSound, SoundKind } from '@/api/sounds';
@@ -113,6 +114,8 @@ interface SessionState {
reloadDmChannels: () => Promise<void>;
/** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */
removeChannel: (guildId: string, channelId: string) => void;
/** GUILD_CHANNELS_SYNC: перечитать видимые комнаты сервера (права изменились). */
reloadGuildChannels: (guildId: string) => Promise<void>;
/** Первая доступная (видимая) комната сервера — для выбора после удалений. */
firstChannelId: (guildId: string) => string | null;
selectGuild: (guildId: string | null) => void;
@@ -351,6 +354,15 @@ export const useSessionStore = create<SessionState>((set, get) => ({
});
},
reloadGuildChannels: async (guildId) => {
try {
const channels = await fetchChannels(guildId);
get().setGuildChannels(guildId, channels);
} catch {
// Сеть подвела: список обновится при следующем изменении прав.
}
},
setGuildChannels: (guildId, channels) => {
set({
guilds: get().guilds.map((guild) => (guild.id === guildId ? { ...guild, channels } : guild)),