feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):
- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
не участник не получает прав роли @user; калькулятор прав общий для API и
Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.
Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useNavigate } from 'react-router';
|
||||
|
||||
import { createChannel, fetchChannels, guildChannelsQueryKey, leaveGuild } from '@/api/guilds';
|
||||
import { myGuildsQueryKey } from '@/api/users';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field, SelectField } from '@/components/ui/Field';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import { canManageGuild } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import type { Channel } from '@/api/types';
|
||||
|
||||
interface ChannelSidebarProps {
|
||||
guildId: string | null;
|
||||
channelId: string | null;
|
||||
}
|
||||
|
||||
interface ChannelGroup {
|
||||
category: Channel | null;
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
/** Группирует комнаты по категориям, сохраняя порядок `position`. */
|
||||
export function groupChannels(channels: Channel[]): ChannelGroup[] {
|
||||
const visible = channels
|
||||
.filter((channel) => channel.can_view !== false && channel.type !== 'category')
|
||||
.slice()
|
||||
.sort((left, right) => left.position - right.position);
|
||||
const categories = channels
|
||||
.filter((channel) => channel.type === 'category')
|
||||
.slice()
|
||||
.sort((left, right) => left.position - right.position);
|
||||
|
||||
const groups: ChannelGroup[] = [];
|
||||
const uncategorized = visible.filter((channel) => channel.parent_id === undefined);
|
||||
if (uncategorized.length > 0) {
|
||||
groups.push({ category: null, channels: uncategorized });
|
||||
}
|
||||
for (const category of categories) {
|
||||
const children = visible.filter((channel) => channel.parent_id === category.id);
|
||||
if (children.length > 0) {
|
||||
groups.push({ category, channels: children });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Сайдбар комнат выбранного сервера: категории, текстовые и голосовые комнаты. */
|
||||
export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
const guild = useSessionStore(
|
||||
useCallback((state) => state.guilds.find((item) => item.id === guildId) ?? null, [guildId]),
|
||||
);
|
||||
const setGuildChannels = useSessionStore((state) => state.setGuildChannels);
|
||||
const upsertChannel = useSessionStore((state) => state.upsertChannel);
|
||||
const removeGuild = useSessionStore((state) => state.removeGuild);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [channelName, setChannelName] = useState('');
|
||||
const [channelType, setChannelType] = useState<'text' | 'voice'>('text');
|
||||
const [parentId, setParentId] = useState('');
|
||||
|
||||
const channels = guild?.channels ?? [];
|
||||
const needsChannels = guildId !== null && guild !== null && channels.length === 0;
|
||||
|
||||
const remoteChannels = useQuery({
|
||||
queryKey: guildChannelsQueryKey(guildId ?? ''),
|
||||
queryFn: ({ signal }) => fetchChannels(guildId ?? '', signal),
|
||||
enabled: needsChannels,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (guildId === null || remoteChannels.data === undefined) {
|
||||
return;
|
||||
}
|
||||
setGuildChannels(guildId, remoteChannels.data);
|
||||
}, [guildId, remoteChannels.data, setGuildChannels]);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
createChannel(guildId ?? '', {
|
||||
name: channelName.trim(),
|
||||
type: channelType,
|
||||
...(parentId === '' ? {} : { parent_id: parentId }),
|
||||
}),
|
||||
onSuccess: (channel) => {
|
||||
if (guildId !== null) {
|
||||
upsertChannel(guildId, channel);
|
||||
}
|
||||
setCreateOpen(false);
|
||||
setChannelName('');
|
||||
void queryClient.invalidateQueries({ queryKey: guildChannelsQueryKey(guildId ?? '') });
|
||||
void navigate(`/app/${guildId ?? ''}/${channel.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const leave = useMutation({
|
||||
mutationFn: () => leaveGuild(guildId ?? ''),
|
||||
onSuccess: () => {
|
||||
if (guildId !== null) {
|
||||
removeGuild(guildId);
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
|
||||
void navigate('/app/empty', { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const groups = useMemo(() => groupChannels(channels), [channels]);
|
||||
const categories = channels.filter((channel) => channel.type === 'category');
|
||||
const canManage =
|
||||
guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id);
|
||||
|
||||
if (guildId === null) {
|
||||
return (
|
||||
<aside className="hidden w-60 shrink-0 border-r border-border/40 bg-surface-1 p-3 md:block" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label={t('channels.title')}
|
||||
className="flex w-60 shrink-0 flex-col border-r border-border/40 bg-surface-1"
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2 border-b border-border/40 px-3 py-3">
|
||||
<h1 className="truncate text-sm font-semibold" title={guild?.name}>
|
||||
{guild?.name ?? t('channels.notInGuild.title')}
|
||||
</h1>
|
||||
{canManage ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('channels.add')}
|
||||
title={t('channels.add')}
|
||||
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted hover:text-fg"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
||||
{guild === null ? (
|
||||
<p className="px-2 text-sm text-fg-muted">{t('channels.notInGuild.body')}</p>
|
||||
) : groups.length === 0 ? (
|
||||
<p className="px-2 text-sm text-fg-muted">{t('channels.empty')}</p>
|
||||
) : (
|
||||
<nav aria-label={t('channels.title')}>
|
||||
{groups.map((group) => (
|
||||
<section key={group.category?.id ?? 'uncategorized'} className="mb-3">
|
||||
{group.category === null ? null : (
|
||||
<h2 className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{group.category.name}
|
||||
</h2>
|
||||
)}
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{group.channels.map((channel) => (
|
||||
<li key={channel.id}>
|
||||
<NavLink
|
||||
to={`/app/${guildId}/${channel.id}`}
|
||||
aria-current={channel.id === channelId ? 'page' : undefined}
|
||||
title={channel.name}
|
||||
className={`flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm ${
|
||||
channel.id === channelId
|
||||
? 'bg-surface-3 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">{channel.type === 'voice' ? '🔊' : '#'}</span>
|
||||
<span className="truncate">{channel.name}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{remoteChannels.isError ? (
|
||||
<ErrorNotice
|
||||
className="mt-2"
|
||||
error={remoteChannels.error}
|
||||
onRetry={() => void remoteChannels.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-border/40 px-2 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
disabled={leave.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t('guilds.leaveConfirm', { name: guild?.name ?? '' }))) {
|
||||
leave.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('guilds.leave')}
|
||||
</Button>
|
||||
{leave.isError ? <ErrorNotice className="mt-2" error={leave.error} /> : null}
|
||||
</footer>
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title={t('channels.create.title')}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setCreateOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => create.mutate()}
|
||||
disabled={create.isPending || channelName.trim().length === 0}
|
||||
>
|
||||
{t(create.isPending ? 'common.creating' : 'channels.create.submit')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (channelName.trim() !== '') {
|
||||
create.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label={t('channels.create.name')}
|
||||
placeholder={t('channels.create.namePlaceholder')}
|
||||
value={channelName}
|
||||
onChange={(event) => setChannelName(event.target.value)}
|
||||
maxLength={64}
|
||||
/>
|
||||
<SelectField
|
||||
label={t('channels.create.type')}
|
||||
value={channelType}
|
||||
onChange={(value) => setChannelType(value === 'voice' ? 'voice' : 'text')}
|
||||
options={[
|
||||
{ value: 'text', label: t('channels.create.typeText') },
|
||||
{ value: 'voice', label: t('channels.create.typeVoice') },
|
||||
]}
|
||||
/>
|
||||
{categories.length === 0 ? null : (
|
||||
<SelectField
|
||||
label={t('channels.create.parent')}
|
||||
value={parentId}
|
||||
onChange={setParentId}
|
||||
options={[
|
||||
{ value: '', label: t('channels.create.noParent') },
|
||||
...categories.map((category) => ({
|
||||
value: category.id,
|
||||
label: category.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{create.isError ? <ErrorNotice error={create.error} /> : null}
|
||||
</form>
|
||||
</Modal>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user