diff --git a/.env.example b/.env.example index b7ba11f..bc67640 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ POSTGRES_DB=fable POSTGRES_USER=fable POSTGRES_PASSWORD=change_me_for_real_environment -JWT_SECRET=change_me_before_production +DATABASE_URL=postgres://fable:change_me_for_real_environment@127.0.0.1:5432/fable?sslmode=disable +TOKEN_SECRET=change_me_before_production +TOKEN_TTL=24h +VITE_API_URL=http://127.0.0.1:3000/api GATEWAY_PORT=3000 WEB_PORT=5173 diff --git a/README.md b/README.md index 7eb88f1..f7fd8d5 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,14 @@ Все данные в интерфейсе и API являются явно демонстрационными заглушками. Реальные люди, подразделения, интеграции, юридические сведения и брендовые материалы не добавлялись. -Backend сейчас использует in-memory demo store внутри Go-сервисов. PostgreSQL schema и Docker PostgreSQL подготовлены, но постоянное хранение через БД остается следующим этапом. +Backend теперь использует PostgreSQL через GORM в Go-сервисах. Демо-данные остаются только как seed-набор для локального запуска и проверки сценариев. ## Локальный запуск Установить зависимости: ```bash -npm install +pnpm install ``` Запустить весь проект без Docker: @@ -40,13 +40,15 @@ npm run dev npm run dev:backend ``` +Для этого локально должен быть доступен PostgreSQL на `127.0.0.1:5432` или должен быть задан `DATABASE_URL`. + Запустить только gateway с fallback demo API: ```bash npm run dev:gateway ``` -В обычном локальном режиме gateway работает с fallback demo API, если Go-сервисы не запущены и `*_SERVICE_URL` не заданы. +`npm run dev:gateway` оставляет только gateway. Для рабочего backend-контура используйте `npm run dev:backend`, потому что Go-сервисы теперь требуют реальную БД и не должны подменяться скрытым demo-store. Запустить frontend: @@ -60,7 +62,13 @@ npm run dev:web npm run test:go ``` -Docker сейчас необязателен. Для текущей разработки используйте `npm run dev:backend` и `npm run dev:web`. +Docker сейчас необязателен, но PostgreSQL обязателен для Go-сервисов. Для текущей разработки используйте `npm run dev:backend` и `npm run dev:web`. + +Проверить сборку всего проекта: + +```bash +npm run check +``` Запуск всего окружения через Docker, если он понадобится позже: @@ -82,7 +90,7 @@ Internal services health through gateway: `http://localhost:3000/api/services/he Пароль: `demo_password` -Auth service принимает демо-учетную запись и возвращает demo token. Это не production-аутентификация; для промышленного контура нужен подписанный JWT или другой проверяемый token format. +Auth service принимает демо-учетную запись из seed-данных, проверяет хэш пароля и выдает подписанный token для локального backend-контура. ## Backend API diff --git a/apps/gateway/src/index.ts b/apps/gateway/src/index.ts index d593f63..52fde6d 100644 --- a/apps/gateway/src/index.ts +++ b/apps/gateway/src/index.ts @@ -4,7 +4,7 @@ import { Elysia } from 'elysia'; import { WebStandardAdapter } from 'elysia/adapter/web-standard'; type ContentType = 'news' | 'article' | 'video' | 'audio' | 'graphic' | 'event'; -type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Архив'; +type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Возвращен' | 'Архив'; type Visibility = 'Публично' | 'После входа' | 'По роли'; type RoleCode = 'администратор' | 'редактор' | 'менеджер' | 'пользователь'; @@ -28,6 +28,11 @@ type ContentItem = { mimeType?: string; fileName?: string; fileSize?: number; + moderatorComment?: string; + reviewComment?: string; + ratingAverage?: number; + ratingCount?: number; + myRating?: number; }; type MediaKind = NonNullable; @@ -40,6 +45,18 @@ type StoredUpload = { data: Buffer; }; +type NormalizedContentPatch = Partial & { + title?: string; + category?: string; + lead?: string; + body?: string; + type?: ContentType; + status?: ContentStatus; + tags?: string[]; + moderatorComment?: string; + reviewComment?: string; +}; + type UserProfile = { id: string; name: string; @@ -76,6 +93,28 @@ const serviceUrls: Record = { const categories = ['Новости', 'Статьи', 'Видео', 'Аудио', 'Графика', 'Мероприятия']; const tags = ['медиапроизводство', 'интервью', 'анонс', 'образование', 'редакция', 'архив']; const contentTypes: ContentType[] = ['news', 'article', 'video', 'audio', 'graphic', 'event']; +const loginAliases: Record = { + 'admin@dstu.ru': 'demo_admin', + 'editor@dstu.ru': 'demo_editor', + 'moderator@dstu.ru': 'demo_moderator', + 'manager@dstu.ru': 'demo_moderator', + 'user@dstu.ru': 'demo_user' +}; +const statusAliases: Record = { + draft: 'Черновик', + moderation: 'На модерации', + pending: 'На модерации', + review: 'На проверке', + published: 'Опубликовано', + returned: 'Возвращен', + archived: 'Архив', + черновик: 'Черновик', + 'на модерации': 'На модерации', + 'на проверке': 'На проверке', + опубликовано: 'Опубликовано', + возвращен: 'Возвращен', + архив: 'Архив' +}; const demoUser: UserProfile = { id: 'demo-user-1', @@ -88,6 +127,7 @@ const demoUser: UserProfile = { const users: UserProfile[] = [ demoUser, { id: 'demo-user-2', name: 'Демо-редактор', login: 'demo_editor', roles: ['редактор'], subscriptions: ['Видео'] }, + { id: 'demo-user-4', name: 'Демо-модератор', login: 'demo_moderator', roles: ['менеджер'], subscriptions: ['Мероприятия'] }, { id: 'demo-user-3', name: 'Демо-пользователь', login: 'demo_user', roles: ['пользователь'], subscriptions: ['Аудио'] } ]; @@ -298,6 +338,97 @@ function searchContent(query: Record) { return sorted; } +function normalizeLogin(value = '') { + const login = value.trim().toLowerCase(); + return loginAliases[login] ?? login; +} + +function normalizeStatus(value: unknown): ContentStatus | undefined { + if (typeof value !== 'string') { + return undefined; + } + return statusAliases[value.trim().toLowerCase()] ?? (value as ContentStatus); +} + +function normalizeContentType(value: unknown): ContentType | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === 'research') return 'article'; + if (normalized === 'announcement') return 'news'; + if (contentTypes.includes(normalized as ContentType)) return normalized as ContentType; + return undefined; +} + +function normalizeTags(value: unknown) { + if (Array.isArray(value)) { + return value.map((item) => String(item).trim()).filter(Boolean); + } + if (typeof value === 'string') { + return parseTags(value); + } + return []; +} + +function normalizeContentPatch(payload: Record): NormalizedContentPatch { + const nextType = normalizeContentType(payload.type); + const nextStatus = normalizeStatus(payload.status); + const nextTags = payload.tags === undefined ? undefined : normalizeTags(payload.tags); + + return { + title: typeof payload.title === 'string' ? payload.title.trim() : undefined, + category: typeof payload.category === 'string' ? payload.category.trim() : undefined, + lead: typeof payload.lead === 'string' ? payload.lead : typeof payload.excerpt === 'string' ? payload.excerpt : undefined, + body: typeof payload.body === 'string' ? payload.body : typeof payload.content === 'string' ? payload.content : undefined, + type: nextType, + status: nextStatus, + tags: nextTags, + visibility: payload.visibility as Visibility | undefined, + imageTone: typeof payload.imageTone === 'string' ? payload.imageTone : undefined, + mediaUrl: typeof payload.mediaUrl === 'string' ? payload.mediaUrl : undefined, + mediaKind: payload.mediaKind as ContentItem['mediaKind'] | undefined, + mimeType: typeof payload.mimeType === 'string' ? payload.mimeType : undefined, + fileName: typeof payload.fileName === 'string' ? payload.fileName : undefined, + fileSize: typeof payload.fileSize === 'number' ? payload.fileSize : undefined, + ratingAverage: typeof payload.ratingAverage === 'number' ? payload.ratingAverage : undefined, + ratingCount: typeof payload.ratingCount === 'number' ? payload.ratingCount : undefined, + myRating: typeof payload.myRating === 'number' ? payload.myRating : undefined, + moderatorComment: + typeof payload.moderatorComment === 'string' + ? payload.moderatorComment.trim() + : typeof payload.reviewComment === 'string' + ? payload.reviewComment.trim() + : undefined + }; +} + +function listContent(query: Record, user: UserProfile | null) { + const requestedType = normalizeContentType(query.type); + const requestedStatus = normalizeStatus(query.status); + const requestedAuthor = query.authorId; + const mine = query.mine === 'true'; + const limit = Number(query.limit ?? 0); + const exclude = query.exclude; + const term = (query.q ?? '').trim().toLowerCase(); + + const items = content + .filter((item) => !exclude || item.id !== exclude) + .filter((item) => !requestedType || item.type === requestedType) + .filter((item) => !query.category || item.category === query.category) + .filter((item) => !requestedStatus || item.status === requestedStatus) + .filter((item) => !requestedAuthor || item.author === users.find((entry) => entry.id === requestedAuthor)?.name) + .filter((item) => !mine || (user ? item.author === user.name : false)) + .filter((item) => !term || [item.title, item.lead, item.body, item.author, item.category, item.status, ...item.tags].join(' ').toLowerCase().includes(term)) + .sort((a, b) => { + if (query.sort === 'popular') return b.views - a.views; + return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(); + }); + + return limit > 0 ? items.slice(0, limit) : items; +} + function isBlobLike(value: unknown): value is Blob & { name?: string; type?: string; size: number } { return typeof value === 'object' && value !== null && 'arrayBuffer' in value && typeof (value as { arrayBuffer?: unknown }).arrayBuffer === 'function'; } @@ -533,14 +664,15 @@ const app = new Elysia({ adapter: WebStandardAdapter }) .get('/api/health', ({ set }) => ({ status: 'ok', service: 'gateway', requestId: set.headers['x-request-id'] })) .get('/api/services/health', async () => ({ items: await Promise.all(Object.entries(serviceUrls).map(([name, url]) => readServiceHealth(name, url))) })) .post('/api/auth/register', ({ body, set }) => { - const payload = body as Partial<{ login: string; password: string; name: string }>; - if (!payload.login || !payload.password || payload.password.length < 8) { + const payload = body as Partial<{ login: string; email: string; password: string; name: string }>; + const login = normalizeLogin(payload.login ?? payload.email ?? ''); + if (!login || !payload.password || payload.password.length < 8) { return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль не короче 8 символов'); } const user: UserProfile = { id: `demo-user-${Date.now()}`, name: payload.name?.trim() || 'Демо-пользователь', - login: payload.login, + login, roles: ['пользователь'], subscriptions: [] }; @@ -551,11 +683,12 @@ const app = new Elysia({ adapter: WebStandardAdapter }) return { token, user }; }) .post('/api/auth/login', ({ body, set }) => { - const payload = body as Partial<{ login: string; password: string }>; - if (!payload.login || !payload.password) { + const payload = body as Partial<{ login: string; email: string; password: string }>; + const login = normalizeLogin(payload.login ?? payload.email ?? ''); + if (!login || !payload.password) { return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль'); } - const user = users.find((item) => item.login === payload.login) ?? demoUser; + const user = users.find((item) => item.login === login) ?? demoUser; const token = `demo-token-${crypto.randomUUID()}`; tokens.set(token, user); return { token, user }; @@ -585,7 +718,7 @@ const app = new Elysia({ adapter: WebStandardAdapter }) } return { ok: true }; }) - .get('/api/content', () => ({ items: content })) + .get('/api/content', ({ query, request }) => ({ items: listContent(query as Record, readUser(request)) })) .get('/api/content/:id', ({ params, set }) => { const item = content.find((entry) => entry.id === params.id); if (!item) { @@ -602,7 +735,7 @@ const app = new Elysia({ adapter: WebStandardAdapter }) if (!auth.user.roles.some((role) => role === 'администратор' || role === 'редактор' || role === 'менеджер')) { return fail(set, 403, 'FORBIDDEN', 'Создание материалов требует роли редактора, менеджера или администратора'); } - const payload = body as Partial; + const payload = normalizeContentPatch(body as Record); if (!payload.title || !payload.category || !payload.type) { return fail(set, 400, 'VALIDATION_ERROR', 'Укажите название, категорию и тип материала'); } @@ -617,14 +750,19 @@ const app = new Elysia({ adapter: WebStandardAdapter }) author: auth.user.name, publishedAt: new Date().toISOString().slice(0, 10), visibility: payload.visibility ?? 'После входа', - status: 'Черновик', + status: payload.status ?? 'Черновик', views: 0, imageTone: payload.imageTone ?? 'from-university-800 via-slate-700 to-sky-300', mediaUrl: payload.mediaUrl, mediaKind: payload.mediaKind, mimeType: payload.mimeType, fileName: payload.fileName, - fileSize: payload.fileSize + fileSize: payload.fileSize, + moderatorComment: payload.moderatorComment, + reviewComment: payload.reviewComment, + ratingAverage: 0, + ratingCount: 0, + myRating: 0 }; content = [item, ...content]; set.status = 201; @@ -639,7 +777,24 @@ const app = new Elysia({ adapter: WebStandardAdapter }) if (index === -1) { return fail(set, 404, 'NOT_FOUND', 'Материал не найден'); } - content[index] = { ...content[index], ...(body as Partial) }; + const payload = normalizeContentPatch(body as Record); + const nextRating = typeof payload.ratingAverage === 'number' ? payload.ratingAverage : undefined; + const nextUserRating = typeof body === 'object' && body !== null && typeof (body as Record).rating === 'number' + ? Number((body as Record).rating) + : undefined; + + content[index] = { + ...content[index], + ...payload, + lead: payload.lead ?? content[index].lead, + body: payload.body ?? content[index].body, + tags: payload.tags ?? content[index].tags, + status: payload.status ?? content[index].status, + ratingAverage: nextUserRating ?? nextRating ?? content[index].ratingAverage, + ratingCount: nextUserRating ? Math.max(Number(content[index].ratingCount ?? 0) + 1, 1) : content[index].ratingCount, + myRating: nextUserRating ?? content[index].myRating, + reviewComment: payload.moderatorComment ?? payload.reviewComment ?? content[index].reviewComment + }; return { item: content[index] }; }) .delete('/api/content/:id', ({ request, params, set }) => { @@ -770,11 +925,13 @@ const app = new Elysia({ adapter: WebStandardAdapter }) return { item }; }) .get('/api/analytics/summary', ({ request, set }) => { - const auth = requireRole(request, set, 'администратор'); + const auth = requireUser(request, set); if (!auth.user) { return auth.response; } return { + materials: content.filter((item) => item.author === auth.user?.name).length, + comments: comments.length, totalViews: content.reduce((sum, item) => sum + item.views, 0), subscribers: speakers.reduce((sum, item) => sum + item.subscribers, 0), activeUsers: users.length, @@ -788,8 +945,18 @@ const app = new Elysia({ adapter: WebStandardAdapter }) } return { users: users.length, - content: content.length, - moderationQueue: content.filter((item) => item.status !== 'Опубликовано').length, + materials: content.length, + views: content.reduce((sum, item) => sum + item.views, 0), + pending: content.filter((item) => item.status !== 'Опубликовано').length, + viewsByDay: [ + { label: 'Пн', value: 120 }, + { label: 'Вт', value: 180 }, + { label: 'Ср', value: 160 }, + { label: 'Чт', value: 220 }, + { label: 'Пт', value: 260 }, + { label: 'Сб', value: 140 }, + { label: 'Вс', value: 110 } + ], roles: ['администратор', 'редактор', 'менеджер', 'пользователь'] }; }) diff --git a/apps/web/README.md b/apps/web/README.md index a7eb7df..175c4cb 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -5,22 +5,29 @@ Frontend MVP информационной системы управления м ## Запуск -Требуются Node.js 22+ и Corepack. +Требуются Node.js 22+ и npm. + +Из корня монорепозитория: ```bash -corepack prepare pnpm@11.0.0 --activate -corepack pnpm install -corepack pnpm dev +npm install +npm run dev ``` -Откройте `http://localhost:5173`. +Для запуска только frontend: + +```bash +npm run dev:web +``` + +Откройте `http://localhost:5173`. В dev-режиме Vite проксирует `/api` на `http://localhost:3000`. ## Проверки ```bash -corepack pnpm lint -corepack pnpm test -corepack pnpm build +npm --workspace @fable/web run lint +npm --workspace @fable/web run test +npm --workspace @fable/web run build ``` ## Структура @@ -56,7 +63,7 @@ src/ ## Тестовые роли -Пароль может быть любым от 6 символов. +Основной demo-вход: `demo_admin` / `demo_password`. | Роль | Учётная запись | | --- | --- | @@ -77,7 +84,7 @@ src/ - `/admin/users`, `/admin/stats`, `/admin/audit-log`; - `POST /uploads`. -Адрес API задаётся через `VITE_API_URL`. Пока интерфейс использует локальные mock-данные. +Адрес API можно переопределить через `VITE_API_URL`. По умолчанию интерфейс работает с `/api` и ожидает gateway на `localhost:3000`. ## Безопасность зависимостей @@ -92,7 +99,6 @@ Lockfile необходимо хранить в репозитории. ## Ограничения MVP -- данные не сохраняются после обновления страницы; -- загрузка файлов и комментарии представлены интерфейсом без backend; +- данные backend по-прежнему демонстрационные и в основном живут в in-memory store; - внешние изображения загружаются с Unsplash; - график администратора демонстрационный. diff --git a/apps/web/src/app/store/session.js b/apps/web/src/app/store/session.js index 6e5c3a6..494a29e 100644 --- a/apps/web/src/app/store/session.js +++ b/apps/web/src/app/store/session.js @@ -2,16 +2,56 @@ import { create } from "zustand"; import { authApi } from "../../shared/api/endpoints"; import { tokenStorage } from "../../shared/api/client"; +const roleMap = { + admin: "admin", + administrator: "admin", + user: "user", + editor: "editor", + moderator: "moderator", + manager: "moderator", + администратор: "admin", + редактор: "editor", + менеджер: "moderator", + пользователь: "user", +}; + +function pickRole(user = {}) { + const roles = Array.isArray(user.roles) ? user.roles : [user.role].filter(Boolean); + const normalizedRoles = roles.map((role) => roleMap[String(role).toLowerCase()] ?? role); + + if (normalizedRoles.includes("admin")) return "admin"; + if (normalizedRoles.includes("moderator")) return "moderator"; + if (normalizedRoles.includes("editor")) return "editor"; + return "user"; +} + +function normalizeUser(user) { + if (!user) return null; + + const roles = Array.isArray(user.roles) ? user.roles : [user.role].filter(Boolean); + const role = pickRole(user); + const login = user.login ?? user.username ?? ""; + + return { + ...user, + login, + email: user.email ?? (login && login.includes("@") ? login : ""), + roles, + role, + }; +} + function readStoredUser() { try { - return JSON.parse(sessionStorage.getItem("user") ?? "null"); + return normalizeUser(JSON.parse(sessionStorage.getItem("user") ?? "null")); } catch { return null; } } function persistUser(user) { - if (user) sessionStorage.setItem("user", JSON.stringify(user)); + const normalizedUser = normalizeUser(user); + if (normalizedUser) sessionStorage.setItem("user", JSON.stringify(normalizedUser)); else sessionStorage.removeItem("user"); } @@ -20,7 +60,7 @@ function getToken(payload) { } function getUser(payload) { - return payload?.user ?? payload?.profile ?? payload; + return normalizeUser(payload?.user ?? payload?.profile ?? payload); } export const useSession = create((set, get) => ({ @@ -37,12 +77,13 @@ export const useSession = create((set, get) => ({ return user; }, - login: async ({ email, password }) => { - const response = await authApi.login({ email, password }); + login: async ({ identifier, email, login, password }) => { + const credential = identifier ?? login ?? email; + const response = await authApi.login({ login: credential, email: credential, password }); const token = getToken(response); tokenStorage.set(token); - const user = response.user ?? (await authApi.me()); + const user = normalizeUser(response.user ?? (await authApi.me())); set({ user }); persistUser(user); return user; @@ -53,7 +94,7 @@ export const useSession = create((set, get) => ({ set({ initializing: true }); try { - const user = await authApi.me(); + const user = normalizeUser(await authApi.me()); set({ user, initializing: false }); persistUser(user); return user; @@ -87,7 +128,15 @@ export const useSession = create((set, get) => ({ return user; }, - switchRole: () => get().user, + switchRole: (nextRole) => { + const current = get().user; + if (!current) return null; + + const user = normalizeUser({ ...current, role: nextRole }); + set({ user }); + persistUser(user); + return user; + }, })); if (typeof window !== "undefined") { diff --git a/apps/web/src/pages/EventsPage.jsx b/apps/web/src/pages/EventsPage.jsx index cca1384..8b0f002 100644 --- a/apps/web/src/pages/EventsPage.jsx +++ b/apps/web/src/pages/EventsPage.jsx @@ -7,12 +7,32 @@ import { Badge } from "../shared/ui/Badge"; import { Button } from "../shared/ui/Button"; import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States"; +function normalizeEvent(event) { + const rawDate = event.date ?? event.startsAt ?? event.publishedAt ?? ""; + const parsedDate = rawDate ? new Date(rawDate) : null; + const day = parsedDate && !Number.isNaN(parsedDate.getTime()) + ? parsedDate.toLocaleDateString("ru-RU", { day: "numeric" }) + : String(rawDate).split(" ")[0] ?? "--"; + const month = parsedDate && !Number.isNaN(parsedDate.getTime()) + ? parsedDate.toLocaleDateString("ru-RU", { month: "short" }) + : String(rawDate).split(" ")[1] ?? ""; + + return { + ...event, + day, + month, + description: event.description ?? event.lead ?? event.body ?? "Описание события появится после загрузки данных.", + time: event.time ?? event.startsAt ?? "Время уточняется", + place: event.place ?? event.location ?? "Место уточняется", + }; +} + export function EventsPage() { const { data: eventsPayload, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.events(), queryFn: directoriesApi.events, }); - const pageEvents = toList(eventsPayload); + const pageEvents = toList(eventsPayload).map(normalizeEvent); return (
@@ -51,10 +71,10 @@ export function EventsPage() { className="group grid gap-5 py-8 md:grid-cols-[minmax(7rem,1fr)_minmax(0,5fr)_auto] md:items-center" >
- {event.date.split(" ")[0]} - - {event.date.split(" ")[1]} - + {event.day} + + {event.month} +
{event.category} diff --git a/apps/web/src/pages/HomePage.jsx b/apps/web/src/pages/HomePage.jsx index 9af80f9..bd0c85d 100644 --- a/apps/web/src/pages/HomePage.jsx +++ b/apps/web/src/pages/HomePage.jsx @@ -179,8 +179,9 @@ export function HomePage() {

Навигация по темам

{pageCategories.map((category, index) => { - const name = category.name ?? category.title ?? category.label; - const value = category.slug ?? category.id ?? name; + const name = typeof category === "string" ? category : category.name ?? category.title ?? category.label; + const value = typeof category === "string" ? category : category.slug ?? category.id ?? name; + const description = typeof category === "string" ? "Материалы этой категории доступны в едином каталоге." : category.description; return (

{name}

- {category.description} + {description}

); diff --git a/apps/web/src/pages/LoginPage.jsx b/apps/web/src/pages/LoginPage.jsx index 26fa42b..656f3f2 100644 --- a/apps/web/src/pages/LoginPage.jsx +++ b/apps/web/src/pages/LoginPage.jsx @@ -8,7 +8,7 @@ import { Button } from "../shared/ui/Button"; import { Input } from "../shared/ui/Field"; const schema = z.object({ - email: z.email("Введите корректный адрес"), + identifier: z.string().min(3, "Введите логин или почту"), password: z.string().min(6, "Минимум 6 символов"), }); @@ -22,7 +22,7 @@ export function LoginPage() { formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(schema), - defaultValues: { email: "editor@dstu.ru", password: "password" }, + defaultValues: { identifier: "demo_admin", password: "demo_password" }, }); const onSubmit = async (data) => { @@ -52,14 +52,13 @@ export function LoginPage() {

Вход в систему

- Используйте тестовый адрес роли или учетную запись backend. + Используйте демо-логин backend или тестовую почту роли.

- Тестовые роли: user@dstu.ru, editor@dstu.ru, - moderator@dstu.ru, admin@dstu.ru + Демо-вход: demo_admin / demo_password. Также поддерживаются + user@dstu.ru, editor@dstu.ru, moderator@dstu.ru и admin@dstu.ru.
diff --git a/apps/web/src/pages/LoginPage.test.jsx b/apps/web/src/pages/LoginPage.test.jsx index e279613..6677266 100644 --- a/apps/web/src/pages/LoginPage.test.jsx +++ b/apps/web/src/pages/LoginPage.test.jsx @@ -12,15 +12,15 @@ describe("LoginPage", () => { , ); - const email = screen.getByLabelText("Корпоративная почта"); + const email = screen.getByLabelText("Логин или почта"); const password = screen.getByLabelText("Пароль"); await user.clear(email); - await user.type(email, "wrong"); + await user.type(email, "ab"); await user.clear(password); await user.type(password, "123"); await user.click(screen.getByRole("button", { name: "Войти" })); - expect(await screen.findByText("Введите корректный адрес")).toBeInTheDocument(); + expect(await screen.findByText("Введите логин или почту")).toBeInTheDocument(); expect(screen.getByText("Минимум 6 символов")).toBeInTheDocument(); }); }); diff --git a/apps/web/src/pages/MaterialsPage.jsx b/apps/web/src/pages/MaterialsPage.jsx index 1cd12d9..4ad67e0 100644 --- a/apps/web/src/pages/MaterialsPage.jsx +++ b/apps/web/src/pages/MaterialsPage.jsx @@ -18,11 +18,17 @@ const materialTypes = [ ]; function normalizeCategories(payload) { - return toList(payload).map((item) => ({ - id: item.id ?? item.slug ?? item.name, - name: item.name ?? item.title ?? item.label, - value: item.slug ?? item.id ?? item.name, - })); + return toList(payload).map((item) => { + if (typeof item === "string") { + return { id: item, name: item, value: item }; + } + + return { + id: item.id ?? item.slug ?? item.name, + name: item.name ?? item.title ?? item.label, + value: item.slug ?? item.id ?? item.name, + }; + }); } export function MaterialsPage() { diff --git a/apps/web/src/pages/cabinet/AdminPage.jsx b/apps/web/src/pages/cabinet/AdminPage.jsx index 97460b9..3e69f16 100644 --- a/apps/web/src/pages/cabinet/AdminPage.jsx +++ b/apps/web/src/pages/cabinet/AdminPage.jsx @@ -13,6 +13,19 @@ function number(value) { return Number(value ?? 0).toLocaleString("ru-RU"); } +function normalizeUsers(payload) { + return toList(payload).map((user) => { + const roles = Array.isArray(user.roles) ? user.roles : [user.role].filter(Boolean); + const primaryRole = roles[0] ?? "user"; + + return { + ...user, + email: user.email ?? user.login ?? "-", + role: user.role ?? primaryRole, + }; + }); +} + function pickChart(summary) { const values = summary.viewsByDay ?? summary.weeklyViews ?? summary.chart ?? []; if (!Array.isArray(values)) return []; @@ -34,7 +47,7 @@ export function AdminPage() { }); const summary = toEntity(dashboardQuery.data) ?? {}; - const users = toList(usersQuery.data); + const users = normalizeUsers(usersQuery.data); const popular = normalizeContentList(popularQuery.data); const chart = pickChart(summary); const maxChart = Math.max(...chart.map((item) => Number(item.value ?? item.views ?? 0)), 1); diff --git a/apps/web/src/pages/cabinet/CabinetHomePage.jsx b/apps/web/src/pages/cabinet/CabinetHomePage.jsx index 33501fd..90d19ca 100644 --- a/apps/web/src/pages/cabinet/CabinetHomePage.jsx +++ b/apps/web/src/pages/cabinet/CabinetHomePage.jsx @@ -89,7 +89,7 @@ export function CabinetHomePage() {

{item.title ?? "Уведомление"}

-

{item.text ?? item.message ?? item.body}

+

{item.text ?? item.message ?? item.description ?? item.body}

{formatDate(item.createdAt ?? item.date)}

diff --git a/apps/web/src/pages/cabinet/MaterialFormPage.jsx b/apps/web/src/pages/cabinet/MaterialFormPage.jsx index 7860fae..ba449a7 100644 --- a/apps/web/src/pages/cabinet/MaterialFormPage.jsx +++ b/apps/web/src/pages/cabinet/MaterialFormPage.jsx @@ -36,11 +36,17 @@ function formatFileSize(size) { } function normalizeCategories(payload) { - return toList(payload).map((item) => ({ - id: item.id ?? item.slug ?? item.name, - label: item.name ?? item.title ?? item.label, - value: item.slug ?? item.id ?? item.name, - })); + return toList(payload).map((item) => { + if (typeof item === "string") { + return { id: item, label: item, value: item }; + } + + return { + id: item.id ?? item.slug ?? item.name, + label: item.name ?? item.title ?? item.label, + value: item.slug ?? item.id ?? item.name, + }; + }); } function makePayload(data, photos, status) { diff --git a/apps/web/src/pages/cabinet/ProfilePage.jsx b/apps/web/src/pages/cabinet/ProfilePage.jsx index 80543c0..b9da698 100644 --- a/apps/web/src/pages/cabinet/ProfilePage.jsx +++ b/apps/web/src/pages/cabinet/ProfilePage.jsx @@ -112,7 +112,7 @@ export function ProfilePage() {
- +