feat(admin): transfer user accounts and show local cover coverage
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / db / db (push) Successful in 2m50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / db / db (push) Successful in 2m50s
This commit is contained in:
1 parent
0a4cd4bff1
commit
f1528de948
22 files changed
+1661
-313
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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<UserArchive | null>(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 <>
|
||||
<Button busy={exporting.isPending} onClick={() => exporting.mutate()}>{t('transfer.export')}</Button>
|
||||
<Button onClick={() => { importing.reset(); setFileError(false); setOpen(true); }}>{t('transfer.import')}</Button>
|
||||
<FormError>{exporting.error ? errorText(exporting.error) : null}</FormError>
|
||||
{open && <Dialog title={t('transfer.import')} onClose={close} footer={<>
|
||||
<Button variant="ghost" disabled={busy} onClick={close}>{t('common:form.close')}</Button>
|
||||
<Button variant="primary" busy={importing.isPending} disabled={!archive || busy} onClick={() => importing.mutate()}>
|
||||
{t('transfer.apply')}
|
||||
</Button>
|
||||
</>}>
|
||||
<p className={s.note}>{t('transfer.description')}</p>
|
||||
<p className={s.note}>{t('transfer.sensitive')}</p>
|
||||
<label className={s.sessions}>
|
||||
<span>{t('transfer.file')}</span>
|
||||
<input type="file" accept=".json,application/json" disabled={busy} onChange={(e) => void read(e.target.files?.[0])} />
|
||||
</label>
|
||||
{archive && <p className={s.note}>{t('transfer.preview', { count: archive.users.length })}</p>}
|
||||
<FormError>{fileError ? t('transfer.invalid') : importing.error ? errorText(importing.error) : null}</FormError>
|
||||
{importing.data && <div role="status">
|
||||
<p>{t('transfer.result', { count: importing.data.created, skipped: importing.data.skipped.length })}</p>
|
||||
{importing.data.skipped.length > 0 && <p className={s.note}>{importing.data.skipped.join(', ')}</p>}
|
||||
</div>}
|
||||
</Dialog>}
|
||||
</>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -75,6 +75,11 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
|
||||
const [test, setTest] = useState<TestResult | null>(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 })
|
||||
</div>
|
||||
<div className={l.cardFoot}>
|
||||
<ScanLine scan={source.scan} lastScanAt={source.last_scan_at} />
|
||||
{covers.data && <span className={s.note} role="status">
|
||||
{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 })}`}
|
||||
</span>}
|
||||
<FormError>{covers.error ? errorText(covers.error) : null}</FormError>
|
||||
{test && (
|
||||
<span className={`${l.test} ${test.ok ? l.testOk : l.testFail}`} role="status">
|
||||
{test.ok ? t('sources.test_ok', { count: test.audio_files }) : t('sources.test_fail', { message: test.message })}
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
<div className={s.toolbar}>
|
||||
{me.role === 'admin' && <UserTransfer />}
|
||||
<label className={s.search}>
|
||||
<Icon src={searchIcon} size={18} className={s.searchIcon} />
|
||||
<input
|
||||
|
||||
@@ -24,6 +24,7 @@ const file: components['schemas']['UploadFile'] = {
|
||||
export const mobileFixtures: Record<string, unknown> = {
|
||||
'/api/v1/admin/sources': { items: [source] },
|
||||
[`/api/v1/admin/sources/${source.id}`]: source,
|
||||
[`/api/v1/admin/sources/${source.id}/cover-stats`]: { files_total: 120, files_with_cover: 87, albums_pending: 3 },
|
||||
[`/api/v1/admin/sources/${source.id}/patterns`]: { patterns: [] },
|
||||
'/api/v1/admin/uploads': { items: [file], next_cursor: null },
|
||||
'/api/v1/uploads/quota': { limit_bytes: 10000000000, used_bytes: 42000000, per_user_limit_bytes: null,
|
||||
|
||||
Reference in new issue
Block a user