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,23 @@
|
||||
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import { fetchInstance, instanceQueryKey } from '@/api/instance';
|
||||
import { fetchCurrentUser, currentUserQueryKey } from '@/api/users';
|
||||
import type { InstanceInfo, User } from '@/api/types';
|
||||
|
||||
/** Текущий пользователь: единственный источник правды о сессии (cookie). */
|
||||
export function useCurrentUser(): UseQueryResult<User> {
|
||||
return useQuery({
|
||||
queryKey: currentUserQueryKey,
|
||||
queryFn: ({ signal }) => fetchCurrentUser(signal),
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstance(): UseQueryResult<InstanceInfo> {
|
||||
return useQuery({
|
||||
queryKey: instanceQueryKey,
|
||||
queryFn: ({ signal }) => fetchInstance(signal),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Инициалы для аватара-заглушки: до двух первых букв значимых слов. */
|
||||
export function initials(name: string): string {
|
||||
const parts = name
|
||||
.trim()
|
||||
.split(/\s+/u)
|
||||
.filter((part) => part.length > 0);
|
||||
if (parts.length === 0) {
|
||||
return '?';
|
||||
}
|
||||
const first = parts[0] ?? '';
|
||||
if (parts.length === 1) {
|
||||
return [...first].slice(0, 2).join('').toLocaleUpperCase();
|
||||
}
|
||||
const second = parts[1] ?? '';
|
||||
return `${[...first][0] ?? ''}${[...second][0] ?? ''}`.toLocaleUpperCase();
|
||||
}
|
||||
|
||||
/** Стабильный оттенок для заглушки сервера/пользователя. */
|
||||
export function hueFromId(id: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < id.length; index += 1) {
|
||||
hash = (hash * 31 + id.charCodeAt(index)) % 360;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ссылка на файл аватара. Файловый сервис отдаётся тем же origin
|
||||
* (`/files/{id}`), поэтому отдельный хост не нужен.
|
||||
*/
|
||||
export function avatarUrl(fileId: string | undefined): string | null {
|
||||
return fileId === undefined || fileId === '' ? null : `/files/${encodeURIComponent(fileId)}`;
|
||||
}
|
||||
|
||||
/** Разбирает число прав из строковой битовой маски, не падая на мусоре. */
|
||||
export function parseBigInt(value: string | number | undefined): bigint {
|
||||
if (value === undefined) {
|
||||
return 0n;
|
||||
}
|
||||
try {
|
||||
return BigInt(value);
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
|
||||
/** Бит ADMINISTRATOR в маске прав (совпадает с серверной константой). */
|
||||
export const PERMISSION_ADMINISTRATOR = 1n << 3n;
|
||||
export const PERMISSION_MANAGE_GUILD = 1n << 5n;
|
||||
export const PERMISSION_MANAGE_CHANNELS = 1n << 6n;
|
||||
export const PERMISSION_OWNER = 1n << 62n;
|
||||
|
||||
/** Проверяет право в списке масок прав текущего пользователя. */
|
||||
export function hasPermission(permissions: readonly string[], mask: bigint): boolean {
|
||||
for (const value of permissions) {
|
||||
const parsed = parseBigInt(value);
|
||||
if (parsed === PERMISSION_OWNER || (parsed & mask) !== 0n) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Может ли пользователь управлять сервером (создавать комнаты и т.п.). */
|
||||
export function canManageGuild(
|
||||
permissions: readonly string[],
|
||||
ownerId: string,
|
||||
userId: string | undefined,
|
||||
): boolean {
|
||||
if (userId !== undefined && userId === ownerId) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
|
||||
hasPermission(permissions, PERMISSION_MANAGE_GUILD) ||
|
||||
hasPermission(permissions, PERMISSION_MANAGE_CHANNELS)
|
||||
);
|
||||
}
|
||||
|
||||
/** Человекочитаемая дата/время для списков (сессии, аудит). */
|
||||
export function formatDateTime(value: string, locale: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
/**
|
||||
* Единый QueryClient приложения. Живёт в модуле (а не в main.tsx), чтобы
|
||||
* не-React код — например стор шлюза — мог инвалидировать кэш REST-запросов.
|
||||
*/
|
||||
let client: QueryClient | null = null;
|
||||
|
||||
export function getQueryClient(): QueryClient {
|
||||
client ??= new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Только для тестов: подменяет общий клиент изолированным. */
|
||||
export function setQueryClient(next: QueryClient | null): void {
|
||||
client = next;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { errorCode } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* Переводит ошибку API по коду (`errors.<code>`), а неизвестные коды
|
||||
* показывает через `errors.unknown` — единый механизм для всего клиента.
|
||||
*/
|
||||
export function useApiErrorMessage(): (error: unknown) => string {
|
||||
const { t } = useTranslation();
|
||||
return (error: unknown): string => {
|
||||
const code = errorCode(error);
|
||||
const key = `errors.${code}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? t('errors.unknown', { code }) : translated;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user