From f1528de948d95549d3c2b54551ffbb77c701a52a Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:17:06 +0300 Subject: [PATCH] feat(admin): transfer user accounts and show local cover coverage --- api/openapi.yaml | 119 +++ apps/web/e2e/user-transfer.spec.ts | 70 ++ apps/web/src/features/users/UserTransfer.tsx | 68 ++ apps/web/src/features/users/transfer.test.ts | 18 + apps/web/src/features/users/transfer.ts | 29 + apps/web/src/pages/admin/SourcesPage.tsx | 10 + apps/web/src/pages/admin/UsersPage.tsx | 2 + apps/web/src/test/mobileFixtures.ts | 1 + docs/auth-and-roles.md | 16 + docs/library-and-storage.md | 7 + packages/api-client/src/generated/schema.ts | 160 +++ packages/i18n/locales/en/admin.json | 15 + packages/i18n/locales/en/errors.json | 1 + packages/i18n/locales/ru/admin.json | 15 + packages/i18n/locales/ru/errors.json | 1 + services/api/internal/api/gen/api.gen.go | 968 ++++++++++++------ services/api/internal/auth/middleware.go | 3 + services/api/internal/auth/transfer.go | 139 +++ .../api/internal/auth/transfer_handler.go | 61 ++ services/api/internal/auth/transfer_test.go | 164 +++ .../api/internal/dbtest/cover_stats_test.go | 58 ++ services/api/internal/library/cover_stats.go | 49 + 22 files changed, 1661 insertions(+), 313 deletions(-) create mode 100644 apps/web/e2e/user-transfer.spec.ts create mode 100644 apps/web/src/features/users/UserTransfer.tsx create mode 100644 apps/web/src/features/users/transfer.test.ts create mode 100644 apps/web/src/features/users/transfer.ts create mode 100644 services/api/internal/auth/transfer.go create mode 100644 services/api/internal/auth/transfer_handler.go create mode 100644 services/api/internal/auth/transfer_test.go create mode 100644 services/api/internal/dbtest/cover_stats_test.go create mode 100644 services/api/internal/library/cover_stats.go diff --git a/api/openapi.yaml b/api/openapi.yaml index c912fc9..83b6864 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -460,6 +460,61 @@ paths: $ref: "#/components/schemas/CreatedUser" default: $ref: "#/components/responses/Problem" + /api/v1/admin/sources/{id}/cover-stats: + get: + operationId: getSourceCoverStats + tags: [admin] + summary: Покрытие аудиофайлов локальными обложками (admin) + parameters: + - $ref: "#/components/parameters/SourceId" + responses: + "200": + description: Доступные аудиофайлы без исключённых папок; внешние обложки не учитываются + content: + application/json: + schema: + $ref: "#/components/schemas/SourceCoverStats" + default: + $ref: "#/components/responses/Problem" + /api/v1/admin/users/export: + get: + operationId: exportUsers + tags: [admin] + summary: Экспорт учётных записей с ролями и хэшами паролей (admin) + description: Только учётные записи и права доступа, без рекомендаций, истории, лайков и сессий. Ответ не кэшируется. + responses: + "200": + description: Файл переноса пользователей + content: + application/json: + schema: + $ref: "#/components/schemas/UserArchive" + default: + $ref: "#/components/responses/Problem" + /api/v1/admin/users/import: + post: + operationId: importUsers + tags: [admin] + summary: Импорт учётных записей с сохранением паролей (admin) + description: | + Атомарное добавление пользователей из экспорта, до 10000 записей и 16 MiB. + Существующие логины пропускаются без изменений. Новые записи получают новые ID. + Рекомендации, история, лайки и сессии не импортируются. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserArchive" + responses: + "200": + description: Результат импорта + content: + application/json: + schema: + $ref: "#/components/schemas/UserImportResult" + default: + $ref: "#/components/responses/Problem" /api/v1/admin/users/{id}: parameters: - $ref: "#/components/parameters/UserId" @@ -2730,6 +2785,70 @@ components: type: string new_password: type: string + SourceCoverStats: + type: object + required: [files_total, files_with_cover, albums_pending] + properties: + files_total: + type: integer + format: int64 + files_with_cover: + type: integer + format: int64 + description: Файлы, альбом которых имеет готовую локальную обложку (встроенную или из папки) + albums_pending: + type: integer + format: int64 + UserArchive: + type: object + additionalProperties: false + required: [version, users] + properties: + version: + type: integer + enum: [1] + users: + type: array + maxItems: 10000 + items: + $ref: "#/components/schemas/UserArchiveEntry" + UserArchiveEntry: + type: object + additionalProperties: false + required: [username, display_name, role, password_hash, is_blocked, must_change_password, must_change_credentials, reviews_uploads, manages_updates] + properties: + username: + type: string + display_name: + type: string + nullable: true + maxLength: 64 + role: + $ref: "#/components/schemas/Role" + password_hash: + type: string + maxLength: 512 + description: Argon2id PHC. Секретные данные; файл нужно хранить в защищённом месте. + is_blocked: + type: boolean + must_change_password: + type: boolean + must_change_credentials: + type: boolean + reviews_uploads: + type: boolean + manages_updates: + type: boolean + UserImportResult: + type: object + required: [created, skipped] + properties: + created: + type: integer + skipped: + type: array + items: + type: string AdminUser: type: object required: [id, username, display_name, role, reviews_uploads, locale, is_blocked, must_change_password, must_change_credentials, created_at, last_login_at] diff --git a/apps/web/e2e/user-transfer.spec.ts b/apps/web/e2e/user-transfer.spec.ts new file mode 100644 index 0000000..bef6f83 --- /dev/null +++ b/apps/web/e2e/user-transfer.spec.ts @@ -0,0 +1,70 @@ +import { expect, test, type Page } from '@playwright/test'; + +async function login(page: Page, username = 'mobile-admin', target = '/admin/users') { + await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru')); + await page.goto(`/login?redirect=${encodeURIComponent(target)}`); + await page.getByLabel('Логин', { exact: true }).fill(username); + await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`); + await page.getByRole('button', { name: 'Войти', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible(); +} + +for (const width of [320, 1280]) { + test(`user transfer and cover counts at ${width}px`, async ({ page }, info) => { + await page.setViewportSize({ width, height: 900 }); + await login(page); + await page.evaluate(() => { + const original = window.fetch; + window.fetch = async (input, init) => { + const req = new Request(input, init); + const path = new URL(req.url).pathname; + if (path === '/api/v1/admin/users/export') return new Response(JSON.stringify({ + version: 1, users: [{ username: 'alice', role: 'user', password_hash: '$argon2id$test' }], + }), { headers: { 'Content-Type': 'application/json' } }); + if (path === '/api/v1/admin/users/import') { + const archive = await req.json(); + if (archive.users[0].password_hash !== '$argon2id$test') throw new Error('lost hash'); + return new Response(JSON.stringify({ created: 1, skipped: ['alice'] }), { headers: { 'Content-Type': 'application/json' } }); + } + return original(input, init); + }; + }); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Экспорт пользователей', exact: true }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('pipu-users.json'); + await page.getByRole('button', { name: 'Импорт пользователей', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Импорт пользователей' }); + const file = dialog.getByLabel('Файл пользователей (.json)'); + await file.setInputFiles({ name: 'bad.json', mimeType: 'application/json', buffer: Buffer.from('{}') }); + await expect(dialog.getByRole('alert')).toContainText('Некорректный файл'); + await expect(dialog.getByRole('button', { name: 'Импортировать', exact: true })).toBeDisabled(); + await file.setInputFiles({ name: 'users.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ + version: 1, users: [{ username: 'new-user', role: 'moderator', password_hash: '$argon2id$test' }], + })) }); + await expect(dialog).toContainText('Учётных записей в файле: 1'); + await page.screenshot({ path: info.outputPath('user-import.png') }); + await dialog.getByRole('button', { name: 'Импортировать', exact: true }).click(); + await expect(dialog.getByRole('status')).toContainText('Создано: 1. Пропущено существующих: 1.'); + await expect(dialog.getByRole('status')).toContainText('alice'); + await page.keyboard.press('Escape'); + if (width < 768) { + await page.getByRole('button', { name: 'Ещё', exact: true }).click(); + await page.getByRole('dialog').getByRole('link', { name: 'Хранилища', exact: true }).click(); + } else await page.evaluate(() => { + history.pushState({}, '', '/admin/sources'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + await expect(page.getByRole('article')).toContainText('Локальные обложки: 87 из 120 файлов'); + await expect(page.getByRole('article')).toContainText('Альбомов ожидают обработки: 3'); + await page.screenshot({ path: info.outputPath('source-covers.png') }); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + }); +} + +test('moderator cannot see user transfer controls', async ({ page }) => { + await login(page, 'moderator'); + await expect(page.getByRole('heading', { name: 'Пользователи', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Экспорт пользователей', exact: true })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Импорт пользователей', exact: true })).toHaveCount(0); +}); diff --git a/apps/web/src/features/users/UserTransfer.tsx b/apps/web/src/features/users/UserTransfer.tsx new file mode 100644 index 0000000..1d5feea --- /dev/null +++ b/apps/web/src/features/users/UserTransfer.tsx @@ -0,0 +1,68 @@ +import { unwrap } from '@pipu/core'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useSession } from '../../app/session'; +import { useErrorText } from '../../shared/lib/errors'; +import { Button, Dialog, FormError } from '../../shared/ui/ui'; +import s from '../../pages/page.module.css'; +import { downloadUserArchive, MAX_ARCHIVE_BYTES, parseUserArchive, type UserArchive } from './transfer'; + +export function UserTransfer() { + const { t } = useTranslation(['admin', 'common']); + const session = useSession(); + const queryClient = useQueryClient(); + const errorText = useErrorText(); + const [open, setOpen] = useState(false); + const [archive, setArchive] = useState(null); + const [fileError, setFileError] = useState(false); + const [reading, setReading] = useState(false); + const exporting = useMutation({ + mutationFn: async () => downloadUserArchive(unwrap(await session.client.GET('/api/v1/admin/users/export'))), + }); + const importing = useMutation({ + mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/users/import', { body: archive! })), + onSuccess: () => { setArchive(null); void queryClient.invalidateQueries({ queryKey: ['admin-users'] }); }, + }); + const busy = importing.isPending || reading; + const close = () => { if (!busy) { setOpen(false); setArchive(null); } }; + + const read = async (file: File | undefined) => { + setArchive(null); + setFileError(false); + importing.reset(); + if (!file) return; + setReading(true); + try { + if (file.size > MAX_ARCHIVE_BYTES) throw new Error('size'); + setArchive(parseUserArchive(await file.text())); + } catch { setFileError(true); } + finally { setReading(false); } + }; + + return <> + + + {exporting.error ? errorText(exporting.error) : null} + {open && + + + }> +

