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
@@ -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>