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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user