{t('transfer.description')}

+

{t('transfer.sensitive')}

+ + {archive &&

{t('transfer.preview', { count: archive.users.length })}

} + {fileError ? t('transfer.invalid') : importing.error ? errorText(importing.error) : null} + {importing.data &&
+

{t('transfer.result', { count: importing.data.created, skipped: importing.data.skipped.length })}

+ {importing.data.skipped.length > 0 &&

{importing.data.skipped.join(', ')}

} +
} +
} + ; +} diff --git a/apps/web/src/features/users/transfer.test.ts b/apps/web/src/features/users/transfer.test.ts new file mode 100644 index 0000000..0e71408 --- /dev/null +++ b/apps/web/src/features/users/transfer.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { parseUserArchive } from './transfer'; + +describe('user archive preview', () => { + const entry = { username: 'alice', role: 'user', password_hash: '$argon2id$hash' }; + it('accepts a versioned export and a UTF-8 BOM', () => { + const archive = { version: 1, users: [entry] }; + expect(parseUserArchive(`\uFEFF${JSON.stringify(archive)}`)).toEqual(archive); + }); + it.each([ + '{}', '{"version":2,"users":[]}', '{"version":1,"users":{}}', + JSON.stringify({ version: 1, users: [{ ...entry, role: 'owner' }] }), + JSON.stringify({ version: 1, users: [{ username: 'alice', role: 'user' }] }), + JSON.stringify({ version: 1, users: Array(10001).fill(entry) }), + ])('rejects unsupported or malformed input', (text) => { + expect(() => parseUserArchive(text)).toThrow(); + }); +}); diff --git a/apps/web/src/features/users/transfer.ts b/apps/web/src/features/users/transfer.ts new file mode 100644 index 0000000..6066d84 --- /dev/null +++ b/apps/web/src/features/users/transfer.ts @@ -0,0 +1,29 @@ +import type { components } from '@pipu/api-client'; + +export type UserArchive = components['schemas']['UserArchive']; +export const MAX_ARCHIVE_BYTES = 16 * 1024 * 1024; + +/** Only previews metadata; the server validates every account before writing. */ +export function parseUserArchive(text: string): UserArchive { + const data: unknown = JSON.parse(text.replace(/^\uFEFF/, '')); + if (!data || typeof data !== 'object' || !('version' in data) || data.version !== 1 || + !('users' in data) || !Array.isArray(data.users) || data.users.length > 10000) throw new Error('archive'); + for (const entry of data.users) { + if (!entry || typeof entry !== 'object' || typeof entry.username !== 'string' || + !['admin', 'moderator', 'user'].includes(entry.role) || typeof entry.password_hash !== 'string') { + throw new Error('archive'); + } + } + return data as UserArchive; +} + +export function downloadUserArchive(archive: UserArchive) { + const url = URL.createObjectURL(new Blob([JSON.stringify(archive, null, 2)], { type: 'application/json' })); + const link = document.createElement('a'); + link.href = url; + link.download = 'pipu-users.json'; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} diff --git a/apps/web/src/pages/admin/SourcesPage.tsx b/apps/web/src/pages/admin/SourcesPage.tsx index f71f520..e67b981 100644 --- a/apps/web/src/pages/admin/SourcesPage.tsx +++ b/apps/web/src/pages/admin/SourcesPage.tsx @@ -75,6 +75,11 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void }) const [test, setTest] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const id = source.id; + const covers = useQuery({ + queryKey: ['source-cover-stats', id], + queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources/{id}/cover-stats', { params: { path: { id } } })), + refetchInterval: 10000, + }); const refresh = () => queryClient.invalidateQueries({ queryKey: ['sources'] }); const scan = useMutation({ @@ -144,6 +149,11 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
+ {covers.data && + {t('sources.cover_files', { count: covers.data.files_with_cover, total: covers.data.files_total })} + {covers.data.albums_pending > 0 && ` · ${t('sources.cover_pending', { count: covers.data.albums_pending })}`} + } + {covers.error ? errorText(covers.error) : null} {test && ( {test.ok ? t('sources.test_ok', { count: test.audio_files }) : t('sources.test_fail', { message: test.message })} diff --git a/apps/web/src/pages/admin/UsersPage.tsx b/apps/web/src/pages/admin/UsersPage.tsx index 9011f38..516879a 100644 --- a/apps/web/src/pages/admin/UsersPage.tsx +++ b/apps/web/src/pages/admin/UsersPage.tsx @@ -5,6 +5,7 @@ import { useEffect, useState, type FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession, useUser } from '../../app/session'; +import { UserTransfer } from '../../features/users/UserTransfer'; import { useErrorText, useFieldError } from '../../shared/lib/errors'; import { formatRelative } from '../../shared/lib/format'; import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy'; @@ -163,6 +164,7 @@ export function UsersPage() {
+ {me.role === 'admin' && }