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:
2026-09-19 21:50:06 +03:00
parent 86dff94a02
commit 1b1a679827
63 changed files with 8477 additions and 187 deletions
+193
View File
@@ -0,0 +1,193 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Outlet, useLocation, useNavigate } from 'react-router';
import { createGuild, fetchGuild, guildQueryKey } from '@/api/guilds';
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { instanceQueryKey } from '@/api/instance';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { useInstance } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { ChannelSidebar } from '@/pages/app/ChannelSidebar';
import { GuildRail } from '@/pages/app/GuildRail';
import { UserPanel } from '@/pages/app/UserPanel';
import { errorCode } from '@/lib/format';
/**
* Оболочка приложения: рейка серверов, сайдбар комнат, контент и панель
* пользователя. Данные серверов приходят из снапшота шлюза, а если шлюз ещё
* не подключился — подстраховываемся REST-запросом `GET /users/@me/guilds`.
*/
export default function AppLayout() {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const instance = useInstance();
const guilds = useSessionStore((state) => state.guilds);
const selectedGuildId = useSessionStore((state) => state.selectedGuildId);
const selectChannel = useSessionStore((state) => state.selectChannel);
const upsertGuild = useSessionStore((state) => state.upsertGuild);
const [createOpen, setCreateOpen] = useState(false);
const [guildName, setGuildName] = useState('');
const segments = location.pathname.split('/').filter((segment) => segment !== '');
const guildId = segments[1] === undefined ? null : decodeURIComponent(segments[1]);
const channelId = segments[2] === undefined ? null : decodeURIComponent(segments[2]);
// URL — источник правды для выбранного сервера и комнаты.
useEffect(() => {
if (guildId === selectedGuildId) {
return;
}
selectChannel(guildId, channelId);
}, [guildId, channelId, selectedGuildId, selectChannel]);
const myGuilds = useQuery({
queryKey: myGuildsQueryKey,
queryFn: ({ signal }) => fetchMyGuilds(signal),
retry: 0,
});
// Список из снапшота шлюза; если он пуст — берём REST-ответ.
useEffect(() => {
if (guilds.length > 0 || myGuilds.data === undefined) {
return;
}
for (const guild of myGuilds.data) {
upsertGuild(guild);
}
}, [guilds.length, myGuilds.data, upsertGuild]);
const guildInSnapshot = guildId !== null && guilds.some((guild) => guild.id === guildId);
const fallbackGuild = useQuery({
queryKey: guildQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchGuild(guildId ?? '', signal),
enabled: guildId !== null && !guildInSnapshot,
retry: 0,
});
const hydrated = useRef<string | null>(null);
// Сервер есть в URL, но отсутствует в снапшоте (например, после RESUME).
useEffect(() => {
const guild = fallbackGuild.data;
if (guild === undefined || hydrated.current === guild.id) {
return;
}
hydrated.current = guild.id;
upsertGuild({
id: guild.id,
name: guild.name,
owner_id: guild.owner_id,
is_main: guild.is_main,
member_count: guild.member_count,
my_role_ids: guild.my_role_ids,
my_permissions: guild.my_permissions,
...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }),
});
}, [fallbackGuild.data, upsertGuild]);
const create = useMutation({
mutationFn: (name: string) => createGuild(name),
onSuccess: (guild) => {
setCreateOpen(false);
setGuildName('');
upsertGuild(guild);
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
void navigate(`/app/${guild.id}`);
},
});
const closeCreate = useCallback(() => {
setCreateOpen(false);
}, []);
const guildList = useMemo(
() => guilds.map((guild) => ({ id: guild.id, name: guild.name })),
[guilds],
);
const canCreate = instance.data?.allow_guild_creation !== false;
const limitReached =
instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
return (
<div className="flex h-full min-h-full flex-col">
<a
href="#app-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-2 focus:top-2 focus:z-50 focus:rounded-[var(--radius-sm)] focus:bg-surface-2 focus:px-3 focus:py-1"
>
{t('app.skipToContent')}
</a>
<div className="flex min-h-0 flex-1">
<GuildRail
guilds={guildList}
selectedGuildId={guildId}
canCreate={canCreate}
onCreate={() => setCreateOpen(true)}
/>
<ChannelSidebar guildId={guildId} channelId={channelId} />
<div className="flex min-w-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<Outlet />
</div>
<UserPanel />
</div>
</div>
<Modal
open={createOpen}
title={t('guilds.create.title')}
onClose={closeCreate}
footer={
<>
<Button variant="ghost" onClick={closeCreate}>
{t('common.cancel')}
</Button>
<Button
onClick={() => create.mutate(guildName.trim())}
disabled={create.isPending || guildName.trim().length === 0}
>
{t(create.isPending ? 'common.creating' : 'guilds.create.submit')}
</Button>
</>
}
>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
if (guildName.trim() !== '') {
create.mutate(guildName.trim());
}
}}
>
<Field
label={t('guilds.create.name')}
placeholder={t('guilds.create.namePlaceholder')}
value={guildName}
onChange={(event) => setGuildName(event.target.value)}
maxLength={64}
hint={t('guilds.create.hint')}
/>
{instance.data === undefined ? null : (
<p className="text-xs text-fg-muted">
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}
</p>
)}
{limitReached && errorCode(create.error) === null ? (
<p className="text-xs text-warning">{t('errors.auth.guild_limit_reached')}</p>
) : null}
{create.isError ? <ErrorNotice error={create.error} /> : null}
</form>
</Modal>
</div>
);
}
+275
View File
@@ -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>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useTranslation } from 'react-i18next';
import { NavLink } from 'react-router';
import { Avatar } from '@/components/ui/Avatar';
interface GuildRailProps {
guilds: { id: string; name: string }[];
selectedGuildId: string | null;
canCreate: boolean;
onCreate: () => void;
}
/** Левая рейка: иконки серверов-инициалов и кнопка создания сервера. */
export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: GuildRailProps) {
const { t } = useTranslation();
return (
<nav
aria-label={t('guilds.railLabel')}
className="flex w-[72px] shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border/40 bg-surface-2/60 py-3"
>
<ul className="flex flex-col items-center gap-2" data-testid="guild-rail">
{guilds.map((guild) => {
const active = guild.id === selectedGuildId;
return (
<li key={guild.id}>
<NavLink
to={`/app/${guild.id}`}
title={guild.name}
aria-label={t('guilds.switchTo', { name: guild.name })}
aria-current={active ? 'page' : undefined}
className={`block rounded-[var(--radius-md)] transition-[border-radius,background-color] duration-[var(--duration-fast)] ${
active
? 'rounded-[var(--radius-lg)] ring-2 ring-accent'
: 'hover:rounded-[var(--radius-lg)]'
}`}
>
<Avatar name={guild.name} seed={guild.id} size="lg" shape="square" />
</NavLink>
</li>
);
})}
</ul>
{canCreate ? (
<button
type="button"
onClick={onCreate}
aria-label={t('guilds.add')}
title={t('guilds.add')}
className="flex h-14 w-14 items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-surface-1 text-2xl text-success hover:rounded-[var(--radius-lg)] hover:border-success"
>
+
</button>
) : null}
</nav>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { Card } from '@/components/ui/primitives';
import { useSessionStore } from '@/stores/session';
/**
* Область контента комнаты. В Фазе 1 — заглушка: сообщения, голос и видео
* появятся в следующих фазах, но маршрутизация и выбор комнаты уже работают.
*/
export default function GuildView() {
const { t } = useTranslation();
const params = useParams<{ guildId?: string; channelId?: string }>();
const guild = useSessionStore((state) =>
params.guildId === undefined
? null
: (state.guilds.find((item) => item.id === params.guildId) ?? null),
);
const channel = guild?.channels.find((item) => item.id === params.channelId) ?? null;
if (params.channelId === undefined || channel === null) {
return (
<section id="app-content" className="flex h-full items-center justify-center p-6">
<Card className="max-w-md text-center">
<h2 className="text-lg font-semibold">{t('channels.noSelection.title')}</h2>
<p className="mt-2 text-sm text-fg-muted">{t('channels.noSelection.body')}</p>
</Card>
</section>
);
}
const isVoice = channel.type === 'voice';
return (
<section id="app-content" className="flex h-full flex-col">
<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>
<h2 className="text-sm font-semibold">{channel.name}</h2>
{channel.slowmode_seconds === undefined || channel.slowmode_seconds === 0 ? null : (
<span className="text-xs text-fg-muted">slowmode {channel.slowmode_seconds}s</span>
)}
</header>
<div className="flex flex-1 items-center justify-center p-6">
<Card className="max-w-md text-center">
<h3 className="text-lg font-semibold">
{t(isVoice ? 'channels.voicePlaceholder.title' : 'channels.chatPlaceholder.title')}
</h3>
<p className="mt-2 text-sm text-fg-muted">
{t(isVoice ? 'channels.voicePlaceholder.body' : 'channels.chatPlaceholder.body', {
name: channel.name,
})}
</p>
</Card>
</div>
</section>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { joinGuild } from '@/api/guilds';
import { instanceQueryKey } from '@/api/instance';
import { myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Button, Card } from '@/components/ui/primitives';
import { useInstance } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
/**
* Экран пустого списка серверов: присоединиться к главному серверу инстанса
* (id берём из `GET /instance`) или создать свой (кнопка есть в рейке).
*/
export default function NoGuildView() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const instance = useInstance();
const guilds = useSessionStore((state) => state.guilds);
const mainGuildId = instance.data?.main_guild_id;
const alreadyMember =
mainGuildId !== undefined && guilds.some((guild) => guild.id === mainGuildId);
const join = useMutation({
mutationFn: (guildId: string) => joinGuild(guildId),
onSuccess: () => {
// Список серверов обновится снапшотом шлюза; REST — подстраховка.
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
},
});
return (
<section id="app-content" className="flex h-full items-center justify-center p-6">
<Card className="max-w-lg text-center">
<h2 className="text-lg font-semibold">{t('guilds.empty.title')}</h2>
<p className="mt-2 text-sm text-fg-muted">{t('guilds.empty.body')}</p>
{instance.isPending ? (
<p className="mt-4 text-sm text-fg-muted" role="status">
{t('common.loading')}
</p>
) : null}
{mainGuildId !== undefined && !alreadyMember ? (
<Button
className="mt-4"
disabled={join.isPending}
onClick={() => join.mutate(mainGuildId)}
>
{t(join.isPending ? 'guilds.empty.joining' : 'guilds.empty.joinMain')}
</Button>
) : null}
{mainGuildId === undefined && !instance.isPending ? (
<p className="mt-4 text-sm text-fg-muted">{t('guilds.empty.noMain')}</p>
) : null}
{join.isError ? <ErrorNotice className="mt-3" error={join.error} /> : null}
{instance.isError ? (
<ErrorNotice
className="mt-3"
error={instance.error}
onRetry={() => void instance.refetch()}
/>
) : null}
</Card>
</section>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router';
import { logout } from '@/api/auth';
import { Avatar } from '@/components/ui/Avatar';
import { useCurrentUser } from '@/lib/hooks';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
import { useGatewayStore } from '@/stores/gateway';
import { useSessionStore } from '@/stores/session';
import { userStatuses, type UserStatus } from '@/api/types';
/** Панель пользователя внизу: аватар, имя, статус, настройки и выход. */
export function UserPanel() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const describeError = useApiErrorMessage();
const sessionUser = useSessionStore((state) => state.user);
const disconnect = useGatewayStore((state) => state.disconnect);
const name = sessionUser?.display_name ?? currentUser.data?.display_name ?? '';
const username = sessionUser?.username ?? currentUser.data?.username ?? '';
const status: UserStatus = sessionUser?.status ?? currentUser.data?.status ?? 'online';
const customStatus = sessionUser?.custom_status ?? '';
const emoji = sessionUser?.custom_status_emoji ?? '';
const seed = currentUser.data?.id ?? sessionUser?.id ?? username;
const avatarFileId = sessionUser?.avatar_file_id ?? currentUser.data?.avatar_file_id;
const signOut = useMutation({
mutationFn: () => logout(),
onSuccess: () => {
// Сессия в cookie уже погашена — сбрасываем локальное состояние.
disconnect();
useSessionStore.getState().reset();
queryClient.clear();
void navigate('/login', { replace: true });
},
});
const statusLabel = userStatuses.includes(status)
? t(`user.status.${status}`)
: t('user.status.offline');
const onCopyId = useCallback(() => {
if (currentUser.data === undefined) {
return;
}
void navigator.clipboard?.writeText(currentUser.data.id);
}, [currentUser.data]);
return (
<footer
aria-label={t('user.panelLabel')}
className="flex items-center gap-2 border-t border-border/40 bg-surface-2/80 px-2 py-2"
>
<Avatar name={name === '' ? username : name} seed={seed} fileId={avatarFileId} size="md" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium" title={name}>
{name === '' ? username : name}
</p>
<p className="truncate text-xs text-fg-muted" title={statusLabel}>
<span aria-hidden="true" className="mr-1">
{emoji}
</span>
{customStatus === '' ? statusLabel : `${statusLabel} · ${customStatus}`}
</p>
</div>
<button
type="button"
aria-label={t('user.copyId')}
title={t('user.copyId')}
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted hover:bg-surface-3 hover:text-fg"
onClick={onCopyId}
>
#
</button>
<Link
to="/settings"
aria-label={t('user.settings')}
title={t('user.settings')}
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted hover:bg-surface-3 hover:text-fg"
>
⚙
</Link>
<button
type="button"
aria-label={t('user.logout')}
title={t('user.logout')}
disabled={signOut.isPending}
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted hover:bg-surface-3 hover:text-fg disabled:opacity-50"
onClick={() => signOut.mutate()}
>
⏻
</button>
{signOut.isError ? (
<span role="alert" className="max-w-40 truncate text-xs text-danger">
{describeError(signOut.error)}
</span>
) : null}
</footer>
);
}