Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
976ffb574b | ||
|
|
ec3c2810c8 | ||
|
|
c05e523cbe | ||
|
|
8b76aa3964 | ||
|
|
a685b1b7ca | ||
|
|
c1160cccd3 | ||
|
|
c583991d14 | ||
|
|
ab092def54 | ||
|
|
f075863f15 | ||
|
|
ed4a492dba | ||
|
|
b6def39e29 | ||
|
|
6fe76aea90 | ||
|
|
0acdc3fbb3 | ||
|
|
a996b97694 |
No files matched your search
@@ -52,6 +52,10 @@ jobs:
|
|||||||
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
|
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
|
||||||
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
|
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
|
||||||
# (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново.
|
# (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново.
|
||||||
|
# с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail)
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
container:
|
container:
|
||||||
image: docker.gitea.com/runner-images:ubuntu-latest
|
image: docker.gitea.com/runner-images:ubuntu-latest
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -34,3 +34,4 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
.cache/
|
||||||
@@ -34,7 +34,7 @@ docker compose -f deploy/compose.dev.yaml up -d
|
|||||||
API (из `services/api`):
|
API (из `services/api`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" go run ./cmd/api
|
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" MEILI_URL="http://localhost:7700" MEILI_MASTER_KEY="dev-master-key-change-me" LIBRARY_HOST_ROOT="$PWD/../../tests/fixtures/music/out" X_ACCEL=false CACHE_DIR="$PWD/.cache" go run ./cmd/api
|
||||||
```
|
```
|
||||||
|
|
||||||
Веб на http://localhost:5173 (проксирует `/api` на `:8080`):
|
Веб на http://localhost:5173 (проксирует `/api` на `:8080`):
|
||||||
|
|||||||
+1957
-1
File diff suppressed because it is too large.
Load diff
@@ -14,18 +14,22 @@
|
|||||||
"@fontsource/jetbrains-mono": "^5.2.6",
|
"@fontsource/jetbrains-mono": "^5.2.6",
|
||||||
"@fontsource/onest": "^5.2.6",
|
"@fontsource/onest": "^5.2.6",
|
||||||
"@fontsource/unbounded": "^5.2.6",
|
"@fontsource/unbounded": "^5.2.6",
|
||||||
|
"@material-symbols/svg-400": "^0.31.9",
|
||||||
"@pipu/api-client": "workspace:*",
|
"@pipu/api-client": "workspace:*",
|
||||||
"@pipu/core": "workspace:*",
|
"@pipu/core": "workspace:*",
|
||||||
"@pipu/design-tokens": "workspace:*",
|
"@pipu/design-tokens": "workspace:*",
|
||||||
"@pipu/i18n": "workspace:*",
|
"@pipu/i18n": "workspace:*",
|
||||||
"@tanstack/react-query": "^5.80.7",
|
"@tanstack/react-query": "^5.80.7",
|
||||||
|
"@tanstack/react-router": "^1.120.20",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-i18next": "^15.5.3"
|
"react-i18next": "^15.5.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.5.2",
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
|
|||||||
+275
-29
@@ -1,51 +1,297 @@
|
|||||||
import { createApiClient } from '@pipu/api-client';
|
import { AuthSession } from '@pipu/core';
|
||||||
import { createI18n } from '@pipu/i18n';
|
import { createI18n } from '@pipu/i18n';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
import { createMemoryHistory } from '@tanstack/react-router';
|
||||||
|
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
|
import { createAppRouter, safeRedirect } from './app/router';
|
||||||
|
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const info = {
|
async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) {
|
||||||
name: 'pipu-music',
|
const api = fakeApi(users);
|
||||||
version: '0.1.0',
|
|
||||||
api_version: 'v1',
|
|
||||||
supported_locales: ['ru', 'en'],
|
|
||||||
min_android_version_code: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function renderApp(fetchImpl: typeof fetch) {
|
|
||||||
const i18n = await createI18n('ru');
|
const i18n = await createI18n('ru');
|
||||||
const client = createApiClient({ baseUrl: 'http://localhost', fetch: fetchImpl });
|
const session = new AuthSession({ baseUrl: 'http://localhost', fetch: api.fetch, clientType: 'web' });
|
||||||
|
if (opts.signedInAs) await session.login(opts.signedInAs.username, opts.signedInAs.password);
|
||||||
|
else await session.restore();
|
||||||
|
const router = createAppRouter(session, { history: createMemoryHistory({ initialEntries: [path] }) });
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
render(
|
render(
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
<App session={session} router={router} queryClient={queryClient} />
|
||||||
<App client={client} />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</I18nextProvider>,
|
</I18nextProvider>,
|
||||||
);
|
);
|
||||||
|
return { api, router, session, i18n, user: userEvent.setup() };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('App', () => {
|
describe('sign-in', () => {
|
||||||
it('shows the server version when the server is online', async () => {
|
it('sends a guest to the login page and back after signing in', async () => {
|
||||||
await renderApp(async () => Response.json(info));
|
const alice = makeUser('alice', 'user', { display_name: 'Алиса' });
|
||||||
expect(await screen.findByText('Сервер 0.1.0 на связи')).toBeTruthy();
|
const { user, router } = await renderApp([alice], '/settings');
|
||||||
|
|
||||||
|
await screen.findByRole('heading', { name: 'Вход' });
|
||||||
|
expect(router.state.location.pathname).toBe('/login');
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText('Логин'), 'alice');
|
||||||
|
await user.type(screen.getByLabelText('Пароль'), 'wrong');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Неверный логин или пароль');
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText('Пароль'), alice.password);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Настройки' });
|
||||||
|
expect(router.state.location.pathname).toBe('/settings');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows an error when the server is unreachable', async () => {
|
it('shows the bootstrap hint on first start', async () => {
|
||||||
await renderApp(async () => {
|
await renderApp([makeUser('admin', 'admin', { must_change_credentials: true })], '/login');
|
||||||
throw new TypeError('Failed to fetch');
|
expect(await screen.findByText(/Первый запуск/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces the bootstrap admin to change credentials', async () => {
|
||||||
|
const admin = makeUser('admin', 'admin', { must_change_credentials: true });
|
||||||
|
const { user, router, api } = await renderApp([admin], '/admin/users', { signedInAs: admin });
|
||||||
|
|
||||||
|
await screen.findByRole('heading', { name: 'Смените логин и пароль' });
|
||||||
|
expect(router.state.location.pathname).toBe('/setup/admin');
|
||||||
|
|
||||||
|
const login = screen.getByLabelText('Новый логин');
|
||||||
|
expect(login).toHaveValue('admin');
|
||||||
|
await user.clear(login);
|
||||||
|
await user.type(login, 'owner');
|
||||||
|
await user.type(screen.getByLabelText('Новый пароль'), 'short');
|
||||||
|
await user.type(screen.getByLabelText('Повторите пароль'), 'shorter');
|
||||||
|
expect(screen.getByText('Пароли не совпадают')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Сохранить и продолжить' })).toBeDisabled();
|
||||||
|
|
||||||
|
await user.clear(screen.getByLabelText('Повторите пароль'));
|
||||||
|
await user.type(screen.getByLabelText('Повторите пароль'), 'short');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' }));
|
||||||
|
expect(await screen.findByText('Пароль должен быть не короче 12 символов')).toBeInTheDocument();
|
||||||
|
|
||||||
|
for (const label of ['Новый пароль', 'Повторите пароль']) {
|
||||||
|
await user.clear(screen.getByLabelText(label));
|
||||||
|
await user.type(screen.getByLabelText(label), 'a-strong-owner-pass');
|
||||||
|
}
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' }));
|
||||||
|
await screen.findByText('Привет, owner!');
|
||||||
|
expect(router.state.location.pathname).toBe('/');
|
||||||
|
expect(api.calls.find((c) => c.path === '/api/v1/me/bootstrap-credentials' && c.body)?.body).toMatchObject({
|
||||||
|
new_username: 'owner',
|
||||||
});
|
});
|
||||||
expect(await screen.findByText(/Сервер не отвечает/)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('switches the interface language', async () => {
|
it('forces a temporary password to be replaced', async () => {
|
||||||
await renderApp(async () => Response.json(info));
|
const bob = makeUser('bob', 'user', { must_change_password: true });
|
||||||
await screen.findByText('Сервер 0.1.0 на связи');
|
const { user } = await renderApp([bob], '/', { signedInAs: bob });
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'EN' }));
|
await screen.findByRole('heading', { name: 'Задайте новый пароль' });
|
||||||
expect(await screen.findByText('Server 0.1.0 is online')).toBeTruthy();
|
await user.type(screen.getByLabelText('Новый пароль'), 'bobs-own-pass');
|
||||||
|
await user.type(screen.getByLabelText('Повторите пароль'), 'bobs-own-pass');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' }));
|
||||||
|
await screen.findByText('Привет, bob!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs out from the profile menu', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, router } = await renderApp([alice], '/', { signedInAs: alice });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
|
||||||
|
await user.click(screen.getByRole('menuitem', { name: 'Выйти' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Вход' });
|
||||||
|
expect(router.state.location.pathname).toBe('/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('navigation by role', () => {
|
||||||
|
it('hides the admin section from listeners and blocks the route', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { router } = await renderApp([alice], '/admin/users', { signedInAs: alice });
|
||||||
|
await screen.findByText('Привет, alice!');
|
||||||
|
expect(router.state.location.pathname).toBe('/');
|
||||||
|
expect(screen.queryByRole('link', { name: 'Пользователи' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows users but not the audit log to moderators', async () => {
|
||||||
|
const mod = makeUser('mod', 'moderator');
|
||||||
|
await renderApp([mod], '/', { signedInAs: mod });
|
||||||
|
expect(await screen.findByRole('link', { name: 'Пользователи' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('link', { name: 'Журнал' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the audit log to admins', async () => {
|
||||||
|
const root = makeUser('root', 'admin');
|
||||||
|
await renderApp([root], '/admin/audit', { signedInAs: root });
|
||||||
|
expect(await screen.findByText('Записей пока нет')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('users admin', () => {
|
||||||
|
it('lets an admin create a moderator and shows the temporary password once', async () => {
|
||||||
|
const root = makeUser('root', 'admin');
|
||||||
|
const anna = makeUser('anna', 'user', { last_login_at: new Date().toISOString() });
|
||||||
|
const { user } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
|
||||||
|
|
||||||
|
const table = await screen.findByRole('table');
|
||||||
|
await within(table).findByText('@anna');
|
||||||
|
expect(within(table).getByText('(вы)')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Новый пользователь' });
|
||||||
|
await user.type(within(dialog).getByLabelText('Логин'), 'ilya');
|
||||||
|
await user.selectOptions(within(dialog).getByRole('combobox'), 'moderator');
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Создать' }));
|
||||||
|
|
||||||
|
const secret = await screen.findByRole('dialog', { name: 'Временный пароль' });
|
||||||
|
expect(within(secret).getByTestId('temporary-password')).toHaveTextContent('Temp-Pass-123');
|
||||||
|
await user.click(within(secret).getByRole('button', { name: 'Готово' }));
|
||||||
|
expect(screen.queryByTestId('temporary-password')).not.toBeInTheDocument();
|
||||||
|
expect(await within(table).findByText('@ilya')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers moderators only what they may do', async () => {
|
||||||
|
const mod = makeUser('mod', 'moderator');
|
||||||
|
const anna = makeUser('anna', 'user');
|
||||||
|
const other = makeUser('other', 'moderator');
|
||||||
|
const { user } = await renderApp([mod, anna, other], '/admin/users', { signedInAs: mod });
|
||||||
|
|
||||||
|
await screen.findByText('@anna');
|
||||||
|
expect(screen.queryByRole('button', { name: 'Действия с other' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Действия с mod' })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Действия с anna' }));
|
||||||
|
const items = screen.getAllByRole('menuitem').map((i) => i.textContent);
|
||||||
|
expect(items).toEqual(['Сбросить пароль', 'Заблокировать']);
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Новый пользователь' });
|
||||||
|
expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by role', async () => {
|
||||||
|
const root = makeUser('root', 'admin');
|
||||||
|
const { user, api } = await renderApp([root, makeUser('anna', 'user'), makeUser('mod', 'moderator')], '/admin/users', {
|
||||||
|
signedInAs: root,
|
||||||
|
});
|
||||||
|
await screen.findByText('@anna');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Модератор' }));
|
||||||
|
await waitFor(() => expect(screen.queryByText('@anna')).not.toBeInTheDocument());
|
||||||
|
expect(screen.getByText('@mod')).toBeInTheDocument();
|
||||||
|
expect(api.calls.some((c) => c.path === '/api/v1/admin/users' && c.method === 'GET')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('language and settings', () => {
|
||||||
|
it('saves the language in the profile', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, api } = await renderApp([alice], '/settings', { signedInAs: alice });
|
||||||
|
await screen.findByRole('heading', { name: 'Настройки' });
|
||||||
|
const groups = screen.getAllByRole('group', { name: 'Язык' });
|
||||||
|
await user.click(within(groups[0]!).getByRole('button', { name: 'EN' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Settings' });
|
||||||
|
await waitFor(() => expect(api.users.get(alice.id)?.locale).toBe('en'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the theme from the profile', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user } = await renderApp([alice], '/settings', { signedInAs: alice });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Светлая' }));
|
||||||
|
await waitFor(() => expect(document.documentElement.dataset.theme).toBe('light'));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Тёмная' }));
|
||||||
|
await waitFor(() => expect(document.documentElement.dataset.theme).toBe('dark'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches the language on the login page without a session', async () => {
|
||||||
|
const { user } = await renderApp([], '/login');
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'EN' }));
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('search box', () => {
|
||||||
|
it('opens the search page on Enter and an item with the arrows', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, router } = await renderApp([alice], '/', { signedInAs: alice });
|
||||||
|
const box = await screen.findByRole('combobox', { name: 'Поиск' });
|
||||||
|
|
||||||
|
await user.type(box, 'rbyj');
|
||||||
|
const list = await screen.findByRole('listbox');
|
||||||
|
const options = within(list).getAllByRole('option');
|
||||||
|
expect(options.map((o) => o.textContent)).toEqual(['КиноИсполнитель', 'Группа кровиКино · 1988']);
|
||||||
|
expect(screen.getByText('Показаны результаты для «кино»')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe('/search'));
|
||||||
|
expect(router.state.location.search).toEqual({ q: 'rbyj' });
|
||||||
|
|
||||||
|
await user.clear(box);
|
||||||
|
await user.type(box, 'кино');
|
||||||
|
await screen.findByRole('listbox');
|
||||||
|
await user.keyboard('{ArrowDown}{Enter}');
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe('/artist/00000000-0000-4000-8000-0000000000aa'));
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Кино' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses on the slash key', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user } = await renderApp([alice], '/', { signedInAs: alice });
|
||||||
|
const box = await screen.findByRole('combobox', { name: 'Поиск' });
|
||||||
|
await user.keyboard('/');
|
||||||
|
expect(box).toHaveFocus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('player', () => {
|
||||||
|
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
||||||
|
|
||||||
|
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!;
|
||||||
|
await user.click(row);
|
||||||
|
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
|
||||||
|
expect(row).toHaveAttribute('aria-current', 'true');
|
||||||
|
expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Нравится' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.calls).toContainEqual({
|
||||||
|
method: 'PUT',
|
||||||
|
path: '/api/v1/me/reactions/tracks/00000000-0000-4000-8000-000000000101',
|
||||||
|
body: { reaction: 'like' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(within(bar).getByRole('button', { name: 'Убрать из лайков' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
// Пробел на кнопке нажимает её саму; горячая клавиша работает, когда фокус не на элементе управления.
|
||||||
|
(document.activeElement as HTMLElement).blur();
|
||||||
|
await user.keyboard(' ');
|
||||||
|
expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Уход со страницы досылает события и сохраняет состояние плеера.
|
||||||
|
window.dispatchEvent(new Event('pagehide'));
|
||||||
|
await waitFor(() => {
|
||||||
|
const sent = api.calls.filter((c) => c.path === '/api/v1/events').flatMap((c) => (c.body as { events: { type: string; context_type?: string }[] }).events);
|
||||||
|
expect(sent.find((e) => e.type === 'play_start')).toMatchObject({ context_type: 'album' });
|
||||||
|
});
|
||||||
|
expect(api.calls.find((c) => c.method === 'PUT' && c.path === '/api/v1/me/playback-state')?.body).toMatchObject({
|
||||||
|
track_id: '00000000-0000-4000-8000-000000000101',
|
||||||
|
queue_index: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeRedirect', () => {
|
||||||
|
it.each([
|
||||||
|
['/settings', '/settings'],
|
||||||
|
['//evil.example', '/'],
|
||||||
|
['https://evil.example', '/'],
|
||||||
|
[undefined, '/'],
|
||||||
|
])('%s → %s', (input, expected) => {
|
||||||
|
expect(safeRedirect(input)).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+24
-51
@@ -1,57 +1,30 @@
|
|||||||
import { createApiClient, type ApiClient } from '@pipu/api-client';
|
import type { AuthSession } from '@pipu/core';
|
||||||
import { checkServer } from '@pipu/core';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { SUPPORTED_LOCALES } from '@pipu/i18n';
|
import { RouterProvider } from '@tanstack/react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const defaultClient = createApiClient();
|
import type { createAppRouter } from './app/router';
|
||||||
|
import { SessionProvider } from './app/session';
|
||||||
|
|
||||||
export function App({ client = defaultClient }: { client?: ApiClient }) {
|
export function createQueryClient() {
|
||||||
const { t, i18n } = useTranslation();
|
return new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } });
|
||||||
const server = useQuery({ queryKey: ['server-info'], queryFn: () => checkServer(client) });
|
}
|
||||||
|
|
||||||
|
export function App({
|
||||||
|
session,
|
||||||
|
router,
|
||||||
|
queryClient,
|
||||||
|
}: {
|
||||||
|
session: AuthSession;
|
||||||
|
router: ReturnType<typeof createAppRouter>;
|
||||||
|
queryClient?: QueryClient;
|
||||||
|
}) {
|
||||||
|
const [client] = useState(() => queryClient ?? createQueryClient());
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<SessionProvider session={session}>
|
||||||
<header className="top">
|
<QueryClientProvider client={client}>
|
||||||
<div className="logo">
|
<RouterProvider router={router} />
|
||||||
<span className="logo-dot" aria-hidden />
|
</QueryClientProvider>
|
||||||
{t('app.name')}
|
</SessionProvider>
|
||||||
</div>
|
|
||||||
<div className="langs" role="group" aria-label={t('language.label')}>
|
|
||||||
{SUPPORTED_LOCALES.map((l) => (
|
|
||||||
<button
|
|
||||||
key={l}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={i18n.language === l}
|
|
||||||
onClick={() => {
|
|
||||||
void i18n.changeLanguage(l);
|
|
||||||
document.documentElement.lang = l;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{l.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main className="main">
|
|
||||||
<section className="card">
|
|
||||||
<h1>{t('app.name')}</h1>
|
|
||||||
<p className="muted">{t('app.tagline')}</p>
|
|
||||||
{server.data?.status === 'ok' ? (
|
|
||||||
<div className="status" data-state="ok" role="status">
|
|
||||||
{t('server.online', { version: server.data.info.version })}
|
|
||||||
</div>
|
|
||||||
) : server.data ? (
|
|
||||||
<div className="status" data-state="error" role="status">
|
|
||||||
{t('server.offline')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="status" data-state="pending" role="status">
|
|
||||||
{t('server.checking')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import type { AuthSession, AuthUser } from '@pipu/core';
|
||||||
|
import {
|
||||||
|
createRootRouteWithContext,
|
||||||
|
createRoute,
|
||||||
|
createRouter,
|
||||||
|
Outlet,
|
||||||
|
redirect,
|
||||||
|
type ParsedLocation,
|
||||||
|
} from '@tanstack/react-router';
|
||||||
|
|
||||||
|
import { AuditPage } from '../pages/admin/AuditPage';
|
||||||
|
import { LibraryPage } from '../pages/admin/LibraryPage';
|
||||||
|
import { PatternsPage } from '../pages/admin/PatternsPage';
|
||||||
|
import { SourcesPage } from '../pages/admin/SourcesPage';
|
||||||
|
import { UsersPage } from '../pages/admin/UsersPage';
|
||||||
|
import { AlbumPage } from '../pages/AlbumPage';
|
||||||
|
import { ArtistPage } from '../pages/ArtistPage';
|
||||||
|
import { CollectionPage } from '../pages/CollectionPage';
|
||||||
|
import { HomePage } from '../pages/HomePage';
|
||||||
|
import { LoginPage } from '../pages/LoginPage';
|
||||||
|
import { SearchPage, type SearchType } from '../pages/SearchPage';
|
||||||
|
import { SettingsPage } from '../pages/SettingsPage';
|
||||||
|
import { SetupPage } from '../pages/SetupPage';
|
||||||
|
import { AppShell } from '../widgets/AppShell';
|
||||||
|
import { RouteError } from '../widgets/RouteError';
|
||||||
|
|
||||||
|
export interface RouterContext {
|
||||||
|
session: AuthSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
||||||
|
export function homeFor(user: AuthUser): '/setup/admin' | '/setup/password' | '/' {
|
||||||
|
if (user.must_change_credentials) return '/setup/admin';
|
||||||
|
if (user.must_change_password) return '/setup/password';
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedInUser(session: AuthSession): AuthUser | null {
|
||||||
|
return session.state.status === 'signed_in' ? session.state.user : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Внутренний путь из ?redirect=; внешние адреса игнорируются. */
|
||||||
|
export function safeRedirect(target: unknown): string {
|
||||||
|
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
|
||||||
|
const user = signedInUser(session);
|
||||||
|
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||||
|
component: Outlet,
|
||||||
|
errorComponent: RouteError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/login',
|
||||||
|
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
||||||
|
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
||||||
|
beforeLoad: ({ context, search }) => {
|
||||||
|
const user = signedInUser(context.session);
|
||||||
|
if (!user) return;
|
||||||
|
const home = homeFor(user);
|
||||||
|
if (home !== '/') throw redirect({ to: home });
|
||||||
|
throw redirect({ href: safeRedirect(search.redirect) });
|
||||||
|
},
|
||||||
|
component: LoginPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupAdminRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/setup/admin',
|
||||||
|
beforeLoad: ({ context, location }) => {
|
||||||
|
const user = requireUser(context.session, location);
|
||||||
|
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
|
||||||
|
},
|
||||||
|
component: () => <SetupPage variant="admin" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupPasswordRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/setup/password',
|
||||||
|
beforeLoad: ({ context, location }) => {
|
||||||
|
const user = requireUser(context.session, location);
|
||||||
|
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
|
||||||
|
},
|
||||||
|
component: () => <SetupPage variant="password" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Каркас приложения: только для вошедших, прошедших обязательные шаги. */
|
||||||
|
const appRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
id: 'app',
|
||||||
|
beforeLoad: ({ context, location }) => {
|
||||||
|
const user = requireUser(context.session, location);
|
||||||
|
const home = homeFor(user);
|
||||||
|
if (home !== '/') throw redirect({ to: home });
|
||||||
|
return { user };
|
||||||
|
},
|
||||||
|
component: AppShell,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
||||||
|
if (context.user.role !== 'admin') throw redirect({ to: '/' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||||
|
|
||||||
|
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
||||||
|
|
||||||
|
const usersRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/admin/users',
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
if (context.user.role === 'user') throw redirect({ to: '/' });
|
||||||
|
},
|
||||||
|
component: UsersPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const auditRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/admin/audit',
|
||||||
|
beforeLoad: adminOnly,
|
||||||
|
component: AuditPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const historyRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/collection/history',
|
||||||
|
component: () => <CollectionPage tab="history" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const likesRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/collection/likes',
|
||||||
|
component: () => <CollectionPage tab="likes" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dislikesRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/collection/dislikes',
|
||||||
|
component: () => <CollectionPage tab="dislikes" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/search',
|
||||||
|
validateSearch: (search: Record<string, unknown>): { q: string; type?: Exclude<SearchType, 'all'> } => {
|
||||||
|
const type = search.type;
|
||||||
|
return {
|
||||||
|
q: typeof search.q === 'string' ? search.q : '',
|
||||||
|
...(type === 'artists' || type === 'albums' || type === 'tracks' ? { type } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: function SearchRoute() {
|
||||||
|
const { q, type } = searchRoute.useSearch();
|
||||||
|
return <SearchPage q={q} type={type ?? 'all'} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const artistRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/artist/$id',
|
||||||
|
component: function ArtistRoute() {
|
||||||
|
const { id } = artistRoute.useParams();
|
||||||
|
return <ArtistPage key={id} id={id} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/album/$id',
|
||||||
|
component: function AlbumRoute() {
|
||||||
|
const { id } = albumRoute.useParams();
|
||||||
|
return <AlbumPage key={id} id={id} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourcesRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/admin/sources',
|
||||||
|
beforeLoad: adminOnly,
|
||||||
|
component: SourcesPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patternsRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/admin/sources/$id/patterns',
|
||||||
|
beforeLoad: adminOnly,
|
||||||
|
component: function PatternsRoute() {
|
||||||
|
const { id } = patternsRoute.useParams();
|
||||||
|
return <PatternsPage id={id} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const libraryRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/admin/library',
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
if (context.user.role === 'user') throw redirect({ to: '/' });
|
||||||
|
},
|
||||||
|
component: LibraryPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeTree = rootRoute.addChildren([
|
||||||
|
loginRoute,
|
||||||
|
setupAdminRoute,
|
||||||
|
setupPasswordRoute,
|
||||||
|
appRoute.addChildren([
|
||||||
|
homeRoute,
|
||||||
|
settingsRoute,
|
||||||
|
searchRoute,
|
||||||
|
historyRoute,
|
||||||
|
likesRoute,
|
||||||
|
dislikesRoute,
|
||||||
|
artistRoute,
|
||||||
|
albumRoute,
|
||||||
|
usersRoute,
|
||||||
|
auditRoute,
|
||||||
|
sourcesRoute,
|
||||||
|
patternsRoute,
|
||||||
|
libraryRoute,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
|
||||||
|
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
|
||||||
|
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
||||||
|
session.subscribe(() => void router.invalidate());
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: ReturnType<typeof createAppRouter>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { AuthSession, AuthState, AuthUser } from '@pipu/core';
|
||||||
|
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
||||||
|
import { createContext, useContext, useEffect, useSyncExternalStore, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const SessionContext = createContext<AuthSession | null>(null);
|
||||||
|
|
||||||
|
export function SessionProvider({ session, children }: { session: AuthSession; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<SessionContext.Provider value={session}>
|
||||||
|
<UserEffects session={session} />
|
||||||
|
{children}
|
||||||
|
</SessionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession(): AuthSession {
|
||||||
|
const s = useContext(SessionContext);
|
||||||
|
if (!s) throw new Error('useSession outside SessionProvider');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthState(): AuthState {
|
||||||
|
const session = useSession();
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(fn) => session.subscribe(fn),
|
||||||
|
() => session.state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текущий пользователь; вызывать только на защищённых страницах. */
|
||||||
|
export function useUser(): AuthUser {
|
||||||
|
const state = useAuthState();
|
||||||
|
if (state.status !== 'signed_in') throw new Error('useUser: not signed in');
|
||||||
|
return state.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly string[]).includes(l);
|
||||||
|
|
||||||
|
/** Применяет настройки вошедшего пользователя: язык и тему. */
|
||||||
|
function UserEffects({ session }: { session: AuthSession }) {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const state = useSyncExternalStore(
|
||||||
|
(fn) => session.subscribe(fn),
|
||||||
|
() => session.state,
|
||||||
|
);
|
||||||
|
const user = state.status === 'signed_in' ? state.user : null;
|
||||||
|
const locale = user?.locale;
|
||||||
|
const theme = user?.ui_prefs.theme;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (locale && isLocale(locale) && locale !== i18n.language) void i18n.changeLanguage(locale);
|
||||||
|
}, [locale, i18n]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тёмная тема по умолчанию (docs/design.md); system — по ОС через prefers-color-scheme (tokens.css). */
|
||||||
|
export function applyTheme(theme: string | undefined) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (theme === 'system') delete root.dataset.theme;
|
||||||
|
else root.dataset.theme = theme === 'light' ? 'light' : 'dark';
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
.cover {
|
||||||
|
position: relative;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card,
|
||||||
|
.artistChip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px;
|
||||||
|
margin: -10px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover,
|
||||||
|
.artistChip:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artistChip {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
width: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardCover {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardMeta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text3);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disc {
|
||||||
|
margin: 14px 12px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:has(.trackAlbum) {
|
||||||
|
grid-template-columns: 40px minmax(0, 2fr) minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unavailable {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playHint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(5, 7, 13, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:not(.unavailable) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:hover .playHint,
|
||||||
|
.track:focus-visible .playHint {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current .trackTitle {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.end {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text3);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:hover .like,
|
||||||
|
.track:focus-within .like,
|
||||||
|
.liked {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liked {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowMeta {
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackMain {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackTitle {
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackArtists,
|
||||||
|
.trackAlbum {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.track:has(.trackAlbum) {
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackAlbum {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
|
import type { PlayContext } from '../player/player';
|
||||||
|
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon } from '../shared/ui/icons';
|
||||||
|
import { cx, Icon } from '../shared/ui/ui';
|
||||||
|
import s from './catalog.module.css';
|
||||||
|
|
||||||
|
export type Track = components['schemas']['Track'];
|
||||||
|
export type AlbumSummary = components['schemas']['AlbumSummary'];
|
||||||
|
export type ArtistRef = components['schemas']['ArtistRef'];
|
||||||
|
|
||||||
|
const WAVE = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||||
|
|
||||||
|
/** Детерминированная палитра по id, пока у альбома нет своей обложки. */
|
||||||
|
function fallbackColors(id: string): string[] {
|
||||||
|
let h = 0;
|
||||||
|
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||||
|
const base = h % 360;
|
||||||
|
return [`hsl(${base} 70% 55%)`, `hsl(${(base + 50) % 360} 65% 45%)`, `hsl(${(base + 110) % 360} 70% 60%)`];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
|
||||||
|
export function coverSrc(url: string, cssPx: number): string {
|
||||||
|
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
|
||||||
|
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
|
||||||
|
return `${url}&size=${size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обложка альбома; без картинки — градиент из палитры обложки или из id. */
|
||||||
|
export function Cover({
|
||||||
|
album,
|
||||||
|
size = 48,
|
||||||
|
round,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
album: { id: string; cover_url?: string | null; cover_colors?: string[] };
|
||||||
|
size?: number | string;
|
||||||
|
round?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const colors = album.cover_colors?.length === 3 ? album.cover_colors : album.id ? fallbackColors(album.id) : WAVE;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cx(s.cover, round && s.round, className)}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
// Резиновая обложка (size="100%") — высоту задаёт aspect-ratio карточки.
|
||||||
|
height: typeof size === 'number' ? size : undefined,
|
||||||
|
background: `radial-gradient(circle at 30% 25%, ${colors[2]}, transparent 60%), linear-gradient(135deg, ${colors[0]}, ${colors[1]})`,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{album.cover_url ? (
|
||||||
|
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Исполнители через запятую, каждый — ссылка. */
|
||||||
|
export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{artists.map((a, i) => (
|
||||||
|
<Fragment key={a.id}>
|
||||||
|
{i > 0 && ', '}
|
||||||
|
<Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
|
||||||
|
{a.name}
|
||||||
|
</Link>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlbumCard({ album }: { album: AlbumSummary }) {
|
||||||
|
return (
|
||||||
|
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
|
||||||
|
<Cover album={album} size="100%" className={s.cardCover} />
|
||||||
|
<span className={s.cardTitle}>{album.title}</span>
|
||||||
|
<span className={s.cardMeta}>
|
||||||
|
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
|
||||||
|
return (
|
||||||
|
<div className={s.grid}>
|
||||||
|
{albums.map((a) => (
|
||||||
|
<AlbumCard key={a.id} album={a} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома).
|
||||||
|
* Клик по строке запускает весь список с этого трека в контексте context; Enter — то же с клавиатуры.
|
||||||
|
*/
|
||||||
|
export function TrackList({
|
||||||
|
tracks,
|
||||||
|
showAlbum,
|
||||||
|
numbered,
|
||||||
|
context,
|
||||||
|
rowMeta,
|
||||||
|
}: {
|
||||||
|
tracks: Track[];
|
||||||
|
showAlbum?: boolean;
|
||||||
|
numbered?: boolean;
|
||||||
|
context: PlayContext;
|
||||||
|
/** Дополнительный текст под исполнителями (например, время прослушивания в истории). */
|
||||||
|
rowMeta?: (t: Track, i: number) => string | undefined;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const player = usePlayer();
|
||||||
|
const current = usePlayerState().track;
|
||||||
|
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
||||||
|
let lastDisc: number | null = null;
|
||||||
|
return (
|
||||||
|
<div className={s.tracks} role="table" aria-label={t('search.tracks')}>
|
||||||
|
{tracks.map((tr, i) => {
|
||||||
|
const disc = tr.disc_no ?? 1;
|
||||||
|
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
||||||
|
lastDisc = disc;
|
||||||
|
const isCurrent = current?.id === tr.id;
|
||||||
|
const play = () => tr.available && player.playList(tracks, i, context);
|
||||||
|
const liked = tr.reaction === 'like';
|
||||||
|
const meta = rowMeta?.(tr, i);
|
||||||
|
return (
|
||||||
|
<Fragment key={`${tr.id}-${i}`}>
|
||||||
|
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||||
|
<div
|
||||||
|
role="row"
|
||||||
|
tabIndex={tr.available ? 0 : -1}
|
||||||
|
aria-current={isCurrent || undefined}
|
||||||
|
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||||
|
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!(e.target as HTMLElement).closest('a, button')) play();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={s.num}>
|
||||||
|
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={40} />}
|
||||||
|
{tr.available && (
|
||||||
|
<span className={s.playHint} aria-hidden>
|
||||||
|
<Icon src={playIcon} size={20} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={s.trackMain}>
|
||||||
|
<span className={s.trackTitle}>{tr.title}</span>
|
||||||
|
<span className={s.trackArtists}>
|
||||||
|
<ArtistLinks artists={tr.artists} />
|
||||||
|
{meta && <span className={s.rowMeta}> · {meta}</span>}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{showAlbum && (
|
||||||
|
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
|
||||||
|
{tr.album.title}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span className={s.end}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.like, liked && s.liked)}
|
||||||
|
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
||||||
|
aria-pressed={liked}
|
||||||
|
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
|
||||||
|
>
|
||||||
|
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
|
||||||
|
</button>
|
||||||
|
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtistChip({ artist }: { artist: ArtistRef }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Link to="/artist/$id" params={{ id: artist.id }} className={s.artistChip}>
|
||||||
|
<Cover album={{ id: artist.id }} size={88} round />
|
||||||
|
<span className={s.cardTitle}>{artist.name}</span>
|
||||||
|
<span className={s.cardMeta}>{t('search.artist_kind')}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
+36
-5
@@ -1,12 +1,14 @@
|
|||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
|
import { AuthSession } from '@pipu/core';
|
||||||
import { createI18n, pickLocale } from '@pipu/i18n';
|
import { createI18n, pickLocale } from '@pipu/i18n';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
|
import { createAppRouter } from './app/router';
|
||||||
|
import { applyTheme } from './app/session';
|
||||||
|
|
||||||
const LOCALE_KEY = 'pipu.locale';
|
const LOCALE_KEY = 'pipu.locale';
|
||||||
|
|
||||||
@@ -19,9 +21,37 @@ function storedLocale(): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** «Chrome / Windows» — подпись сессии в списке устройств. */
|
||||||
|
function deviceName(): string {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
const browser = /Edg\//.test(ua)
|
||||||
|
? 'Edge'
|
||||||
|
: /Firefox\//.test(ua)
|
||||||
|
? 'Firefox'
|
||||||
|
: /Chrome\//.test(ua)
|
||||||
|
? 'Chrome'
|
||||||
|
: /Safari\//.test(ua)
|
||||||
|
? 'Safari'
|
||||||
|
: 'Browser';
|
||||||
|
const os = /Windows/.test(ua)
|
||||||
|
? 'Windows'
|
||||||
|
: /Android/.test(ua)
|
||||||
|
? 'Android'
|
||||||
|
: /iPhone|iPad/.test(ua)
|
||||||
|
? 'iOS'
|
||||||
|
: /Mac OS X/.test(ua)
|
||||||
|
? 'macOS'
|
||||||
|
: /Linux/.test(ua)
|
||||||
|
? 'Linux'
|
||||||
|
: '';
|
||||||
|
return os ? `${browser} / ${os}` : browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme(undefined);
|
||||||
const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages]));
|
const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages]));
|
||||||
document.documentElement.lang = i18n.language;
|
document.documentElement.lang = i18n.language;
|
||||||
i18n.on('languageChanged', (l) => {
|
i18n.on('languageChanged', (l) => {
|
||||||
|
document.documentElement.lang = l;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(LOCALE_KEY, l);
|
localStorage.setItem(LOCALE_KEY, l);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -29,14 +59,15 @@ i18n.on('languageChanged', (l) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } });
|
const session = new AuthSession({ clientType: 'web', deviceName: deviceName() });
|
||||||
|
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
||||||
|
await session.restore();
|
||||||
|
const router = createAppRouter(session);
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<App session={session} router={router} />
|
||||||
<App />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { Cover, TrackList } from '../entities/catalog';
|
||||||
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import { NotFound } from './NotFound';
|
||||||
|
import s from './page.module.css';
|
||||||
|
import c from './catalog.module.css';
|
||||||
|
|
||||||
|
export function AlbumPage({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const album = useQuery({
|
||||||
|
queryKey: ['album', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (album.isError) return <NotFound error={album.error} />;
|
||||||
|
const a = album.data;
|
||||||
|
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<header className={c.hero}>
|
||||||
|
{colors && (
|
||||||
|
<span
|
||||||
|
className={c.heroBg}
|
||||||
|
style={{ background: `linear-gradient(135deg, ${colors[0]}, ${colors[1]})` }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Cover album={a ?? { id }} size={200} />
|
||||||
|
<div className={c.heroBody}>
|
||||||
|
<span className={c.kind}>{t('catalog.album')}</span>
|
||||||
|
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1>
|
||||||
|
{a && (
|
||||||
|
<span className={c.heroMeta}>
|
||||||
|
<Link to="/artist/$id" params={{ id: a.artist.id }}>
|
||||||
|
{a.artist.name}
|
||||||
|
</Link>
|
||||||
|
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} ·{' '}
|
||||||
|
{formatDuration(a.duration_ms)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{a && <TrackList tracks={a.tracks} numbered context={{ type: 'album', id: a.id, label: a.title }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { AlbumGrid, Cover } from '../entities/catalog';
|
||||||
|
import { NotFound } from './NotFound';
|
||||||
|
import s from './page.module.css';
|
||||||
|
import c from './catalog.module.css';
|
||||||
|
|
||||||
|
export function ArtistPage({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const artist = useQuery({
|
||||||
|
queryKey: ['artist', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
const albums = useQuery({
|
||||||
|
queryKey: ['artist-albums', id],
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (artist.isError) return <NotFound error={artist.error} />;
|
||||||
|
const a = artist.data;
|
||||||
|
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
|
||||||
|
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<header className={c.hero}>
|
||||||
|
<Cover album={{ id }} size={168} round />
|
||||||
|
<div className={c.heroBody}>
|
||||||
|
<span className={c.kind}>{t('catalog.artist')}</span>
|
||||||
|
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
|
||||||
|
{a && (
|
||||||
|
<span className={c.heroMeta}>
|
||||||
|
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{own.length > 0 && (
|
||||||
|
<section className={c.section}>
|
||||||
|
<h2>{t('catalog.albums')}</h2>
|
||||||
|
<AlbumGrid albums={own} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{appears.length > 0 && (
|
||||||
|
<section className={c.section}>
|
||||||
|
<h2>{t('catalog.appears_on')}</h2>
|
||||||
|
<AlbumGrid albums={appears} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Logo } from '../shared/ui/ui';
|
||||||
|
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||||
|
import s from './auth.module.css';
|
||||||
|
|
||||||
|
/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */
|
||||||
|
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={s.screen}>
|
||||||
|
<div className={s.backdrop} aria-hidden>
|
||||||
|
<span className={s.blob1} />
|
||||||
|
<span className={s.blob2} />
|
||||||
|
<span className={s.blob3} />
|
||||||
|
</div>
|
||||||
|
<header className={s.top}>
|
||||||
|
<Logo />
|
||||||
|
<LanguageSwitch />
|
||||||
|
</header>
|
||||||
|
<main className={s.center}>
|
||||||
|
<section className={s.card}>{children}</section>
|
||||||
|
{footer && <div className={s.footer}>{footer}</div>}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { TrackList, type Track } from '../entities/catalog';
|
||||||
|
import { usePlayer } from '../player/context';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { playIcon } from '../shared/ui/icons';
|
||||||
|
import { Button, Dialog, FormError } from '../shared/ui/ui';
|
||||||
|
import c from './catalog.module.css';
|
||||||
|
import s from './page.module.css';
|
||||||
|
|
||||||
|
export type CollectionTab = 'history' | 'likes' | 'dislikes';
|
||||||
|
const TABS: CollectionTab[] = ['history', 'likes', 'dislikes'];
|
||||||
|
|
||||||
|
export function CollectionPage({ tab }: { tab: CollectionTab }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<h1>{t('collection.title')}</h1>
|
||||||
|
</div>
|
||||||
|
<div className={s.chips} role="tablist" aria-label={t('collection.title')}>
|
||||||
|
{TABS.map((k) => (
|
||||||
|
<Link key={k} to={`/collection/${k}`} role="tab" aria-selected={tab === k} className={c.tab}>
|
||||||
|
{t(`collection.${k}`)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{tab === 'history' ? <History /> : <Reacted reaction={tab === 'likes' ? 'like' : 'dislike'} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ключ дня в локальном времени: история группируется на клиенте. */
|
||||||
|
function dayKey(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayTitle(iso: string, locale: string, t: (k: 'collection.today' | 'collection.yesterday') => string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
|
||||||
|
if (dayKey(iso) === dayKey(today.toISOString())) return t('collection.today');
|
||||||
|
if (dayKey(iso) === dayKey(yesterday.toISOString())) return t('collection.yesterday');
|
||||||
|
return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', year: 'numeric' }).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function History() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const history = useInfiniteQuery({
|
||||||
|
queryKey: ['history'],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) =>
|
||||||
|
unwrap(await session.client.GET('/api/v1/me/history', { params: { query: { cursor: pageParam, limit: 100 } } })),
|
||||||
|
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
||||||
|
});
|
||||||
|
const clear = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/history')),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['history'] }),
|
||||||
|
});
|
||||||
|
const items = history.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
|
||||||
|
const groups: { key: string; title: string; items: typeof items }[] = [];
|
||||||
|
for (const it of items) {
|
||||||
|
const key = dayKey(it.played_at);
|
||||||
|
const last = groups.at(-1);
|
||||||
|
if (last?.key === key) last.items.push(it);
|
||||||
|
else groups.push({ key, title: dayTitle(it.played_at, i18n.language, t), items: [it] });
|
||||||
|
}
|
||||||
|
const time = new Intl.DateTimeFormat(i18n.language, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button variant="ghost" onClick={() => setConfirm(true)}>
|
||||||
|
{t('collection.clear_history')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
|
||||||
|
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<section key={g.key} className={c.section}>
|
||||||
|
<h2>{g.title}</h2>
|
||||||
|
<TrackList
|
||||||
|
tracks={g.items.map((i) => i.track)}
|
||||||
|
context={{ type: 'history' }}
|
||||||
|
rowMeta={(_, i) => time.format(new Date(g.items[i]!.played_at))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{history.hasNextPage && (
|
||||||
|
<Button className={s.more} busy={history.isFetchingNextPage} onClick={() => void history.fetchNextPage()}>
|
||||||
|
{t('collection.more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{confirm && (
|
||||||
|
<Dialog
|
||||||
|
title={t('collection.clear_history')}
|
||||||
|
onClose={() => setConfirm(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setConfirm(false)}>
|
||||||
|
{t('form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirm(false);
|
||||||
|
clear.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('collection.clear_history')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className={s.note}>{t('collection.clear_confirm')}</p>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const player = usePlayer();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const path = reaction === 'like' ? '/api/v1/me/likes' : '/api/v1/me/dislikes';
|
||||||
|
const list = useInfiniteQuery({
|
||||||
|
queryKey: [reaction === 'like' ? 'likes' : 'dislikes'],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) =>
|
||||||
|
unwrap(await session.client.GET(path, { params: { query: { cursor: pageParam, limit: 100 } } })),
|
||||||
|
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
||||||
|
});
|
||||||
|
const tracks: Track[] = list.data?.pages.flatMap((p) => p.items.map((i) => i.track)) ?? [];
|
||||||
|
const total = list.data?.pages[0]?.total ?? 0;
|
||||||
|
const context = { type: 'collection' as const, id: reaction === 'like' ? 'likes' : 'dislikes' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{reaction === 'like' && tracks.length > 0 && (
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<Button variant="primary" icon={playIcon} onClick={() => player.playList(tracks, 0, context)}>
|
||||||
|
{t('collection.play_all')}
|
||||||
|
</Button>
|
||||||
|
<span className={s.note}>{t('catalog.track_count', { count: total })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||||
|
{list.isSuccess && tracks.length === 0 && (
|
||||||
|
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
|
||||||
|
)}
|
||||||
|
{tracks.length > 0 && <TrackList tracks={tracks} showAlbum context={context} />}
|
||||||
|
{list.hasNextPage && (
|
||||||
|
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||||
|
{t('collection.more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useUser } from '../app/session';
|
||||||
|
import { playIcon } from '../shared/ui/icons';
|
||||||
|
import { Icon } from '../shared/ui/ui';
|
||||||
|
import s from './home.module.css';
|
||||||
|
|
||||||
|
/** Главная. Кнопка волны — заглушка до M4 (рекомендации). */
|
||||||
|
export function HomePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const user = useUser();
|
||||||
|
return (
|
||||||
|
<div className={s.home}>
|
||||||
|
<h1 className={s.greeting}>{t('home.greeting', { name: user.display_name ?? user.username })}</h1>
|
||||||
|
<div className={s.orb} aria-disabled="true">
|
||||||
|
<Icon src={playIcon} size={44} />
|
||||||
|
<span className={s.orbLabel}>{t('home.wave')}</span>
|
||||||
|
</div>
|
||||||
|
<p className={s.soon}>{t('home.wave_soon')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||||
|
import { AuthLayout } from './AuthLayout';
|
||||||
|
import s from './auth.module.css';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { t } = useTranslation(['auth', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
|
||||||
|
const info = useQuery({
|
||||||
|
queryKey: ['server-info'],
|
||||||
|
queryFn: async () => (await session.client.GET('/api/v1/server-info')).data ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Переход выполнит роутер: смена состояния сессии пересчитывает guards.
|
||||||
|
await session.login(username.trim(), password);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
setPassword('');
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout footer={info.data ? `pipu ${info.data.version}` : undefined}>
|
||||||
|
<h1>{t('login.title')}</h1>
|
||||||
|
<p className={s.lead}>{t('login.subtitle')}</p>
|
||||||
|
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
|
||||||
|
<form onSubmit={(e) => void submit(e)} noValidate>
|
||||||
|
<Field
|
||||||
|
label={t('login.username')}
|
||||||
|
value={username}
|
||||||
|
onChange={setUsername}
|
||||||
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('login.password')}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError>{error ? errorText(error) : null}</FormError>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className={s.submit}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!username.trim() || !password}
|
||||||
|
>
|
||||||
|
{t('login.submit')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiError } from '@pipu/core';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import s from './page.module.css';
|
||||||
|
|
||||||
|
/** Сущность каталога не найдена (удалена сканом) или не загрузилась. */
|
||||||
|
export function NotFound({ error }: { error: unknown }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const missing = error instanceof ApiError && error.status === 404;
|
||||||
|
return (
|
||||||
|
<div className={s.empty} role="alert">
|
||||||
|
<p>{missing ? t('catalog.not_found') : errorText(error)}</p>
|
||||||
|
<Link to="/">{t('catalog.back_home')}</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { Button, FormError } from '../shared/ui/ui';
|
||||||
|
import s from './page.module.css';
|
||||||
|
import c from './catalog.module.css';
|
||||||
|
|
||||||
|
export type SearchType = 'all' | 'artists' | 'albums' | 'tracks';
|
||||||
|
const TYPES: SearchType[] = ['all', 'artists', 'albums', 'tracks'];
|
||||||
|
|
||||||
|
export function SearchPage({ q, type }: { q: string; type: SearchType }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const query = q.trim();
|
||||||
|
|
||||||
|
const results = useInfiniteQuery({
|
||||||
|
queryKey: ['search', query, type],
|
||||||
|
enabled: query !== '',
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam, signal }) =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.GET('/api/v1/search', {
|
||||||
|
params: { query: { q: query, type, cursor: pageParam, limit: type === 'all' ? undefined : 40 } },
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
||||||
|
});
|
||||||
|
const pages = results.data?.pages ?? [];
|
||||||
|
const first = pages[0];
|
||||||
|
const artists = pages.flatMap((p) => p.artists);
|
||||||
|
const albums = pages.flatMap((p) => p.albums);
|
||||||
|
const tracks = pages.flatMap((p) => p.tracks);
|
||||||
|
const empty = results.isSuccess && artists.length + albums.length + tracks.length === 0;
|
||||||
|
const count = (k: Exclude<SearchType, 'all'>) => first?.totals[k];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('search.title', { query })}</h1>
|
||||||
|
{first?.corrected_query && (
|
||||||
|
<p className={s.subtitle}>{t('search.corrected', { query: first.corrected_query })}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.chips} role="tablist" aria-label={t('search.label')}>
|
||||||
|
{TYPES.map((k) => (
|
||||||
|
<Link
|
||||||
|
key={k}
|
||||||
|
to="/search"
|
||||||
|
search={{ q, type: k === 'all' ? undefined : k }}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={type === k}
|
||||||
|
className={c.tab}
|
||||||
|
>
|
||||||
|
{t(`search.${k}`)}
|
||||||
|
{k !== 'all' && count(k) !== undefined && type === 'all' && <span className={c.tabCount}>{count(k)}</span>}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormError>{results.error ? errorText(results.error) : null}</FormError>
|
||||||
|
{empty && <div className={s.empty}>{t('search.nothing')}</div>}
|
||||||
|
|
||||||
|
{artists.length > 0 && (
|
||||||
|
<section className={c.section}>
|
||||||
|
{type === 'all' && <h2>{t('search.artists')}</h2>}
|
||||||
|
<div className={c.artistRow}>
|
||||||
|
{artists.map((a) => (
|
||||||
|
<ArtistChip key={a.id} artist={a} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{albums.length > 0 && (
|
||||||
|
<section className={c.section}>
|
||||||
|
{type === 'all' && <h2>{t('search.albums')}</h2>}
|
||||||
|
<AlbumGrid albums={albums} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{tracks.length > 0 && (
|
||||||
|
<section className={c.section}>
|
||||||
|
{type === 'all' && <h2>{t('search.tracks')}</h2>}
|
||||||
|
<TrackList tracks={tracks} showAlbum context={{ type: 'search', id: query, label: query }} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{results.hasNextPage && (
|
||||||
|
<Button className={s.more} busy={results.isFetchingNextPage} onClick={() => void results.fetchNextPage()}>
|
||||||
|
{t('search.more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession, useUser } from '../app/session';
|
||||||
|
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
||||||
|
import { formatDateTime } from '../shared/lib/format';
|
||||||
|
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||||
|
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||||
|
import s from './page.module.css';
|
||||||
|
|
||||||
|
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className={`${s.page} ${s.narrow}`}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<h1>{t('settings.title')}</h1>
|
||||||
|
</div>
|
||||||
|
<ProfileSection />
|
||||||
|
<AppearanceSection />
|
||||||
|
<PasswordSection />
|
||||||
|
<SessionsSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [name, setName] = useState(user.display_name ?? '');
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/me', { body: { display_name: name.trim() } })),
|
||||||
|
onSuccess: (u) => session.setUser(u),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('settings.account')}</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
save.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={s.row}>
|
||||||
|
<Field
|
||||||
|
label={t('settings.display_name')}
|
||||||
|
value={name}
|
||||||
|
onChange={(v) => {
|
||||||
|
setName(v);
|
||||||
|
save.reset();
|
||||||
|
}}
|
||||||
|
maxLength={64}
|
||||||
|
hint={`@${user.username}`}
|
||||||
|
/>
|
||||||
|
<Button type="submit" busy={save.isPending} disabled={name.trim() === (user.display_name ?? '')}>
|
||||||
|
{t('settings.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{save.isSuccess && <span className={s.ok}>{t('settings.saved')}</span>}
|
||||||
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppearanceSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const theme: Theme = user.ui_prefs.theme ?? 'dark';
|
||||||
|
const setTheme = async (value: Theme) => {
|
||||||
|
// Оптимистично: тема применяется сразу, сервер подтверждает.
|
||||||
|
session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, theme: value } });
|
||||||
|
try {
|
||||||
|
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: { theme: value } } })));
|
||||||
|
} catch {
|
||||||
|
session.setUser(user);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('settings.appearance')}</h2>
|
||||||
|
<div className={s.settingRow}>
|
||||||
|
<span>{t('settings.theme')}</span>
|
||||||
|
<Segmented<Theme>
|
||||||
|
label={t('settings.theme')}
|
||||||
|
value={theme}
|
||||||
|
onChange={(v) => void setTheme(v)}
|
||||||
|
options={[
|
||||||
|
{ value: 'dark', label: t('settings.theme_dark') },
|
||||||
|
{ value: 'light', label: t('settings.theme_light') },
|
||||||
|
{ value: 'system', label: t('settings.theme_system') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={s.settingRow}>
|
||||||
|
<span>{t('language.label')}</span>
|
||||||
|
<LanguageSwitch />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordSection() {
|
||||||
|
const { t } = useTranslation(['common', 'auth']);
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const fieldError = useFieldError();
|
||||||
|
const [current, setCurrent] = useState('');
|
||||||
|
const [next, setNext] = useState('');
|
||||||
|
const [repeat, setRepeat] = useState('');
|
||||||
|
const mismatch = repeat !== '' && repeat !== next;
|
||||||
|
|
||||||
|
const change = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.POST('/api/v1/me/password', { body: { current_password: current, new_password: next } }),
|
||||||
|
),
|
||||||
|
onSuccess: async (tokens) => {
|
||||||
|
await session.apply(tokens);
|
||||||
|
setCurrent('');
|
||||||
|
setNext('');
|
||||||
|
setRepeat('');
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const err = change.error;
|
||||||
|
const currentError = fieldError(err, 'current_password');
|
||||||
|
const nextError = fieldError(err, 'new_password');
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!mismatch) change.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('settings.password')}</h2>
|
||||||
|
<form onSubmit={submit} noValidate>
|
||||||
|
<Field
|
||||||
|
label={t('settings.current_password')}
|
||||||
|
type="password"
|
||||||
|
value={current}
|
||||||
|
onChange={setCurrent}
|
||||||
|
autoComplete="current-password"
|
||||||
|
error={currentError}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('settings.new_password')}
|
||||||
|
type="password"
|
||||||
|
value={next}
|
||||||
|
onChange={setNext}
|
||||||
|
autoComplete="new-password"
|
||||||
|
hint={t('auth:setup.min_length', { min: user.role === 'user' ? 8 : 12 })}
|
||||||
|
error={nextError}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('settings.repeat_password')}
|
||||||
|
type="password"
|
||||||
|
value={repeat}
|
||||||
|
onChange={setRepeat}
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={mismatch ? t('form.passwords_mismatch') : undefined}
|
||||||
|
/>
|
||||||
|
{change.isSuccess && <span className={s.ok}>{t('settings.password_changed')}</span>}
|
||||||
|
<FormError>{err && !currentError && !nextError ? errorText(err) : null}</FormError>
|
||||||
|
<div className={s.actions}>
|
||||||
|
<Button type="submit" variant="primary" busy={change.isPending} disabled={!current || !next || next !== repeat}>
|
||||||
|
{t('settings.change_password')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionsSection() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const sessions = useQuery({
|
||||||
|
queryKey: ['sessions'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/sessions')).items,
|
||||||
|
});
|
||||||
|
const revoke = useMutation({
|
||||||
|
mutationFn: async (id: string) =>
|
||||||
|
unwrap(await session.client.DELETE('/api/v1/me/sessions/{id}', { params: { path: { id } } })),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sessions'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('settings.sessions')}</h2>
|
||||||
|
<div className={s.sessions}>
|
||||||
|
{sessions.data?.map((ss) => (
|
||||||
|
<div key={ss.id} className={s.session}>
|
||||||
|
<div>
|
||||||
|
<div>{ss.device_name ?? ss.client_type}</div>
|
||||||
|
<div className={s.sessionMeta}>
|
||||||
|
{ss.current
|
||||||
|
? t('settings.session_current')
|
||||||
|
: t('settings.session_last_used', { date: formatDateTime(ss.last_used_at, i18n.language) })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!ss.current && (
|
||||||
|
<Button variant="ghost" busy={revoke.isPending && revoke.variables === ss.id} onClick={() => revoke.mutate(ss.id)}>
|
||||||
|
{t('settings.session_end')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
||||||
|
<div>
|
||||||
|
<Button variant="danger" onClick={() => void session.logout(true)}>
|
||||||
|
{t('settings.logout_all')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuthState, useSession, useUser } from '../app/session';
|
||||||
|
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
||||||
|
import { lockResetIcon, shieldIcon } from '../shared/ui/icons';
|
||||||
|
import { Button, Field, FormError, Icon } from '../shared/ui/ui';
|
||||||
|
import { AuthLayout } from './AuthLayout';
|
||||||
|
import s from './auth.module.css';
|
||||||
|
|
||||||
|
/** Обязательный шаг после входа: новые логин и пароль админа (admin) или замена временного пароля (password). */
|
||||||
|
export function SetupPage({ variant }: { variant: 'admin' | 'password' }) {
|
||||||
|
return useAuthState().status === 'signed_in' ? <SetupForm variant={variant} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SetupForm({ variant }: { variant: 'admin' | 'password' }) {
|
||||||
|
const { t } = useTranslation(['auth', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const fieldError = useFieldError();
|
||||||
|
const admin = variant === 'admin';
|
||||||
|
const minLength = user.role === 'user' ? 8 : 12;
|
||||||
|
|
||||||
|
const [username, setUsername] = useState(user.username);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [repeat, setRepeat] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const mismatch = repeat !== '' && repeat !== password;
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (mismatch) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = admin
|
||||||
|
? await session.client.POST('/api/v1/me/bootstrap-credentials', {
|
||||||
|
body: { new_username: username.trim(), new_password: password },
|
||||||
|
})
|
||||||
|
: await session.client.POST('/api/v1/me/password', { body: { new_password: password } });
|
||||||
|
await session.apply(unwrap(res));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const usernameError = fieldError(error, 'new_username');
|
||||||
|
const passwordError = fieldError(error, 'new_password');
|
||||||
|
const generalError = error && !usernameError && !passwordError ? errorText(error) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<span className={s.badge}>
|
||||||
|
<Icon src={admin ? shieldIcon : lockResetIcon} size={16} />
|
||||||
|
{t(admin ? 'setup.admin_badge' : 'setup.password_badge')}
|
||||||
|
</span>
|
||||||
|
<h1>{t(admin ? 'setup.admin_title' : 'setup.password_title')}</h1>
|
||||||
|
<p className={s.lead}>{t(admin ? 'setup.admin_text' : 'setup.password_text')}</p>
|
||||||
|
<form onSubmit={(e) => void submit(e)} noValidate>
|
||||||
|
{admin && (
|
||||||
|
<Field
|
||||||
|
label={t('setup.new_username')}
|
||||||
|
value={username}
|
||||||
|
onChange={setUsername}
|
||||||
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
error={usernameError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Field
|
||||||
|
label={t('setup.new_password')}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
autoComplete="new-password"
|
||||||
|
hint={t('setup.min_length', { min: minLength })}
|
||||||
|
error={passwordError}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('setup.repeat_password')}
|
||||||
|
type="password"
|
||||||
|
value={repeat}
|
||||||
|
onChange={setRepeat}
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={mismatch ? t('common:form.passwords_mismatch') : undefined}
|
||||||
|
/>
|
||||||
|
<FormError>{generalError}</FormError>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className={s.submit}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!password || password !== repeat || (admin && !username.trim())}
|
||||||
|
>
|
||||||
|
{t('setup.submit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" className={s.link} onClick={() => void session.logout()}>
|
||||||
|
{t('setup.logout')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { formatDateTime } from '../../shared/lib/format';
|
||||||
|
import { Button, FormError } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
|
||||||
|
type Entry = components['schemas']['AuditEntry'];
|
||||||
|
|
||||||
|
/** Краткое описание полезной нагрузки записи: «user → moderator», «anna (user)». */
|
||||||
|
function details(e: Entry, roleName: (r: string) => string): string {
|
||||||
|
const p = e.payload ?? {};
|
||||||
|
if (e.action === 'user.role_change') return `${roleName(String(p.from))} → ${roleName(String(p.to))}`;
|
||||||
|
const parts = [p.username, p.role && roleName(String(p.role)), p.display_name].filter(
|
||||||
|
(v): v is string => typeof v === 'string' && v !== '',
|
||||||
|
);
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditPage() {
|
||||||
|
const { t, i18n } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const list = useInfiniteQuery({
|
||||||
|
queryKey: ['admin-audit'],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) =>
|
||||||
|
unwrap(await session.client.GET('/api/v1/admin/audit', { params: { query: { cursor: pageParam, limit: 50 } } })),
|
||||||
|
getNextPageParam: (page) => page.next_cursor ?? undefined,
|
||||||
|
});
|
||||||
|
const entries = list.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
const tr = t as unknown as (key: string, opts?: Record<string, unknown>) => string;
|
||||||
|
const roleName = (r: string) => tr(`common:roles.${r}`, { defaultValue: r });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('audit.title')}</h1>
|
||||||
|
<p className={s.subtitle}>{t('audit.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||||
|
<div className={s.tableWrap}>
|
||||||
|
<table className={s.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('audit.col_when')}</th>
|
||||||
|
<th>{t('audit.col_who')}</th>
|
||||||
|
<th>{t('audit.col_action')}</th>
|
||||||
|
<th>{t('audit.col_details')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.id}>
|
||||||
|
<td className={s.muted}>{formatDateTime(e.created_at, i18n.language)}</td>
|
||||||
|
<td>{e.actor ? `@${e.actor.username}` : <span className={s.muted}>{t('audit.system')}</span>}</td>
|
||||||
|
<td>{tr(`admin:audit.actions.${e.action}`, { defaultValue: e.action })}</td>
|
||||||
|
<td className={s.muted}>{details(e, roleName)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{list.isSuccess && entries.length === 0 && <div className={s.empty}>{t('audit.empty')}</div>}
|
||||||
|
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
|
||||||
|
</div>
|
||||||
|
{list.hasNextPage && (
|
||||||
|
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||||
|
{t('audit.more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession, useUser } from '../../app/session';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { refreshIcon } from '../../shared/ui/icons';
|
||||||
|
import { Button, FormError } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
import l from './library.module.css';
|
||||||
|
import { ScanLine, StatusBadge } from './ScanStatus';
|
||||||
|
|
||||||
|
/** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */
|
||||||
|
export function LibraryPage() {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const admin = user.role === 'admin';
|
||||||
|
|
||||||
|
const status = useQuery({
|
||||||
|
queryKey: ['library-status'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/status')),
|
||||||
|
refetchInterval: (q) => (q.state.data?.sources.some((x) => x.scan?.running) ? 2000 : 15000),
|
||||||
|
});
|
||||||
|
const errors = useQuery({
|
||||||
|
queryKey: ['library-errors'],
|
||||||
|
enabled: admin,
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/errors', { params: { query: { limit: 50 } } })),
|
||||||
|
});
|
||||||
|
const reindex = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
|
||||||
|
});
|
||||||
|
const d = status.data;
|
||||||
|
const tiles = d
|
||||||
|
? ([
|
||||||
|
['artists', d.artists],
|
||||||
|
['albums', d.albums],
|
||||||
|
['tracks', d.tracks],
|
||||||
|
['files', d.files],
|
||||||
|
['missing', d.missing_files],
|
||||||
|
['errors', d.file_errors],
|
||||||
|
] as const)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('library.title')}</h1>
|
||||||
|
<p className={s.subtitle}>{t('library.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
{admin && (
|
||||||
|
<Button icon={refreshIcon} busy={reindex.isPending} onClick={() => reindex.mutate()}>
|
||||||
|
{reindex.isSuccess ? t('library.reindex_started') : t('library.reindex')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FormError>{status.error || reindex.error ? errorText(status.error ?? reindex.error) : null}</FormError>
|
||||||
|
<div className={l.tiles}>
|
||||||
|
{tiles.map(([k, v]) => (
|
||||||
|
<div key={k} className={l.tile}>
|
||||||
|
<span className={l.tileValue}>{v.toLocaleString()}</span>
|
||||||
|
<span className={l.tileLabel}>{t(`library.${k}`)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{d && d.sources.length > 0 && (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('library.sources')}</h2>
|
||||||
|
{d.sources.map((src) => (
|
||||||
|
<div key={src.id} className={l.cardFoot}>
|
||||||
|
<div className={l.cardTitle}>
|
||||||
|
{src.name} <StatusBadge status={src.status} />
|
||||||
|
</div>
|
||||||
|
<ScanLine scan={src.scan} lastScanAt={src.last_scan_at} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{admin && errors.data && errors.data.items.length > 0 && (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('library.error_files')}</h2>
|
||||||
|
{errors.data.items.map((e) => (
|
||||||
|
<div key={e.file_id} className={l.cardFoot}>
|
||||||
|
<span className={l.location}>{e.rel_path}</span>
|
||||||
|
<span className={l.scanError}>{e.error}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { ApiError, unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { addIcon, arrowDownIcon, arrowUpIcon, deleteIcon } from '../../shared/ui/icons';
|
||||||
|
import { Button, cx, Field, FormError, Icon } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
import l from './library.module.css';
|
||||||
|
|
||||||
|
type Pattern = components['schemas']['PathPattern'];
|
||||||
|
type Preview = components['schemas']['PatternPreview'];
|
||||||
|
|
||||||
|
/** Примеры из docs/library-and-storage.md — частые раскладки папок. */
|
||||||
|
const EXAMPLES = [
|
||||||
|
String.raw`^(?P<artist>[^/]+)/(?P<year>\d{4}) - (?P<album>[^/]+)/(?:(?P<disc>\d)-)?(?P<track>\d{1,3}) - (?P<title>[^/]+)\.\w+$`,
|
||||||
|
String.raw`^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \((?P<year>\d{4})\)/(?P<track>\d+)\. (?P<title>[^/]+)\.\w+$`,
|
||||||
|
String.raw`^Compilations/(?P<album>[^/]+)/(?P<track>\d+) - (?P<artist>.+?) - (?P<title>[^/]+)\.\w+$`,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Ошибка компиляции из 422: номер шаблона и причина. */
|
||||||
|
function patternError(err: unknown): { index: number; reason: string } | null {
|
||||||
|
if (err instanceof ApiError && err.code === 'validation.pattern_invalid') {
|
||||||
|
return { index: Number(err.params.index), reason: String(err.params.reason) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PatternsPage({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const source = useQuery({
|
||||||
|
queryKey: ['source', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
const saved = useQuery({
|
||||||
|
queryKey: ['patterns', id],
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await session.client.GET('/api/v1/admin/sources/{id}/patterns', { params: { path: { id } } })).patterns,
|
||||||
|
});
|
||||||
|
const [rows, setRows] = useState<Pattern[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (saved.data) setRows(saved.data);
|
||||||
|
}, [saved.data]);
|
||||||
|
|
||||||
|
const preview = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.POST('/api/v1/admin/sources/{id}/patterns/preview', {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: { patterns: rows, sample: 50 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.PUT('/api/v1/admin/sources/{id}/patterns', {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: { patterns: rows },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: (r) => setRows(r.patterns),
|
||||||
|
});
|
||||||
|
|
||||||
|
const err = save.error ?? preview.error;
|
||||||
|
const perr = patternError(err);
|
||||||
|
const update = (i: number, p: Partial<Pattern>) => {
|
||||||
|
save.reset();
|
||||||
|
setRows((rs) => rs.map((r, k) => (k === i ? { ...r, ...p } : r)));
|
||||||
|
};
|
||||||
|
const move = (i: number, d: -1 | 1) =>
|
||||||
|
setRows((rs) => {
|
||||||
|
const next = [...rs];
|
||||||
|
[next[i], next[i + d]] = [next[i + d]!, next[i]!];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${s.page} ${s.narrow}`} style={{ maxWidth: 1100 }}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('patterns.title', { name: source.data?.name ?? '…' })}</h1>
|
||||||
|
<p className={s.subtitle}>
|
||||||
|
<Link to="/admin/sources">{t('sources.title')}</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className={s.section}>
|
||||||
|
<p className={s.note}>{t('patterns.intro')}</p>
|
||||||
|
<p className={l.groups}>{t('patterns.groups')}</p>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i} className={l.patternRow}>
|
||||||
|
<span className={l.patternNum}>{i + 1}</span>
|
||||||
|
<Field
|
||||||
|
label={t('patterns.regex', { n: i + 1 })}
|
||||||
|
value={r.regex}
|
||||||
|
onChange={(v) => update(i, { regex: v })}
|
||||||
|
maxLength={1000}
|
||||||
|
spellCheck={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
mono
|
||||||
|
error={perr?.index === i ? t('patterns.error_at', { n: i + 1, reason: perr.reason }) : undefined}
|
||||||
|
/>
|
||||||
|
<div className={l.patternTools} style={{ marginTop: 22 }}>
|
||||||
|
<label className={l.check}>
|
||||||
|
<input type="checkbox" checked={r.is_enabled} onChange={(e) => update(i, { is_enabled: e.target.checked })} />
|
||||||
|
{t('patterns.enabled')}
|
||||||
|
</label>
|
||||||
|
<Button variant="ghost" aria-label={t('patterns.up')} disabled={i === 0} onClick={() => move(i, -1)}>
|
||||||
|
<Icon src={arrowUpIcon} size={18} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t('patterns.down')}
|
||||||
|
disabled={i === rows.length - 1}
|
||||||
|
onClick={() => move(i, 1)}
|
||||||
|
>
|
||||||
|
<Icon src={arrowDownIcon} size={18} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t('patterns.remove')}
|
||||||
|
onClick={() => setRows((rs) => rs.filter((_, k) => k !== i))}
|
||||||
|
>
|
||||||
|
<Icon src={deleteIcon} size={18} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<Button icon={addIcon} onClick={() => setRows((rs) => [...rs, { regex: '', is_enabled: true }])}>
|
||||||
|
{t('patterns.add')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setRows((rs) => [...rs, ...EXAMPLES.map((regex) => ({ regex, is_enabled: true }))])}
|
||||||
|
>
|
||||||
|
{t('patterns.examples')}
|
||||||
|
</Button>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button busy={preview.isPending} onClick={() => preview.mutate()}>
|
||||||
|
{t('patterns.preview')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
|
||||||
|
{t('patterns.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{save.isSuccess && <span className={s.ok}>{t('patterns.saved')}</span>}
|
||||||
|
<FormError>{err && !perr ? errorText(err) : null}</FormError>
|
||||||
|
</section>
|
||||||
|
{preview.data && <PreviewTable preview={preview.data} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewTable({ preview }: { preview: Preview }) {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
const fields = ['artist', 'album', 'title'] as const;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className={s.note}>{t('patterns.unmatched', { count: preview.unmatched, total: preview.items.length })}</p>
|
||||||
|
<div className={s.tableWrap} style={{ overflowX: 'auto' }}>
|
||||||
|
<table className={s.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('patterns.col_path')}</th>
|
||||||
|
<th>{t('patterns.col_pattern')}</th>
|
||||||
|
<th>{t('patterns.col_result')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{preview.items.map((it) => (
|
||||||
|
<tr key={it.rel_path}>
|
||||||
|
<td className={l.match}>{it.rel_path}</td>
|
||||||
|
<td className={cx(l.match, it.pattern_index === null && l.noMatch)}>
|
||||||
|
{it.skipped
|
||||||
|
? t('patterns.skipped')
|
||||||
|
: it.pattern_index === null
|
||||||
|
? t('patterns.no_match')
|
||||||
|
: `#${it.pattern_index + 1}`}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className={l.result}>
|
||||||
|
{fields.map((f) => {
|
||||||
|
const fromPath = f in it.path_fields;
|
||||||
|
const fromTags = (f === 'artist' ? 'artist' : f) in it.tags;
|
||||||
|
return (
|
||||||
|
<span key={f}>
|
||||||
|
<b>{it.result[f]}</b>{' '}
|
||||||
|
{!fromPath && fromTags && <span className={l.fromTags}>{t('patterns.from_tags')}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<span className={l.fromTags}>
|
||||||
|
{[it.result.year, it.result.track && `#${it.result.track}`, it.result.genres.join(', ')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
{it.error && <span className={l.scanError}>{it.error}</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { formatDateTime } from '../../shared/lib/format';
|
||||||
|
import { cx } from '../../shared/ui/ui';
|
||||||
|
import s from './library.module.css';
|
||||||
|
|
||||||
|
type ScanProgress = components['schemas']['ScanProgress'];
|
||||||
|
export type SourceStatus = components['schemas']['SourceStatus'];
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: SourceStatus }) {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
return <span className={cx(s.status, s[`st_${status}`])}>{t(`sources.status_${status}`)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Прогресс идущего скана или итог последнего. */
|
||||||
|
export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) {
|
||||||
|
const { t, i18n } = useTranslation('admin');
|
||||||
|
if (scan?.running) {
|
||||||
|
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div className={s.scan}>
|
||||||
|
<div className={s.scanText}>
|
||||||
|
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
|
||||||
|
</div>
|
||||||
|
<div className={s.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
<span style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={s.scan}>
|
||||||
|
<div className={s.scanText}>
|
||||||
|
{lastScanAt ? t('sources.last_scan', { date: formatDateTime(lastScanAt, i18n.language) }) : t('sources.never_scanned')}
|
||||||
|
</div>
|
||||||
|
{scan?.error ? (
|
||||||
|
<div className={s.scanError}>{t('sources.scan_failed', { error: scan.error })}</div>
|
||||||
|
) : (
|
||||||
|
scan && <div className={s.scanSummary}>{t('sources.scan_done', { ...scan })}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||||
|
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
|
||||||
|
import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
import l from './library.module.css';
|
||||||
|
import { ScanLine, StatusBadge } from './ScanStatus';
|
||||||
|
|
||||||
|
type Source = components['schemas']['Source'];
|
||||||
|
type TestResult = components['schemas']['SourceTestResult'];
|
||||||
|
|
||||||
|
const SCAN_POLL_MS = 2000;
|
||||||
|
|
||||||
|
export function SourcesPage() {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [editing, setEditing] = useState<Source | 'new' | null>(null);
|
||||||
|
|
||||||
|
const sources = useQuery({
|
||||||
|
queryKey: ['sources'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
|
||||||
|
// Пока идёт скан — опрашиваем прогресс.
|
||||||
|
refetchInterval: (q) => (q.state.data?.some((x) => x.scan?.running) ? SCAN_POLL_MS : false),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('sources.title')}</h1>
|
||||||
|
<p className={s.subtitle}>{t('sources.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" icon={addIcon} onClick={() => setEditing('new')}>
|
||||||
|
{t('sources.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormError>{sources.error ? errorText(sources.error) : null}</FormError>
|
||||||
|
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>}
|
||||||
|
<div className={l.list}>
|
||||||
|
{sources.data?.map((src) => (
|
||||||
|
<SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{editing && <SourceDialog source={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function location(src: Source): string {
|
||||||
|
if (src.type === 'nfs') return `${src.nfs_server ?? ''}:${src.nfs_export ?? ''}`;
|
||||||
|
return `LIBRARY_HOST/${src.local_path ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [test, setTest] = useState<TestResult | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const id = source.id;
|
||||||
|
const refresh = () => queryClient.invalidateQueries({ queryKey: ['sources'] });
|
||||||
|
|
||||||
|
const scan = useMutation({
|
||||||
|
mutationFn: async (full: boolean) =>
|
||||||
|
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/scan', { params: { path: { id } }, body: { full } })),
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const check = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/test', { params: { path: { id } } })),
|
||||||
|
onSuccess: setTest,
|
||||||
|
});
|
||||||
|
const patch = useMutation({
|
||||||
|
mutationFn: async (enabled: boolean) =>
|
||||||
|
unwrap(await session.client.PATCH('/api/v1/admin/sources/{id}', { params: { path: { id } }, body: { enabled } })),
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(await session.client.DELETE('/api/v1/admin/sources/{id}', { params: { path: { id } } })),
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const error = scan.error ?? check.error ?? patch.error ?? remove.error;
|
||||||
|
const running = source.scan?.running ?? false;
|
||||||
|
const disabled = source.status === 'disabled';
|
||||||
|
|
||||||
|
const menu: MenuItem[] = [
|
||||||
|
{ label: t('sources.full_scan'), icon: refreshIcon, onSelect: () => scan.mutate(true) },
|
||||||
|
{ label: t('sources.edit'), onSelect: onEdit },
|
||||||
|
disabled
|
||||||
|
? { label: t('sources.enable'), onSelect: () => patch.mutate(true) }
|
||||||
|
: { label: t('sources.disable'), onSelect: () => patch.mutate(false) },
|
||||||
|
{ label: t('sources.delete'), danger: true, onSelect: () => setConfirmDelete(true) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={l.card} aria-label={source.name}>
|
||||||
|
<div className={l.cardHead}>
|
||||||
|
<div className={l.cardTitle}>
|
||||||
|
{source.name}
|
||||||
|
<span className={l.typeBadge}>{t(`sources.type_${source.type}`)}</span>
|
||||||
|
<StatusBadge status={source.status} />
|
||||||
|
</div>
|
||||||
|
<span className={l.location}>{location(source)}</span>
|
||||||
|
{source.status === 'error' && source.status_message && (
|
||||||
|
<span className={l.message}>{source.status_message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={l.cardActions}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
busy={scan.isPending}
|
||||||
|
disabled={running || source.status !== 'mounted'}
|
||||||
|
onClick={() => scan.mutate(false)}
|
||||||
|
>
|
||||||
|
{t('sources.scan')}
|
||||||
|
</Button>
|
||||||
|
<Button busy={check.isPending} onClick={() => check.mutate()}>
|
||||||
|
{t('sources.test')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void navigate({ to: '/admin/sources/$id/patterns', params: { id } })}>
|
||||||
|
{t('sources.patterns')}
|
||||||
|
</Button>
|
||||||
|
<Menu label={t('users.actions', { username: source.name })} trigger={<Icon src={moreIcon} />} items={menu} />
|
||||||
|
</div>
|
||||||
|
<div className={l.cardFoot}>
|
||||||
|
<ScanLine scan={source.scan} lastScanAt={source.last_scan_at} />
|
||||||
|
{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 })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<FormError>{error ? errorText(error) : null}</FormError>
|
||||||
|
</div>
|
||||||
|
{confirmDelete && (
|
||||||
|
<Dialog
|
||||||
|
title={t('sources.delete')}
|
||||||
|
onClose={() => setConfirmDelete(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmDelete(false);
|
||||||
|
remove.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('users.confirm')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className={s.note}>{t('sources.delete_confirm', { name: source.name })}</p>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTERVALS = [0, 60, 360, 1440];
|
||||||
|
|
||||||
|
function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const fieldError = useFieldError();
|
||||||
|
const [type, setType] = useState<'local' | 'nfs'>(source?.type ?? 'local');
|
||||||
|
const [name, setName] = useState(source?.name ?? '');
|
||||||
|
const [localPath, setLocalPath] = useState(source?.local_path ?? '');
|
||||||
|
const [server, setServer] = useState(source?.nfs_server ?? '');
|
||||||
|
const [exportPath, setExportPath] = useState(source?.nfs_export ?? '');
|
||||||
|
const [options, setOptions] = useState(source?.nfs_options ?? 'nfsvers=4.1,soft,timeo=150,retrans=3');
|
||||||
|
const [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first');
|
||||||
|
const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const common = {
|
||||||
|
name: name.trim(),
|
||||||
|
tag_priority: priority,
|
||||||
|
...(type === 'local'
|
||||||
|
? { local_path: localPath }
|
||||||
|
: { nfs_server: server.trim(), nfs_export: exportPath.trim(), nfs_options: options.trim() }),
|
||||||
|
};
|
||||||
|
if (source)
|
||||||
|
return unwrap(
|
||||||
|
await session.client.PATCH('/api/v1/admin/sources/{id}', {
|
||||||
|
params: { path: { id: source.id } },
|
||||||
|
body: { ...common, scan_interval_minutes: interval },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return unwrap(
|
||||||
|
await session.client.POST('/api/v1/admin/sources', {
|
||||||
|
body: { ...common, type, scan_interval_minutes: interval || null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['sources'] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const err = save.error;
|
||||||
|
const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options'];
|
||||||
|
const general = err && !fields.some((f) => fieldError(err, f)) ? errorText(err) : null;
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
save.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t(source ? 'sources.form_title_edit' : 'sources.form_title_new')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="source-form" variant="primary" busy={save.isPending} disabled={!name.trim()}>
|
||||||
|
{t('sources.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="source-form" onSubmit={submit} noValidate className={s.sessions}>
|
||||||
|
<Field label={t('sources.name')} value={name} onChange={setName} maxLength={100} error={fieldError(err, 'name')} />
|
||||||
|
{!source && (
|
||||||
|
<div className={l.field}>
|
||||||
|
<span className={l.label}>{t('sources.type')}</span>
|
||||||
|
<Segmented
|
||||||
|
label={t('sources.type')}
|
||||||
|
value={type}
|
||||||
|
onChange={setType}
|
||||||
|
options={[
|
||||||
|
{ value: 'local', label: t('sources.type_local') },
|
||||||
|
{ value: 'nfs', label: t('sources.type_nfs') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{type === 'local' ? (
|
||||||
|
<FolderPicker value={localPath} onChange={setLocalPath} error={fieldError(err, 'local_path')} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
label={t('sources.nfs_server')}
|
||||||
|
value={server}
|
||||||
|
onChange={setServer}
|
||||||
|
hint={t('sources.nfs_server_hint')}
|
||||||
|
error={fieldError(err, 'nfs_server')}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('sources.nfs_export')}
|
||||||
|
value={exportPath}
|
||||||
|
onChange={setExportPath}
|
||||||
|
hint={t('sources.nfs_export_hint')}
|
||||||
|
error={fieldError(err, 'nfs_export')}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('sources.nfs_options')}
|
||||||
|
value={options}
|
||||||
|
onChange={setOptions}
|
||||||
|
hint={t('sources.nfs_options_hint')}
|
||||||
|
error={fieldError(err, 'nfs_options')}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className={l.field}>
|
||||||
|
<span className={l.label}>{t('sources.tag_priority')}</span>
|
||||||
|
<Segmented
|
||||||
|
label={t('sources.tag_priority')}
|
||||||
|
value={priority}
|
||||||
|
onChange={setPriority}
|
||||||
|
options={[
|
||||||
|
{ value: 'tags_first', label: t('sources.tags_first') },
|
||||||
|
{ value: 'path_first', label: t('sources.path_first') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className={l.field}>
|
||||||
|
<span className={l.label}>{t('sources.interval')}</span>
|
||||||
|
<select className={s.select} value={interval} onChange={(e) => setIntervalMin(Number(e.target.value))}>
|
||||||
|
{[...new Set([...INTERVALS, interval])].map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m === 0 ? t('sources.interval_off') : t('sources.interval_hours', { count: Math.round(m / 60) })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<FormError>{general}</FormError>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выбор подпапки LIBRARY_HOST: хлебные крошки и список вложенных папок. */
|
||||||
|
function FolderPicker({ value, onChange, error }: { value: string; onChange: (v: string) => void; error?: string }) {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
const session = useSession();
|
||||||
|
const dirs = useQuery({
|
||||||
|
queryKey: ['host-dirs', value],
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await session.client.GET('/api/v1/admin/library/host-dirs', { params: { query: { path: value } } })),
|
||||||
|
});
|
||||||
|
const parts = value ? value.split('/') : [];
|
||||||
|
return (
|
||||||
|
<div className={l.field}>
|
||||||
|
<span className={l.label}>{t('sources.folder')}</span>
|
||||||
|
<div className={l.picker}>
|
||||||
|
<div className={l.crumbs}>
|
||||||
|
<button type="button" className={l.dir} onClick={() => onChange('')}>
|
||||||
|
LIBRARY_HOST
|
||||||
|
</button>
|
||||||
|
{parts.map((p, i) => (
|
||||||
|
<span key={i} className={l.crumbs}>
|
||||||
|
<Icon src={chevronRightIcon} size={14} />
|
||||||
|
<button type="button" className={l.dir} onClick={() => onChange(parts.slice(0, i + 1).join('/'))}>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{dirs.data?.dirs.map((d) => (
|
||||||
|
<button key={d} type="button" className={l.dir} onClick={() => onChange(value ? `${value}/${d}` : d)}>
|
||||||
|
<Icon src={folderIcon} size={18} className={l.dirIcon} />
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className={s.note}>{error ?? (value ? value : t('sources.folder_root'))}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState, type FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession, useUser } from '../../app/session';
|
||||||
|
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||||
|
import { formatRelative } from '../../shared/lib/format';
|
||||||
|
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
|
||||||
|
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, searchIcon } from '../../shared/ui/icons';
|
||||||
|
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
|
||||||
|
type AdminUser = components['schemas']['AdminUser'];
|
||||||
|
|
||||||
|
const ROLES: Role[] = ['admin', 'moderator', 'user'];
|
||||||
|
|
||||||
|
type Confirm = { kind: 'reset' | 'block' | 'delete'; user: AdminUser };
|
||||||
|
|
||||||
|
function useDebounced<T>(value: T, ms: number): T {
|
||||||
|
const [v, setV] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(() => setV(value), ms);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [value, ms]);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(u: AdminUser): 'blocked' | 'must_change' | 'active' {
|
||||||
|
if (u.is_blocked) return 'blocked';
|
||||||
|
if (u.must_change_password || u.must_change_credentials) return 'must_change';
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
const { t, i18n } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const me = useUser();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [role, setRole] = useState<Role | null>(null);
|
||||||
|
const query = useDebounced(q.trim(), 200);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||||
|
const [secret, setSecret] = useState<{ username: string; password: string } | null>(null);
|
||||||
|
const [actionError, setActionError] = useState<unknown>(null);
|
||||||
|
|
||||||
|
const list = useInfiniteQuery({
|
||||||
|
queryKey: ['admin-users', query, role],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.GET('/api/v1/admin/users', {
|
||||||
|
params: { query: { q: query || undefined, role: role ?? undefined, cursor: pageParam, limit: 50 } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
getNextPageParam: (page) => page.next_cursor ?? undefined,
|
||||||
|
});
|
||||||
|
const users = list.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
|
||||||
|
const patch = useMutation({
|
||||||
|
mutationFn: async ({ id, body }: { id: string; body: components['schemas']['UpdateUserRequest'] }) =>
|
||||||
|
unwrap(await session.client.PATCH('/api/v1/admin/users/{id}', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: refresh,
|
||||||
|
onError: setActionError,
|
||||||
|
});
|
||||||
|
const reset = useMutation({
|
||||||
|
mutationFn: async (u: AdminUser) =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.POST('/api/v1/admin/users/{id}/reset-password', { params: { path: { id: u.id } } }),
|
||||||
|
),
|
||||||
|
onSuccess: (r, u) => {
|
||||||
|
setSecret({ username: u.username, password: r.temporary_password });
|
||||||
|
void refresh();
|
||||||
|
},
|
||||||
|
onError: setActionError,
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: async (u: AdminUser) =>
|
||||||
|
unwrap(await session.client.DELETE('/api/v1/admin/users/{id}', { params: { path: { id: u.id } } })),
|
||||||
|
onSuccess: refresh,
|
||||||
|
onError: setActionError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runConfirm = () => {
|
||||||
|
if (!confirm) return;
|
||||||
|
const { kind, user } = confirm;
|
||||||
|
setConfirm(null);
|
||||||
|
setActionError(null);
|
||||||
|
if (kind === 'reset') reset.mutate(user);
|
||||||
|
if (kind === 'block') patch.mutate({ id: user.id, body: { is_blocked: true } });
|
||||||
|
if (kind === 'delete') remove.mutate(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionsFor = (u: AdminUser): MenuItem[] => {
|
||||||
|
const items: MenuItem[] = [];
|
||||||
|
const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a);
|
||||||
|
if (can('reset_password'))
|
||||||
|
items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) });
|
||||||
|
if (can('block'))
|
||||||
|
items.push(
|
||||||
|
u.is_blocked
|
||||||
|
? { label: t('users.unblock'), onSelect: () => patch.mutate({ id: u.id, body: { is_blocked: false } }) }
|
||||||
|
: { label: t('users.block'), onSelect: () => setConfirm({ kind: 'block', user: u }) },
|
||||||
|
);
|
||||||
|
if (can('change_role'))
|
||||||
|
for (const r of ROLES.filter((r) => r !== u.role))
|
||||||
|
items.push({
|
||||||
|
label: t('users.make_role', { role: t(`common:roles.${r}`) }),
|
||||||
|
onSelect: () => patch.mutate({ id: u.id, body: { role: r } }),
|
||||||
|
});
|
||||||
|
if (can('delete'))
|
||||||
|
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('users.title')}</h1>
|
||||||
|
<p className={s.subtitle}>{t('users.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" icon={addIcon} onClick={() => setCreating(true)}>
|
||||||
|
{t('users.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<label className={s.search}>
|
||||||
|
<Icon src={searchIcon} size={18} className={s.searchIcon} />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder={t('users.search')}
|
||||||
|
aria-label={t('users.search')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className={s.chips} role="group" aria-label={t('users.col_role')}>
|
||||||
|
<button type="button" className={s.chip} aria-pressed={role === null} onClick={() => setRole(null)}>
|
||||||
|
{t('users.all')}
|
||||||
|
</button>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<button key={r} type="button" className={s.chip} aria-pressed={role === r} onClick={() => setRole(r)}>
|
||||||
|
{t(`common:roles.${r}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormError>{actionError || list.error ? errorText(actionError ?? list.error) : null}</FormError>
|
||||||
|
|
||||||
|
<div className={s.tableWrap}>
|
||||||
|
<table className={s.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('users.col_user')}</th>
|
||||||
|
<th>{t('users.col_role')}</th>
|
||||||
|
<th>{t('users.col_status')}</th>
|
||||||
|
<th>{t('users.col_last_login')}</th>
|
||||||
|
<th aria-hidden />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => {
|
||||||
|
const items = actionsFor(u);
|
||||||
|
const st = status(u);
|
||||||
|
return (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>
|
||||||
|
<div className={s.userCell}>
|
||||||
|
<Avatar name={u.display_name ?? u.username} />
|
||||||
|
<div>
|
||||||
|
<div className={s.userName}>
|
||||||
|
{u.display_name ?? u.username}
|
||||||
|
{u.id === me.id && <span className={s.you}>({t('users.you')})</span>}
|
||||||
|
</div>
|
||||||
|
<div className={s.userLogin}>@{u.username}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={cx(s.status, s[`status_${st}`])}>{t(`users.status_${st}`)}</span>
|
||||||
|
</td>
|
||||||
|
<td className={s.muted}>
|
||||||
|
{u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<Menu label={t('users.actions', { username: u.username })} trigger={<Icon src={moreIcon} />} items={items} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{list.isSuccess && users.length === 0 && <div className={s.empty}>{t('users.empty')}</div>}
|
||||||
|
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
|
||||||
|
</div>
|
||||||
|
{list.hasNextPage && (
|
||||||
|
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||||
|
{t('users.more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<CreateUserDialog
|
||||||
|
actorRole={me.role}
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
onCreated={(username, password) => {
|
||||||
|
setCreating(false);
|
||||||
|
setSecret({ username, password });
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirm && (
|
||||||
|
<Dialog
|
||||||
|
title={t(`users.${confirm.kind === 'reset' ? 'reset_password' : confirm.kind}`)}
|
||||||
|
onClose={() => setConfirm(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setConfirm(null)}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant={confirm.kind === 'reset' ? 'primary' : 'danger'} onClick={runConfirm}>
|
||||||
|
{t('users.confirm')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className={s.note}>{t(`users.${confirm.kind}_confirm`, { username: confirm.user.username })}</p>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{secret && <TemporaryPasswordDialog {...secret} onClose={() => setSecret(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateUserDialog({
|
||||||
|
actorRole,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
actorRole: Role;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (username: string, password: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const fieldError = useFieldError();
|
||||||
|
const roles = assignableRoles(actorRole);
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [role, setRole] = useState<Role>('user');
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.POST('/api/v1/admin/users', {
|
||||||
|
body: { username: username.trim(), display_name: name.trim() || undefined, role },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: (r) => onCreated(r.user.username, r.temporary_password),
|
||||||
|
});
|
||||||
|
const usernameError = fieldError(create.error, 'username');
|
||||||
|
const nameError = fieldError(create.error, 'display_name');
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
create.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t('create.title')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="create-user" variant="primary" busy={create.isPending} disabled={!username.trim()}>
|
||||||
|
{t('create.submit')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="create-user" onSubmit={submit} noValidate className={s.sessions}>
|
||||||
|
<Field
|
||||||
|
label={t('create.username')}
|
||||||
|
value={username}
|
||||||
|
onChange={setUsername}
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
hint={t('create.username_hint')}
|
||||||
|
error={usernameError}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Field label={t('create.display_name')} value={name} onChange={setName} maxLength={64} error={nameError} />
|
||||||
|
{roles.length > 1 && (
|
||||||
|
<label className={s.sessions}>
|
||||||
|
<span className={s.subtitle}>{t('create.role')}</span>
|
||||||
|
<select className={s.select} value={role} onChange={(e) => setRole(e.target.value as Role)}>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{t(`common:roles.${r}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<p className={s.note}>{t('create.password_note')}</p>
|
||||||
|
<FormError>{create.error && !usernameError && !nameError ? errorText(create.error) : null}</FormError>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemporaryPasswordDialog({ username, password, onClose }: { username: string; password: string; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(password);
|
||||||
|
setCopied(true);
|
||||||
|
} catch {
|
||||||
|
// Буфер обмена недоступен (http, права) — пароль виден на экране.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t('temp_password.title')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<Button variant="primary" onClick={onClose}>
|
||||||
|
{t('temp_password.done')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className={s.note}>{t('temp_password.text', { username })}</p>
|
||||||
|
<div className={s.secret}>
|
||||||
|
<span data-testid="temporary-password">{password}</span>
|
||||||
|
<Button variant="ghost" icon={copied ? checkIcon : copyIcon} onClick={() => void copy()}>
|
||||||
|
{copied ? t('common:form.copied') : t('common:form.copy')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 14px 20px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface-a);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHead {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardFoot {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface3);
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status::before {
|
||||||
|
content: '';
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st_mounted {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st_error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st_pending {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st_disabled {
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanText,
|
||||||
|
.scanSummary {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanError {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--surface3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--violet));
|
||||||
|
transition: width 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testOk {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.testFail {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dirIcon {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface-a);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileValue {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.patternRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patternNum {
|
||||||
|
height: 48px;
|
||||||
|
margin-top: 22px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patternTools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noMatch {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result b {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fromTags {
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardActions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
.screen {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: var(--amb-op);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop span {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob1 {
|
||||||
|
width: 62vmax;
|
||||||
|
height: 62vmax;
|
||||||
|
left: -18vmax;
|
||||||
|
top: -24vmax;
|
||||||
|
background: radial-gradient(circle, rgba(47, 128, 255, 0.34), transparent 65%);
|
||||||
|
animation: drift1 16s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob2 {
|
||||||
|
width: 55vmax;
|
||||||
|
height: 55vmax;
|
||||||
|
right: -20vmax;
|
||||||
|
bottom: -26vmax;
|
||||||
|
background: radial-gradient(circle, rgba(124, 92, 255, 0.3), transparent 65%);
|
||||||
|
animation: drift2 21s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob3 {
|
||||||
|
width: 40vmax;
|
||||||
|
height: 40vmax;
|
||||||
|
left: 40%;
|
||||||
|
top: 30%;
|
||||||
|
background: radial-gradient(circle, rgba(53, 211, 255, 0.16), transparent 65%);
|
||||||
|
animation: drift1 26s ease-in-out infinite alternate-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift1 {
|
||||||
|
to {
|
||||||
|
transform: translate(6vmax, 4vmax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift2 {
|
||||||
|
to {
|
||||||
|
transform: translate(-5vmax, -6vmax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.backdrop span {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 60px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px 16px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 420px;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 36px;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--glass);
|
||||||
|
backdrop-filter: blur(28px) saturate(1.3);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
animation: pipuIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.card {
|
||||||
|
padding: 28px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
margin: -6px 0 4px;
|
||||||
|
color: var(--text2);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
align-self: flex-start;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--violet-soft);
|
||||||
|
color: var(--violet);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
height: 48px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artistRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab[aria-selected='true'] {
|
||||||
|
background: var(--text);
|
||||||
|
color: var(--bg);
|
||||||
|
border-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabCount {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab[aria-selected='true'] .tabCount {
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 28px;
|
||||||
|
padding: 28px;
|
||||||
|
margin: -32px -36px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroBg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.35;
|
||||||
|
filter: blur(60px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroBody {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(28px, 4vw, 48px);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.1;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroMeta {
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroMeta a {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroMeta a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.hero {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: -20px -16px 0;
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
.home {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
max-width: 70vw;
|
||||||
|
max-height: 70vw;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 35% 30%, rgba(156, 198, 255, 0.55), transparent 45%),
|
||||||
|
radial-gradient(circle at 65% 70%, var(--wave-color3), transparent 55%),
|
||||||
|
radial-gradient(circle at 50% 50%, var(--wave-color2), var(--wave-color1) 70%);
|
||||||
|
box-shadow: 0 0 80px rgba(47, 128, 255, 0.35);
|
||||||
|
opacity: 0.85;
|
||||||
|
animation: breathe 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbLabel {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes breathe {
|
||||||
|
50% {
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.orb {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.soon {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text2);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
.page {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface-a);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row > :first-child {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px 0 38px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface2);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip[aria-pressed='true'] {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrap {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface-a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Горизонтальная прокрутка только там, где таблица не помещается: она обрезала бы меню действий. */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.tableWrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text3);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
height: 60px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userCell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userLogin {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.you {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role_admin {
|
||||||
|
background: var(--violet-soft);
|
||||||
|
color: var(--violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role_moderator {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role_user {
|
||||||
|
background: var(--hover);
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status::before {
|
||||||
|
content: '';
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_active {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_blocked {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_must_change {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--text3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 40px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px dashed var(--line2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionMeta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { createContext, useContext, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { Player, type PlayerState } from './player';
|
||||||
|
|
||||||
|
const PlayerContext = createContext<Player | null>(null);
|
||||||
|
|
||||||
|
/** Плеер живёт, пока пользователь вошёл; горячие клавиши и сохранение при уходе со страницы. */
|
||||||
|
export function PlayerProvider({ children }: { children: ReactNode }) {
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [player] = useState(() => new Player(session));
|
||||||
|
player.onReaction = () => void queryClient.invalidateQueries();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
player.start();
|
||||||
|
void player.restore().catch(() => undefined);
|
||||||
|
const onHide = () => player.onHide();
|
||||||
|
const onVisibility = () => document.visibilityState === 'hidden' && onHide();
|
||||||
|
window.addEventListener('pagehide', onHide);
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
const onKey = (e: KeyboardEvent) => handleHotkey(player, e);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pagehide', onHide);
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
player.dispose();
|
||||||
|
};
|
||||||
|
}, [player]);
|
||||||
|
|
||||||
|
return <PlayerContext.Provider value={player}>{children}</PlayerContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пробел — пауза, ←/→ — ±10 с, Shift+←/→ — трек, L — лайк (docs/web.md). */
|
||||||
|
export function handleHotkey(player: Player, e: KeyboardEvent) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]')) return;
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
const s = player.getState();
|
||||||
|
if (!s.track) return;
|
||||||
|
switch (e.key) {
|
||||||
|
case ' ':
|
||||||
|
if (target?.closest('button, a')) return; // пробел на кнопке нажимает её
|
||||||
|
e.preventDefault();
|
||||||
|
player.toggle();
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) player.prev();
|
||||||
|
else player.seek(s.positionMs - 10_000);
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) player.next();
|
||||||
|
else player.seek(s.positionMs + 10_000);
|
||||||
|
break;
|
||||||
|
case 'l':
|
||||||
|
case 'L':
|
||||||
|
case 'д':
|
||||||
|
case 'Д':
|
||||||
|
e.preventDefault();
|
||||||
|
void player.react(s.track, s.track.reaction === 'like' ? null : 'like').catch(() => undefined);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayer(): Player {
|
||||||
|
const p = useContext(PlayerContext);
|
||||||
|
if (!p) throw new Error('usePlayer outside PlayerProvider');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayerState(): PlayerState {
|
||||||
|
const p = usePlayer();
|
||||||
|
return useSyncExternalStore(p.subscribe, p.getState);
|
||||||
|
}
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
import {
|
||||||
|
EventBuffer,
|
||||||
|
PlayAttempt,
|
||||||
|
PlayQueue,
|
||||||
|
unwrap,
|
||||||
|
type AuthSession,
|
||||||
|
type PlayContext,
|
||||||
|
type RepeatMode,
|
||||||
|
type Track,
|
||||||
|
} from '@pipu/core';
|
||||||
|
|
||||||
|
export type { PlayContext, Track };
|
||||||
|
|
||||||
|
export interface PlayerState {
|
||||||
|
track: Track | null;
|
||||||
|
queue: Track[];
|
||||||
|
index: number;
|
||||||
|
context: PlayContext | null;
|
||||||
|
playing: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
positionMs: number;
|
||||||
|
durationMs: number;
|
||||||
|
volume: number;
|
||||||
|
muted: boolean;
|
||||||
|
shuffle: boolean;
|
||||||
|
repeat: RepeatMode;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCAL_KEY = 'pipu.player';
|
||||||
|
const SAVE_EVERY_MS = 10_000;
|
||||||
|
const FLUSH_EVERY_MS = 30_000;
|
||||||
|
const PRELOAD_BEFORE_MS = 20_000;
|
||||||
|
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
|
||||||
|
const MAX_SAVED_QUEUE = 500;
|
||||||
|
|
||||||
|
interface Saved {
|
||||||
|
queue: string[];
|
||||||
|
index: number;
|
||||||
|
positionMs: number;
|
||||||
|
context: PlayContext | null;
|
||||||
|
volume: number;
|
||||||
|
shuffle: boolean;
|
||||||
|
repeat: RepeatMode;
|
||||||
|
savedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceId(): string {
|
||||||
|
try {
|
||||||
|
let id = localStorage.getItem('pipu.device');
|
||||||
|
if (!id) {
|
||||||
|
id = crypto.randomUUID();
|
||||||
|
localStorage.setItem('pipu.device', id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
} catch {
|
||||||
|
return 'web';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Плеер веба. Логика очереди и учёта событий — из @pipu/core; здесь — адаптер к HTMLAudioElement,
|
||||||
|
* Media Session и сохранение «трек и момент» (локально и на сервере).
|
||||||
|
*/
|
||||||
|
export class Player {
|
||||||
|
private readonly audio: HTMLAudioElement;
|
||||||
|
private readonly queue = new PlayQueue();
|
||||||
|
private readonly events: EventBuffer;
|
||||||
|
private attempt: PlayAttempt | null = null;
|
||||||
|
private context: PlayContext | null = null;
|
||||||
|
private state: PlayerState;
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
private readonly device = deviceId();
|
||||||
|
private lastServerSave = 0;
|
||||||
|
private preloaded: string | null = null;
|
||||||
|
private refreshedFor: string | null = null;
|
||||||
|
private timers: ReturnType<typeof setInterval>[] = [];
|
||||||
|
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
||||||
|
onReaction: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly session: AuthSession,
|
||||||
|
audio?: HTMLAudioElement,
|
||||||
|
) {
|
||||||
|
this.audio = audio ?? new Audio();
|
||||||
|
this.audio.preload = 'auto';
|
||||||
|
this.events = new EventBuffer({
|
||||||
|
send: async (events) => {
|
||||||
|
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.state = {
|
||||||
|
track: null, queue: [], index: 0, context: null, playing: false, loading: false, positionMs: 0,
|
||||||
|
durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null,
|
||||||
|
};
|
||||||
|
this.bindAudio();
|
||||||
|
this.bindMediaSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||||
|
start() {
|
||||||
|
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- подписка (useSyncExternalStore) ---
|
||||||
|
|
||||||
|
subscribe = (fn: () => void) => {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
getState = () => this.state;
|
||||||
|
|
||||||
|
private update(patch: Partial<PlayerState>) {
|
||||||
|
this.state = { ...this.state, ...patch };
|
||||||
|
for (const fn of this.listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncQueue() {
|
||||||
|
this.update({
|
||||||
|
track: this.queue.current ?? null,
|
||||||
|
queue: this.queue.tracks,
|
||||||
|
index: this.queue.index,
|
||||||
|
shuffle: this.queue.shuffle,
|
||||||
|
repeat: this.queue.repeat,
|
||||||
|
context: this.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- управление ---
|
||||||
|
|
||||||
|
/** Запустить список с позиции index в контексте (альбом, поиск, коллекция...). */
|
||||||
|
playList(tracks: Track[], index: number, context: PlayContext) {
|
||||||
|
const playable = tracks.filter((t) => t.available && t.stream_url);
|
||||||
|
const target = tracks[index];
|
||||||
|
if (!target || !playable.length) return;
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
this.context = context;
|
||||||
|
this.queue.set(playable, Math.max(playable.indexOf(target), 0));
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if (this.state.playing) this.pause();
|
||||||
|
else this.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
if (!this.queue.current) return;
|
||||||
|
if (!this.audio.src) {
|
||||||
|
void this.load(true, this.state.positionMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
|
||||||
|
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
this.audio.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
next() {
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
if (this.queue.next()) {
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
} else {
|
||||||
|
this.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Назад»: первые 3 с — к предыдущему треку, дальше — в начало текущего. */
|
||||||
|
prev() {
|
||||||
|
if (this.audio.currentTime > 3) {
|
||||||
|
this.seek(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
if (this.queue.prev()) {
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jump(index: number) {
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
this.queue.jump(index);
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(ms: number) {
|
||||||
|
const track = this.queue.current;
|
||||||
|
if (!track) return;
|
||||||
|
const to = Math.min(Math.max(ms, 0), track.duration_ms);
|
||||||
|
const from = Math.round(this.audio.currentTime * 1000);
|
||||||
|
this.attempt?.seek(to);
|
||||||
|
this.audio.currentTime = to / 1000;
|
||||||
|
this.update({ positionMs: to });
|
||||||
|
this.events.push({ type: 'seek', track_id: track.id, from_ms: from, position_ms: to });
|
||||||
|
this.saveSoon();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(v: number) {
|
||||||
|
this.audio.volume = Math.min(Math.max(v, 0), 1);
|
||||||
|
this.audio.muted = false;
|
||||||
|
this.update({ volume: this.audio.volume, muted: false });
|
||||||
|
this.saveLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMute() {
|
||||||
|
this.audio.muted = !this.audio.muted;
|
||||||
|
this.update({ muted: this.audio.muted });
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleShuffle() {
|
||||||
|
this.queue.setShuffle(!this.queue.shuffle);
|
||||||
|
this.syncQueue();
|
||||||
|
this.saveLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleRepeat() {
|
||||||
|
const order: RepeatMode[] = ['off', 'all', 'one'];
|
||||||
|
this.queue.repeat = order[(order.indexOf(this.queue.repeat) + 1) % order.length]!;
|
||||||
|
this.syncQueue();
|
||||||
|
this.saveLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лайк / дизлайк / снять реакцию. Дизлайк текущего трека сразу переключает на следующий. */
|
||||||
|
async react(track: Track, reaction: 'like' | 'dislike' | null) {
|
||||||
|
const prev = track.reaction;
|
||||||
|
const apply = (r: Track['reaction']) => {
|
||||||
|
for (const t of this.queue.tracks) if (t.id === track.id) t.reaction = r;
|
||||||
|
this.syncQueue();
|
||||||
|
};
|
||||||
|
apply(reaction);
|
||||||
|
const type = reaction ?? (prev === 'like' ? 'unlike' : 'undislike');
|
||||||
|
this.events.push({ type, track_id: track.id, position_ms: this.state.positionMs });
|
||||||
|
try {
|
||||||
|
const params = { params: { path: { id: track.id } } };
|
||||||
|
if (reaction) unwrap(await this.session.client.PUT('/api/v1/me/reactions/tracks/{id}', { ...params, body: { reaction } }));
|
||||||
|
else unwrap(await this.session.client.DELETE('/api/v1/me/reactions/tracks/{id}', params));
|
||||||
|
} catch (e) {
|
||||||
|
apply(prev);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
this.onReaction?.();
|
||||||
|
if (reaction === 'dislike' && this.queue.current?.id === track.id) this.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- загрузка трека ---
|
||||||
|
|
||||||
|
private async load(autoplay: boolean, startMs = 0) {
|
||||||
|
const track = this.queue.current;
|
||||||
|
if (!track?.stream_url) return;
|
||||||
|
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||||
|
this.audio.src = track.stream_url;
|
||||||
|
if (startMs > 0) this.audio.currentTime = startMs / 1000;
|
||||||
|
this.attempt = new PlayAttempt(track, startMs);
|
||||||
|
this.preloaded = null;
|
||||||
|
this.events.push({
|
||||||
|
type: 'play_start',
|
||||||
|
track_id: track.id,
|
||||||
|
position_ms: startMs,
|
||||||
|
context_type: this.context?.type,
|
||||||
|
context_id: this.context?.id,
|
||||||
|
});
|
||||||
|
this.updateMediaMetadata(track);
|
||||||
|
this.saveServer();
|
||||||
|
if (autoplay) {
|
||||||
|
try {
|
||||||
|
await this.audio.play();
|
||||||
|
} catch {
|
||||||
|
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
|
||||||
|
this.update({ playing: false, loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Завершает текущую попытку: событие с реально прослушанным временем. */
|
||||||
|
private finishAttempt(type: 'play_end' | 'skip') {
|
||||||
|
const a = this.attempt;
|
||||||
|
if (!a) return;
|
||||||
|
this.attempt = null;
|
||||||
|
const pos = Math.round(this.audio.currentTime * 1000);
|
||||||
|
const kind = type === 'skip' ? a.outcome(pos) : 'play_end';
|
||||||
|
this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||||
|
void this.events.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindAudio() {
|
||||||
|
const a = this.audio;
|
||||||
|
a.addEventListener('playing', () => {
|
||||||
|
this.update({ playing: true, loading: false });
|
||||||
|
if (this.attempt && this.state.positionMs > 0 && a.currentTime > 0.5) {
|
||||||
|
this.events.push({ type: 'resume', track_id: this.attempt.track.id, position_ms: this.state.positionMs });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
a.addEventListener('pause', () => {
|
||||||
|
if (a.ended) return;
|
||||||
|
this.update({ playing: false });
|
||||||
|
const t = this.queue.current;
|
||||||
|
if (t) this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
||||||
|
this.saveServer();
|
||||||
|
});
|
||||||
|
a.addEventListener('waiting', () => this.update({ loading: true }));
|
||||||
|
a.addEventListener('timeupdate', () => {
|
||||||
|
const ms = Math.round(a.currentTime * 1000);
|
||||||
|
this.attempt?.tick(ms);
|
||||||
|
this.update({ positionMs: ms });
|
||||||
|
this.updatePositionState();
|
||||||
|
this.maybePreload(ms);
|
||||||
|
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
||||||
|
this.saveLocal();
|
||||||
|
});
|
||||||
|
a.addEventListener('ended', () => {
|
||||||
|
this.finishAttempt('play_end');
|
||||||
|
if (this.queue.next(true)) {
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
} else {
|
||||||
|
this.update({ playing: false, positionMs: 0 });
|
||||||
|
this.saveServer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
a.addEventListener('error', () => void this.onError());
|
||||||
|
a.addEventListener('volumechange', () => this.update({ volume: a.volume, muted: a.muted }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
|
||||||
|
private async onError() {
|
||||||
|
const track = this.queue.current;
|
||||||
|
if (!track) return;
|
||||||
|
if (this.refreshedFor !== track.id) {
|
||||||
|
this.refreshedFor = track.id;
|
||||||
|
try {
|
||||||
|
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
|
||||||
|
const fresh = res.items[0];
|
||||||
|
if (fresh?.stream_url) {
|
||||||
|
Object.assign(track, fresh);
|
||||||
|
void this.load(true, this.state.positionMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// упадём в пропуск ниже
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.update({ error: 'player.error', loading: false, playing: false });
|
||||||
|
this.attempt = null;
|
||||||
|
if (this.queue.peekNext() && this.queue.peekNext()!.id !== track.id) setTimeout(() => this.next(), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** За 20 с до конца запрашиваем первый байт следующего трека — на сервере прогревается транскод. */
|
||||||
|
private maybePreload(ms: number) {
|
||||||
|
const cur = this.queue.current;
|
||||||
|
const next = this.queue.peekNext();
|
||||||
|
if (!cur || !next?.stream_url || this.preloaded === next.id) return;
|
||||||
|
if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return;
|
||||||
|
this.preloaded = next.id;
|
||||||
|
void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Media Session ---
|
||||||
|
|
||||||
|
private bindMediaSession() {
|
||||||
|
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||||
|
if (!ms) return;
|
||||||
|
const set = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
|
||||||
|
try {
|
||||||
|
ms.setActionHandler(action, fn);
|
||||||
|
} catch {
|
||||||
|
// действие не поддерживается браузером
|
||||||
|
}
|
||||||
|
};
|
||||||
|
set('play', () => this.play());
|
||||||
|
set('pause', () => this.pause());
|
||||||
|
set('nexttrack', () => this.next());
|
||||||
|
set('previoustrack', () => this.prev());
|
||||||
|
set('seekbackward', (d) => this.seek(this.state.positionMs - (d.seekOffset ?? 10) * 1000));
|
||||||
|
set('seekforward', (d) => this.seek(this.state.positionMs + (d.seekOffset ?? 10) * 1000));
|
||||||
|
set('seekto', (d) => d.seekTime !== undefined && this.seek(d.seekTime * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateMediaMetadata(track: Track) {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.mediaSession || typeof MediaMetadata === 'undefined') return;
|
||||||
|
const cover = track.album.cover_url;
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artists.map((a) => a.name).join(', '),
|
||||||
|
album: track.album.title,
|
||||||
|
artwork: cover
|
||||||
|
? [
|
||||||
|
{ src: `${cover}&size=300`, sizes: '300x300', type: 'image/webp' },
|
||||||
|
{ src: `${cover}&size=1200`, sizes: '1200x1200', type: 'image/webp' },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePositionState() {
|
||||||
|
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||||
|
const d = this.audio.duration;
|
||||||
|
if (!ms?.setPositionState || !Number.isFinite(d) || d <= 0) return;
|
||||||
|
try {
|
||||||
|
ms.setPositionState({ duration: d, position: Math.min(this.audio.currentTime, d), playbackRate: 1 });
|
||||||
|
} catch {
|
||||||
|
// некорректная позиция во время смены трека
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- сохранение состояния ---
|
||||||
|
|
||||||
|
private snapshot(): Saved {
|
||||||
|
const ids = this.queue.tracks.map((t) => t.id);
|
||||||
|
const start = Math.max(0, Math.min(this.queue.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
|
||||||
|
return {
|
||||||
|
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
|
||||||
|
index: this.queue.index - start,
|
||||||
|
positionMs: this.state.positionMs,
|
||||||
|
context: this.context,
|
||||||
|
volume: this.state.volume,
|
||||||
|
shuffle: this.queue.shuffle,
|
||||||
|
repeat: this.queue.repeat,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveLocal() {
|
||||||
|
if (!this.queue.current) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot()));
|
||||||
|
} catch {
|
||||||
|
// недоступное хранилище не мешает воспроизведению
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
private saveSoon() {
|
||||||
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||||
|
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
|
||||||
|
saveServer(keepalive = false) {
|
||||||
|
const cur = this.queue.current;
|
||||||
|
if (!cur) return;
|
||||||
|
this.lastServerSave = Date.now();
|
||||||
|
this.saveLocal();
|
||||||
|
const s = this.snapshot();
|
||||||
|
void this.session.client
|
||||||
|
.PUT('/api/v1/me/playback-state', {
|
||||||
|
body: {
|
||||||
|
track_id: cur.id,
|
||||||
|
position_ms: s.positionMs,
|
||||||
|
queue: s.queue,
|
||||||
|
queue_index: s.index,
|
||||||
|
context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined,
|
||||||
|
shuffle: s.shuffle,
|
||||||
|
repeat_mode: s.repeat,
|
||||||
|
volume: s.volume,
|
||||||
|
device_id: this.device,
|
||||||
|
},
|
||||||
|
keepalive,
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Уход со страницы: дослать события и состояние. */
|
||||||
|
onHide() {
|
||||||
|
this.saveServer(true);
|
||||||
|
void this.events.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Восстановление после запуска: сначала локальная копия (мгновенно), затем серверная,
|
||||||
|
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
|
||||||
|
*/
|
||||||
|
async restore() {
|
||||||
|
let local: Saved | null = null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LOCAL_KEY);
|
||||||
|
local = raw ? (JSON.parse(raw) as Saved) : null;
|
||||||
|
} catch {
|
||||||
|
local = null;
|
||||||
|
}
|
||||||
|
let saved = local;
|
||||||
|
try {
|
||||||
|
const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
|
||||||
|
if (
|
||||||
|
server.track_id &&
|
||||||
|
server.updated_at &&
|
||||||
|
(!local || (server.device_id !== this.device && server.updated_at > local.savedAt))
|
||||||
|
) {
|
||||||
|
saved = {
|
||||||
|
queue: server.queue,
|
||||||
|
index: server.queue_index,
|
||||||
|
positionMs: server.position_ms,
|
||||||
|
context: (server.context?.type ? server.context : null) as PlayContext | null,
|
||||||
|
volume: server.volume,
|
||||||
|
shuffle: server.shuffle,
|
||||||
|
repeat: server.repeat_mode,
|
||||||
|
savedAt: server.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// офлайн — остаёмся с локальной копией
|
||||||
|
}
|
||||||
|
if (!saved?.queue.length) return;
|
||||||
|
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
||||||
|
const wanted = saved.queue[saved.index];
|
||||||
|
const tracks = res.items.filter((t) => t.available && t.stream_url);
|
||||||
|
if (!tracks.length) return;
|
||||||
|
const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0);
|
||||||
|
this.context = saved.context;
|
||||||
|
this.queue.repeat = saved.repeat;
|
||||||
|
this.queue.set(tracks, idx);
|
||||||
|
if (saved.shuffle) this.queue.setShuffle(true);
|
||||||
|
this.audio.volume = saved.volume;
|
||||||
|
const pos = tracks[idx]?.id === wanted ? saved.positionMs : 0;
|
||||||
|
this.syncQueue();
|
||||||
|
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
|
||||||
|
this.updateMediaMetadata(tracks[idx]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
this.audio.pause();
|
||||||
|
this.audio.removeAttribute('src');
|
||||||
|
for (const t of this.timers) clearInterval(t);
|
||||||
|
this.timers = [];
|
||||||
|
void this.events.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiError } from '@pipu/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
/** Текст ошибки для пользователя: перевод `errors:<code>` с параметрами, иначе общий текст. */
|
||||||
|
export function useErrorText(): (err: unknown) => string {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const tr = t as unknown as Translate;
|
||||||
|
return (err) => {
|
||||||
|
const code = err instanceof ApiError ? err.code : 'common.internal';
|
||||||
|
const params = err instanceof ApiError ? err.params : {};
|
||||||
|
const key = `errors:${code}`;
|
||||||
|
return i18n.exists(key) ? tr(key, params) : tr('errors:common.internal');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ошибка конкретного поля (если сервер её указал) — для подписи под полем. */
|
||||||
|
export function useFieldError(): (err: unknown, field: string) => string | undefined {
|
||||||
|
const text = useErrorText();
|
||||||
|
return (err, field) => {
|
||||||
|
if (!(err instanceof ApiError)) return undefined;
|
||||||
|
return err.fieldCode(field) ? text(err) : undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/** Дата и время в локали интерфейса: «20 окт. 2026 г., 14:05». */
|
||||||
|
export function formatDateTime(iso: string, locale: string): string {
|
||||||
|
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||||
|
['year', 365 * 24 * 3600],
|
||||||
|
['month', 30 * 24 * 3600],
|
||||||
|
['week', 7 * 24 * 3600],
|
||||||
|
['day', 24 * 3600],
|
||||||
|
['hour', 3600],
|
||||||
|
['minute', 60],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Относительное время: «12 минут назад», «вчера», «сейчас». */
|
||||||
|
export function formatRelative(iso: string, locale: string, now: Date = new Date()): string {
|
||||||
|
const diff = (new Date(iso).getTime() - now.getTime()) / 1000;
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||||
|
for (const [unit, sec] of UNITS) {
|
||||||
|
if (Math.abs(diff) >= sec) return rtf.format(Math.round(diff / sec), unit);
|
||||||
|
}
|
||||||
|
return rtf.format(0, 'minute');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Длительность трека: 215000 → «3:35», 3725000 → «1:02:05». */
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
const total = Math.max(0, Math.round(ms / 1000));
|
||||||
|
const h = Math.floor(total / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = String(total % 60).padStart(2, '0');
|
||||||
|
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
|
||||||
|
export type Role = components['schemas']['Role'];
|
||||||
|
export type UserAction = 'reset_password' | 'block' | 'change_role' | 'delete';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Копия матрицы прав (docs/auth-and-roles.md, services/api/internal/auth/policy.go) — только для UI:
|
||||||
|
* какие действия показывать. Решает всё равно сервер.
|
||||||
|
*/
|
||||||
|
export function canManageUser(actor: { id: string; role: Role }, target: { id: string; role: Role }, action: UserAction) {
|
||||||
|
const self = actor.id === target.id;
|
||||||
|
if (self && action !== 'reset_password') return false;
|
||||||
|
if (actor.role === 'admin') return true;
|
||||||
|
if (actor.role === 'moderator') return (action === 'reset_password' || action === 'block') && target.role === 'user';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Роли, которые actor может выдать при создании. */
|
||||||
|
export function assignableRoles(actor: Role): Role[] {
|
||||||
|
if (actor === 'admin') return ['user', 'moderator', 'admin'];
|
||||||
|
if (actor === 'moderator') return ['user'];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Иконки Material Symbols Rounded (Apache 2.0). Импортируются по одной — в сборку попадают только используемые.
|
||||||
|
export { default as addIcon } from '@material-symbols/svg-400/rounded/add.svg';
|
||||||
|
export { default as checkIcon } from '@material-symbols/svg-400/rounded/check.svg';
|
||||||
|
export { default as closeIcon } from '@material-symbols/svg-400/rounded/close.svg';
|
||||||
|
export { default as copyIcon } from '@material-symbols/svg-400/rounded/content_copy.svg';
|
||||||
|
export { default as groupIcon } from '@material-symbols/svg-400/rounded/group.svg';
|
||||||
|
export { default as groupFillIcon } from '@material-symbols/svg-400/rounded/group-fill.svg';
|
||||||
|
export { default as homeIcon } from '@material-symbols/svg-400/rounded/home.svg';
|
||||||
|
export { default as homeFillIcon } from '@material-symbols/svg-400/rounded/home-fill.svg';
|
||||||
|
export { default as lockResetIcon } from '@material-symbols/svg-400/rounded/lock_reset.svg';
|
||||||
|
export { default as logoutIcon } from '@material-symbols/svg-400/rounded/logout.svg';
|
||||||
|
export { default as moreIcon } from '@material-symbols/svg-400/rounded/more_horiz.svg';
|
||||||
|
export { default as musicIcon } from '@material-symbols/svg-400/rounded/music_note.svg';
|
||||||
|
export { default as playIcon } from '@material-symbols/svg-400/rounded/play_arrow-fill.svg';
|
||||||
|
export { default as auditIcon } from '@material-symbols/svg-400/rounded/receipt_long.svg';
|
||||||
|
export { default as auditFillIcon } from '@material-symbols/svg-400/rounded/receipt_long-fill.svg';
|
||||||
|
export { default as searchIcon } from '@material-symbols/svg-400/rounded/search.svg';
|
||||||
|
export { default as settingsIcon } from '@material-symbols/svg-400/rounded/settings.svg';
|
||||||
|
export { default as shieldIcon } from '@material-symbols/svg-400/rounded/shield_person.svg';
|
||||||
|
export { default as visibilityIcon } from '@material-symbols/svg-400/rounded/visibility.svg';
|
||||||
|
export { default as visibilityOffIcon } from '@material-symbols/svg-400/rounded/visibility_off.svg';
|
||||||
|
export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.svg';
|
||||||
|
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
|
||||||
|
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
|
||||||
|
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
|
||||||
|
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg';
|
||||||
|
export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
|
||||||
|
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
|
||||||
|
export { default as libraryIcon } from '@material-symbols/svg-400/rounded/monitoring.svg';
|
||||||
|
export { default as libraryFillIcon } from '@material-symbols/svg-400/rounded/monitoring-fill.svg';
|
||||||
|
export { default as personIcon } from '@material-symbols/svg-400/rounded/person.svg';
|
||||||
|
export { default as refreshIcon } from '@material-symbols/svg-400/rounded/refresh.svg';
|
||||||
|
export { default as storageIcon } from '@material-symbols/svg-400/rounded/hard_drive.svg';
|
||||||
|
export { default as storageFillIcon } from '@material-symbols/svg-400/rounded/hard_drive-fill.svg';
|
||||||
|
export { default as pauseIcon } from '@material-symbols/svg-400/rounded/pause-fill.svg';
|
||||||
|
export { default as nextIcon } from '@material-symbols/svg-400/rounded/skip_next-fill.svg';
|
||||||
|
export { default as prevIcon } from '@material-symbols/svg-400/rounded/skip_previous-fill.svg';
|
||||||
|
export { default as likeIcon } from '@material-symbols/svg-400/rounded/favorite.svg';
|
||||||
|
export { default as likeFillIcon } from '@material-symbols/svg-400/rounded/favorite-fill.svg';
|
||||||
|
export { default as dislikeIcon } from '@material-symbols/svg-400/rounded/heart_broken.svg';
|
||||||
|
export { default as dislikeFillIcon } from '@material-symbols/svg-400/rounded/heart_broken-fill.svg';
|
||||||
|
export { default as volumeIcon } from '@material-symbols/svg-400/rounded/volume_up.svg';
|
||||||
|
export { default as volumeOffIcon } from '@material-symbols/svg-400/rounded/volume_off.svg';
|
||||||
|
export { default as shuffleIcon } from '@material-symbols/svg-400/rounded/shuffle.svg';
|
||||||
|
export { default as repeatIcon } from '@material-symbols/svg-400/rounded/repeat.svg';
|
||||||
|
export { default as repeatOneIcon } from '@material-symbols/svg-400/rounded/repeat_one.svg';
|
||||||
|
export { default as historyIcon } from '@material-symbols/svg-400/rounded/history.svg';
|
||||||
|
export { default as historyFillIcon } from '@material-symbols/svg-400/rounded/history-fill.svg';
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
.icon {
|
||||||
|
display: inline-block;
|
||||||
|
flex: none;
|
||||||
|
background-color: currentColor;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoDot {
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
|
||||||
|
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
box-shadow 0.15s,
|
||||||
|
opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button[aria-busy='true'] {
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
background: var(--surface3);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary:hover:not(:disabled),
|
||||||
|
.ghost:hover:not(:disabled) {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrap:has(.reveal) .input {
|
||||||
|
padding-right: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid {
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formError {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button[aria-pressed='true'] {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--overlay);
|
||||||
|
animation: pipuIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
width: 440px;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--glass);
|
||||||
|
backdrop-filter: blur(28px) saturate(1.3);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogHead {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 22px 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogHead h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogFoot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 24px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton:hover,
|
||||||
|
.iconButton[aria-expanded='true'] {
|
||||||
|
background: var(--hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuWrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
z-index: 40;
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--elev);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
animation: pipuIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuStart {
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem:hover,
|
||||||
|
.menuItem:focus-visible {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuDanger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--violet));
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ButtonHTMLAttributes,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { closeIcon, visibilityIcon, visibilityOffIcon } from './icons';
|
||||||
|
import s from './ui.module.css';
|
||||||
|
|
||||||
|
const cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
/** Иконка-маска: цвет берётся из currentColor. */
|
||||||
|
export function Icon({ src, size = 20, className }: { src: string; size?: number; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cx(s.icon, className)}
|
||||||
|
style={{ width: size, height: size, maskImage: `url("${src}")`, WebkitMaskImage: `url("${src}")` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logo({ size = 28 }: { size?: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<span className={s.logo} style={{ fontSize: size * 0.68 }}>
|
||||||
|
<span className={s.logoDot} style={{ width: size, height: size }} aria-hidden />
|
||||||
|
{t('app.name')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||||
|
icon?: string;
|
||||||
|
busy?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({ variant = 'secondary', icon, busy, className, children, disabled, ...rest }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{...rest}
|
||||||
|
disabled={disabled || busy}
|
||||||
|
aria-busy={busy || undefined}
|
||||||
|
className={cx(s.button, s[variant], className)}
|
||||||
|
>
|
||||||
|
{icon && <Icon src={icon} size={18} />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
mono?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Поле ввода с подписью, ошибкой и (для паролей) кнопкой «показать». */
|
||||||
|
export function Field({ label, value, onChange, error, hint, mono, type = 'text', className, ...rest }: FieldProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const id = useId();
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
const isPassword = type === 'password';
|
||||||
|
const note = error ?? hint;
|
||||||
|
return (
|
||||||
|
<div className={cx(s.field, className)}>
|
||||||
|
<label htmlFor={id} className={s.label}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className={s.inputWrap}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type={isPassword && shown ? 'text' : type}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
aria-describedby={note ? `${id}-note` : undefined}
|
||||||
|
className={cx(s.input, mono && s.mono, error && s.invalid)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
{isPassword && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={s.reveal}
|
||||||
|
onClick={() => setShown((v) => !v)}
|
||||||
|
aria-label={shown ? t('form.hide_password') : t('form.show_password')}
|
||||||
|
>
|
||||||
|
<Icon src={shown ? visibilityOffIcon : visibilityIcon} size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{note && (
|
||||||
|
<div id={`${id}-note`} className={error ? s.error : s.hint} role={error ? 'alert' : undefined}>
|
||||||
|
{note}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сегментированный переключатель. */
|
||||||
|
export function Segmented<T extends string>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
options: { value: T; label: string }[];
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={s.segmented} role="group" aria-label={label}>
|
||||||
|
{options.map((o) => (
|
||||||
|
<button key={o.value} type="button" aria-pressed={value === o.value} onClick={() => onChange(o.value)}>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormError({ children }: { children?: ReactNode }) {
|
||||||
|
if (!children) return null;
|
||||||
|
return (
|
||||||
|
<div className={s.formError} role="alert">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Модальный диалог: стеклянная карточка поверх подложки, Esc и клик по подложке закрывают. */
|
||||||
|
export function Dialog({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.activeElement as HTMLElement | null;
|
||||||
|
const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])');
|
||||||
|
(first ?? ref.current)?.focus();
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
prev?.focus();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
return (
|
||||||
|
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
|
||||||
|
<div className={s.dialogHead}>
|
||||||
|
<h2 id={titleId}>{title}</h2>
|
||||||
|
<button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}>
|
||||||
|
<Icon src={closeIcon} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={s.dialogBody}>{children}</div>
|
||||||
|
{footer && <div className={s.dialogFoot}>{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuItem = { label: string; icon?: string; danger?: boolean; onSelect: () => void };
|
||||||
|
|
||||||
|
/** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */
|
||||||
|
export function Menu({
|
||||||
|
label,
|
||||||
|
trigger,
|
||||||
|
items,
|
||||||
|
header,
|
||||||
|
align = 'end',
|
||||||
|
triggerClassName,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
trigger: ReactNode;
|
||||||
|
items: MenuItem[];
|
||||||
|
header?: ReactNode;
|
||||||
|
align?: 'start' | 'end';
|
||||||
|
triggerClassName?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDown);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
ref.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
const onMenuKey = (e: ReactKeyboardEvent) => {
|
||||||
|
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||||
|
e.preventDefault();
|
||||||
|
const els = [...(ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
|
||||||
|
const i = els.indexOf(document.activeElement as HTMLElement);
|
||||||
|
const next = e.key === 'ArrowDown' ? (i + 1) % els.length : (i - 1 + els.length) % els.length;
|
||||||
|
els[next]?.focus();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className={s.menuWrap} ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={triggerClassName ?? s.iconButton}
|
||||||
|
aria-label={label}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
|
||||||
|
{header}
|
||||||
|
{items.map((it) => (
|
||||||
|
<button
|
||||||
|
key={it.label}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={cx(s.menuItem, it.danger && s.menuDanger)}
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
it.onSelect();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{it.icon && <Icon src={it.icon} size={18} />}
|
||||||
|
{it.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
|
||||||
|
const initials = name
|
||||||
|
.split(/[\s._-]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((p) => p[0]!.toUpperCase())
|
||||||
|
.join('');
|
||||||
|
return (
|
||||||
|
<span className={s.avatar} style={{ width: size, height: size, fontSize: size * 0.38 }} aria-hidden>
|
||||||
|
{initials || '?'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { cx };
|
||||||
+22
-110
@@ -1,6 +1,8 @@
|
|||||||
@import '@pipu/design-tokens/tokens.css';
|
@import '@pipu/design-tokens/tokens.css';
|
||||||
@import '@fontsource/onest/400.css';
|
@import '@fontsource/onest/400.css';
|
||||||
|
@import '@fontsource/onest/500.css';
|
||||||
@import '@fontsource/onest/600.css';
|
@import '@fontsource/onest/600.css';
|
||||||
|
@import '@fontsource/onest/700.css';
|
||||||
@import '@fontsource/unbounded/600.css';
|
@import '@fontsource/unbounded/600.css';
|
||||||
@import '@fontsource/jetbrains-mono/400.css';
|
@import '@fontsource/jetbrains-mono/400.css';
|
||||||
|
|
||||||
@@ -23,6 +25,11 @@ body {
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='light'] body {
|
||||||
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -34,118 +41,23 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
:focus-visible {
|
||||||
min-height: 100%;
|
outline: 2px solid var(--accent);
|
||||||
display: flex;
|
outline-offset: 2px;
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.top {
|
@keyframes pipuIn {
|
||||||
display: flex;
|
from {
|
||||||
align-items: center;
|
opacity: 0;
|
||||||
justify-content: space-between;
|
transform: translateY(6px);
|
||||||
height: 60px;
|
}
|
||||||
padding: 0 20px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
display: flex;
|
*,
|
||||||
align-items: center;
|
*::before,
|
||||||
gap: 10px;
|
*::after {
|
||||||
font-family: var(--font-display);
|
animation-duration: 0.01ms !important;
|
||||||
font-weight: 600;
|
transition-duration: 0.01ms !important;
|
||||||
font-size: 19px;
|
}
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-dot {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
|
|
||||||
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.langs {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 3px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--glass);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.langs button {
|
|
||||||
height: 30px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.langs button[aria-pressed='true'] {
|
|
||||||
background: var(--accent-soft);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 24px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
width: 400px;
|
|
||||||
max-width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 36px;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
background: var(--glass);
|
|
||||||
border: 1px solid var(--line2);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text2);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status[data-state='ok'] {
|
|
||||||
background: var(--ok-soft);
|
|
||||||
color: var(--ok);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status[data-state='error'] {
|
|
||||||
background: var(--danger-soft);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status[data-state='pending'] {
|
|
||||||
background: var(--surface2);
|
|
||||||
color: var(--text2);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
|
||||||
|
type Me = components['schemas']['Me'];
|
||||||
|
type AdminUser = components['schemas']['AdminUser'];
|
||||||
|
type Role = components['schemas']['Role'];
|
||||||
|
|
||||||
|
export interface FakeUser extends AdminUser {
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' },
|
||||||
|
});
|
||||||
|
const problem = (status: number, code: string, extra: object = {}) => json({ status, code, ...extra }, status);
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
const kino = { id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' };
|
||||||
|
const albumRef = { id: '00000000-0000-4000-8000-0000000000bb', title: 'Группа крови', year: 1988, cover_url: null, cover_colors: [] };
|
||||||
|
export const albumTracks = ['Группа крови', 'Закрой за мной дверь'].map((title, i) => ({
|
||||||
|
id: `00000000-0000-4000-8000-00000000010${i}`,
|
||||||
|
title,
|
||||||
|
duration_ms: 200000,
|
||||||
|
artists: [{ id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' }],
|
||||||
|
album: albumRef,
|
||||||
|
genres: [],
|
||||||
|
disc_no: 1,
|
||||||
|
track_no: i + 1,
|
||||||
|
available: true,
|
||||||
|
stream_url: `/api/v1/stream/00000000-0000-4000-8000-00000000010${i}?sig=x`,
|
||||||
|
reaction: null as 'like' | 'dislike' | null,
|
||||||
|
}));
|
||||||
|
const albumSummary = {
|
||||||
|
id: '00000000-0000-4000-8000-0000000000bb',
|
||||||
|
title: 'Группа крови',
|
||||||
|
year: 1988,
|
||||||
|
cover_url: null,
|
||||||
|
cover_colors: [],
|
||||||
|
artist: kino,
|
||||||
|
track_count: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function makeUser(username: string, role: Role, extra: Partial<FakeUser> = {}): FakeUser {
|
||||||
|
seq++;
|
||||||
|
return {
|
||||||
|
id: `00000000-0000-4000-8000-${String(seq).padStart(12, '0')}`,
|
||||||
|
username,
|
||||||
|
display_name: null,
|
||||||
|
role,
|
||||||
|
locale: 'ru',
|
||||||
|
is_blocked: false,
|
||||||
|
must_change_password: false,
|
||||||
|
must_change_credentials: false,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
last_login_at: null,
|
||||||
|
password: `${username}-password`,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мини-сервер API для компонентных тестов: вход, refresh по cookie, /me, админка пользователей.
|
||||||
|
* Проверки прав упрощены — точная матрица покрыта тестами Go.
|
||||||
|
*/
|
||||||
|
export function fakeApi(initial: FakeUser[]) {
|
||||||
|
const users = new Map(initial.map((u) => [u.id, { ...u }]));
|
||||||
|
const prefs = new Map<string, components['schemas']['UiPrefs']>();
|
||||||
|
let current: string | null = null; // id вошедшего (одна сессия на тест)
|
||||||
|
let token = 0;
|
||||||
|
const calls: { method: string; path: string; body: unknown }[] = [];
|
||||||
|
const reactions = new Map<string, 'like' | 'dislike'>();
|
||||||
|
|
||||||
|
const me = (u: FakeUser): Me => ({
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
display_name: u.display_name,
|
||||||
|
role: u.role,
|
||||||
|
locale: u.locale,
|
||||||
|
ui_prefs: prefs.get(u.id) ?? {},
|
||||||
|
must_change_password: u.must_change_password,
|
||||||
|
must_change_credentials: u.must_change_credentials,
|
||||||
|
created_at: u.created_at,
|
||||||
|
});
|
||||||
|
const tokens = (u: FakeUser) => json({ access_token: `T${++token}`, token_type: 'Bearer', expires_in: 900, user: me(u) });
|
||||||
|
const admin = (u: FakeUser): AdminUser => {
|
||||||
|
const { password, ...rest } = u;
|
||||||
|
void password;
|
||||||
|
return rest;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const req = input as Request;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const path = url.pathname;
|
||||||
|
const text = await req.text();
|
||||||
|
const body = text ? (JSON.parse(text) as Record<string, unknown>) : undefined;
|
||||||
|
calls.push({ method: req.method, path, body });
|
||||||
|
const route = `${req.method} ${path}`;
|
||||||
|
const user = current ? users.get(current) : undefined;
|
||||||
|
|
||||||
|
switch (route) {
|
||||||
|
case 'GET /api/v1/server-info':
|
||||||
|
return json({
|
||||||
|
name: 'pipu-music',
|
||||||
|
version: '0.2.0',
|
||||||
|
api_version: 'v1',
|
||||||
|
supported_locales: ['ru', 'en'],
|
||||||
|
min_android_version_code: 0,
|
||||||
|
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
|
||||||
|
});
|
||||||
|
case 'POST /api/v1/auth/login': {
|
||||||
|
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
|
||||||
|
if (!u) return problem(401, 'auth.invalid_credentials');
|
||||||
|
current = u.id;
|
||||||
|
return tokens(u);
|
||||||
|
}
|
||||||
|
case 'POST /api/v1/auth/refresh':
|
||||||
|
return user ? tokens(user) : problem(401, 'auth.refresh_invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
||||||
|
|
||||||
|
switch (route) {
|
||||||
|
case 'POST /api/v1/auth/logout':
|
||||||
|
case 'POST /api/v1/auth/logout-all':
|
||||||
|
current = null;
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
case 'GET /api/v1/me':
|
||||||
|
return json(me(user));
|
||||||
|
case 'PATCH /api/v1/me': {
|
||||||
|
if (typeof body?.locale === 'string') user.locale = body.locale;
|
||||||
|
if (typeof body?.display_name === 'string') user.display_name = body.display_name || null;
|
||||||
|
if (body?.ui_prefs) prefs.set(user.id, { ...prefs.get(user.id), ...(body.ui_prefs as object) });
|
||||||
|
return json(me(user));
|
||||||
|
}
|
||||||
|
case 'POST /api/v1/me/bootstrap-credentials': {
|
||||||
|
const pw = String(body?.new_password ?? '');
|
||||||
|
if (pw.length < 12)
|
||||||
|
return problem(422, 'validation.password_too_short', {
|
||||||
|
params: { min: 12 },
|
||||||
|
errors: [{ field: 'new_password', code: 'validation.password_too_short' }],
|
||||||
|
});
|
||||||
|
Object.assign(user, { username: body?.new_username, password: pw, must_change_credentials: false });
|
||||||
|
return tokens(user);
|
||||||
|
}
|
||||||
|
case 'POST /api/v1/me/password':
|
||||||
|
Object.assign(user, { password: body?.new_password, must_change_password: false });
|
||||||
|
return tokens(user);
|
||||||
|
case 'GET /api/v1/me/sessions':
|
||||||
|
return json({ items: [] });
|
||||||
|
case 'GET /api/v1/admin/users': {
|
||||||
|
if (user.role === 'user') return problem(403, 'auth.forbidden');
|
||||||
|
const q = (url.searchParams.get('q') ?? '').toLowerCase();
|
||||||
|
const role = url.searchParams.get('role');
|
||||||
|
const items = [...users.values()]
|
||||||
|
.filter((u) => (!q || u.username.includes(q)) && (!role || u.role === role))
|
||||||
|
.sort((a, b) => a.username.localeCompare(b.username))
|
||||||
|
.map(admin);
|
||||||
|
return json({ items, next_cursor: null });
|
||||||
|
}
|
||||||
|
case 'POST /api/v1/admin/users': {
|
||||||
|
if (user.role === 'moderator' && body?.role !== 'user') return problem(403, 'auth.forbidden');
|
||||||
|
const u = makeUser(String(body?.username), body?.role as Role, {
|
||||||
|
display_name: (body?.display_name as string) ?? null,
|
||||||
|
must_change_password: true,
|
||||||
|
password: 'Temp-Pass-123',
|
||||||
|
});
|
||||||
|
users.set(u.id, u);
|
||||||
|
return json({ user: admin(u), temporary_password: u.password }, 201);
|
||||||
|
}
|
||||||
|
case 'GET /api/v1/admin/audit':
|
||||||
|
return json({ items: [], next_cursor: null });
|
||||||
|
case 'GET /api/v1/search/suggest':
|
||||||
|
case 'GET /api/v1/search': {
|
||||||
|
// «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление.
|
||||||
|
const q = url.searchParams.get('q') ?? '';
|
||||||
|
const hit = q === 'кино' || q === 'rbyj';
|
||||||
|
return json({
|
||||||
|
artists: hit ? [kino] : [],
|
||||||
|
albums: hit ? [albumSummary] : [],
|
||||||
|
tracks: [],
|
||||||
|
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: 0 },
|
||||||
|
corrected_query: q === 'rbyj' ? 'кино' : null,
|
||||||
|
next_cursor: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case `GET /api/v1/albums/${albumSummary.id}`:
|
||||||
|
return json({
|
||||||
|
...albumRef,
|
||||||
|
artist: kino,
|
||||||
|
duration_ms: 400000,
|
||||||
|
tracks: albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null })),
|
||||||
|
});
|
||||||
|
case 'POST /api/v1/events':
|
||||||
|
return json({ accepted: (body?.events as unknown[]).length, duplicates: 0, rejected: 0 });
|
||||||
|
case 'GET /api/v1/me/playback-state':
|
||||||
|
return json({ track_id: null, position_ms: 0, queue: [], queue_index: 0, context: null, shuffle: false,
|
||||||
|
repeat_mode: 'off', volume: 1, device_id: null, updated_at: null });
|
||||||
|
case 'PUT /api/v1/me/playback-state':
|
||||||
|
return json({ ...body, updated_at: new Date().toISOString() });
|
||||||
|
case `GET /api/v1/artists/${kino.id}`:
|
||||||
|
return json({ ...kino, album_count: 1, track_count: 0 });
|
||||||
|
case `GET /api/v1/artists/${kino.id}/albums`:
|
||||||
|
return json({ items: [albumSummary] });
|
||||||
|
}
|
||||||
|
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
|
||||||
|
if (reactionPath) {
|
||||||
|
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
|
||||||
|
else reactions.delete(reactionPath[1]!);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
return problem(404, 'common.not_found');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { fetch: fetchImpl as typeof fetch, calls, users, signOut: () => (current = null) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
// jsdom не умеет играть звук: play/pause только переключают флаг и шлют события, как браузер.
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
|
||||||
|
configurable: true,
|
||||||
|
value(this: HTMLMediaElement) {
|
||||||
|
Object.defineProperty(this, 'paused', { configurable: true, value: false });
|
||||||
|
this.dispatchEvent(new Event('playing'));
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'pause', {
|
||||||
|
configurable: true,
|
||||||
|
value(this: HTMLMediaElement) {
|
||||||
|
Object.defineProperty(this, 'paused', { configurable: true, value: true });
|
||||||
|
this.dispatchEvent(new Event('pause'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'load', { configurable: true, value() {} });
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
grid-template-rows: 60px 1fr 88px;
|
||||||
|
grid-template-areas:
|
||||||
|
'top top'
|
||||||
|
'side content'
|
||||||
|
'player player';
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
grid-area: top;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--bar-a);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoLink {
|
||||||
|
width: 196px;
|
||||||
|
flex: none;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.logoLink {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileButton {
|
||||||
|
border-radius: 50%;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileButton[aria-expanded='true'] {
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileHead {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 4px 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 10px 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileHead > :last-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-top: 10px;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileName {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileMeta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
grid-area: side;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navSection {
|
||||||
|
margin: 18px 12px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text2);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem[data-status='active'] {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navIcon {
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem[data-status='active'] .navIcon {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
grid-area: content;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 32px 36px;
|
||||||
|
animation: pipuIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 72px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navSection {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 60px 1fr 72px 64px;
|
||||||
|
grid-template-areas:
|
||||||
|
'top'
|
||||||
|
'content'
|
||||||
|
'player'
|
||||||
|
'side';
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-right: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navSection {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuthState, useSession, useUser } from '../app/session';
|
||||||
|
import {
|
||||||
|
auditFillIcon,
|
||||||
|
auditIcon,
|
||||||
|
groupFillIcon,
|
||||||
|
groupIcon,
|
||||||
|
homeFillIcon,
|
||||||
|
homeIcon,
|
||||||
|
libraryFillIcon,
|
||||||
|
libraryIcon,
|
||||||
|
logoutIcon,
|
||||||
|
historyFillIcon,
|
||||||
|
historyIcon,
|
||||||
|
likeFillIcon,
|
||||||
|
likeIcon,
|
||||||
|
dislikeFillIcon,
|
||||||
|
dislikeIcon,
|
||||||
|
settingsIcon,
|
||||||
|
storageFillIcon,
|
||||||
|
storageIcon,
|
||||||
|
} from '../shared/ui/icons';
|
||||||
|
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||||
|
import { LanguageSwitch } from './LanguageSwitch';
|
||||||
|
import { PlayerProvider } from '../player/context';
|
||||||
|
import { PlayerBar } from './PlayerBar';
|
||||||
|
import { SearchBox } from './SearchBox';
|
||||||
|
import s from './AppShell.module.css';
|
||||||
|
|
||||||
|
type NavPath =
|
||||||
|
| '/'
|
||||||
|
| '/admin/users'
|
||||||
|
| '/admin/audit'
|
||||||
|
| '/admin/sources'
|
||||||
|
| '/admin/library'
|
||||||
|
| '/collection/history'
|
||||||
|
| '/collection/likes'
|
||||||
|
| '/collection/dislikes';
|
||||||
|
|
||||||
|
function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) {
|
||||||
|
return (
|
||||||
|
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
|
||||||
|
{label}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
|
||||||
|
export function AppShell() {
|
||||||
|
return useAuthState().status === 'signed_in' ? (
|
||||||
|
<PlayerProvider>
|
||||||
|
<Shell />
|
||||||
|
</PlayerProvider>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shell() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const user = useUser();
|
||||||
|
const session = useSession();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const name = user.display_name ?? user.username;
|
||||||
|
const staff = user.role !== 'user';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.shell}>
|
||||||
|
<header className={s.top}>
|
||||||
|
<Link to="/" className={s.logoLink}>
|
||||||
|
<Logo />
|
||||||
|
</Link>
|
||||||
|
<SearchBox />
|
||||||
|
<div className={s.topRight}>
|
||||||
|
<Menu
|
||||||
|
label={t('profile.menu')}
|
||||||
|
triggerClassName={s.profileButton}
|
||||||
|
trigger={<Avatar name={name} />}
|
||||||
|
header={
|
||||||
|
<div className={s.profileHead}>
|
||||||
|
<Avatar name={name} size={40} />
|
||||||
|
<div>
|
||||||
|
<div className={s.profileName}>{name}</div>
|
||||||
|
<div className={s.profileMeta}>
|
||||||
|
@{user.username} · {t(`roles.${user.role}`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LanguageSwitch />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
items={[
|
||||||
|
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
||||||
|
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className={s.side} aria-label={t('nav.main')}>
|
||||||
|
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
||||||
|
<div className={s.navSection}>{t('nav.collection')}</div>
|
||||||
|
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
||||||
|
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} />
|
||||||
|
<NavLink to="/collection/dislikes" label={t('nav.dislikes')} icon={dislikeIcon} activeIcon={dislikeFillIcon} />
|
||||||
|
{staff && (
|
||||||
|
<>
|
||||||
|
<div className={s.navSection}>{t('nav.admin')}</div>
|
||||||
|
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
||||||
|
)}
|
||||||
|
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className={s.content}>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<PlayerBar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuthState, useSession } from '../app/session';
|
||||||
|
import { Segmented } from '../shared/ui/ui';
|
||||||
|
|
||||||
|
/** Переключатель языка. Для вошедшего пользователя язык сохраняется в профиле (PATCH /me). */
|
||||||
|
export function LanguageSwitch() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const auth = useAuthState();
|
||||||
|
|
||||||
|
const change = async (l: Locale) => {
|
||||||
|
document.documentElement.lang = l;
|
||||||
|
await i18n.changeLanguage(l);
|
||||||
|
if (auth.status !== 'signed_in') return;
|
||||||
|
try {
|
||||||
|
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } })));
|
||||||
|
} catch {
|
||||||
|
// Язык уже переключён локально; сохранится при следующей смене.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Segmented
|
||||||
|
label={t('language.label')}
|
||||||
|
value={(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language) ? (i18n.language as Locale) : 'en'}
|
||||||
|
options={SUPPORTED_LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }))}
|
||||||
|
onChange={(l) => void change(l)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
.bar {
|
||||||
|
grid-area: player;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--bar-a);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.now {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artists {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
color: #05070d;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: transform 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play:hover {
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play[aria-busy='true'] {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 38px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
appearance: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(90deg, var(--accent2) var(--pct, 0%), var(--surface3) var(--pct, 0%));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range:hover::-webkit-slider-thumb,
|
||||||
|
.range:focus-visible::-webkit-slider-thumb {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume {
|
||||||
|
flex: 0 1 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.bar {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress,
|
||||||
|
.right,
|
||||||
|
.controls > :first-child,
|
||||||
|
.controls > :last-child,
|
||||||
|
.now > button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||||
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import {
|
||||||
|
dislikeFillIcon,
|
||||||
|
dislikeIcon,
|
||||||
|
likeFillIcon,
|
||||||
|
likeIcon,
|
||||||
|
musicIcon,
|
||||||
|
nextIcon,
|
||||||
|
pauseIcon,
|
||||||
|
playIcon,
|
||||||
|
prevIcon,
|
||||||
|
repeatIcon,
|
||||||
|
repeatOneIcon,
|
||||||
|
shuffleIcon,
|
||||||
|
volumeIcon,
|
||||||
|
volumeOffIcon,
|
||||||
|
} from '../shared/ui/icons';
|
||||||
|
import { cx, Icon } from '../shared/ui/ui';
|
||||||
|
import s from './PlayerBar.module.css';
|
||||||
|
|
||||||
|
/** Плеер внизу экрана (вид `bar`, docs/design.md). */
|
||||||
|
export function PlayerBar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const player = usePlayer();
|
||||||
|
const st = usePlayerState();
|
||||||
|
const track = st.track;
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
return (
|
||||||
|
<footer className={s.bar} aria-label={t('player.label')}>
|
||||||
|
<span className={s.empty}>
|
||||||
|
<span className={s.placeholder}>
|
||||||
|
<Icon src={musicIcon} size={22} />
|
||||||
|
</span>
|
||||||
|
{t('player.empty')}
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const react = (r: 'like' | 'dislike') =>
|
||||||
|
void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||||
|
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className={s.bar} aria-label={t('player.label')}>
|
||||||
|
<div className={s.now}>
|
||||||
|
<Cover album={track.album} size={52} />
|
||||||
|
<div className={s.meta}>
|
||||||
|
<span className={s.title}>{track.title}</span>
|
||||||
|
<span className={s.artists}>
|
||||||
|
<ArtistLinks artists={track.artists} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.icon, track.reaction === 'dislike' && s.danger)}
|
||||||
|
aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}
|
||||||
|
aria-pressed={track.reaction === 'dislike'}
|
||||||
|
onClick={() => react('dislike')}
|
||||||
|
>
|
||||||
|
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.icon, track.reaction === 'like' && s.accent)}
|
||||||
|
aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}
|
||||||
|
aria-pressed={track.reaction === 'like'}
|
||||||
|
onClick={() => react('like')}
|
||||||
|
>
|
||||||
|
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.center}>
|
||||||
|
<div className={s.controls}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.icon, st.shuffle && s.accent)}
|
||||||
|
aria-label={t('player.shuffle')}
|
||||||
|
aria-pressed={st.shuffle}
|
||||||
|
onClick={() => player.toggleShuffle()}
|
||||||
|
>
|
||||||
|
<Icon src={shuffleIcon} size={20} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||||
|
<Icon src={prevIcon} size={26} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={s.play}
|
||||||
|
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
|
||||||
|
aria-busy={st.loading || undefined}
|
||||||
|
onClick={() => player.toggle()}
|
||||||
|
>
|
||||||
|
<Icon src={st.playing ? pauseIcon : playIcon} size={28} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||||||
|
<Icon src={nextIcon} size={26} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
|
||||||
|
aria-label={repeatLabel}
|
||||||
|
title={repeatLabel}
|
||||||
|
onClick={() => player.cycleRepeat()}
|
||||||
|
>
|
||||||
|
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={s.progress}>
|
||||||
|
<span className={s.time}>{formatDuration(st.positionMs)}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className={s.range}
|
||||||
|
min={0}
|
||||||
|
max={st.durationMs || track.duration_ms}
|
||||||
|
step={1000}
|
||||||
|
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
|
||||||
|
aria-label={t('player.progress')}
|
||||||
|
aria-valuetext={formatDuration(st.positionMs)}
|
||||||
|
onChange={(e) => player.seek(Number(e.target.value))}
|
||||||
|
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
|
||||||
|
/>
|
||||||
|
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
|
||||||
|
</div>
|
||||||
|
{st.error && (
|
||||||
|
<span className={s.error} role="status">
|
||||||
|
{t(st.error as 'player.error')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.right}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={s.icon}
|
||||||
|
aria-label={t(st.muted ? 'player.unmute' : 'player.mute')}
|
||||||
|
onClick={() => player.toggleMute()}
|
||||||
|
>
|
||||||
|
<Icon src={st.muted || st.volume === 0 ? volumeOffIcon : volumeIcon} size={20} />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className={cx(s.range, s.volume)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={st.muted ? 0 : st.volume}
|
||||||
|
aria-label={t('player.volume')}
|
||||||
|
onChange={(e) => player.setVolume(Number(e.target.value))}
|
||||||
|
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Button } from '../shared/ui/ui';
|
||||||
|
import s from '../pages/page.module.css';
|
||||||
|
|
||||||
|
/** Непредвиденная ошибка рендера страницы: короткое сообщение и перезагрузка. */
|
||||||
|
export function RouteError() {
|
||||||
|
const { t } = useTranslation(['common', 'errors']);
|
||||||
|
return (
|
||||||
|
<div className={s.empty} role="alert">
|
||||||
|
<p>{t('errors:common.internal')}</p>
|
||||||
|
<Button onClick={() => window.location.reload()}>{t('route_error.reload')}</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
.box {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text3);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 40px 0 42px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
color: var(--text3);
|
||||||
|
font: 12px var(--font-mono);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus ~ .kbd {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
z-index: 45;
|
||||||
|
max-height: min(70vh, 560px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--elev);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
animation: pipuIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corrected {
|
||||||
|
margin: 4px 10px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupTitle {
|
||||||
|
margin: 8px 10px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionText {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionTitle {
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionSub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nothing {
|
||||||
|
padding: 16px 10px;
|
||||||
|
color: var(--text3);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--accent2);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.kbd {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { Cover } from '../entities/catalog';
|
||||||
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import { searchIcon } from '../shared/ui/icons';
|
||||||
|
import { cx, Icon } from '../shared/ui/ui';
|
||||||
|
import s from './SearchBox.module.css';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
key: string;
|
||||||
|
group: 'artists' | 'albums' | 'tracks';
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
cover: { id: string; cover_url?: string | null; cover_colors?: string[] };
|
||||||
|
round?: boolean;
|
||||||
|
go: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */
|
||||||
|
export function SearchBox() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const listId = useId();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [debounced, setDebounced] = useState('');
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [active, setActive] = useState(-1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
// «/» или Ctrl+K — фокус в поиск (если пользователь не печатает в другом поле).
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const typing = target.closest('input, textarea, select, [contenteditable="true"]');
|
||||||
|
if ((e.key === '/' && !typing) || (e.key.toLowerCase() === 'k' && (e.ctrlKey || e.metaKey))) {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const suggest = useQuery({
|
||||||
|
queryKey: ['suggest', debounced],
|
||||||
|
enabled: debounced !== '',
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
// signal отменяет устаревший запрос, когда пользователь печатает дальше.
|
||||||
|
queryFn: async ({ signal }) =>
|
||||||
|
unwrap(await session.client.GET('/api/v1/search/suggest', { params: { query: { q: debounced } }, signal })),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
const data = debounced ? suggest.data : undefined;
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setOpen(false);
|
||||||
|
setActive(-1);
|
||||||
|
};
|
||||||
|
const goSearch = () => {
|
||||||
|
const query = q.trim();
|
||||||
|
if (!query) return;
|
||||||
|
close();
|
||||||
|
inputRef.current?.blur();
|
||||||
|
void navigate({ to: '/search', search: { q: query } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const items: Item[] = [];
|
||||||
|
if (data) {
|
||||||
|
for (const a of data.artists)
|
||||||
|
items.push({
|
||||||
|
key: `ar-${a.id}`,
|
||||||
|
group: 'artists',
|
||||||
|
title: a.name,
|
||||||
|
subtitle: t('search.artist_kind'),
|
||||||
|
cover: { id: a.id },
|
||||||
|
round: true,
|
||||||
|
go: () => void navigate({ to: '/artist/$id', params: { id: a.id } }),
|
||||||
|
});
|
||||||
|
for (const al of data.albums)
|
||||||
|
items.push({
|
||||||
|
key: `al-${al.id}`,
|
||||||
|
group: 'albums',
|
||||||
|
title: al.title,
|
||||||
|
subtitle: [al.artist.name, al.year].filter(Boolean).join(' · '),
|
||||||
|
cover: al,
|
||||||
|
go: () => void navigate({ to: '/album/$id', params: { id: al.id } }),
|
||||||
|
});
|
||||||
|
for (const tr of data.tracks)
|
||||||
|
items.push({
|
||||||
|
key: `tr-${tr.id}`,
|
||||||
|
group: 'tracks',
|
||||||
|
title: tr.title,
|
||||||
|
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
|
||||||
|
cover: tr.album,
|
||||||
|
// Воспроизведение — M3; пока трек открывает свой альбом.
|
||||||
|
go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const choose = (i: number) => {
|
||||||
|
const it = items[i];
|
||||||
|
if (!it) return goSearch();
|
||||||
|
close();
|
||||||
|
setQ('');
|
||||||
|
inputRef.current?.blur();
|
||||||
|
it.go();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
setActive((i) => Math.min(items.length - 1, i + 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive((i) => Math.max(-1, i - 1));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
choose(active);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
close();
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showList = open && debounced !== '' && data !== undefined;
|
||||||
|
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
|
||||||
|
const optionId = (i: number) => `${listId}-${i}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.box}>
|
||||||
|
<Icon src={searchIcon} size={18} className={s.icon} />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="search"
|
||||||
|
className={s.input}
|
||||||
|
value={q}
|
||||||
|
placeholder={t('search.placeholder')}
|
||||||
|
aria-label={t('search.label')}
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={showList}
|
||||||
|
aria-controls={listId}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-activedescendant={showList && active >= 0 ? optionId(active) : undefined}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQ(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
setActive(-1);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setTimeout(close, 120)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
<kbd className={s.kbd} aria-hidden>
|
||||||
|
/
|
||||||
|
</kbd>
|
||||||
|
{showList && (
|
||||||
|
<div className={s.popover}>
|
||||||
|
{data.corrected_query && (
|
||||||
|
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
|
||||||
|
)}
|
||||||
|
<div id={listId} role="listbox" aria-label={t('search.label')}>
|
||||||
|
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
|
||||||
|
{groups.map((g) => {
|
||||||
|
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
|
||||||
|
if (groupItems.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={g} role="group" aria-label={t(`search.${g}`)}>
|
||||||
|
<div className={s.groupTitle}>{t(`search.${g}`)}</div>
|
||||||
|
{groupItems.map(({ it, i }) => (
|
||||||
|
<div
|
||||||
|
key={it.key}
|
||||||
|
id={optionId(i)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === active}
|
||||||
|
className={cx(s.option, i === active && s.active)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onMouseEnter={() => setActive(i)}
|
||||||
|
onClick={() => choose(i)}
|
||||||
|
>
|
||||||
|
<Cover album={it.cover} size={36} round={it.round} />
|
||||||
|
<span className={s.optionText}>
|
||||||
|
<span className={s.optionTitle}>{it.title}</span>
|
||||||
|
<span className={s.optionSub}>{it.subtitle}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={s.all}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={goSearch}
|
||||||
|
>
|
||||||
|
{t('search.show_all')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,5 +19,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['src/test/setup.ts'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -20,6 +20,13 @@ HTTP_PORT=80
|
|||||||
POSTGRES_PASSWORD=
|
POSTGRES_PASSWORD=
|
||||||
MEILI_MASTER_KEY=
|
MEILI_MASTER_KEY=
|
||||||
INTERNAL_TOKEN=
|
INTERNAL_TOKEN=
|
||||||
|
# Ключ подписи access-токенов (Ed25519, base64 от 32 случайных байт). install.sh и update.sh
|
||||||
|
# создают его, если строки нет. Смена ключа разлогинит всех на ≤15 мин — старый можно оставить
|
||||||
|
# в JWT_PREVIOUS_KEYS (через запятую), пока не истекут выданные им токены.
|
||||||
|
JWT_PRIVATE_KEY=
|
||||||
|
# Подпись ссылок на обложки и аудио (base64 от 32 байт); создают install.sh / update.sh.
|
||||||
|
STREAM_SIGNING_KEY=
|
||||||
|
#JWT_PREVIOUS_KEYS=
|
||||||
|
|
||||||
# --- Первый администратор (необязательно; иначе пароль генерируется и выводится в лог api один раз) ---
|
# --- Первый администратор (необязательно; иначе пароль генерируется и выводится в лог api один раз) ---
|
||||||
#BOOTSTRAP_ADMIN_USERNAME=admin
|
#BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable
|
DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
MEILI_URL: http://meilisearch:7700
|
||||||
|
MEILI_MASTER_KEY: dev-master-key-change-me
|
||||||
|
INTERNAL_TOKEN: dev-internal-token
|
||||||
|
COOKIE_SECURE: "false"
|
||||||
|
X_ACCEL: "false"
|
||||||
|
CACHE_DIR: /tmp/pipu-cache
|
||||||
LOG_LEVEL: debug
|
LOG_LEVEL: debug
|
||||||
ports: ["127.0.0.1:8080:8080"]
|
ports: ["127.0.0.1:8080:8080"]
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
+3
-4
@@ -18,7 +18,8 @@ x-library-ro: &library-ro
|
|||||||
bind: { propagation: rslave }
|
bind: { propagation: rslave }
|
||||||
|
|
||||||
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
|
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
|
||||||
# Пишет в него только storage-agent (импорт одобренных загрузок), остальным — только чтение.
|
# Всем сервисам, включая storage-agent, — только чтение: импорт загрузок ещё не реализован,
|
||||||
|
# запись откроем вместе с ним (docs/library-and-storage.md).
|
||||||
x-library-host-ro: &library-host-ro
|
x-library-host-ro: &library-host-ro
|
||||||
type: bind
|
type: bind
|
||||||
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
||||||
@@ -85,9 +86,7 @@ services:
|
|||||||
source: ${PIPU_DATA:-/srv/pipu}/library
|
source: ${PIPU_DATA:-/srv/pipu}/library
|
||||||
target: /library
|
target: /library
|
||||||
bind: { propagation: rshared }
|
bind: { propagation: rshared }
|
||||||
- type: bind
|
- *library-host-ro
|
||||||
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
|
||||||
target: /library-host
|
|
||||||
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
|
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
|
||||||
|
|
||||||
recommender:
|
recommender:
|
||||||
|
|||||||
Regular → Executable
+10
@@ -66,6 +66,16 @@ if [ ! -f .env ]; then
|
|||||||
echo " Заполните в .env: DOMAIN и ACME_EMAIL (или настройте свой прокси — docs/install-guide.md)"
|
echo " Заполните в .env: DOMAIN и ACME_EMAIL (или настройте свой прокси — docs/install-guide.md)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
|
||||||
|
# в том числе в .env, сделанном до их появления.
|
||||||
|
for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
|
||||||
|
if ! grep -q "^$key=." .env; then
|
||||||
|
sed -i "/^$key=\$/d" .env
|
||||||
|
echo "$key=$(head -c 32 /dev/urandom | base64)" >> .env
|
||||||
|
echo "→ в .env добавлен $key"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
|
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
|
||||||
if [ -n "$LIBRARY_HOST" ]; then
|
if [ -n "$LIBRARY_HOST" ]; then
|
||||||
if grep -q '^LIBRARY_HOST=' .env; then
|
if grep -q '^LIBRARY_HOST=' .env; then
|
||||||
|
|||||||
Regular → Executable
+12
-1
@@ -16,6 +16,16 @@ cd "$PIPU_DEPLOY_DIR"
|
|||||||
|
|
||||||
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
|
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
|
||||||
|
# в том числе в .env, сделанном до их появления.
|
||||||
|
for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
|
||||||
|
if ! grep -q "^$key=." .env; then
|
||||||
|
sed -i "/^$key=\$/d" .env
|
||||||
|
echo "$key=$(head -c 32 /dev/urandom | base64)" >> .env
|
||||||
|
echo "→ в .env добавлен $key"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
REF="${1:-}"
|
REF="${1:-}"
|
||||||
git fetch --tags --prune --quiet origin
|
git fetch --tags --prune --quiet origin
|
||||||
|
|
||||||
@@ -62,7 +72,8 @@ docker image prune -f >/dev/null
|
|||||||
|
|
||||||
echo "→ ждём готовности api"
|
echo "→ ждём готовности api"
|
||||||
for _ in $(seq 1 60); do
|
for _ in $(seq 1 60); do
|
||||||
if docker compose exec -T nginx wget -qO- http://localhost/readyz >/dev/null 2>&1; then
|
# 127.0.0.1, а не localhost: в alpine localhost — сначала ::1, а nginx слушает только IPv4
|
||||||
|
if docker compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
|
||||||
echo "pipu $VERSION запущен"
|
echo "pipu $VERSION запущен"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
- argon2id: `m=64 MiB, t=3, p=2`, соль 16 байт; параметры в хэше (PHC), при изменении — rehash при входе.
|
- argon2id: `m=64 MiB, t=3, p=2`, соль 16 байт; параметры в хэше (PHC), при изменении — rehash при входе.
|
||||||
- Политика: ≥ 8 символов для `user`, ≥ 12 для `moderator`/`admin`; не совпадает с логином; проверка по
|
- Политика: ≥ 8 символов для `user`, ≥ 12 для `moderator`/`admin`; не совпадает с логином; проверка по
|
||||||
встроенному списку 10k самых частых паролей.
|
встроенному списку частых паролей (`internal/auth/common_passwords.txt`; пока ~150 записей, цель — 10k).
|
||||||
- Временный пароль при создании пользователя и при сбросе генерирует сервер: 12 символов, показывается **один раз**;
|
- Временный пароль при создании пользователя и при сбросе генерирует сервер: 12 символов, показывается **один раз**;
|
||||||
у пользователя ставится `must_change_password = true`.
|
у пользователя ставится `must_change_password = true`.
|
||||||
- Защита от перебора: после 5 неудачных попыток — `locked_until = now() + 5 мин` (растёт экспоненциально до 1 ч),
|
- Защита от перебора: после 5 неудачных попыток — `locked_until = now() + 5 мин` (растёт экспоненциально до 1 ч),
|
||||||
@@ -73,10 +73,16 @@
|
|||||||
| Android | в памяти | `expo-secure-store` |
|
| Android | в памяти | `expo-secure-store` |
|
||||||
|
|
||||||
- **Ротация**: каждый `/auth/refresh` помечает старый токен `used_at` и выдаёт новый в той же `family_id`.
|
- **Ротация**: каждый `/auth/refresh` помечает старый токен `used_at` и выдаёт новый в той же `family_id`.
|
||||||
Повторное предъявление уже использованного токена ⇒ компрометация ⇒ отзыв всей семьи.
|
Повторное предъявление уже использованного токена ⇒ компрометация ⇒ отзыв всей семьи
|
||||||
|
(`401 auth.refresh_reused`). Исключение — первые 10 с после ротации: так бывает, когда две вкладки
|
||||||
|
обновляют токен одновременно; ответ `401 auth.refresh_race` без отзыва, клиент повторяет запрос
|
||||||
|
с уже новой cookie.
|
||||||
- **Мгновенный отзыв** (блокировка, смена роли, смена пароля): у пользователя инкрементируется `token_version`
|
- **Мгновенный отзыв** (блокировка, смена роли, смена пароля): у пользователя инкрементируется `token_version`
|
||||||
(в Redis-кэше), middleware сравнивает с `ver` в access-токене.
|
(в Redis-кэше), middleware сравнивает с `ver` в access-токене.
|
||||||
- Ключи подписи: `JWT_PRIVATE_KEY` из env / файла; поддержка `kid` для ротации ключей.
|
- Ключи подписи: `JWT_PRIVATE_KEY` (base64 seed Ed25519) из env; `kid` = первые 8 байт SHA-256 публичного ключа,
|
||||||
|
прежние ключи для проверки — `JWT_PREVIOUS_KEYS`.
|
||||||
|
- Смена пароля и учётных данных отзывает все сессии и сразу выдаёт новую пару текущему устройству —
|
||||||
|
пользователь остаётся в системе, остальные устройства разлогиниваются.
|
||||||
- CSRF: refresh-cookie `SameSite=Strict` и путь только `/api/v1/auth`; остальные запросы — Bearer-заголовок,
|
- CSRF: refresh-cookie `SameSite=Strict` и путь только `/api/v1/auth`; остальные запросы — Bearer-заголовок,
|
||||||
поэтому CSRF к ним неприменим.
|
поэтому CSRF к ним неприменим.
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -38,6 +38,7 @@ CREATE TABLE refresh_tokens (
|
|||||||
id uuid PRIMARY KEY,
|
id uuid PRIMARY KEY,
|
||||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
family_id uuid NOT NULL, -- цепочка ротаций одного входа
|
family_id uuid NOT NULL, -- цепочка ротаций одного входа
|
||||||
|
family_started_at timestamptz NOT NULL, -- время входа: абсолютный срок семьи (180 дней)
|
||||||
token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена
|
token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена
|
||||||
device_name text, -- "Chrome / Windows", "Pixel 8"
|
device_name text, -- "Chrome / Windows", "Pixel 8"
|
||||||
client_type text NOT NULL, -- 'web' | 'android'
|
client_type text NOT NULL, -- 'web' | 'android'
|
||||||
@@ -196,7 +197,7 @@ CREATE TABLE albums (
|
|||||||
id uuid PRIMARY KEY,
|
id uuid PRIMARY KEY,
|
||||||
title text NOT NULL,
|
title text NOT NULL,
|
||||||
title_norm text NOT NULL,
|
title_norm text NOT NULL,
|
||||||
album_artist_id uuid REFERENCES artists(id),
|
album_artist_id uuid NOT NULL REFERENCES artists(id), -- без тегов — исполнитель трека или «Unknown artist»
|
||||||
year int,
|
year int,
|
||||||
cover_path text, -- путь в кэше обложек (/cache/covers/<hash>.webp)
|
cover_path text, -- путь в кэше обложек (/cache/covers/<hash>.webp)
|
||||||
cover_colors text[], -- палитра обложки (3 цвета: доминирующий, второй, акцент) — для ambient-фона,
|
cover_colors text[], -- палитра обложки (3 цвета: доминирующий, второй, акцент) — для ambient-фона,
|
||||||
@@ -207,7 +208,8 @@ CREATE TABLE albums (
|
|||||||
CREATE TABLE tracks (
|
CREATE TABLE tracks (
|
||||||
id uuid PRIMARY KEY,
|
id uuid PRIMARY KEY,
|
||||||
title text NOT NULL,
|
title text NOT NULL,
|
||||||
album_id uuid REFERENCES albums(id),
|
title_norm text NOT NULL, -- ключ поиска дублей: (album_id, title_norm, duration ±2 с)
|
||||||
|
album_id uuid NOT NULL REFERENCES albums(id),
|
||||||
disc_no int, track_no int,
|
disc_no int, track_no int,
|
||||||
year int,
|
year int,
|
||||||
duration_ms int NOT NULL,
|
duration_ms int NOT NULL,
|
||||||
|
|||||||
+6
-3
@@ -30,8 +30,8 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
Музыка на локальном диске сервера подключается через `LIBRARY_HOST` в `.env` (например, `/mnt/music`):
|
Музыка на локальном диске сервера подключается через `LIBRARY_HOST` в `.env` (например, `/mnt/music`):
|
||||||
каталог хоста монтируется в `api`, `nginx` и `analyzer` только на чтение, в `storage-agent` — на запись
|
каталог хоста монтируется во все сервисы, включая `storage-agent`, только на чтение (запись появится вместе
|
||||||
(импорт одобренных загрузок), внутри контейнеров он виден как `/library-host`.
|
с импортом одобренных загрузок), внутри контейнеров он виден как `/library-host`.
|
||||||
|
|
||||||
Перед первым запуском: `mkdir -p /srv/pipu/library && mount --bind /srv/pipu/library /srv/pipu/library && mount --make-rshared /srv/pipu/library`
|
Перед первым запуском: `mkdir -p /srv/pipu/library && mount --bind /srv/pipu/library /srv/pipu/library && mount --make-rshared /srv/pipu/library`
|
||||||
(делает `deploy/install.sh`, плюс systemd-unit для повторения после перезагрузки).
|
(делает `deploy/install.sh`, плюс systemd-unit для повторения после перезагрузки).
|
||||||
@@ -67,7 +67,10 @@
|
|||||||
| `DATABASE_URL` | `postgres://pipu:…@postgres:5432/pipu` | |
|
| `DATABASE_URL` | `postgres://pipu:…@postgres:5432/pipu` | |
|
||||||
| `REDIS_URL` | `redis://redis:6379/0` | |
|
| `REDIS_URL` | `redis://redis:6379/0` | |
|
||||||
| `MEILI_URL`, `MEILI_MASTER_KEY` | | |
|
| `MEILI_URL`, `MEILI_MASTER_KEY` | | |
|
||||||
| `JWT_PRIVATE_KEY_FILE` | `/run/secrets/jwt_ed25519.pem` | Генерируется `install.sh` |
|
| `JWT_PRIVATE_KEY` | base64 от 32 байт (seed Ed25519) | Подпись access-токенов. Создают `install.sh` / `update.sh`, если его нет; пусто — случайный ключ до перезапуска |
|
||||||
|
| `JWT_PREVIOUS_KEYS` | пусто | Прежние ключи через запятую — токены, подписанные ими, ещё принимаются (ротация) |
|
||||||
|
| `ACCESS_TOKEN_TTL` | `15m` | Срок access-токена |
|
||||||
|
| `COOKIE_SECURE` | `true` | Флаг `Secure` у refresh-cookie; `false` — только для разработки по http не на localhost |
|
||||||
| `STREAM_SIGNING_KEY` | 32 байта base64 | HMAC подписанных ссылок |
|
| `STREAM_SIGNING_KEY` | 32 байта base64 | HMAC подписанных ссылок |
|
||||||
| `INTERNAL_TOKEN` | случайный | Межсервисная аутентификация |
|
| `INTERNAL_TOKEN` | случайный | Межсервисная аутентификация |
|
||||||
| `BOOTSTRAP_ADMIN_USERNAME` | `admin` | |
|
| `BOOTSTRAP_ADMIN_USERNAME` | `admin` | |
|
||||||
|
|||||||
+3
-3
@@ -10,10 +10,10 @@
|
|||||||
| Серверное состояние | TanStack Query (кэш, инвалидация, бесконечные списки) |
|
| Серверное состояние | TanStack Query (кэш, инвалидация, бесконечные списки) |
|
||||||
| Клиентское состояние | Zustand (плеер, очередь, UI) |
|
| Клиентское состояние | Zustand (плеер, очередь, UI) |
|
||||||
| Стили | CSS Modules + CSS-переменные из `packages/design-tokens`; тёмная и светлая темы ([design.md](design.md#2-токены)) |
|
| Стили | CSS Modules + CSS-переменные из `packages/design-tokens`; тёмная и светлая темы ([design.md](design.md#2-токены)) |
|
||||||
| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono, сабсет Material Symbols Rounded — всё встроено в сборку |
|
| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono; иконки — SVG Material Symbols Rounded (`@material-symbols/svg-400`), импортируются поштучно в `shared/ui/icons.ts` и рисуются CSS-маской (`currentColor`), поэтому в сборку попадают только используемые — всё встроено в сборку |
|
||||||
| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска) |
|
| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска); в M1 — собственные `Menu` / `Dialog` в `shared/ui`, переход на Radix вместе с поиском (M2) |
|
||||||
| Анимация | Motion (переходы UI), Canvas 2D для кнопки волны (порт `wave.js` из прототипа) |
|
| Анимация | Motion (переходы UI), Canvas 2D для кнопки волны (порт `wave.js` из прототипа) |
|
||||||
| Формы | React Hook Form + Zod |
|
| Формы | React Hook Form + Zod (в M1 формы простые — контролируемые поля, валидация на сервере с ошибками полей) |
|
||||||
| i18n | i18next + react-i18next, словари из `packages/i18n` |
|
| i18n | i18next + react-i18next, словари из `packages/i18n` |
|
||||||
| API | `packages/api-client` (сгенерирован из OpenAPI, `openapi-typescript` + `openapi-fetch`) |
|
| API | `packages/api-client` (сгенерирован из OpenAPI, `openapi-typescript` + `openapi-fetch`) |
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,198 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ApiError, AuthSession, type AuthTokens, type RefreshStore } from './auth';
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
id: '00000000-0000-0000-0000-000000000001',
|
||||||
|
username: 'alice',
|
||||||
|
display_name: null,
|
||||||
|
role: 'user' as const,
|
||||||
|
locale: 'ru',
|
||||||
|
ui_prefs: {},
|
||||||
|
must_change_password: false,
|
||||||
|
must_change_credentials: false,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokens = (access: string, refresh?: string): AuthTokens => ({
|
||||||
|
access_token: access,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: 900,
|
||||||
|
refresh_token: refresh,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const problem = (status: number, code: string, extra: object = {}) => json({ status, code, ...extra }, status);
|
||||||
|
|
||||||
|
type Handler = (req: Request) => Response | Promise<Response>;
|
||||||
|
|
||||||
|
/** Фейковый сервер: маршруты по «METHOD path», журнал запросов. */
|
||||||
|
function server(routes: Record<string, Handler>) {
|
||||||
|
const log: { route: string; auth: string | null; body: string }[] = [];
|
||||||
|
const fetchImpl = async (input: RequestInfo | URL) => {
|
||||||
|
const req = input as Request;
|
||||||
|
const route = `${req.method} ${new URL(req.url).pathname}`;
|
||||||
|
log.push({ route, auth: req.headers.get('Authorization'), body: await req.clone().text() });
|
||||||
|
const h = routes[route];
|
||||||
|
if (!h) return problem(404, 'common.not_found');
|
||||||
|
return h(req);
|
||||||
|
};
|
||||||
|
return { fetch: fetchImpl as typeof fetch, log };
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryStore = (initial: string | null = null): RefreshStore & { value: string | null } => ({
|
||||||
|
value: initial,
|
||||||
|
async get() {
|
||||||
|
return this.value;
|
||||||
|
},
|
||||||
|
async set(v) {
|
||||||
|
this.value = v;
|
||||||
|
},
|
||||||
|
async clear() {
|
||||||
|
this.value = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AuthSession', () => {
|
||||||
|
it('logs in and sends the bearer token', async () => {
|
||||||
|
const s = server({
|
||||||
|
'POST /api/v1/auth/login': () => json(tokens('A1')),
|
||||||
|
'GET /api/v1/me': (req) =>
|
||||||
|
req.headers.get('Authorization') === 'Bearer A1' ? json(user) : problem(401, 'auth.unauthorized'),
|
||||||
|
});
|
||||||
|
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web', deviceName: 'Test' });
|
||||||
|
const states: string[] = [];
|
||||||
|
auth.subscribe((st) => states.push(st.status));
|
||||||
|
|
||||||
|
await auth.login('alice', 'secret');
|
||||||
|
expect(JSON.parse(s.log[0]!.body)).toEqual({
|
||||||
|
username: 'alice',
|
||||||
|
password: 'secret',
|
||||||
|
client_type: 'web',
|
||||||
|
device_name: 'Test',
|
||||||
|
});
|
||||||
|
const me = await auth.client.GET('/api/v1/me');
|
||||||
|
expect(me.data?.username).toBe('alice');
|
||||||
|
expect(states).toEqual(['signed_in']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns problem+json into ApiError with field errors', async () => {
|
||||||
|
const s = server({
|
||||||
|
'POST /api/v1/auth/login': () => problem(401, 'auth.invalid_credentials'),
|
||||||
|
});
|
||||||
|
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
|
||||||
|
const err = await auth.login('alice', 'bad').catch((e: unknown) => e);
|
||||||
|
expect(err).toBeInstanceOf(ApiError);
|
||||||
|
expect((err as ApiError).code).toBe('auth.invalid_credentials');
|
||||||
|
expect(auth.state.status).toBe('unknown');
|
||||||
|
|
||||||
|
const e2 = new ApiError(422, 'validation.password_too_short', { min: 8 }, [
|
||||||
|
{ field: 'new_password', code: 'validation.password_too_short' },
|
||||||
|
]);
|
||||||
|
expect(e2.fieldCode('new_password')).toBe('validation.password_too_short');
|
||||||
|
expect(e2.fieldCode('other')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes once for parallel expired requests and retries them', async () => {
|
||||||
|
let refreshes = 0;
|
||||||
|
const s = server({
|
||||||
|
'POST /api/v1/auth/login': () => json(tokens('old')),
|
||||||
|
'POST /api/v1/auth/refresh': async () => {
|
||||||
|
refreshes++;
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
return json(tokens('new'));
|
||||||
|
},
|
||||||
|
'GET /api/v1/me': (req) =>
|
||||||
|
req.headers.get('Authorization') === 'Bearer new' ? json(user) : problem(401, 'auth.token_expired'),
|
||||||
|
});
|
||||||
|
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
|
||||||
|
await auth.login('alice', 'secret');
|
||||||
|
|
||||||
|
const results = await Promise.all([1, 2, 3].map(() => auth.client.GET('/api/v1/me')));
|
||||||
|
expect(results.every((r) => r.data?.username === 'alice')).toBe(true);
|
||||||
|
expect(refreshes).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not refresh on other 401 codes and signs out a blocked user', async () => {
|
||||||
|
const s = server({
|
||||||
|
'POST /api/v1/auth/login': () => json(tokens('A')),
|
||||||
|
'GET /api/v1/me': () => problem(401, 'auth.user_blocked'),
|
||||||
|
});
|
||||||
|
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
|
||||||
|
await auth.login('alice', 'secret');
|
||||||
|
const res = await auth.client.GET('/api/v1/me');
|
||||||
|
expect(res.response.status).toBe(401);
|
||||||
|
expect(s.log.some((l) => l.route.includes('refresh'))).toBe(false);
|
||||||
|
expect(auth.state).toEqual({ status: 'signed_out', reason: 'auth.user_blocked' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs out when refresh is rejected', async () => {
|
||||||
|
const s = server({
|
||||||
|
'POST /api/v1/auth/refresh': () => problem(401, 'auth.refresh_reused'),
|
||||||
|
});
|
||||||
|
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
|
||||||
|
expect(await auth.restore()).toEqual({ status: 'signed_out', reason: 'auth.refresh_reused' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries a refresh race once', async () => {
|
||||||
|
let n = 0;
|
||||||
|
const s = server({
|
||||||
|
'POST /api/v1/auth/refresh': () => (++n === 1 ? problem(401, 'auth.refresh_race') : json(tokens('A'))),
|
||||||
|
});
|
||||||
|
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
|
||||||
|
expect((await auth.restore()).status).toBe('signed_in');
|
||||||
|
expect(n).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the session on network errors during refresh', async () => {
|
||||||
|
const auth = new AuthSession({
|
||||||
|
baseUrl: 'http://x',
|
||||||
|
fetch: async () => {
|
||||||
|
throw new TypeError('Failed to fetch');
|
||||||
|
},
|
||||||
|
clientType: 'web',
|
||||||
|
});
|
||||||
|
expect(await auth.refresh()).toBe(false);
|
||||||
|
expect(auth.state.status).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('android keeps the refresh token in the store', async () => {
|
||||||
|
const store = memoryStore('R0');
|
||||||
|
const s = server({
|
||||||
|
'POST /api/v1/auth/refresh': async (req) => {
|
||||||
|
const body = (await req.json()) as { refresh_token: string };
|
||||||
|
return body.refresh_token === 'R0' ? json(tokens('A1', 'R1')) : problem(401, 'auth.refresh_invalid');
|
||||||
|
},
|
||||||
|
'POST /api/v1/auth/logout': () => new Response(null, { status: 204 }),
|
||||||
|
});
|
||||||
|
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'android', refreshStore: store });
|
||||||
|
expect((await auth.restore()).status).toBe('signed_in');
|
||||||
|
expect(store.value).toBe('R1');
|
||||||
|
|
||||||
|
await auth.logout();
|
||||||
|
expect(store.value).toBeNull();
|
||||||
|
expect(auth.state.status).toBe('signed_out');
|
||||||
|
expect(s.log.at(-1)).toMatchObject({ route: 'POST /api/v1/auth/logout', auth: 'Bearer A1' });
|
||||||
|
expect(await auth.refresh()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a refresh store for android', () => {
|
||||||
|
expect(() => new AuthSession({ clientType: 'android' })).toThrow(/refreshStore/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies subscribers about profile updates', async () => {
|
||||||
|
const s = server({ 'POST /api/v1/auth/login': () => json(tokens('A')) });
|
||||||
|
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
|
||||||
|
const fn = vi.fn();
|
||||||
|
auth.subscribe(fn);
|
||||||
|
await auth.login('alice', 'x');
|
||||||
|
auth.setUser({ ...user, display_name: 'Alice' });
|
||||||
|
expect(fn).toHaveBeenLastCalledWith({ status: 'signed_in', user: { ...user, display_name: 'Alice' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { createApiClient, type ApiClient, type components } from '@pipu/api-client';
|
||||||
|
|
||||||
|
export type AuthUser = components['schemas']['Me'];
|
||||||
|
export type AuthTokens = components['schemas']['AuthTokens'];
|
||||||
|
export type ClientType = components['schemas']['ClientType'];
|
||||||
|
|
||||||
|
/** Ошибка API с машинным кодом (problem+json). Текст для пользователя — перевод `errors:<code>`. */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string;
|
||||||
|
readonly params: Record<string, unknown>;
|
||||||
|
readonly fields: { field: string; code: string }[];
|
||||||
|
|
||||||
|
constructor(status: number, code: string, params: Record<string, unknown> = {}, fields: ApiError['fields'] = []) {
|
||||||
|
super(code);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.params = params;
|
||||||
|
this.fields = fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Код ошибки конкретного поля формы, если сервер его указал. */
|
||||||
|
fieldCode(field: string): string | undefined {
|
||||||
|
return this.fields.find((f) => f.field === field)?.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Превращает ответ openapi-fetch с ошибкой в ApiError. Сетевые сбои — код `common.network`. */
|
||||||
|
export function toApiError(error: unknown, response?: Response): ApiError {
|
||||||
|
if (error instanceof ApiError) return error;
|
||||||
|
const p = (error ?? {}) as Partial<components['schemas']['Problem']>;
|
||||||
|
if (typeof p.code === 'string') {
|
||||||
|
return new ApiError(p.status ?? response?.status ?? 0, p.code, p.params ?? {}, p.errors ?? []);
|
||||||
|
}
|
||||||
|
if (response) return new ApiError(response.status, response.status >= 500 ? 'common.internal' : 'common.bad_request');
|
||||||
|
return new ApiError(0, 'common.network');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Достаёт данные из результата openapi-fetch или бросает ApiError. */
|
||||||
|
export function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): T {
|
||||||
|
if (res.error !== undefined || !res.response.ok) throw toApiError(res.error, res.response);
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Хранилище refresh-токена для клиентов без cookie (Android: expo-secure-store). */
|
||||||
|
export interface RefreshStore {
|
||||||
|
get(): Promise<string | null>;
|
||||||
|
set(token: string): Promise<void>;
|
||||||
|
clear(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthState =
|
||||||
|
| { status: 'unknown' }
|
||||||
|
| { status: 'signed_out'; reason?: string }
|
||||||
|
| { status: 'signed_in'; user: AuthUser };
|
||||||
|
|
||||||
|
export interface AuthSessionOptions {
|
||||||
|
/** Адрес сервера; пусто — тот же origin (веб). */
|
||||||
|
baseUrl?: string;
|
||||||
|
fetch?: typeof globalThis.fetch;
|
||||||
|
clientType: ClientType;
|
||||||
|
deviceName?: string;
|
||||||
|
/** Обязателен для android: веб хранит refresh в HttpOnly-cookie. */
|
||||||
|
refreshStore?: RefreshStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Коды 401, после которых имеет смысл обновить access-токен и повторить запрос. */
|
||||||
|
const RETRYABLE = new Set(['auth.token_expired', 'auth.token_invalid', 'auth.token_revoked']);
|
||||||
|
|
||||||
|
const RACE_RETRY_MS = 300;
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сессия пользователя: access-токен в памяти, refresh — в cookie (веб) или RefreshStore (Android).
|
||||||
|
* `client` сам подставляет Bearer и при истёкшем токене делает **один** общий refresh на все
|
||||||
|
* параллельные запросы (single-flight), после чего повторяет их.
|
||||||
|
*/
|
||||||
|
export class AuthSession {
|
||||||
|
readonly client: ApiClient;
|
||||||
|
private readonly raw: ApiClient;
|
||||||
|
private readonly opts: AuthSessionOptions;
|
||||||
|
private readonly baseFetch: typeof globalThis.fetch;
|
||||||
|
private access: string | null = null;
|
||||||
|
private refreshing: Promise<boolean> | null = null;
|
||||||
|
private current: AuthState = { status: 'unknown' };
|
||||||
|
private readonly listeners = new Set<(s: AuthState) => void>();
|
||||||
|
|
||||||
|
constructor(opts: AuthSessionOptions) {
|
||||||
|
if (opts.clientType === 'android' && !opts.refreshStore) {
|
||||||
|
throw new Error('AuthSession: android client needs a refreshStore');
|
||||||
|
}
|
||||||
|
this.opts = opts;
|
||||||
|
this.baseFetch = opts.fetch ?? ((...args) => globalThis.fetch(...args));
|
||||||
|
this.raw = createApiClient({ baseUrl: opts.baseUrl, fetch: this.baseFetch });
|
||||||
|
this.client = createApiClient({ baseUrl: opts.baseUrl, fetch: (req) => this.authFetch(req as Request) });
|
||||||
|
}
|
||||||
|
|
||||||
|
get state(): AuthState {
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подписка на смену состояния; возвращает функцию отписки. */
|
||||||
|
subscribe(fn: (s: AuthState) => void): () => void {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(s: AuthState) {
|
||||||
|
this.current = s;
|
||||||
|
for (const fn of this.listeners) fn(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Восстанавливает сессию после перезагрузки по refresh-токену. */
|
||||||
|
async restore(): Promise<AuthState> {
|
||||||
|
if (!(await this.refresh())) {
|
||||||
|
if (this.current.status !== 'signed_out') this.setState({ status: 'signed_out' });
|
||||||
|
}
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(username: string, password: string): Promise<AuthUser> {
|
||||||
|
const res = await this.raw.POST('/api/v1/auth/login', {
|
||||||
|
body: { username, password, client_type: this.opts.clientType, device_name: this.opts.deviceName },
|
||||||
|
});
|
||||||
|
return this.apply(unwrap(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Принимает новую пару токенов (вход, смена пароля, смена учётных данных). */
|
||||||
|
async apply(tokens: AuthTokens): Promise<AuthUser> {
|
||||||
|
this.access = tokens.access_token;
|
||||||
|
if (tokens.refresh_token && this.opts.refreshStore) await this.opts.refreshStore.set(tokens.refresh_token);
|
||||||
|
this.setState({ status: 'signed_in', user: tokens.user });
|
||||||
|
return tokens.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обновляет профиль в состоянии (после PATCH /me). */
|
||||||
|
setUser(user: AuthUser) {
|
||||||
|
if (this.current.status === 'signed_in') this.setState({ status: 'signed_in', user });
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(all = false): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (this.access) await this.client.POST(all ? '/api/v1/auth/logout-all' : '/api/v1/auth/logout');
|
||||||
|
} finally {
|
||||||
|
await this.signOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async signOut(reason?: string) {
|
||||||
|
this.access = null;
|
||||||
|
await this.opts.refreshStore?.clear();
|
||||||
|
this.setState({ status: 'signed_out', reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-flight: параллельные вызовы ждут один и тот же запрос. */
|
||||||
|
refresh(): Promise<boolean> {
|
||||||
|
this.refreshing ??= this.doRefresh().finally(() => {
|
||||||
|
this.refreshing = null;
|
||||||
|
});
|
||||||
|
return this.refreshing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doRefresh(attempt = 0): Promise<boolean> {
|
||||||
|
let body: { refresh_token?: string } | undefined;
|
||||||
|
if (this.opts.refreshStore) {
|
||||||
|
const token = await this.opts.refreshStore.get();
|
||||||
|
if (!token) return false;
|
||||||
|
body = { refresh_token: token };
|
||||||
|
}
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await this.raw.POST('/api/v1/auth/refresh', { body });
|
||||||
|
} catch {
|
||||||
|
return false; // сеть недоступна: сессию не трогаем, следующий запрос попробует снова
|
||||||
|
}
|
||||||
|
if (res.data) {
|
||||||
|
await this.apply(res.data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const err = toApiError(res.error, res.response);
|
||||||
|
if (err.code === 'auth.refresh_race' && attempt === 0) {
|
||||||
|
// Другая вкладка только что обновила cookie — повторяем уже с новой.
|
||||||
|
await sleep(RACE_RETRY_MS);
|
||||||
|
return this.doRefresh(attempt + 1);
|
||||||
|
}
|
||||||
|
if (res.response.status === 401 || res.response.status === 403) await this.signOut(err.code);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async authFetch(request: Request): Promise<Response> {
|
||||||
|
const retry = request.clone();
|
||||||
|
const sent = this.access;
|
||||||
|
const res = await this.baseFetch(this.withAuth(request));
|
||||||
|
if (res.status !== 401) return res;
|
||||||
|
|
||||||
|
const code = await res
|
||||||
|
.clone()
|
||||||
|
.json()
|
||||||
|
.then((p: { code?: string }) => p.code)
|
||||||
|
.catch(() => undefined);
|
||||||
|
if (!code || !RETRYABLE.has(code)) {
|
||||||
|
if (code === 'auth.user_blocked') await this.signOut(code);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
// Пока запрос был в пути, токен уже обновил кто-то другой — повторяем без новой ротации.
|
||||||
|
const fresh = this.access !== null && this.access !== sent;
|
||||||
|
if (!fresh && !(await this.refresh())) return res;
|
||||||
|
return this.baseFetch(this.withAuth(retry));
|
||||||
|
}
|
||||||
|
|
||||||
|
private withAuth(request: Request): Request {
|
||||||
|
if (!this.access) return request;
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
headers.set('Authorization', `Bearer ${this.access}`);
|
||||||
|
return new Request(request, { headers });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,24 @@
|
|||||||
|
export {
|
||||||
|
ApiError,
|
||||||
|
AuthSession,
|
||||||
|
toApiError,
|
||||||
|
unwrap,
|
||||||
|
type AuthSessionOptions,
|
||||||
|
type AuthState,
|
||||||
|
type AuthTokens,
|
||||||
|
type AuthUser,
|
||||||
|
type ClientType,
|
||||||
|
type RefreshStore,
|
||||||
|
} from './auth';
|
||||||
|
export {
|
||||||
|
EventBuffer,
|
||||||
|
PlayAttempt,
|
||||||
|
PlayQueue,
|
||||||
|
uuidv7,
|
||||||
|
type EventSink,
|
||||||
|
type PlayContext,
|
||||||
|
type PlayEvent,
|
||||||
|
type RepeatMode,
|
||||||
|
type Track,
|
||||||
|
} from './player';
|
||||||
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
|
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { EventBuffer, PlayAttempt, PlayQueue, uuidv7, type Track } from './player';
|
||||||
|
|
||||||
|
const track = (id: string, duration_ms = 200_000): Track => ({
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
duration_ms,
|
||||||
|
artists: [],
|
||||||
|
album: { id: 'a', title: 'A', year: null, cover_url: null, cover_colors: [] },
|
||||||
|
genres: [],
|
||||||
|
disc_no: null,
|
||||||
|
track_no: null,
|
||||||
|
available: true,
|
||||||
|
stream_url: `/s/${id}`,
|
||||||
|
reaction: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = (q: PlayQueue) => q.order.map((i) => q.tracks[i]!.id);
|
||||||
|
|
||||||
|
describe('PlayQueue', () => {
|
||||||
|
it('walks forward and back, stops at the end', () => {
|
||||||
|
const q = new PlayQueue();
|
||||||
|
q.set([track('a'), track('b'), track('c')], 1);
|
||||||
|
expect(q.current?.id).toBe('b');
|
||||||
|
expect(q.next()?.id).toBe('c');
|
||||||
|
expect(q.peekNext()).toBeNull();
|
||||||
|
expect(q.next()).toBeNull();
|
||||||
|
expect(q.prev()?.id).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repeats one track only when it ends by itself', () => {
|
||||||
|
const q = new PlayQueue();
|
||||||
|
q.set([track('a'), track('b')]);
|
||||||
|
q.repeat = 'one';
|
||||||
|
expect(q.next(true)?.id).toBe('a');
|
||||||
|
expect(q.next()?.id).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps around with repeat all', () => {
|
||||||
|
const q = new PlayQueue();
|
||||||
|
q.set([track('a'), track('b')], 1);
|
||||||
|
q.repeat = 'all';
|
||||||
|
expect(q.peekNext()?.id).toBe('a');
|
||||||
|
expect(q.next()?.id).toBe('a');
|
||||||
|
expect(q.prev()?.id).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shuffles keeping the current track first and restores order', () => {
|
||||||
|
let seed = 0.3;
|
||||||
|
const q = new PlayQueue(() => (seed = (seed * 9301 + 0.49297) % 1));
|
||||||
|
q.set(['a', 'b', 'c', 'd', 'e'].map((id) => track(id)), 2);
|
||||||
|
q.setShuffle(true);
|
||||||
|
expect(q.current?.id).toBe('c');
|
||||||
|
expect(ids(q)[0]).toBe('c');
|
||||||
|
expect([...ids(q)].sort()).toEqual(['a', 'b', 'c', 'd', 'e']);
|
||||||
|
q.next();
|
||||||
|
const cur = q.current!.id;
|
||||||
|
q.setShuffle(false);
|
||||||
|
expect(q.current?.id).toBe(cur);
|
||||||
|
expect(ids(q)).toEqual(['a', 'b', 'c', 'd', 'e']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PlayAttempt', () => {
|
||||||
|
it('counts only time actually played', () => {
|
||||||
|
const a = new PlayAttempt(track('a'));
|
||||||
|
for (let t = 250; t <= 10_000; t += 250) a.tick(t);
|
||||||
|
expect(a.playedMs).toBe(10_000);
|
||||||
|
expect(a.tick(60_000)).toBe(true); // перемотка вперёд
|
||||||
|
a.tick(60_250);
|
||||||
|
expect(a.playedMs).toBe(10_250);
|
||||||
|
a.seek(5000);
|
||||||
|
a.tick(5250);
|
||||||
|
expect(a.playedMs).toBe(10_500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the last seconds as finished', () => {
|
||||||
|
const a = new PlayAttempt(track('a', 200_000));
|
||||||
|
expect(a.outcome(196_000)).toBe('play_end');
|
||||||
|
expect(a.outcome(120_000)).toBe('skip');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EventBuffer', () => {
|
||||||
|
it('sends in batches and retries with backoff', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const send = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(undefined);
|
||||||
|
const buf = new EventBuffer({ send }, () => Date.UTC(2026, 0, 1));
|
||||||
|
buf.push({ type: 'play_start', track_id: 't1' });
|
||||||
|
buf.push({ type: 'skip', track_id: 't1', played_ms: 1000 });
|
||||||
|
|
||||||
|
expect(await buf.flush()).toBe(false);
|
||||||
|
expect(buf.pending).toHaveLength(2);
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(send).toHaveBeenCalledTimes(2);
|
||||||
|
expect(buf.pending).toHaveLength(0);
|
||||||
|
const sent = send.mock.calls[1]![0];
|
||||||
|
expect(sent[0].occurred_at).toBe('2026-01-01T00:00:00.000Z');
|
||||||
|
expect(sent[0].id).not.toBe(sent[1].id);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('uuidv7', () => {
|
||||||
|
it('encodes time and version', () => {
|
||||||
|
const id = uuidv7(0x0190_0000_0000, () => new Uint8Array(16).fill(0xff));
|
||||||
|
expect(id).toMatch(/^01900000-0000-7fff-bfff-ffffffffffff$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
|
||||||
|
export type Track = components['schemas']['Track'];
|
||||||
|
export type PlayEvent = components['schemas']['PlayEvent'];
|
||||||
|
export type RepeatMode = 'off' | 'one' | 'all';
|
||||||
|
export type PlayContext = { type: NonNullable<PlayEvent['context_type']>; id?: string; label?: string };
|
||||||
|
|
||||||
|
/** UUIDv7: время в первых 48 битах — сервер дедуплицирует события по id, сортировка по времени. */
|
||||||
|
export function uuidv7(now = Date.now(), rnd: (n: number) => Uint8Array = randomBytes): string {
|
||||||
|
const b = rnd(16);
|
||||||
|
const ts = BigInt(now);
|
||||||
|
for (let i = 0; i < 6; i++) b[i] = Number((ts >> BigInt(8 * (5 - i))) & 0xffn);
|
||||||
|
b[6] = (b[6]! & 0x0f) | 0x70;
|
||||||
|
b[8] = (b[8]! & 0x3f) | 0x80;
|
||||||
|
const h = [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
||||||
|
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomBytes(n: number): Uint8Array {
|
||||||
|
const b = new Uint8Array(n);
|
||||||
|
globalThis.crypto.getRandomValues(b);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очередь воспроизведения. Перемешивание хранится порядком индексов: текущий трек остаётся текущим,
|
||||||
|
* а выключение возвращает исходный порядок.
|
||||||
|
*/
|
||||||
|
export class PlayQueue {
|
||||||
|
tracks: Track[] = [];
|
||||||
|
/** Позиция в order. */
|
||||||
|
pos = 0;
|
||||||
|
order: number[] = [];
|
||||||
|
shuffle = false;
|
||||||
|
repeat: RepeatMode = 'off';
|
||||||
|
|
||||||
|
constructor(private readonly random: () => number = Math.random) {}
|
||||||
|
|
||||||
|
get current(): Track | undefined {
|
||||||
|
return this.tracks[this.order[this.pos] ?? -1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Индекс текущего трека в исходном списке. */
|
||||||
|
get index(): number {
|
||||||
|
return this.order[this.pos] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(tracks: Track[], index = 0) {
|
||||||
|
this.tracks = tracks;
|
||||||
|
this.order = tracks.map((_, i) => i);
|
||||||
|
this.pos = Math.min(Math.max(index, 0), Math.max(tracks.length - 1, 0));
|
||||||
|
if (this.shuffle) this.reshuffle();
|
||||||
|
}
|
||||||
|
|
||||||
|
setShuffle(on: boolean) {
|
||||||
|
const cur = this.index;
|
||||||
|
this.shuffle = on;
|
||||||
|
this.order = this.tracks.map((_, i) => i);
|
||||||
|
this.pos = cur;
|
||||||
|
if (on) this.reshuffle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reshuffle() {
|
||||||
|
const cur = this.order[this.pos] ?? 0;
|
||||||
|
const rest = this.order.filter((i) => i !== cur);
|
||||||
|
for (let i = rest.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(this.random() * (i + 1));
|
||||||
|
[rest[i], rest[j]] = [rest[j]!, rest[i]!];
|
||||||
|
}
|
||||||
|
this.order = this.tracks.length ? [cur, ...rest] : [];
|
||||||
|
this.pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Следующий трек. auto — трек доиграл сам (repeat one повторяет его). null — очередь кончилась. */
|
||||||
|
next(auto = false): Track | null {
|
||||||
|
if (!this.tracks.length) return null;
|
||||||
|
if (auto && this.repeat === 'one') return this.current ?? null;
|
||||||
|
if (this.pos + 1 < this.order.length) {
|
||||||
|
this.pos++;
|
||||||
|
return this.current ?? null;
|
||||||
|
}
|
||||||
|
if (this.repeat === 'all') {
|
||||||
|
this.pos = 0;
|
||||||
|
return this.current ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Предыдущий трек (с начала очереди при repeat all — последний). */
|
||||||
|
prev(): Track | null {
|
||||||
|
if (!this.tracks.length) return null;
|
||||||
|
if (this.pos > 0) this.pos--;
|
||||||
|
else if (this.repeat === 'all') this.pos = this.order.length - 1;
|
||||||
|
return this.current ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Что сыграет после текущего — для предзагрузки. */
|
||||||
|
peekNext(): Track | null {
|
||||||
|
if (this.repeat === 'one') return this.current ?? null;
|
||||||
|
const i = this.pos + 1 < this.order.length ? this.pos + 1 : this.repeat === 'all' ? 0 : -1;
|
||||||
|
return i < 0 ? null : (this.tracks[this.order[i]!] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
jump(index: number) {
|
||||||
|
const p = this.order.indexOf(index);
|
||||||
|
if (p >= 0) this.pos = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Скачок позиции больше этого — перемотка, а не воспроизведение. */
|
||||||
|
const SEEK_GAP_MS = 2000;
|
||||||
|
/** Осталось меньше этого — трек доигран (play_end, а не skip). */
|
||||||
|
const END_SLACK_MS = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Учёт попытки прослушивания одного трека: played_ms — реально прослушанное время,
|
||||||
|
* перемотки вперёд не засчитываются.
|
||||||
|
*/
|
||||||
|
export class PlayAttempt {
|
||||||
|
playedMs = 0;
|
||||||
|
private last: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly track: Track,
|
||||||
|
startMs = 0,
|
||||||
|
) {
|
||||||
|
this.last = startMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Новая позиция воспроизведения (timeupdate). Возвращает true, если это была перемотка. */
|
||||||
|
tick(positionMs: number): boolean {
|
||||||
|
const delta = positionMs - this.last;
|
||||||
|
this.last = positionMs;
|
||||||
|
if (delta > 0 && delta <= SEEK_GAP_MS) {
|
||||||
|
this.playedMs += delta;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return delta !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Явная перемотка: позиция меняется без засчитывания. */
|
||||||
|
seek(positionMs: number) {
|
||||||
|
this.last = positionMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тип завершения попытки по позиции. */
|
||||||
|
outcome(positionMs: number): 'play_end' | 'skip' {
|
||||||
|
return this.track.duration_ms - positionMs <= END_SLACK_MS ? 'play_end' : 'skip';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventSink {
|
||||||
|
send(events: PlayEvent[]): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Буфер событий: отправка пачками (по таймеру, при смене трека, при уходе в фон),
|
||||||
|
* при ошибке — повтор с экспоненциальной задержкой. Сервер дедуплицирует по id.
|
||||||
|
*/
|
||||||
|
export class EventBuffer {
|
||||||
|
private queue: PlayEvent[] = [];
|
||||||
|
private sending = false;
|
||||||
|
private retryMs = 0;
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly sink: EventSink,
|
||||||
|
private readonly now: () => number = Date.now,
|
||||||
|
private readonly maxRetryMs = 5 * 60_000,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get pending(): readonly PlayEvent[] {
|
||||||
|
return this.queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
push(e: Omit<PlayEvent, 'id' | 'occurred_at'> & Partial<Pick<PlayEvent, 'id' | 'occurred_at'>>) {
|
||||||
|
const t = this.now();
|
||||||
|
this.queue.push({ id: e.id ?? uuidv7(t), occurred_at: e.occurred_at ?? new Date(t).toISOString(), ...e } as PlayEvent);
|
||||||
|
if (this.queue.length >= 500) void this.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
async flush(): Promise<boolean> {
|
||||||
|
if (this.sending || this.queue.length === 0) return true;
|
||||||
|
this.sending = true;
|
||||||
|
const batch = this.queue.slice(0, 500);
|
||||||
|
try {
|
||||||
|
await this.sink.send(batch);
|
||||||
|
this.queue = this.queue.slice(batch.length);
|
||||||
|
this.retryMs = 0;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
this.retryMs = Math.min(this.retryMs ? this.retryMs * 2 : 2000, this.maxRetryMs);
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(() => void this.flush(), this.retryMs);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
{
|
||||||
|
"users": {
|
||||||
|
"title": "Users",
|
||||||
|
"subtitle": "Create users and moderators, manage roles, reset passwords, block accounts",
|
||||||
|
"create": "Create user",
|
||||||
|
"search": "Name or username",
|
||||||
|
"all": "All",
|
||||||
|
"col_user": "User",
|
||||||
|
"col_role": "Role",
|
||||||
|
"col_status": "Status",
|
||||||
|
"col_last_login": "Last seen",
|
||||||
|
"status_active": "Active",
|
||||||
|
"status_blocked": "Blocked",
|
||||||
|
"status_must_change": "Must change password",
|
||||||
|
"never": "Never signed in",
|
||||||
|
"empty": "Nobody found",
|
||||||
|
"more": "Show more",
|
||||||
|
"actions": "Actions for {{username}}",
|
||||||
|
"reset_password": "Reset password",
|
||||||
|
"block": "Block",
|
||||||
|
"unblock": "Unblock",
|
||||||
|
"make_role": "Make {{role}}",
|
||||||
|
"delete": "Delete",
|
||||||
|
"delete_confirm": "Delete {{username}}? Their history and likes will be lost.",
|
||||||
|
"block_confirm": "Block {{username}}? All their sessions will end.",
|
||||||
|
"reset_confirm": "Reset {{username}}'s password? All their sessions will end.",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"you": "you"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"title": "New user",
|
||||||
|
"username": "Username",
|
||||||
|
"username_hint": "Latin letters, digits, \".\", \"_\", \"-\"",
|
||||||
|
"display_name": "Name",
|
||||||
|
"role": "Role",
|
||||||
|
"password_note": "The server will generate a password and show it once.",
|
||||||
|
"submit": "Create"
|
||||||
|
},
|
||||||
|
"temp_password": {
|
||||||
|
"title": "Temporary password",
|
||||||
|
"text": "Give this password to {{username}}. It is shown only now; they will be asked to change it on sign-in.",
|
||||||
|
"done": "Done"
|
||||||
|
},
|
||||||
|
"audit": {
|
||||||
|
"title": "Audit log",
|
||||||
|
"subtitle": "Actions of administrators and moderators",
|
||||||
|
"col_when": "When",
|
||||||
|
"col_who": "Who",
|
||||||
|
"col_action": "Action",
|
||||||
|
"col_details": "Details",
|
||||||
|
"system": "System",
|
||||||
|
"empty": "No entries yet",
|
||||||
|
"more": "Show more",
|
||||||
|
"actions": {
|
||||||
|
"user.bootstrap_create": "Initial administrator created",
|
||||||
|
"user.bootstrap_credentials": "Administrator credentials set",
|
||||||
|
"user.create": "User created",
|
||||||
|
"user.rename": "Name changed",
|
||||||
|
"user.role_change": "Role changed",
|
||||||
|
"user.block": "User blocked",
|
||||||
|
"user.unblock": "User unblocked",
|
||||||
|
"user.password_reset": "Password reset",
|
||||||
|
"user.delete": "User deleted"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"title": "Storage",
|
||||||
|
"subtitle": "Where the server takes music from: a folder on the server or NFS",
|
||||||
|
"add": "Add storage",
|
||||||
|
"empty": "No storage yet. Connect a music folder to get started.",
|
||||||
|
"type_local": "Server folder",
|
||||||
|
"type_nfs": "NFS",
|
||||||
|
"status_pending": "Connecting",
|
||||||
|
"status_mounted": "Connected",
|
||||||
|
"status_error": "Error",
|
||||||
|
"status_disabled": "Disabled",
|
||||||
|
"last_scan": "Last scan: {{date}}",
|
||||||
|
"never_scanned": "Not scanned yet",
|
||||||
|
"scan": "Scan",
|
||||||
|
"full_scan": "Full scan",
|
||||||
|
"test": "Test",
|
||||||
|
"patterns": "Path patterns",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"disable": "Disable",
|
||||||
|
"enable": "Enable",
|
||||||
|
"delete_confirm": "Delete “{{name}}”? Tracks that exist only in this storage will disappear from the library along with their likes.",
|
||||||
|
"scanning": "Scanning: {{processed}} of {{total}}",
|
||||||
|
"scan_walking": "Walking folders…",
|
||||||
|
"scan_done": "Found {{found}} · new {{added}} · updated {{updated}} · moved {{moved}} · missing {{missing}} · errors {{errors}}",
|
||||||
|
"scan_failed": "Scan stopped: {{error}}",
|
||||||
|
"test_ok": "Reachable, audio files found: {{count}}",
|
||||||
|
"test_fail": "Not reachable: {{message}}",
|
||||||
|
"form_title_new": "New storage",
|
||||||
|
"form_title_edit": "Storage",
|
||||||
|
"name": "Name",
|
||||||
|
"type": "Type",
|
||||||
|
"folder": "Folder",
|
||||||
|
"folder_root": "Whole folder",
|
||||||
|
"folder_hint": "A subfolder of LIBRARY_HOST on the server",
|
||||||
|
"up": "Up",
|
||||||
|
"nfs_server": "Server",
|
||||||
|
"nfs_server_hint": "Host name or IP, e.g. nas.lan",
|
||||||
|
"nfs_export": "Export",
|
||||||
|
"nfs_export_hint": "Path on the server, e.g. /export/music",
|
||||||
|
"nfs_options": "Mount options",
|
||||||
|
"nfs_options_hint": "Read-only is added automatically",
|
||||||
|
"tag_priority": "Prefer",
|
||||||
|
"tags_first": "File tags",
|
||||||
|
"path_first": "Path patterns",
|
||||||
|
"interval": "Auto scan",
|
||||||
|
"interval_off": "Manual",
|
||||||
|
"interval_hours": "Every {{count}} h",
|
||||||
|
"save": "Save"
|
||||||
|
},
|
||||||
|
"patterns": {
|
||||||
|
"title": "Path patterns: {{name}}",
|
||||||
|
"intro": "RE2 regular expressions with named groups are matched against the file path relative to the storage root. The first match wins.",
|
||||||
|
"groups": "Groups: artist, albumartist, album, year, disc, track, title, genre; skip ignores the file.",
|
||||||
|
"add": "Add pattern",
|
||||||
|
"regex": "Pattern {{n}}",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"up": "Up",
|
||||||
|
"down": "Down",
|
||||||
|
"remove": "Remove",
|
||||||
|
"preview": "Preview",
|
||||||
|
"save": "Save",
|
||||||
|
"saved": "Saved. Run a full scan to apply it to files already found.",
|
||||||
|
"col_path": "Path",
|
||||||
|
"col_pattern": "Pattern",
|
||||||
|
"col_result": "Result",
|
||||||
|
"no_match": "no match",
|
||||||
|
"skipped": "skipped",
|
||||||
|
"unmatched": "No pattern matched: {{count}} of {{total}}",
|
||||||
|
"from_tags": "from tags",
|
||||||
|
"error_at": "Error in pattern {{n}}: {{reason}}",
|
||||||
|
"examples": "Examples"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"title": "Library",
|
||||||
|
"subtitle": "Catalog and scan status",
|
||||||
|
"artists": "Artists",
|
||||||
|
"albums": "Albums",
|
||||||
|
"tracks": "Tracks",
|
||||||
|
"files": "Files",
|
||||||
|
"missing": "Missing files",
|
||||||
|
"errors": "Unreadable",
|
||||||
|
"sources": "Storage",
|
||||||
|
"error_files": "Files with errors",
|
||||||
|
"reindex": "Rebuild search index",
|
||||||
|
"reindex_started": "Rebuild started"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in",
|
||||||
|
"subtitle": "Your music from your own server",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"submit": "Sign in",
|
||||||
|
"bootstrap_hint": "First start: sign in as the administrator. The password is set in BOOTSTRAP_ADMIN_PASSWORD or printed in the api service log."
|
||||||
|
},
|
||||||
|
"setup": {
|
||||||
|
"admin_badge": "First administrator sign-in",
|
||||||
|
"admin_title": "Change the username and password",
|
||||||
|
"admin_text": "The server stays locked until you do: the initial administrator credentials are visible in logs and settings.",
|
||||||
|
"password_badge": "Password reset",
|
||||||
|
"password_title": "Set a new password",
|
||||||
|
"password_text": "You signed in with a temporary password. Choose your own — the temporary one will stop working.",
|
||||||
|
"new_username": "New username",
|
||||||
|
"new_password": "New password",
|
||||||
|
"repeat_password": "Repeat the password",
|
||||||
|
"min_length": "At least {{min}} characters",
|
||||||
|
"submit": "Save and continue",
|
||||||
|
"logout": "Sign out"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,5 +17,143 @@
|
|||||||
"label": "Language",
|
"label": "Language",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
"en": "English"
|
"en": "English"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"main": "Navigation",
|
||||||
|
"home": "Home",
|
||||||
|
"admin": "Admin",
|
||||||
|
"users": "Users",
|
||||||
|
"audit": "Audit log",
|
||||||
|
"library": "Library",
|
||||||
|
"sources": "Storage",
|
||||||
|
"collection": "Collection",
|
||||||
|
"history": "History",
|
||||||
|
"likes": "Likes",
|
||||||
|
"dislikes": "Dislikes"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"menu": "Profile menu",
|
||||||
|
"settings": "Settings",
|
||||||
|
"logout": "Sign out"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"admin": "Administrator",
|
||||||
|
"moderator": "Moderator",
|
||||||
|
"user": "User"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"greeting": "Hi, {{name}}!",
|
||||||
|
"wave": "My Wave",
|
||||||
|
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"empty": "Nothing is playing",
|
||||||
|
"label": "Player",
|
||||||
|
"play": "Play",
|
||||||
|
"pause": "Pause",
|
||||||
|
"next": "Next",
|
||||||
|
"prev": "Previous",
|
||||||
|
"like": "Like",
|
||||||
|
"unlike": "Remove from likes",
|
||||||
|
"dislike": "Dislike",
|
||||||
|
"undislike": "Remove dislike",
|
||||||
|
"volume": "Volume",
|
||||||
|
"mute": "Mute",
|
||||||
|
"unmute": "Unmute",
|
||||||
|
"shuffle": "Shuffle",
|
||||||
|
"repeat_off": "Repeat off",
|
||||||
|
"repeat_all": "Repeat queue",
|
||||||
|
"repeat_one": "Repeat track",
|
||||||
|
"progress": "Position",
|
||||||
|
"unavailable": "Track is unavailable — skipping",
|
||||||
|
"error": "Could not play the track"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"account": "Account",
|
||||||
|
"display_name": "Display name",
|
||||||
|
"save": "Save",
|
||||||
|
"saved": "Saved",
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"theme": "Theme",
|
||||||
|
"theme_dark": "Dark",
|
||||||
|
"theme_light": "Light",
|
||||||
|
"theme_system": "System",
|
||||||
|
"password": "Password",
|
||||||
|
"current_password": "Current password",
|
||||||
|
"new_password": "New password",
|
||||||
|
"repeat_password": "Repeat the password",
|
||||||
|
"change_password": "Change password",
|
||||||
|
"password_changed": "Password changed. Other devices have been signed out.",
|
||||||
|
"sessions": "Devices",
|
||||||
|
"session_current": "This device",
|
||||||
|
"session_last_used": "Active: {{date}}",
|
||||||
|
"session_end": "Sign out",
|
||||||
|
"logout_all": "Sign out everywhere"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"show_password": "Show password",
|
||||||
|
"hide_password": "Hide password",
|
||||||
|
"passwords_mismatch": "Passwords don't match",
|
||||||
|
"required": "Fill in this field",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"close": "Close",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied"
|
||||||
|
},
|
||||||
|
"loading": "Loading…",
|
||||||
|
"route_error": {
|
||||||
|
"reload": "Reload the page"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Artists, albums, tracks",
|
||||||
|
"label": "Search",
|
||||||
|
"artists": "Artists",
|
||||||
|
"albums": "Albums",
|
||||||
|
"tracks": "Tracks",
|
||||||
|
"all": "All",
|
||||||
|
"nothing": "Nothing found",
|
||||||
|
"corrected": "Showing results for “{{query}}”",
|
||||||
|
"show_all": "All results",
|
||||||
|
"title": "Search: {{query}}",
|
||||||
|
"more": "Show more",
|
||||||
|
"artist_kind": "Artist",
|
||||||
|
"hint": "Press / to search"
|
||||||
|
},
|
||||||
|
"catalog": {
|
||||||
|
"albums": "Albums",
|
||||||
|
"appears_on": "Appears on",
|
||||||
|
"album_count_one": "{{count}} album",
|
||||||
|
"album_count_other": "{{count}} albums",
|
||||||
|
"album_count_few": "{{count}} albums",
|
||||||
|
"album_count_many": "{{count}} albums",
|
||||||
|
"track_count_one": "{{count}} track",
|
||||||
|
"track_count_other": "{{count}} tracks",
|
||||||
|
"track_count_few": "{{count}} tracks",
|
||||||
|
"track_count_many": "{{count}} tracks",
|
||||||
|
"unavailable": "Unavailable: storage is offline or the file is gone",
|
||||||
|
"not_found": "Nothing here — it may have been removed by the last scan",
|
||||||
|
"disc": "Disc {{n}}",
|
||||||
|
"col_title": "Title",
|
||||||
|
"col_album": "Album",
|
||||||
|
"col_duration": "Time",
|
||||||
|
"back_home": "Back home",
|
||||||
|
"artist": "Artist",
|
||||||
|
"album": "Album"
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"title": "Collection",
|
||||||
|
"history": "History",
|
||||||
|
"likes": "Likes",
|
||||||
|
"dislikes": "Dislikes",
|
||||||
|
"clear_history": "Clear history",
|
||||||
|
"clear_confirm": "Clear your listening history? Recommendations will still take it into account.",
|
||||||
|
"today": "Today",
|
||||||
|
"yesterday": "Yesterday",
|
||||||
|
"empty_history": "What you listen to will show up here",
|
||||||
|
"empty_likes": "Tap the heart on tracks — they will gather here",
|
||||||
|
"empty_dislikes": "Disliked tracks stay out of the wave. None so far",
|
||||||
|
"play_all": "Play all",
|
||||||
|
"more": "Show more"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"internal": "Something went wrong on the server. Please try again",
|
||||||
|
"bad_request": "Invalid request",
|
||||||
|
"not_found": "Not found",
|
||||||
|
"method_not_allowed": "This action is not supported",
|
||||||
|
"network": "Cannot reach the server. Check your network"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"invalid_credentials": "Wrong username or password",
|
||||||
|
"too_many_attempts": "Too many sign-in attempts. Please wait a bit",
|
||||||
|
"user_blocked": "This account is blocked. Contact your administrator",
|
||||||
|
"unauthorized": "Sign in to continue",
|
||||||
|
"token_expired": "Your session has expired, please sign in again",
|
||||||
|
"token_invalid": "Your session is invalid, please sign in again",
|
||||||
|
"token_revoked": "Your session has ended, please sign in again",
|
||||||
|
"refresh_invalid": "Your session has ended, please sign in again",
|
||||||
|
"refresh_reused": "Your session was ended for security reasons, please sign in again",
|
||||||
|
"refresh_race": "Your session is being refreshed, please retry",
|
||||||
|
"credentials_change_required": "Change the administrator username and password first",
|
||||||
|
"password_change_required": "Change your temporary password first",
|
||||||
|
"current_password_required": "Enter your current password",
|
||||||
|
"current_password_invalid": "The current password is wrong",
|
||||||
|
"bootstrap_not_required": "The administrator credentials have already been changed",
|
||||||
|
"session_not_found": "Session not found",
|
||||||
|
"forbidden": "You don't have permission to do this"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"password_too_short": "The password must be at least {{min}} characters",
|
||||||
|
"password_too_long": "The password must be at most {{max}} characters",
|
||||||
|
"password_same_as_username": "The password must not match the username",
|
||||||
|
"password_too_common": "This password is too common — choose another one",
|
||||||
|
"password_unchanged": "The new password must differ from the current one",
|
||||||
|
"username_invalid": "Username: {{min}} to {{max}} characters — Latin letters, digits, \".\", \"_\", \"-\"",
|
||||||
|
"username_taken": "This username is already taken",
|
||||||
|
"locale_unsupported": "This language is not supported",
|
||||||
|
"display_name_too_long": "The name must be at most {{max}} characters",
|
||||||
|
"ui_prefs_invalid": "Invalid setting value",
|
||||||
|
"client_type_invalid": "Unknown app type",
|
||||||
|
"role_invalid": "Unknown role",
|
||||||
|
"cursor_invalid": "The list is outdated, please reload",
|
||||||
|
"pattern_invalid": "Pattern error: {{reason}}",
|
||||||
|
"nfs_invalid": "Invalid value",
|
||||||
|
"local_path_invalid": "Folder not found",
|
||||||
|
"name_invalid": "The name must be 1 to {{max}} characters",
|
||||||
|
"scan_interval_invalid": "The interval must be at least {{min}} minutes",
|
||||||
|
"source_type_invalid": "Unknown storage type",
|
||||||
|
"query_empty": "Enter a query"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"not_found": "User not found",
|
||||||
|
"last_admin": "The last administrator cannot be demoted, blocked or deleted"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"source_not_found": "Storage not found",
|
||||||
|
"scan_running": "A scan is already running",
|
||||||
|
"source_offline": "The storage is not connected"
|
||||||
|
},
|
||||||
|
"catalog": {
|
||||||
|
"not_found": "Not found"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"unavailable": "Search is temporarily unavailable"
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"link_invalid": "The link has expired — reload the page",
|
||||||
|
"track_unavailable": "Track is unavailable",
|
||||||
|
"transcode_failed": "Could not prepare the track"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
{
|
||||||
|
"users": {
|
||||||
|
"title": "Пользователи",
|
||||||
|
"subtitle": "Создание пользователей и модераторов, роли, сброс пароля, блокировка",
|
||||||
|
"create": "Создать пользователя",
|
||||||
|
"search": "Имя или логин",
|
||||||
|
"all": "Все",
|
||||||
|
"col_user": "Пользователь",
|
||||||
|
"col_role": "Роль",
|
||||||
|
"col_status": "Статус",
|
||||||
|
"col_last_login": "Был в сети",
|
||||||
|
"status_active": "Активен",
|
||||||
|
"status_blocked": "Заблокирован",
|
||||||
|
"status_must_change": "Ждёт смены пароля",
|
||||||
|
"never": "Ещё не входил",
|
||||||
|
"empty": "Никого не нашли",
|
||||||
|
"more": "Показать ещё",
|
||||||
|
"actions": "Действия с {{username}}",
|
||||||
|
"reset_password": "Сбросить пароль",
|
||||||
|
"block": "Заблокировать",
|
||||||
|
"unblock": "Разблокировать",
|
||||||
|
"make_role": "Сделать: {{role}}",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"delete_confirm": "Удалить {{username}}? Его история и лайки пропадут.",
|
||||||
|
"block_confirm": "Заблокировать {{username}}? Все его сессии завершатся.",
|
||||||
|
"reset_confirm": "Сбросить пароль {{username}}? Все его сессии завершатся.",
|
||||||
|
"confirm": "Подтвердить",
|
||||||
|
"you": "вы"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"title": "Новый пользователь",
|
||||||
|
"username": "Логин",
|
||||||
|
"username_hint": "Латинские буквы, цифры, «.», «_», «-»",
|
||||||
|
"display_name": "Имя",
|
||||||
|
"role": "Роль",
|
||||||
|
"password_note": "Пароль сгенерирует сервер и покажет один раз.",
|
||||||
|
"submit": "Создать"
|
||||||
|
},
|
||||||
|
"temp_password": {
|
||||||
|
"title": "Временный пароль",
|
||||||
|
"text": "Передайте пароль пользователю {{username}}. Он виден только сейчас; при входе его попросят сменить.",
|
||||||
|
"done": "Готово"
|
||||||
|
},
|
||||||
|
"audit": {
|
||||||
|
"title": "Журнал",
|
||||||
|
"subtitle": "Действия администраторов и модераторов",
|
||||||
|
"col_when": "Когда",
|
||||||
|
"col_who": "Кто",
|
||||||
|
"col_action": "Действие",
|
||||||
|
"col_details": "Подробности",
|
||||||
|
"system": "Система",
|
||||||
|
"empty": "Записей пока нет",
|
||||||
|
"more": "Показать ещё",
|
||||||
|
"actions": {
|
||||||
|
"user.bootstrap_create": "Создан стартовый администратор",
|
||||||
|
"user.bootstrap_credentials": "Заданы учётные данные администратора",
|
||||||
|
"user.create": "Создан пользователь",
|
||||||
|
"user.rename": "Изменено имя",
|
||||||
|
"user.role_change": "Изменена роль",
|
||||||
|
"user.block": "Пользователь заблокирован",
|
||||||
|
"user.unblock": "Пользователь разблокирован",
|
||||||
|
"user.password_reset": "Сброшен пароль",
|
||||||
|
"user.delete": "Пользователь удалён"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"title": "Хранилища",
|
||||||
|
"subtitle": "Откуда сервер берёт музыку: папка на диске сервера или NFS",
|
||||||
|
"add": "Подключить хранилище",
|
||||||
|
"empty": "Хранилищ пока нет. Подключите папку с музыкой, чтобы начать.",
|
||||||
|
"type_local": "Папка на сервере",
|
||||||
|
"type_nfs": "NFS",
|
||||||
|
"status_pending": "Подключается",
|
||||||
|
"status_mounted": "Подключено",
|
||||||
|
"status_error": "Ошибка",
|
||||||
|
"status_disabled": "Отключено",
|
||||||
|
"last_scan": "Последний скан: {{date}}",
|
||||||
|
"never_scanned": "Ещё не сканировалось",
|
||||||
|
"scan": "Сканировать",
|
||||||
|
"full_scan": "Полный скан",
|
||||||
|
"test": "Проверить",
|
||||||
|
"patterns": "Шаблоны путей",
|
||||||
|
"edit": "Изменить",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"disable": "Отключить",
|
||||||
|
"enable": "Подключить",
|
||||||
|
"delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.",
|
||||||
|
"scanning": "Сканирование: {{processed}} из {{total}}",
|
||||||
|
"scan_walking": "Обход папок…",
|
||||||
|
"scan_done": "Найдено {{found}} · новых {{added}} · обновлено {{updated}} · перемещено {{moved}} · пропало {{missing}} · ошибок {{errors}}",
|
||||||
|
"scan_failed": "Скан прервался: {{error}}",
|
||||||
|
"test_ok": "Доступно, найдено аудиофайлов: {{count}}",
|
||||||
|
"test_fail": "Нет доступа: {{message}}",
|
||||||
|
"form_title_new": "Новое хранилище",
|
||||||
|
"form_title_edit": "Хранилище",
|
||||||
|
"name": "Название",
|
||||||
|
"type": "Тип",
|
||||||
|
"folder": "Папка",
|
||||||
|
"folder_root": "Весь каталог",
|
||||||
|
"folder_hint": "Подпапка каталога LIBRARY_HOST на сервере",
|
||||||
|
"up": "Вверх",
|
||||||
|
"nfs_server": "Сервер",
|
||||||
|
"nfs_server_hint": "Имя или IP, например nas.lan",
|
||||||
|
"nfs_export": "Экспорт",
|
||||||
|
"nfs_export_hint": "Путь на сервере, например /export/music",
|
||||||
|
"nfs_options": "Опции монтирования",
|
||||||
|
"nfs_options_hint": "Только чтение добавляется автоматически",
|
||||||
|
"tag_priority": "Что важнее",
|
||||||
|
"tags_first": "Теги файлов",
|
||||||
|
"path_first": "Шаблоны путей",
|
||||||
|
"interval": "Автоскан",
|
||||||
|
"interval_off": "Вручную",
|
||||||
|
"interval_hours": "Каждые {{count}} ч",
|
||||||
|
"save": "Сохранить"
|
||||||
|
},
|
||||||
|
"patterns": {
|
||||||
|
"title": "Шаблоны путей: {{name}}",
|
||||||
|
"intro": "Регулярные выражения RE2 с именованными группами применяются к пути файла относительно корня хранилища. Побеждает первое совпавшее.",
|
||||||
|
"groups": "Группы: artist, albumartist, album, year, disc, track, title, genre; skip — игнорировать файл.",
|
||||||
|
"add": "Добавить шаблон",
|
||||||
|
"regex": "Шаблон {{n}}",
|
||||||
|
"enabled": "Включён",
|
||||||
|
"up": "Выше",
|
||||||
|
"down": "Ниже",
|
||||||
|
"remove": "Удалить",
|
||||||
|
"preview": "Предпросмотр",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"saved": "Сохранено. Запустите полный скан, чтобы применить к уже найденным файлам.",
|
||||||
|
"col_path": "Путь",
|
||||||
|
"col_pattern": "Шаблон",
|
||||||
|
"col_result": "Результат",
|
||||||
|
"no_match": "не совпал",
|
||||||
|
"skipped": "пропускается",
|
||||||
|
"unmatched": "Не совпал ни один шаблон: {{count}} из {{total}}",
|
||||||
|
"from_tags": "из тегов",
|
||||||
|
"error_at": "Ошибка в шаблоне {{n}}: {{reason}}",
|
||||||
|
"examples": "Примеры"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"title": "Библиотека",
|
||||||
|
"subtitle": "Состояние каталога и сканирования",
|
||||||
|
"artists": "Исполнителей",
|
||||||
|
"albums": "Альбомов",
|
||||||
|
"tracks": "Треков",
|
||||||
|
"files": "Файлов",
|
||||||
|
"missing": "Пропавших файлов",
|
||||||
|
"errors": "Не прочитано",
|
||||||
|
"sources": "Хранилища",
|
||||||
|
"error_files": "Файлы с ошибками",
|
||||||
|
"reindex": "Переиндексировать поиск",
|
||||||
|
"reindex_started": "Переиндексация запущена"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"title": "Вход",
|
||||||
|
"subtitle": "Ваша музыка с вашего сервера",
|
||||||
|
"username": "Логин",
|
||||||
|
"password": "Пароль",
|
||||||
|
"submit": "Войти",
|
||||||
|
"bootstrap_hint": "Первый запуск: войдите как администратор. Пароль задан в BOOTSTRAP_ADMIN_PASSWORD или напечатан в логе сервиса api."
|
||||||
|
},
|
||||||
|
"setup": {
|
||||||
|
"admin_badge": "Первый вход администратора",
|
||||||
|
"admin_title": "Смените логин и пароль",
|
||||||
|
"admin_text": "Без этого шага доступ к серверу закрыт: стартовые данные администратора видны в логах и настройках.",
|
||||||
|
"password_badge": "Пароль сброшен",
|
||||||
|
"password_title": "Задайте новый пароль",
|
||||||
|
"password_text": "Вы вошли по временному паролю. Придумайте свой — временный больше не подойдёт.",
|
||||||
|
"new_username": "Новый логин",
|
||||||
|
"new_password": "Новый пароль",
|
||||||
|
"repeat_password": "Повторите пароль",
|
||||||
|
"min_length": "Не короче {{min}} символов",
|
||||||
|
"submit": "Сохранить и продолжить",
|
||||||
|
"logout": "Выйти"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,5 +17,143 @@
|
|||||||
"label": "Язык",
|
"label": "Язык",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
"en": "English"
|
"en": "English"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"main": "Навигация",
|
||||||
|
"home": "Главная",
|
||||||
|
"admin": "Админка",
|
||||||
|
"users": "Пользователи",
|
||||||
|
"audit": "Журнал",
|
||||||
|
"library": "Библиотека",
|
||||||
|
"sources": "Хранилища",
|
||||||
|
"collection": "Коллекция",
|
||||||
|
"history": "История",
|
||||||
|
"likes": "Лайки",
|
||||||
|
"dislikes": "Дизлайки"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"menu": "Меню профиля",
|
||||||
|
"settings": "Настройки",
|
||||||
|
"logout": "Выйти"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"admin": "Администратор",
|
||||||
|
"moderator": "Модератор",
|
||||||
|
"user": "Пользователь"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"greeting": "Привет, {{name}}!",
|
||||||
|
"wave": "Моя волна",
|
||||||
|
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"empty": "Ничего не играет",
|
||||||
|
"label": "Плеер",
|
||||||
|
"play": "Слушать",
|
||||||
|
"pause": "Пауза",
|
||||||
|
"next": "Следующий",
|
||||||
|
"prev": "Предыдущий",
|
||||||
|
"like": "Нравится",
|
||||||
|
"unlike": "Убрать из лайков",
|
||||||
|
"dislike": "Не нравится",
|
||||||
|
"undislike": "Убрать дизлайк",
|
||||||
|
"volume": "Громкость",
|
||||||
|
"mute": "Выключить звук",
|
||||||
|
"unmute": "Включить звук",
|
||||||
|
"shuffle": "Перемешать",
|
||||||
|
"repeat_off": "Повтор выключен",
|
||||||
|
"repeat_all": "Повторять очередь",
|
||||||
|
"repeat_one": "Повторять трек",
|
||||||
|
"progress": "Позиция",
|
||||||
|
"unavailable": "Трек недоступен — пропускаем",
|
||||||
|
"error": "Не удалось воспроизвести трек"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Настройки",
|
||||||
|
"account": "Аккаунт",
|
||||||
|
"display_name": "Отображаемое имя",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"saved": "Сохранено",
|
||||||
|
"appearance": "Внешний вид",
|
||||||
|
"theme": "Тема",
|
||||||
|
"theme_dark": "Тёмная",
|
||||||
|
"theme_light": "Светлая",
|
||||||
|
"theme_system": "Как в системе",
|
||||||
|
"password": "Пароль",
|
||||||
|
"current_password": "Текущий пароль",
|
||||||
|
"new_password": "Новый пароль",
|
||||||
|
"repeat_password": "Повторите пароль",
|
||||||
|
"change_password": "Сменить пароль",
|
||||||
|
"password_changed": "Пароль изменён. Другие устройства вышли из аккаунта.",
|
||||||
|
"sessions": "Устройства",
|
||||||
|
"session_current": "Это устройство",
|
||||||
|
"session_last_used": "Активность: {{date}}",
|
||||||
|
"session_end": "Завершить",
|
||||||
|
"logout_all": "Выйти на всех устройствах"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"show_password": "Показать пароль",
|
||||||
|
"hide_password": "Скрыть пароль",
|
||||||
|
"passwords_mismatch": "Пароли не совпадают",
|
||||||
|
"required": "Заполните поле",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"close": "Закрыть",
|
||||||
|
"copy": "Копировать",
|
||||||
|
"copied": "Скопировано"
|
||||||
|
},
|
||||||
|
"loading": "Загрузка…",
|
||||||
|
"route_error": {
|
||||||
|
"reload": "Обновить страницу"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Исполнители, альбомы, треки",
|
||||||
|
"label": "Поиск",
|
||||||
|
"artists": "Исполнители",
|
||||||
|
"albums": "Альбомы",
|
||||||
|
"tracks": "Треки",
|
||||||
|
"all": "Всё",
|
||||||
|
"nothing": "Ничего не нашли",
|
||||||
|
"corrected": "Показаны результаты для «{{query}}»",
|
||||||
|
"show_all": "Все результаты",
|
||||||
|
"title": "Поиск: {{query}}",
|
||||||
|
"more": "Показать ещё",
|
||||||
|
"artist_kind": "Исполнитель",
|
||||||
|
"hint": "Нажмите / для поиска"
|
||||||
|
},
|
||||||
|
"catalog": {
|
||||||
|
"albums": "Альбомы",
|
||||||
|
"appears_on": "Встречается на",
|
||||||
|
"album_count_one": "{{count}} альбом",
|
||||||
|
"album_count_few": "{{count}} альбома",
|
||||||
|
"album_count_many": "{{count}} альбомов",
|
||||||
|
"album_count_other": "{{count}} альбома",
|
||||||
|
"track_count_one": "{{count}} трек",
|
||||||
|
"track_count_few": "{{count}} трека",
|
||||||
|
"track_count_many": "{{count}} треков",
|
||||||
|
"track_count_other": "{{count}} трека",
|
||||||
|
"unavailable": "Недоступен: хранилище отключено или файл пропал",
|
||||||
|
"not_found": "Ничего не найдено — возможно, удалено при последнем скане",
|
||||||
|
"disc": "Диск {{n}}",
|
||||||
|
"col_title": "Название",
|
||||||
|
"col_album": "Альбом",
|
||||||
|
"col_duration": "Время",
|
||||||
|
"back_home": "На главную",
|
||||||
|
"artist": "Исполнитель",
|
||||||
|
"album": "Альбом"
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"title": "Коллекция",
|
||||||
|
"history": "История",
|
||||||
|
"likes": "Лайки",
|
||||||
|
"dislikes": "Дизлайки",
|
||||||
|
"clear_history": "Очистить историю",
|
||||||
|
"clear_confirm": "Очистить историю прослушиваний? Рекомендации её всё равно учтут.",
|
||||||
|
"today": "Сегодня",
|
||||||
|
"yesterday": "Вчера",
|
||||||
|
"empty_history": "Здесь появится то, что вы слушали",
|
||||||
|
"empty_likes": "Отмечайте треки сердечком — они соберутся здесь",
|
||||||
|
"empty_dislikes": "Треки с дизлайком не попадут в волну. Пока таких нет",
|
||||||
|
"play_all": "Слушать всё",
|
||||||
|
"more": "Показать ещё"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"internal": "Что-то пошло не так на сервере. Попробуйте ещё раз",
|
||||||
|
"bad_request": "Некорректный запрос",
|
||||||
|
"not_found": "Не найдено",
|
||||||
|
"method_not_allowed": "Действие не поддерживается",
|
||||||
|
"network": "Нет связи с сервером. Проверьте сеть"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"invalid_credentials": "Неверный логин или пароль",
|
||||||
|
"too_many_attempts": "Слишком много попыток входа. Подождите немного",
|
||||||
|
"user_blocked": "Учётная запись заблокирована. Обратитесь к администратору",
|
||||||
|
"unauthorized": "Войдите, чтобы продолжить",
|
||||||
|
"token_expired": "Сессия истекла, войдите снова",
|
||||||
|
"token_invalid": "Сессия недействительна, войдите снова",
|
||||||
|
"token_revoked": "Сессия завершена, войдите снова",
|
||||||
|
"refresh_invalid": "Сессия завершена, войдите снова",
|
||||||
|
"refresh_reused": "Сессия завершена из соображений безопасности, войдите снова",
|
||||||
|
"refresh_race": "Сессия обновляется, повторите действие",
|
||||||
|
"credentials_change_required": "Сначала смените логин и пароль администратора",
|
||||||
|
"password_change_required": "Сначала смените временный пароль",
|
||||||
|
"current_password_required": "Введите текущий пароль",
|
||||||
|
"current_password_invalid": "Текущий пароль указан неверно",
|
||||||
|
"bootstrap_not_required": "Учётные данные администратора уже изменены",
|
||||||
|
"session_not_found": "Сессия не найдена",
|
||||||
|
"forbidden": "Недостаточно прав для этого действия"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"password_too_short": "Пароль должен быть не короче {{min}} символов",
|
||||||
|
"password_too_long": "Пароль должен быть не длиннее {{max}} символов",
|
||||||
|
"password_same_as_username": "Пароль не должен совпадать с логином",
|
||||||
|
"password_too_common": "Этот пароль слишком распространён — придумайте другой",
|
||||||
|
"password_unchanged": "Новый пароль должен отличаться от текущего",
|
||||||
|
"username_invalid": "Логин: от {{min}} до {{max}} символов — латинские буквы, цифры, «.», «_», «-»",
|
||||||
|
"username_taken": "Этот логин уже занят",
|
||||||
|
"locale_unsupported": "Этот язык не поддерживается",
|
||||||
|
"display_name_too_long": "Имя должно быть не длиннее {{max}} символов",
|
||||||
|
"ui_prefs_invalid": "Недопустимое значение настройки",
|
||||||
|
"client_type_invalid": "Неизвестный тип приложения",
|
||||||
|
"role_invalid": "Неизвестная роль",
|
||||||
|
"cursor_invalid": "Список устарел, обновите страницу",
|
||||||
|
"pattern_invalid": "Ошибка в шаблоне: {{reason}}",
|
||||||
|
"nfs_invalid": "Недопустимое значение",
|
||||||
|
"local_path_invalid": "Папка не найдена",
|
||||||
|
"name_invalid": "Название — от 1 до {{max}} символов",
|
||||||
|
"scan_interval_invalid": "Интервал — не меньше {{min}} минут",
|
||||||
|
"source_type_invalid": "Неизвестный тип хранилища",
|
||||||
|
"query_empty": "Введите запрос"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"not_found": "Пользователь не найден",
|
||||||
|
"last_admin": "Нельзя понизить, заблокировать или удалить последнего администратора"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"source_not_found": "Хранилище не найдено",
|
||||||
|
"scan_running": "Скан уже идёт",
|
||||||
|
"source_offline": "Хранилище не подключено"
|
||||||
|
},
|
||||||
|
"catalog": {
|
||||||
|
"not_found": "Не найдено"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"unavailable": "Поиск временно недоступен"
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"link_invalid": "Ссылка устарела — обновите страницу",
|
||||||
|
"track_unavailable": "Трек недоступен",
|
||||||
|
"transcode_failed": "Не удалось подготовить трек"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import i18next, { type i18n as I18n } from 'i18next';
|
import i18next, { type i18n as I18n } from 'i18next';
|
||||||
|
|
||||||
|
import enAdmin from '../locales/en/admin.json';
|
||||||
|
import enAuth from '../locales/en/auth.json';
|
||||||
import enCommon from '../locales/en/common.json';
|
import enCommon from '../locales/en/common.json';
|
||||||
|
import enErrors from '../locales/en/errors.json';
|
||||||
|
import ruAdmin from '../locales/ru/admin.json';
|
||||||
|
import ruAuth from '../locales/ru/auth.json';
|
||||||
import ruCommon from '../locales/ru/common.json';
|
import ruCommon from '../locales/ru/common.json';
|
||||||
|
import ruErrors from '../locales/ru/errors.json';
|
||||||
|
|
||||||
/** Поддерживаемые языки. Новый язык = папка в locales/ + строка здесь. */
|
/** Поддерживаемые языки. Новый язык = папка в locales/ + строка здесь. */
|
||||||
export const SUPPORTED_LOCALES = ['ru', 'en'] as const;
|
export const SUPPORTED_LOCALES = ['ru', 'en'] as const;
|
||||||
@@ -9,8 +15,8 @@ export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
|||||||
export const DEFAULT_LOCALE: Locale = 'en';
|
export const DEFAULT_LOCALE: Locale = 'en';
|
||||||
|
|
||||||
export const resources = {
|
export const resources = {
|
||||||
ru: { common: ruCommon },
|
ru: { common: ruCommon, auth: ruAuth, admin: ruAdmin, errors: ruErrors },
|
||||||
en: { common: enCommon },
|
en: { common: enCommon, auth: enAuth, admin: enAdmin, errors: enErrors },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
declare module 'i18next' {
|
declare module 'i18next' {
|
||||||
@@ -38,7 +44,7 @@ export async function createI18n(locale: Locale): Promise<I18n> {
|
|||||||
fallbackLng: DEFAULT_LOCALE,
|
fallbackLng: DEFAULT_LOCALE,
|
||||||
supportedLngs: [...SUPPORTED_LOCALES],
|
supportedLngs: [...SUPPORTED_LOCALES],
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
ns: ['common'],
|
ns: ['common', 'auth', 'admin', 'errors'],
|
||||||
resources,
|
resources,
|
||||||
interpolation: { escapeValue: false },
|
interpolation: { escapeValue: false },
|
||||||
returnNull: false,
|
returnNull: false,
|
||||||
|
|||||||
Generated
+155
@@ -69,6 +69,9 @@ importers:
|
|||||||
'@fontsource/unbounded':
|
'@fontsource/unbounded':
|
||||||
specifier: ^5.2.6
|
specifier: ^5.2.6
|
||||||
version: 5.3.0
|
version: 5.3.0
|
||||||
|
'@material-symbols/svg-400':
|
||||||
|
specifier: ^0.31.9
|
||||||
|
version: 0.31.9
|
||||||
'@pipu/api-client':
|
'@pipu/api-client':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/api-client
|
version: link:../../packages/api-client
|
||||||
@@ -84,6 +87,9 @@ importers:
|
|||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.80.7
|
specifier: ^5.80.7
|
||||||
version: 5.104.4(@types/react@19.3.0)(react@19.3.1)
|
version: 5.104.4(@types/react@19.3.0)(react@19.3.1)
|
||||||
|
'@tanstack/react-router':
|
||||||
|
specifier: ^1.120.20
|
||||||
|
version: 1.120.20(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
react:
|
react:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.3.1
|
version: 19.3.1
|
||||||
@@ -97,9 +103,15 @@ importers:
|
|||||||
'@testing-library/dom':
|
'@testing-library/dom':
|
||||||
specifier: ^10.4.0
|
specifier: ^10.4.0
|
||||||
version: 10.4.2
|
version: 10.4.2
|
||||||
|
'@testing-library/jest-dom':
|
||||||
|
specifier: ^6.9.1
|
||||||
|
version: 6.9.1
|
||||||
'@testing-library/react':
|
'@testing-library/react':
|
||||||
specifier: ^16.3.0
|
specifier: ^16.3.0
|
||||||
version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
|
'@testing-library/user-event':
|
||||||
|
specifier: ^14.6.1
|
||||||
|
version: 14.6.1(@testing-library/dom@10.4.2)
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.1.8
|
specifier: ^19.1.8
|
||||||
version: 19.3.0
|
version: 19.3.0
|
||||||
@@ -157,6 +169,9 @@ packages:
|
|||||||
graphql:
|
graphql:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@adobe/css-tools@4.5.0':
|
||||||
|
resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
|
||||||
|
|
||||||
'@asamuzakjp/css-color@3.2.0':
|
'@asamuzakjp/css-color@3.2.0':
|
||||||
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
||||||
|
|
||||||
@@ -985,6 +1000,9 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@material-symbols/svg-400@0.31.9':
|
||||||
|
resolution: {integrity: sha512-LeQrY/qYdRdIlV6ZR+hUQDXjtkDYgP1LGnMQh/Grm6FsXz+6GR2Q+DrotEJjzDnmao/oHygHFGLnkKAR/PM1hQ==}
|
||||||
|
|
||||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
||||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||||
@@ -1215,6 +1233,10 @@ packages:
|
|||||||
'@sinonjs/fake-timers@10.3.0':
|
'@sinonjs/fake-timers@10.3.0':
|
||||||
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
|
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
|
||||||
|
|
||||||
|
'@tanstack/history@1.120.17':
|
||||||
|
resolution: {integrity: sha512-k07LFI4Qo074IIaWzT/XjD0KlkGx2w1V3fnNtclKx0oAl8z4O9kCh6za+FPEIRe98xLgNFEiddDbJeAYGSlPtw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
'@tanstack/query-core@5.104.4':
|
'@tanstack/query-core@5.104.4':
|
||||||
resolution: {integrity: sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==}
|
resolution: {integrity: sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==}
|
||||||
|
|
||||||
@@ -1227,10 +1249,34 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@tanstack/react-router@1.120.20':
|
||||||
|
resolution: {integrity: sha512-+zNruUE9NsfGm9cHd22Xs7FRtBrBhDZe94pB69BEIjqjrEPZct6f5VhTV9WQ+bDZ6fRz8tUuxNFAgm/3Lm4AIg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=18.0.0 || >=19.0.0'
|
||||||
|
react-dom: '>=18.0.0 || >=19.0.0'
|
||||||
|
|
||||||
|
'@tanstack/react-store@0.7.7':
|
||||||
|
resolution: {integrity: sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
'@tanstack/router-core@1.120.19':
|
||||||
|
resolution: {integrity: sha512-5JUVgkxnIM3NxMwzKt0tfz2UopZVxwq6Kl7Rp33zlFJaPjpiRs46VuRjVeAvkpJd6samo1gcH1rWqmnPUmtGcw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@tanstack/store@0.7.7':
|
||||||
|
resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==}
|
||||||
|
|
||||||
'@testing-library/dom@10.4.2':
|
'@testing-library/dom@10.4.2':
|
||||||
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
|
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@testing-library/jest-dom@6.9.1':
|
||||||
|
resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
|
||||||
|
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
|
||||||
|
|
||||||
'@testing-library/react@16.3.3':
|
'@testing-library/react@16.3.3':
|
||||||
resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==}
|
resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1246,6 +1292,12 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@testing-library/user-event@14.6.1':
|
||||||
|
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
|
||||||
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
|
peerDependencies:
|
||||||
|
'@testing-library/dom': '>=7.21.4'
|
||||||
|
|
||||||
'@types/aria-query@5.0.4':
|
'@types/aria-query@5.0.4':
|
||||||
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
||||||
|
|
||||||
@@ -1705,6 +1757,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
|
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
css.escape@1.5.1:
|
||||||
|
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
|
||||||
|
|
||||||
cssstyle@4.6.0:
|
cssstyle@4.6.0:
|
||||||
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1783,6 +1838,9 @@ packages:
|
|||||||
dom-accessibility-api@0.5.16:
|
dom-accessibility-api@0.5.16:
|
||||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||||
|
|
||||||
|
dom-accessibility-api@0.6.3:
|
||||||
|
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||||
|
|
||||||
dotenv-expand@11.0.7:
|
dotenv-expand@11.0.7:
|
||||||
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
|
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2121,6 +2179,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||||
engines: {node: '>=0.8.19'}
|
engines: {node: '>=0.8.19'}
|
||||||
|
|
||||||
|
indent-string@4.0.0:
|
||||||
|
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
index-to-position@1.2.0:
|
index-to-position@1.2.0:
|
||||||
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
|
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2487,6 +2549,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==}
|
resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
min-indent@1.0.1:
|
||||||
|
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
minimatch@3.1.5:
|
minimatch@3.1.5:
|
||||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||||
|
|
||||||
@@ -2838,6 +2904,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
|
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
redent@3.0.0:
|
||||||
|
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
regenerate-unicode-properties@10.3.0:
|
regenerate-unicode-properties@10.3.0:
|
||||||
resolution: {integrity: sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==}
|
resolution: {integrity: sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -3058,6 +3128,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
strip-indent@3.0.0:
|
||||||
|
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
strip-json-comments@2.0.1:
|
strip-json-comments@2.0.1:
|
||||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3131,6 +3205,12 @@ packages:
|
|||||||
throat@5.0.0:
|
throat@5.0.0:
|
||||||
resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==}
|
resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==}
|
||||||
|
|
||||||
|
tiny-invariant@1.3.3:
|
||||||
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
|
tiny-warning@1.0.3:
|
||||||
|
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
|
||||||
|
|
||||||
tinybench@2.9.0:
|
tinybench@2.9.0:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
@@ -3277,6 +3357,11 @@ packages:
|
|||||||
uri-js-replace@1.0.1:
|
uri-js-replace@1.0.1:
|
||||||
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
|
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
|
||||||
|
|
||||||
|
use-sync-external-store@1.7.0:
|
||||||
|
resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
utils-merge@1.0.1:
|
utils-merge@1.0.1:
|
||||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||||
engines: {node: '>= 0.4.0'}
|
engines: {node: '>= 0.4.0'}
|
||||||
@@ -3528,6 +3613,8 @@ snapshots:
|
|||||||
|
|
||||||
'@0no-co/graphql.web@1.3.4': {}
|
'@0no-co/graphql.web@1.3.4': {}
|
||||||
|
|
||||||
|
'@adobe/css-tools@4.5.0': {}
|
||||||
|
|
||||||
'@asamuzakjp/css-color@3.2.0':
|
'@asamuzakjp/css-color@3.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||||
@@ -4590,6 +4677,8 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.6.0
|
'@jridgewell/sourcemap-codec': 1.6.0
|
||||||
|
|
||||||
|
'@material-symbols/svg-400@0.31.9': {}
|
||||||
|
|
||||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4826,6 +4915,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@sinonjs/commons': 3.0.1
|
'@sinonjs/commons': 3.0.1
|
||||||
|
|
||||||
|
'@tanstack/history@1.120.17': {}
|
||||||
|
|
||||||
'@tanstack/query-core@5.104.4': {}
|
'@tanstack/query-core@5.104.4': {}
|
||||||
|
|
||||||
'@tanstack/react-query@5.104.4(@types/react@19.3.0)(react@19.3.1)':
|
'@tanstack/react-query@5.104.4(@types/react@19.3.0)(react@19.3.1)':
|
||||||
@@ -4835,6 +4926,32 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.3.0
|
'@types/react': 19.3.0
|
||||||
|
|
||||||
|
'@tanstack/react-router@1.120.20(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/history': 1.120.17
|
||||||
|
'@tanstack/react-store': 0.7.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
|
'@tanstack/router-core': 1.120.19
|
||||||
|
jsesc: 3.1.0
|
||||||
|
react: 19.3.1
|
||||||
|
react-dom: 19.3.1(react@19.3.1)
|
||||||
|
tiny-invariant: 1.3.3
|
||||||
|
tiny-warning: 1.0.3
|
||||||
|
|
||||||
|
'@tanstack/react-store@0.7.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/store': 0.7.7
|
||||||
|
react: 19.3.1
|
||||||
|
react-dom: 19.3.1(react@19.3.1)
|
||||||
|
use-sync-external-store: 1.7.0(react@19.3.1)
|
||||||
|
|
||||||
|
'@tanstack/router-core@1.120.19':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/history': 1.120.17
|
||||||
|
'@tanstack/store': 0.7.7
|
||||||
|
tiny-invariant: 1.3.3
|
||||||
|
|
||||||
|
'@tanstack/store@0.7.7': {}
|
||||||
|
|
||||||
'@testing-library/dom@10.4.2':
|
'@testing-library/dom@10.4.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
@@ -4846,6 +4963,15 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
pretty-format: 27.5.1
|
pretty-format: 27.5.1
|
||||||
|
|
||||||
|
'@testing-library/jest-dom@6.9.1':
|
||||||
|
dependencies:
|
||||||
|
'@adobe/css-tools': 4.5.0
|
||||||
|
aria-query: 5.3.0
|
||||||
|
css.escape: 1.5.1
|
||||||
|
dom-accessibility-api: 0.6.3
|
||||||
|
picocolors: 1.1.1
|
||||||
|
redent: 3.0.0
|
||||||
|
|
||||||
'@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
'@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.10
|
'@babel/runtime': 7.29.10
|
||||||
@@ -4856,6 +4982,10 @@ snapshots:
|
|||||||
'@types/react': 19.3.0
|
'@types/react': 19.3.0
|
||||||
'@types/react-dom': 19.3.0(@types/react@19.3.0)
|
'@types/react-dom': 19.3.0(@types/react@19.3.0)
|
||||||
|
|
||||||
|
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.2)':
|
||||||
|
dependencies:
|
||||||
|
'@testing-library/dom': 10.4.2
|
||||||
|
|
||||||
'@types/aria-query@5.0.4': {}
|
'@types/aria-query@5.0.4': {}
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
@@ -5395,6 +5525,8 @@ snapshots:
|
|||||||
|
|
||||||
crypto-random-string@2.0.0: {}
|
crypto-random-string@2.0.0: {}
|
||||||
|
|
||||||
|
css.escape@1.5.1: {}
|
||||||
|
|
||||||
cssstyle@4.6.0:
|
cssstyle@4.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@asamuzakjp/css-color': 3.2.0
|
'@asamuzakjp/css-color': 3.2.0
|
||||||
@@ -5445,6 +5577,8 @@ snapshots:
|
|||||||
|
|
||||||
dom-accessibility-api@0.5.16: {}
|
dom-accessibility-api@0.5.16: {}
|
||||||
|
|
||||||
|
dom-accessibility-api@0.6.3: {}
|
||||||
|
|
||||||
dotenv-expand@11.0.7:
|
dotenv-expand@11.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
dotenv: 16.4.7
|
dotenv: 16.4.7
|
||||||
@@ -5792,6 +5926,8 @@ snapshots:
|
|||||||
|
|
||||||
imurmurhash@0.1.4: {}
|
imurmurhash@0.1.4: {}
|
||||||
|
|
||||||
|
indent-string@4.0.0: {}
|
||||||
|
|
||||||
index-to-position@1.2.0: {}
|
index-to-position@1.2.0: {}
|
||||||
|
|
||||||
inflight@1.0.6:
|
inflight@1.0.6:
|
||||||
@@ -6269,6 +6405,8 @@ snapshots:
|
|||||||
|
|
||||||
mimic-fn@1.2.0: {}
|
mimic-fn@1.2.0: {}
|
||||||
|
|
||||||
|
min-indent@1.0.1: {}
|
||||||
|
|
||||||
minimatch@3.1.5:
|
minimatch@3.1.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 1.1.21
|
brace-expansion: 1.1.21
|
||||||
@@ -6625,6 +6763,11 @@ snapshots:
|
|||||||
|
|
||||||
react@19.3.1: {}
|
react@19.3.1: {}
|
||||||
|
|
||||||
|
redent@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
indent-string: 4.0.0
|
||||||
|
strip-indent: 3.0.0
|
||||||
|
|
||||||
regenerate-unicode-properties@10.3.0:
|
regenerate-unicode-properties@10.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
regenerate: 1.5.0
|
regenerate: 1.5.0
|
||||||
@@ -6852,6 +6995,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex: 6.4.0
|
ansi-regex: 6.4.0
|
||||||
|
|
||||||
|
strip-indent@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
min-indent: 1.0.1
|
||||||
|
|
||||||
strip-json-comments@2.0.1: {}
|
strip-json-comments@2.0.1: {}
|
||||||
|
|
||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
@@ -6931,6 +7078,10 @@ snapshots:
|
|||||||
|
|
||||||
throat@5.0.0: {}
|
throat@5.0.0: {}
|
||||||
|
|
||||||
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
|
tiny-warning@1.0.3: {}
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinyexec@0.3.2: {}
|
tinyexec@0.3.2: {}
|
||||||
@@ -7036,6 +7187,10 @@ snapshots:
|
|||||||
|
|
||||||
uri-js-replace@1.0.1: {}
|
uri-js-replace@1.0.1: {}
|
||||||
|
|
||||||
|
use-sync-external-store@1.7.0(react@19.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 19.3.1
|
||||||
|
|
||||||
utils-merge@1.0.1: {}
|
utils-merge@1.0.1: {}
|
||||||
|
|
||||||
uuid@7.0.3: {}
|
uuid@7.0.3: {}
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache
|
|||||||
go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/api ./cmd/api && \
|
go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/api ./cmd/api && \
|
||||||
go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/storage-agent ./cmd/storage-agent
|
go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/storage-agent ./cmd/storage-agent
|
||||||
|
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot AS api
|
# api вызывает ffprobe (сканер) и ffmpeg (транскодинг, M3) — нужен не distroless, а alpine с ffmpeg.
|
||||||
|
FROM alpine:3.22 AS api
|
||||||
|
RUN apk add --no-cache ffmpeg ca-certificates tzdata
|
||||||
COPY --from=build /out/api /usr/local/bin/api
|
COPY --from=build /out/api /usr/local/bin/api
|
||||||
|
USER 65532:65532
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/usr/local/bin/api"]
|
ENTRYPOINT ["/usr/local/bin/api"]
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,29 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
"pipu/api/internal/auth"
|
||||||
"pipu/api/internal/buildinfo"
|
"pipu/api/internal/buildinfo"
|
||||||
|
"pipu/api/internal/library"
|
||||||
"pipu/api/internal/platform/config"
|
"pipu/api/internal/platform/config"
|
||||||
"pipu/api/internal/platform/db"
|
"pipu/api/internal/platform/db"
|
||||||
"pipu/api/internal/platform/httpx"
|
"pipu/api/internal/platform/httpx"
|
||||||
|
"pipu/api/internal/platform/sign"
|
||||||
|
"pipu/api/internal/playback"
|
||||||
|
"pipu/api/internal/search"
|
||||||
"pipu/api/internal/server"
|
"pipu/api/internal/server"
|
||||||
"pipu/api/internal/system"
|
"pipu/api/internal/system"
|
||||||
)
|
)
|
||||||
@@ -56,16 +68,118 @@ func run() error {
|
|||||||
rdb := redis.NewClient(redisOpts)
|
rdb := redis.NewClient(redisOpts)
|
||||||
defer func() { _ = rdb.Close() }()
|
defer func() { _ = rdb.Close() }()
|
||||||
|
|
||||||
|
authSvc, err := newAuthService(cfg, pool, rdb, log)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := authSvc.Bootstrap(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
sys := system.NewHandler(system.Info{
|
sys := system.NewHandler(system.Info{
|
||||||
Version: buildinfo.Version,
|
Version: buildinfo.Version,
|
||||||
SupportedLocales: cfg.SupportedLocales,
|
SupportedLocales: cfg.SupportedLocales,
|
||||||
MinAndroidVersionCode: cfg.MinAndroidVersionCode,
|
MinAndroidVersionCode: cfg.MinAndroidVersionCode,
|
||||||
|
BootstrapRequired: authSvc.BootstrapRequired,
|
||||||
}, log, map[string]system.Checker{
|
}, log, map[string]system.Checker{
|
||||||
"postgres": system.CheckerFunc(pool.Ping),
|
"postgres": system.CheckerFunc(pool.Ping),
|
||||||
"redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }),
|
"redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }),
|
||||||
|
"search": system.CheckerFunc(search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey).Healthy),
|
||||||
})
|
})
|
||||||
|
|
||||||
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{Log: log, System: sys}))
|
libStore := library.NewStore(pool)
|
||||||
|
prober := library.FFprobe{Bin: cfg.FFprobePath, Timeout: time.Minute}
|
||||||
|
board := library.NewProgressBoard()
|
||||||
|
scanner := library.NewScanner(libStore, prober, board, log)
|
||||||
|
libSvc := library.NewService(libStore, scanner, board, library.NewHTTPAgent(cfg.StorageAgentURL, cfg.InternalToken),
|
||||||
|
prober, library.Config{LibraryRoot: cfg.LibraryRoot, HostRoot: cfg.LibraryHostRoot}, log)
|
||||||
|
meili := search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey)
|
||||||
|
indexer := search.NewIndexer(pool, meili, log)
|
||||||
|
signer, err := sign.New(cfg.SigningKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.SigningKey == "" {
|
||||||
|
log.Warn("STREAM_SIGNING_KEY is not set: cover and stream links will break on restart")
|
||||||
|
}
|
||||||
|
covers := library.NewCovers(libStore, libSvc, cfg.FFmpegPath, filepath.Join(cfg.CacheDir, "covers"), log)
|
||||||
|
scanner.AfterScan = func(ctx context.Context) {
|
||||||
|
covers.Run(ctx)
|
||||||
|
indexer.Trigger()
|
||||||
|
}
|
||||||
|
go covers.Run(ctx) // альбомы, оставшиеся без обложки с прошлого запуска
|
||||||
|
|
||||||
|
concurrency := cfg.TranscodeConcurrency
|
||||||
|
if concurrency <= 0 {
|
||||||
|
concurrency = max(1, runtime.NumCPU()/2)
|
||||||
|
}
|
||||||
|
transcoder := playback.NewTranscoder(cfg.FFmpegPath, filepath.Join(cfg.CacheDir, "transcode"), concurrency,
|
||||||
|
cfg.TranscodeCacheLimitGB, log)
|
||||||
|
go transcoder.RunCleanup(ctx)
|
||||||
|
rootFor := func(ctx context.Context, sourceID uuid.UUID) (string, error) {
|
||||||
|
src, err := libStore.GetSource(ctx, sourceID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return libSvc.Root(src)
|
||||||
|
}
|
||||||
|
var dto library.DTO
|
||||||
|
pb := playback.NewHandler(playback.NewStore(pool), libStore, func() library.DTO { return dto }, signer, transcoder,
|
||||||
|
rootFor, cfg.XAccel, log)
|
||||||
|
dto = library.DTO{Signer: signer, Personal: pb}
|
||||||
|
libSvc.OnCatalogChanged = indexer.Trigger
|
||||||
|
indexer.Trigger() // индекс мог отстать (новая версия, сбой Meilisearch) — пересобираем при старте
|
||||||
|
go libSvc.Restore(ctx)
|
||||||
|
go libSvc.RunSchedule(ctx)
|
||||||
|
|
||||||
|
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{
|
||||||
|
Log: log,
|
||||||
|
System: sys,
|
||||||
|
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
|
||||||
|
AuthService: authSvc,
|
||||||
|
Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
|
||||||
|
Search: search.NewHandler(meili, libStore, indexer, dto, log),
|
||||||
|
Playback: pb,
|
||||||
|
}))
|
||||||
log.Info("starting", "version", buildinfo.Version)
|
log.Info("starting", "version", buildinfo.Version)
|
||||||
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
|
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newAuthService(cfg config.API, pool *pgxpool.Pool, rdb *redis.Client, log *slog.Logger) (*auth.Service, error) {
|
||||||
|
var (
|
||||||
|
key ed25519.PrivateKey
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if cfg.JWTPrivateKey == "" {
|
||||||
|
log.Warn("JWT_PRIVATE_KEY is not set: using a random key, access tokens will not survive a restart")
|
||||||
|
key, err = auth.GenerateKey()
|
||||||
|
} else {
|
||||||
|
key, err = auth.ParsePrivateKey(cfg.JWTPrivateKey)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var previous []ed25519.PrivateKey
|
||||||
|
for _, s := range cfg.JWTPreviousKeys {
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, err := auth.ParsePrivateKey(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("JWT_PREVIOUS_KEYS: %w", err)
|
||||||
|
}
|
||||||
|
previous = append(previous, k)
|
||||||
|
}
|
||||||
|
clock := auth.SystemClock{}
|
||||||
|
acfg := auth.DefaultConfig()
|
||||||
|
acfg.BootstrapUsername = cfg.BootstrapAdminUsername
|
||||||
|
acfg.BootstrapPassword = cfg.BootstrapAdminPassword
|
||||||
|
acfg.SupportedLocales = cfg.SupportedLocales
|
||||||
|
return auth.NewService(
|
||||||
|
auth.NewPgStore(pool),
|
||||||
|
auth.NewHasher(auth.DefaultArgon2Params),
|
||||||
|
auth.NewTokenIssuer(key, previous, cfg.AccessTokenTTL, clock.Now),
|
||||||
|
auth.NewRedisLimiter(rdb),
|
||||||
|
clock, auth.UUIDv7{}, log, acfg,
|
||||||
|
)
|
||||||
|
}
|
||||||
+5
-1
@@ -8,14 +8,18 @@ require (
|
|||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
github.com/getkin/kin-openapi v0.127.0
|
github.com/getkin/kin-openapi v0.127.0
|
||||||
github.com/go-chi/chi/v5 v5.2.2
|
github.com/go-chi/chi/v5 v5.2.2
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.7.5
|
github.com/jackc/pgx/v5 v5.7.5
|
||||||
github.com/oapi-codegen/runtime v1.1.1
|
github.com/oapi-codegen/runtime v1.1.1
|
||||||
github.com/pressly/goose/v3 v3.24.3
|
github.com/pressly/goose/v3 v3.24.3
|
||||||
github.com/redis/go-redis/v9 v9.10.0
|
github.com/redis/go-redis/v9 v9.10.0
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
|
golang.org/x/crypto v0.38.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
@@ -37,9 +41,9 @@ require (
|
|||||||
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
|
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/crypto v0.38.0 // indirect
|
|
||||||
golang.org/x/mod v0.17.0 // indirect
|
golang.org/x/mod v0.17.0 // indirect
|
||||||
golang.org/x/sync v0.14.0 // indirect
|
golang.org/x/sync v0.14.0 // indirect
|
||||||
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
golang.org/x/text v0.25.0 // indirect
|
golang.org/x/text v0.25.0 // indirect
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
|
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
@@ -33,6 +37,8 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ
|
|||||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
@@ -65,6 +71,7 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
|||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -120,6 +127,7 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah
|
|||||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||||
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
|
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
|
||||||
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
|
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
|
||||||
|
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,341 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"pipu/api/internal/platform/apperr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Коды ошибок админки пользователей.
|
||||||
|
const (
|
||||||
|
CodeForbidden = "auth.forbidden"
|
||||||
|
CodeUserNotFound = "users.not_found"
|
||||||
|
CodeLastAdmin = "users.last_admin"
|
||||||
|
CodeRoleInvalid = "validation.role_invalid"
|
||||||
|
CodeCursorBad = "validation.cursor_invalid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Длина временного пароля (создание пользователя, сброс).
|
||||||
|
const tempPasswordLen = 12
|
||||||
|
|
||||||
|
// Пределы размера страницы.
|
||||||
|
const (
|
||||||
|
defaultPageSize = 50
|
||||||
|
maxPageSize = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
func forbidden() error { return apperr.Forbidden(CodeForbidden, "not allowed") }
|
||||||
|
|
||||||
|
func pageSize(limit *int) int {
|
||||||
|
if limit == nil || *limit <= 0 {
|
||||||
|
return defaultPageSize
|
||||||
|
}
|
||||||
|
return min(*limit, maxPageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func actorOf(p Principal) Actor { return Actor{ID: p.UserID, Role: p.Role} }
|
||||||
|
|
||||||
|
func actorOfUser(u User) Actor { return Actor{ID: u.ID, Role: u.Role} }
|
||||||
|
|
||||||
|
// ListUsers — страница пользователей для админки (admin, moderator).
|
||||||
|
func (s *Service) ListUsers(ctx context.Context, p Principal, query string, role *Role, cursor string, limit *int,
|
||||||
|
) ([]User, string, error) {
|
||||||
|
if !Can(p.Role, PermListUsers) {
|
||||||
|
return nil, "", forbidden()
|
||||||
|
}
|
||||||
|
if role != nil && !role.Valid() {
|
||||||
|
return nil, "", apperr.Validation("role", CodeRoleInvalid, "unknown role", nil)
|
||||||
|
}
|
||||||
|
after := ""
|
||||||
|
if cursor != "" {
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", apperr.Validation("cursor", CodeCursorBad, "malformed cursor", nil)
|
||||||
|
}
|
||||||
|
after = string(raw)
|
||||||
|
}
|
||||||
|
n := pageSize(limit)
|
||||||
|
users, err := s.store.ListUsers(ctx, UserFilter{Query: strings.TrimSpace(query), Role: role, After: after, Limit: n + 1})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
next := ""
|
||||||
|
if len(users) > n {
|
||||||
|
users = users[:n]
|
||||||
|
next = base64.RawURLEncoding.EncodeToString([]byte(users[n-1].Username))
|
||||||
|
}
|
||||||
|
return users, next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUserInput — форма создания пользователя.
|
||||||
|
type CreateUserInput struct {
|
||||||
|
Username string
|
||||||
|
DisplayName string
|
||||||
|
Role Role
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser создаёт пользователя с временным паролем, который возвращается один раз.
|
||||||
|
func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInput) (User, string, error) {
|
||||||
|
if !in.Role.Valid() {
|
||||||
|
return User{}, "", apperr.Validation("role", CodeRoleInvalid, "unknown role", nil)
|
||||||
|
}
|
||||||
|
if !CanAssignRole(p.Role, in.Role) || !CanManageUser(actorOf(p), Actor{Role: in.Role}, UserCreate) {
|
||||||
|
return User{}, "", forbidden()
|
||||||
|
}
|
||||||
|
username := NormalizeUsername(in.Username)
|
||||||
|
if err := ValidateUsername("username", username); err != nil {
|
||||||
|
return User{}, "", err
|
||||||
|
}
|
||||||
|
name, err := displayName(in.DisplayName)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, "", err
|
||||||
|
}
|
||||||
|
password, err := GeneratePassword(tempPasswordLen)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, "", err
|
||||||
|
}
|
||||||
|
hash, err := s.hasher.Hash(password)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, "", err
|
||||||
|
}
|
||||||
|
locale := "ru"
|
||||||
|
if len(s.cfg.SupportedLocales) > 0 {
|
||||||
|
locale = s.cfg.SupportedLocales[0]
|
||||||
|
}
|
||||||
|
u := User{
|
||||||
|
ID: s.ids.New(), Username: username, DisplayName: name, PasswordHash: hash, Role: in.Role, Locale: locale,
|
||||||
|
MustChangePassword: true, CreatedAt: s.clock.Now(),
|
||||||
|
}
|
||||||
|
err = s.store.WithTx(ctx, func(tx Store) error {
|
||||||
|
if err := tx.CreateUser(ctx, u, &p.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
||||||
|
ActorID: &p.UserID, Action: "user.create", TargetType: "user", TargetID: &u.ID,
|
||||||
|
Payload: map[string]any{"username": username, "role": string(in.Role)},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if errors.Is(err, ErrUsernameTaken) {
|
||||||
|
return User{}, "", apperr.Validation("username", CodeUsernameTaken, "username is taken", nil)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return User{}, "", err
|
||||||
|
}
|
||||||
|
return u, password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayName обрезает пробелы и проверяет длину; пустое имя — nil.
|
||||||
|
func displayName(s string) (*string, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if utf8.RuneCountInString(s) > MaxDisplayName {
|
||||||
|
return nil, apperr.Validation("display_name", CodeDisplayNameTooLong, "display name too long",
|
||||||
|
map[string]any{"max": MaxDisplayName})
|
||||||
|
}
|
||||||
|
if s == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserPatch — изменения пользователя из админки. nil — не менять.
|
||||||
|
type UserPatch struct {
|
||||||
|
DisplayName *string
|
||||||
|
Role *Role
|
||||||
|
IsBlocked *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser меняет имя, роль и блокировку с проверкой прав по каждому изменению.
|
||||||
|
func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, patch UserPatch) (User, error) {
|
||||||
|
if patch.Role != nil && !patch.Role.Valid() {
|
||||||
|
return User{}, apperr.Validation("role", CodeRoleInvalid, "unknown role", nil)
|
||||||
|
}
|
||||||
|
var out User
|
||||||
|
err := s.store.WithTx(ctx, func(tx Store) error {
|
||||||
|
target, err := s.target(ctx, tx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
actor, tgt := actorOf(p), actorOfUser(target)
|
||||||
|
var (
|
||||||
|
upd UserUpdate
|
||||||
|
audits []AuditEntry
|
||||||
|
)
|
||||||
|
audit := func(action string, payload map[string]any) {
|
||||||
|
audits = append(audits, AuditEntry{
|
||||||
|
ActorID: &p.UserID, Action: action, TargetType: "user", TargetID: &target.ID, Payload: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if patch.DisplayName != nil {
|
||||||
|
if !CanManageUser(actor, tgt, UserEdit) {
|
||||||
|
return forbidden()
|
||||||
|
}
|
||||||
|
name, err := displayName(*patch.DisplayName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
upd.DisplayName = &name
|
||||||
|
audit("user.rename", map[string]any{"display_name": name})
|
||||||
|
}
|
||||||
|
roleChange := patch.Role != nil && *patch.Role != target.Role
|
||||||
|
if roleChange {
|
||||||
|
if !CanManageUser(actor, tgt, UserChangeRole) || !CanAssignRole(p.Role, *patch.Role) {
|
||||||
|
return forbidden()
|
||||||
|
}
|
||||||
|
upd.Role, upd.RevokeTokens = patch.Role, true
|
||||||
|
audit("user.role_change", map[string]any{"from": string(target.Role), "to": string(*patch.Role)})
|
||||||
|
}
|
||||||
|
blockChange := patch.IsBlocked != nil && *patch.IsBlocked != target.IsBlocked
|
||||||
|
if blockChange {
|
||||||
|
if !CanManageUser(actor, tgt, UserBlock) {
|
||||||
|
return forbidden()
|
||||||
|
}
|
||||||
|
upd.IsBlocked, upd.RevokeTokens = patch.IsBlocked, true
|
||||||
|
if *patch.IsBlocked {
|
||||||
|
audit("user.block", nil)
|
||||||
|
} else {
|
||||||
|
audit("user.unblock", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(audits) == 0 {
|
||||||
|
// Пустое изменение: отвечаем текущим состоянием, но только тем, кому оно доступно.
|
||||||
|
if !CanManageUser(actor, tgt, UserEdit) {
|
||||||
|
return forbidden()
|
||||||
|
}
|
||||||
|
out = target
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
losesAdmin := target.Role == RoleAdmin && !target.IsBlocked &&
|
||||||
|
((roleChange && *patch.Role != RoleAdmin) || (blockChange && *patch.IsBlocked))
|
||||||
|
if losesAdmin {
|
||||||
|
if err := s.ensureNotLastAdmin(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out, err = tx.UpdateUser(ctx, target.ID, upd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if blockChange && *patch.IsBlocked {
|
||||||
|
if err := tx.RevokeUserSessions(ctx, target.ID, s.clock.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range audits {
|
||||||
|
if err := tx.InsertAudit(ctx, s.ids.New(), a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) target(ctx context.Context, st Store, id uuid.UUID) (User, error) {
|
||||||
|
u, err := st.GetUserByID(ctx, id)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return User{}, apperr.NotFound(CodeUserNotFound, "user not found")
|
||||||
|
}
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureNotLastAdmin(ctx context.Context, tx Store) error {
|
||||||
|
n, err := tx.LockActiveAdmins(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n <= 1 {
|
||||||
|
return apperr.Conflict(CodeLastAdmin, "the last active admin cannot be demoted, blocked or deleted")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword выдаёт новый временный пароль и завершает все сессии пользователя.
|
||||||
|
func (s *Service) ResetPassword(ctx context.Context, p Principal, id uuid.UUID) (string, error) {
|
||||||
|
password, err := GeneratePassword(tempPasswordLen)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
hash, err := s.hasher.Hash(password)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
err = s.store.WithTx(ctx, func(tx Store) error {
|
||||||
|
target, err := s.target(ctx, tx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !CanManageUser(actorOf(p), actorOfUser(target), UserResetPassword) {
|
||||||
|
return forbidden()
|
||||||
|
}
|
||||||
|
if err := tx.ResetPassword(ctx, target.ID, hash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.RevokeUserSessions(ctx, target.ID, s.clock.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
||||||
|
ActorID: &p.UserID, Action: "user.password_reset", TargetType: "user", TargetID: &target.ID,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser удаляет пользователя (только админ, не себя и не последнего админа).
|
||||||
|
func (s *Service) DeleteUser(ctx context.Context, p Principal, id uuid.UUID) error {
|
||||||
|
return s.store.WithTx(ctx, func(tx Store) error {
|
||||||
|
target, err := s.target(ctx, tx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !CanManageUser(actorOf(p), actorOfUser(target), UserDelete) {
|
||||||
|
return forbidden()
|
||||||
|
}
|
||||||
|
if target.Role == RoleAdmin && !target.IsBlocked {
|
||||||
|
if err := s.ensureNotLastAdmin(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
||||||
|
ActorID: &p.UserID, Action: "user.delete", TargetType: "user", TargetID: &target.ID,
|
||||||
|
Payload: map[string]any{"username": target.Username, "role": string(target.Role)},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.DeleteUser(ctx, target.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAudit — страница журнала действий (только админ).
|
||||||
|
func (s *Service) ListAudit(ctx context.Context, p Principal, cursor string, limit *int) ([]AuditRecord, string, error) {
|
||||||
|
if !Can(p.Role, PermSystemStatus) {
|
||||||
|
return nil, "", forbidden()
|
||||||
|
}
|
||||||
|
before := uuid.Nil
|
||||||
|
if cursor != "" {
|
||||||
|
var err error
|
||||||
|
if before, err = uuid.Parse(cursor); err != nil {
|
||||||
|
return nil, "", apperr.Validation("cursor", CodeCursorBad, "malformed cursor", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := pageSize(limit)
|
||||||
|
records, err := s.store.ListAudit(ctx, before, n+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
next := ""
|
||||||
|
if len(records) > n {
|
||||||
|
records = records[:n]
|
||||||
|
next = records[n-1].ID.String()
|
||||||
|
}
|
||||||
|
return records, next, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"pipu/api/internal/api/gen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListUsers реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) ListUsers(ctx context.Context, req gen.ListUsersRequestObject) (gen.ListUsersResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
q, cursor string
|
||||||
|
role *Role
|
||||||
|
)
|
||||||
|
if req.Params.Q != nil {
|
||||||
|
q = *req.Params.Q
|
||||||
|
}
|
||||||
|
if req.Params.Cursor != nil {
|
||||||
|
cursor = *req.Params.Cursor
|
||||||
|
}
|
||||||
|
if req.Params.Role != nil {
|
||||||
|
r := Role(*req.Params.Role)
|
||||||
|
role = &r
|
||||||
|
}
|
||||||
|
users, next, err := h.svc.ListUsers(ctx, p, q, role, cursor, req.Params.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]gen.AdminUser, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
items = append(items, AdminUserDTO(u))
|
||||||
|
}
|
||||||
|
return gen.ListUsers200JSONResponse{Items: items, NextCursor: optional(next)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) CreateUser(ctx context.Context, req gen.CreateUserRequestObject) (gen.CreateUserResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
in := CreateUserInput{Username: req.Body.Username, Role: Role(req.Body.Role)}
|
||||||
|
if req.Body.DisplayName != nil {
|
||||||
|
in.DisplayName = *req.Body.DisplayName
|
||||||
|
}
|
||||||
|
u, password, err := h.svc.CreateUser(ctx, p, in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.CreateUser201JSONResponse{User: AdminUserDTO(u), TemporaryPassword: password}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) UpdateUser(ctx context.Context, req gen.UpdateUserRequestObject) (gen.UpdateUserResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
patch := UserPatch{DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked}
|
||||||
|
if req.Body.Role != nil {
|
||||||
|
r := Role(*req.Body.Role)
|
||||||
|
patch.Role = &r
|
||||||
|
}
|
||||||
|
u, err := h.svc.UpdateUser(ctx, p, req.Id, patch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.UpdateUser200JSONResponse(AdminUserDTO(u)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) DeleteUser(ctx context.Context, req gen.DeleteUserRequestObject) (gen.DeleteUserResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteUser(ctx, p, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.DeleteUser204Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetUserPassword реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) ResetUserPassword(ctx context.Context, req gen.ResetUserPasswordRequestObject) (gen.ResetUserPasswordResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
password, err := h.svc.ResetPassword(ctx, p, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.ResetUserPassword200JSONResponse{TemporaryPassword: password}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAudit реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) ListAudit(ctx context.Context, req gen.ListAuditRequestObject) (gen.ListAuditResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cursor := ""
|
||||||
|
if req.Params.Cursor != nil {
|
||||||
|
cursor = *req.Params.Cursor
|
||||||
|
}
|
||||||
|
records, next, err := h.svc.ListAudit(ctx, p, cursor, req.Params.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]gen.AuditEntry, 0, len(records))
|
||||||
|
for _, r := range records {
|
||||||
|
e := gen.AuditEntry{
|
||||||
|
Id: r.ID, Action: r.Action, TargetType: r.TargetType, TargetId: r.TargetID, CreatedAt: r.CreatedAt,
|
||||||
|
}
|
||||||
|
if r.ActorID != nil && r.ActorUsername != nil {
|
||||||
|
e.Actor = &gen.AuditActor{Id: *r.ActorID, Username: *r.ActorUsername}
|
||||||
|
}
|
||||||
|
if r.Payload != nil {
|
||||||
|
payload := r.Payload
|
||||||
|
e.Payload = &payload
|
||||||
|
}
|
||||||
|
items = append(items, e)
|
||||||
|
}
|
||||||
|
return gen.ListAudit200JSONResponse{Items: items, NextCursor: optional(next)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUserDTO переводит пользователя в схему AdminUser.
|
||||||
|
func AdminUserDTO(u User) gen.AdminUser {
|
||||||
|
return gen.AdminUser{
|
||||||
|
Id: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
DisplayName: u.DisplayName,
|
||||||
|
Role: gen.Role(u.Role),
|
||||||
|
Locale: u.Locale,
|
||||||
|
IsBlocked: u.IsBlocked,
|
||||||
|
MustChangePassword: u.MustChangePassword,
|
||||||
|
MustChangeCredentials: u.MustChangeCredentials,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
LastLoginAt: u.LastLoginAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func optional(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"pipu/api/internal/auth"
|
||||||
|
"pipu/api/internal/platform/apperr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// signIn входит под пользователем, у которого может стоять must_change_password, и сразу меняет пароль.
|
||||||
|
func (e *env) signIn(username, password string) string {
|
||||||
|
e.t.Helper()
|
||||||
|
r := e.mustLogin(username, password, "web")
|
||||||
|
if user(r)["must_change_password"] == true {
|
||||||
|
r = e.do(http.MethodPost, "/api/v1/me/password",
|
||||||
|
map[string]any{"new_password": username + "-own-password"}, bearer(access(r)))
|
||||||
|
require.Equal(e.t, http.StatusOK, r.Code, r.Body.String())
|
||||||
|
}
|
||||||
|
return access(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) createUser(token, username, role string) resp {
|
||||||
|
e.t.Helper()
|
||||||
|
return e.do(http.MethodPost, "/api/v1/admin/users",
|
||||||
|
map[string]any{"username": username, "role": role, "display_name": " " + username + " "}, bearer(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сценарий 2 из docs/testing.md: админ создаёт модератора, модератор — пользователя,
|
||||||
|
// но не модератора.
|
||||||
|
func TestAdminCreatesModeratorWhoCreatesUser(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||||
|
admin := e.signIn("root", "root-password-1")
|
||||||
|
|
||||||
|
created := e.createUser(admin, "Mod.One", "moderator")
|
||||||
|
require.Equal(t, http.StatusCreated, created.Code, created.Body.String())
|
||||||
|
modUser := created.body["user"].(map[string]any)
|
||||||
|
assert.Equal(t, "mod.one", modUser["username"])
|
||||||
|
assert.Equal(t, "Mod.One", modUser["display_name"])
|
||||||
|
assert.Equal(t, true, modUser["must_change_password"])
|
||||||
|
temp := created.body["temporary_password"].(string)
|
||||||
|
assert.Len(t, temp, 12)
|
||||||
|
|
||||||
|
first := e.mustLogin("mod.one", temp, "web")
|
||||||
|
assert.Equal(t, auth.CodePasswordChangeRequired,
|
||||||
|
e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(access(first))).code())
|
||||||
|
mod := e.signIn("mod.one", temp)
|
||||||
|
|
||||||
|
listenerUser := e.createUser(mod, "listener", "user")
|
||||||
|
require.Equal(t, http.StatusCreated, listenerUser.Code)
|
||||||
|
for _, role := range []string{"moderator", "admin"} {
|
||||||
|
r := e.createUser(mod, "sneaky-"+role, role)
|
||||||
|
assert.Equal(t, http.StatusForbidden, r.Code, role)
|
||||||
|
assert.Equal(t, auth.CodeForbidden, r.code())
|
||||||
|
}
|
||||||
|
|
||||||
|
list := e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(mod))
|
||||||
|
require.Equal(t, http.StatusOK, list.Code)
|
||||||
|
var names []string
|
||||||
|
for _, it := range list.body["items"].([]any) {
|
||||||
|
names = append(names, it.(map[string]any)["username"].(string))
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{"listener", "mod.one", "root"}, names)
|
||||||
|
assert.Nil(t, list.body["next_cursor"])
|
||||||
|
|
||||||
|
listener := e.signIn("listener", listenerUser.body["temporary_password"].(string))
|
||||||
|
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(listener)).code())
|
||||||
|
assert.Equal(t, auth.CodeForbidden, e.createUser(listener, "another", "user").code())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUserValidation(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||||
|
admin := e.signIn("root", "root-password-1")
|
||||||
|
|
||||||
|
assert.Equal(t, auth.CodeUsernameTaken, e.createUser(admin, "ROOT", "user").code())
|
||||||
|
assert.Equal(t, auth.CodeUsernameInvalid, e.createUser(admin, "no spaces", "user").code())
|
||||||
|
assert.Equal(t, auth.CodeRoleInvalid, e.createUser(admin, "valid", "root").code())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Матрица «кто кого может» через API: сброс пароля, блокировка, смена роли, удаление.
|
||||||
|
func TestManageUsersMatrix(t *testing.T) {
|
||||||
|
type actorCase struct {
|
||||||
|
role auth.Role
|
||||||
|
// разрешено ли действие над целью с ролью user / moderator / admin
|
||||||
|
reset, block, role2, del [3]bool
|
||||||
|
}
|
||||||
|
cases := []actorCase{
|
||||||
|
{auth.RoleModerator, [3]bool{true, false, false}, [3]bool{true, false, false}, [3]bool{false, false, false}, [3]bool{false, false, false}},
|
||||||
|
{auth.RoleAdmin, [3]bool{true, true, true}, [3]bool{true, true, true}, [3]bool{true, true, true}, [3]bool{true, true, true}},
|
||||||
|
}
|
||||||
|
targets := [3]auth.Role{auth.RoleUser, auth.RoleModerator, auth.RoleAdmin}
|
||||||
|
for _, ac := range cases {
|
||||||
|
for ti, tr := range targets {
|
||||||
|
actions := []struct {
|
||||||
|
name string
|
||||||
|
allowed bool
|
||||||
|
call func(e *env, tok, id string) resp
|
||||||
|
}{
|
||||||
|
{"reset", ac.reset[ti], func(e *env, tok, id string) resp {
|
||||||
|
return e.do(http.MethodPost, "/api/v1/admin/users/"+id+"/reset-password", nil, bearer(tok))
|
||||||
|
}},
|
||||||
|
{"block", ac.block[ti], func(e *env, tok, id string) resp {
|
||||||
|
return e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"is_blocked": true}, bearer(tok))
|
||||||
|
}},
|
||||||
|
{"role", ac.role2[ti], func(e *env, tok, id string) resp {
|
||||||
|
next := "user"
|
||||||
|
if tr == auth.RoleUser {
|
||||||
|
next = "moderator"
|
||||||
|
}
|
||||||
|
return e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"role": next}, bearer(tok))
|
||||||
|
}},
|
||||||
|
{"delete", ac.del[ti], func(e *env, tok, id string) resp {
|
||||||
|
return e.do(http.MethodDelete, "/api/v1/admin/users/"+id, nil, bearer(tok))
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, a := range actions {
|
||||||
|
t.Run(fmt.Sprintf("%s %s %s", ac.role, a.name, tr), func(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("actor", "actor-password-1", ac.role)
|
||||||
|
e.addUser("spare-admin", "spare-password-1", auth.RoleAdmin) // чтобы не упереться в «последнего админа»
|
||||||
|
target := e.addUser("target", "target-password", tr)
|
||||||
|
tok := e.signIn("actor", "actor-password-1")
|
||||||
|
r := a.call(e, tok, target.ID.String())
|
||||||
|
if a.allowed {
|
||||||
|
assert.Less(t, r.Code, 300, r.Body.String())
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, http.StatusForbidden, r.Code, r.Body.String())
|
||||||
|
assert.Equal(t, auth.CodeForbidden, r.code())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeratorCannotTouchSelfOrRoles(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
mod := e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
|
||||||
|
u := e.addUser("listener", "listener-pass", auth.RoleUser)
|
||||||
|
tok := e.signIn("mod", "moderator-pass-1")
|
||||||
|
|
||||||
|
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+mod.ID.String(),
|
||||||
|
map[string]any{"display_name": "Boss"}, bearer(tok)).code())
|
||||||
|
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+u.ID.String(),
|
||||||
|
map[string]any{"display_name": "ok", "role": "moderator"}, bearer(tok)).code(), "one forbidden change rejects the patch")
|
||||||
|
assert.Nil(t, e.store.Users[u.ID].DisplayName, "rejected patch changes nothing")
|
||||||
|
|
||||||
|
ok := e.do(http.MethodPatch, "/api/v1/admin/users/"+u.ID.String(),
|
||||||
|
map[string]any{"display_name": "Listener", "role": "user"}, bearer(tok))
|
||||||
|
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
|
||||||
|
assert.Equal(t, "Listener", ok.body["display_name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCannotDemoteBlockOrDeleteSelf(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
root := e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||||
|
e.addUser("other", "other-password-1", auth.RoleAdmin)
|
||||||
|
tok := e.signIn("root", "root-password-1")
|
||||||
|
id := root.ID.String()
|
||||||
|
|
||||||
|
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"role": "user"}, bearer(tok)).code())
|
||||||
|
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"is_blocked": true}, bearer(tok)).code())
|
||||||
|
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodDelete, "/api/v1/admin/users/"+id, nil, bearer(tok)).code())
|
||||||
|
rename := e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"display_name": "Root"}, bearer(tok))
|
||||||
|
assert.Equal(t, http.StatusOK, rename.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLastAdminIsProtected(t *testing.T) {
|
||||||
|
// Через API последнего админа не затронуть: себя менять нельзя, а действующий админ — второй.
|
||||||
|
// Остаётся гонка (админ удалён, пока его запрос в пути) — её и закрывает проверка в сервисе.
|
||||||
|
e := newEnv(t)
|
||||||
|
only := e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||||
|
ghost := auth.Principal{UserID: uuid.New(), Role: auth.RoleAdmin}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
role := auth.RoleUser
|
||||||
|
blocked := true
|
||||||
|
for name, call := range map[string]func() error{
|
||||||
|
"demote": func() error { _, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{Role: &role}); return err },
|
||||||
|
"block": func() error {
|
||||||
|
_, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{IsBlocked: &blocked})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
"delete": func() error { return e.svc.DeleteUser(ctx, ghost, only.ID) },
|
||||||
|
} {
|
||||||
|
err := call()
|
||||||
|
var ae *apperr.Error
|
||||||
|
require.ErrorAs(t, err, &ae, name)
|
||||||
|
assert.Equal(t, auth.CodeLastAdmin, ae.Code, name)
|
||||||
|
assert.Equal(t, http.StatusConflict, ae.Status, name)
|
||||||
|
}
|
||||||
|
assert.Equal(t, auth.RoleAdmin, e.store.Users[only.ID].Role)
|
||||||
|
assert.False(t, e.store.Users[only.ID].IsBlocked)
|
||||||
|
assert.Empty(t, e.store.Audit, "rolled back changes leave no audit")
|
||||||
|
|
||||||
|
// Заблокированный админ не считается: второго активного нет.
|
||||||
|
e.addUser("sleeping", "sleeping-pass-1", auth.RoleAdmin, func(u *auth.User) { u.IsBlocked = true })
|
||||||
|
_, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{Role: &role})
|
||||||
|
assert.ErrorIs(t, err, apperr.Conflict(auth.CodeLastAdmin, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockAndRoleChangeRevokeAccess(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||||
|
target := e.addUser("listener", "listener-pass", auth.RoleUser)
|
||||||
|
admin := e.signIn("root", "root-password-1")
|
||||||
|
web := e.mustLogin("listener", "listener-pass", "web")
|
||||||
|
cookie := web.cookie(auth.RefreshCookie)
|
||||||
|
|
||||||
|
r := e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"role": "moderator"}, bearer(admin))
|
||||||
|
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||||
|
assert.Equal(t, "moderator", r.body["role"])
|
||||||
|
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(web))).code())
|
||||||
|
|
||||||
|
refreshed := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(cookie))
|
||||||
|
require.Equal(t, http.StatusOK, refreshed.Code, "role change keeps sessions")
|
||||||
|
assert.Equal(t, "moderator", user(refreshed)["role"])
|
||||||
|
tok := access(refreshed)
|
||||||
|
|
||||||
|
r = e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"is_blocked": true}, bearer(admin))
|
||||||
|
require.Equal(t, http.StatusOK, r.Code)
|
||||||
|
assert.Equal(t, true, r.body["is_blocked"])
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).Code)
|
||||||
|
assert.Equal(t, auth.CodeRefreshInvalid,
|
||||||
|
e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(refreshed.cookie(auth.RefreshCookie))).code(),
|
||||||
|
"blocking ends sessions")
|
||||||
|
|
||||||
|
r = e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"is_blocked": false}, bearer(admin))
|
||||||
|
require.Equal(t, http.StatusOK, r.Code)
|
||||||
|
e.mustLogin("listener", "listener-pass", "web")
|
||||||
|
|
||||||
|
var actions []string
|
||||||
|
for _, a := range e.store.Audit {
|
||||||
|
actions = append(actions, a.Action)
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{"user.role_change", "user.block", "user.unblock"}, actions)
|
||||||
|
assert.Equal(t, map[string]any{"from": "user", "to": "moderator"}, e.store.Audit[0].Payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoOpPatch(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||||
|
target := e.addUser("listener", "listener-pass", auth.RoleUser)
|
||||||
|
admin := e.signIn("root", "root-password-1")
|
||||||
|
r := e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(),
|
||||||
|
map[string]any{"role": "user", "is_blocked": false}, bearer(admin))
|
||||||
|
require.Equal(t, http.StatusOK, r.Code)
|
||||||
|
assert.Empty(t, e.store.Audit)
|
||||||
|
assert.Zero(t, e.store.Users[target.ID].TokenVersion)
|
||||||
|
|
||||||
|
assert.Equal(t, auth.CodeUserNotFound, e.do(http.MethodPatch, "/api/v1/admin/users/"+uuid.NewString(),
|
||||||
|
map[string]any{"display_name": "x"}, bearer(admin)).code())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сценарий 10 из docs/testing.md: сброс пароля → обязательная смена при входе.
|
||||||
|
func TestResetPassword(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
|
||||||
|
target := e.addUser("listener", "listener-pass", auth.RoleUser, func(u *auth.User) { u.FailedLoginCount = 9 })
|
||||||
|
mod := e.signIn("mod", "moderator-pass-1")
|
||||||
|
phone := e.mustLogin("listener", "listener-pass", "android")
|
||||||
|
|
||||||
|
r := e.do(http.MethodPost, "/api/v1/admin/users/"+target.ID.String()+"/reset-password", nil, bearer(mod))
|
||||||
|
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||||
|
temp := r.body["temporary_password"].(string)
|
||||||
|
|
||||||
|
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(phone))).code())
|
||||||
|
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh",
|
||||||
|
map[string]any{"refresh_token": phone.body["refresh_token"]}).code())
|
||||||
|
assert.Equal(t, auth.CodeInvalidCredentials, e.login("listener", "listener-pass", "web").code())
|
||||||
|
|
||||||
|
again := e.mustLogin("listener", temp, "web")
|
||||||
|
assert.Equal(t, true, user(again)["must_change_password"])
|
||||||
|
assert.Equal(t, auth.CodePasswordChangeRequired, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(again))).code())
|
||||||
|
assert.Equal(t, "user.password_reset", e.store.Audit[len(e.store.Audit)-1].Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteUser(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||||
|
target := e.addUser("listener", "listener-pass", auth.RoleUser)
|
||||||
|
admin := e.signIn("root", "root-password-1")
|
||||||
|
tok := access(e.mustLogin("listener", "listener-pass", "web"))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNoContent, e.do(http.MethodDelete, "/api/v1/admin/users/"+target.ID.String(), nil, bearer(admin)).Code)
|
||||||
|
assert.NotContains(t, e.store.Users, target.ID)
|
||||||
|
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code())
|
||||||
|
assert.Equal(t, auth.CodeUserNotFound, e.do(http.MethodDelete, "/api/v1/admin/users/"+target.ID.String(), nil, bearer(admin)).code())
|
||||||
|
last := e.store.Audit[len(e.store.Audit)-1]
|
||||||
|
assert.Equal(t, "user.delete", last.Action)
|
||||||
|
assert.Equal(t, "listener", last.Payload["username"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListUsersFiltersAndPages(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||||
|
for _, n := range []string{"anna", "boris", "vera", "gleb", "dina"} {
|
||||||
|
e.addUser(n, n+"-password", auth.RoleUser)
|
||||||
|
}
|
||||||
|
name := "Анна Каренина"
|
||||||
|
e.addUser("kate", "kate-password", auth.RoleModerator, func(u *auth.User) { u.DisplayName = &name })
|
||||||
|
admin := e.signIn("root", "root-password-1")
|
||||||
|
list := func(q url.Values) resp {
|
||||||
|
return e.do(http.MethodGet, "/api/v1/admin/users?"+q.Encode(), nil, bearer(admin))
|
||||||
|
}
|
||||||
|
usernames := func(r resp) []string {
|
||||||
|
var out []string
|
||||||
|
for _, it := range r.body["items"].([]any) {
|
||||||
|
out = append(out, it.(map[string]any)["username"].(string))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
all []string
|
||||||
|
cursor string
|
||||||
|
)
|
||||||
|
for page := 0; ; page++ {
|
||||||
|
q := url.Values{"limit": {"3"}}
|
||||||
|
if cursor != "" {
|
||||||
|
q.Set("cursor", cursor)
|
||||||
|
}
|
||||||
|
r := list(q)
|
||||||
|
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||||
|
all = append(all, usernames(r)...)
|
||||||
|
next, _ := r.body["next_cursor"].(string)
|
||||||
|
if next == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cursor = next
|
||||||
|
require.Less(t, page, 5)
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{"anna", "boris", "dina", "gleb", "kate", "root", "vera"}, all)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"kate"}, usernames(list(url.Values{"q": {"анна"}})), "matches display name, case-insensitive")
|
||||||
|
assert.Equal(t, []string{"anna"}, usernames(list(url.Values{"q": {"AN"}})))
|
||||||
|
assert.Equal(t, []string{"kate"}, usernames(list(url.Values{"role": {"moderator"}})))
|
||||||
|
assert.Equal(t, auth.CodeCursorBad, list(url.Values{"cursor": {"%%%"}}).code())
|
||||||
|
assert.Equal(t, http.StatusBadRequest, list(url.Values{"limit": {"lots"}}).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudit(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||||
|
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
|
||||||
|
admin := e.signIn("root", "root-password-1")
|
||||||
|
for _, n := range []string{"u1", "u2", "u3"} {
|
||||||
|
require.Equal(t, http.StatusCreated, e.createUser(admin, n+"-user", "user").Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := e.do(http.MethodGet, "/api/v1/admin/audit?limit=2", nil, bearer(admin))
|
||||||
|
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||||
|
items := r.body["items"].([]any)
|
||||||
|
require.Len(t, items, 2)
|
||||||
|
newest := items[0].(map[string]any)
|
||||||
|
assert.Equal(t, "user.create", newest["action"])
|
||||||
|
assert.Equal(t, "u3-user", newest["payload"].(map[string]any)["username"])
|
||||||
|
assert.Equal(t, "root", newest["actor"].(map[string]any)["username"])
|
||||||
|
assert.Equal(t, "user", newest["target_type"])
|
||||||
|
|
||||||
|
next := r.body["next_cursor"].(string)
|
||||||
|
r2 := e.do(http.MethodGet, "/api/v1/admin/audit?limit=2&cursor="+next, nil, bearer(admin))
|
||||||
|
rest := r2.body["items"].([]any)
|
||||||
|
require.Len(t, rest, 1)
|
||||||
|
assert.Equal(t, "u1-user", rest[0].(map[string]any)["payload"].(map[string]any)["username"])
|
||||||
|
assert.Nil(t, r2.body["next_cursor"])
|
||||||
|
|
||||||
|
mod := e.signIn("mod", "moderator-pass-1")
|
||||||
|
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodGet, "/api/v1/admin/audit", nil, bearer(mod)).code())
|
||||||
|
}
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"pipu/api/internal/auth"
|
||||||
|
"pipu/api/internal/auth/authtest"
|
||||||
|
"pipu/api/internal/server"
|
||||||
|
"pipu/api/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Быстрые параметры argon2: тестам важна логика, а не стойкость.
|
||||||
|
var testArgon = auth.Argon2Params{MemoryKiB: 64, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32}
|
||||||
|
|
||||||
|
type env struct {
|
||||||
|
t *testing.T
|
||||||
|
store *authtest.MemStore
|
||||||
|
clock *authtest.Clock
|
||||||
|
limiter *authtest.Limiter
|
||||||
|
hasher *auth.Hasher
|
||||||
|
svc *auth.Service
|
||||||
|
h http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEnv(t *testing.T, mutate ...func(*auth.Config)) *env {
|
||||||
|
t.Helper()
|
||||||
|
e := &env{
|
||||||
|
t: t,
|
||||||
|
store: authtest.NewMemStore(),
|
||||||
|
clock: &authtest.Clock{T: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)},
|
||||||
|
limiter: &authtest.Limiter{},
|
||||||
|
hasher: auth.NewHasher(testArgon),
|
||||||
|
}
|
||||||
|
key, err := auth.GenerateKey()
|
||||||
|
require.NoError(t, err)
|
||||||
|
cfg := auth.DefaultConfig()
|
||||||
|
cfg.BootstrapPassword = ""
|
||||||
|
for _, m := range mutate {
|
||||||
|
m(&cfg)
|
||||||
|
}
|
||||||
|
e.svc, err = auth.NewService(e.store, e.hasher, auth.NewTokenIssuer(key, nil, 15*time.Minute, e.clock.Now),
|
||||||
|
e.limiter, e.clock, auth.UUIDv7{}, nil, cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
sys := system.NewHandler(system.Info{Version: "test", BootstrapRequired: e.svc.BootstrapRequired}, nil, nil)
|
||||||
|
e.h = server.NewRouter(server.Deps{System: sys, Auth: auth.NewHandler(e.svc, true), AuthService: e.svc})
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// addUser создаёт пользователя напрямую в хранилище.
|
||||||
|
func (e *env) addUser(username, password string, role auth.Role, mutate ...func(*auth.User)) auth.User {
|
||||||
|
e.t.Helper()
|
||||||
|
hash, err := e.hasher.Hash(password)
|
||||||
|
require.NoError(e.t, err)
|
||||||
|
u := auth.User{ID: uuid.New(), Username: username, PasswordHash: hash, Role: role, Locale: "ru", CreatedAt: e.clock.Now()}
|
||||||
|
for _, m := range mutate {
|
||||||
|
m(&u)
|
||||||
|
}
|
||||||
|
require.NoError(e.t, e.store.CreateUser(context.Background(), u, nil))
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
type resp struct {
|
||||||
|
*httptest.ResponseRecorder
|
||||||
|
body map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r resp) code() string { s, _ := r.body["code"].(string); return s }
|
||||||
|
|
||||||
|
func (r resp) cookie(name string) *http.Cookie {
|
||||||
|
for _, c := range r.Result().Cookies() {
|
||||||
|
if c.Name == name {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type reqOpt func(*http.Request)
|
||||||
|
|
||||||
|
func bearer(token string) reqOpt {
|
||||||
|
return func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func withCookie(c *http.Cookie) reqOpt { return func(r *http.Request) { r.AddCookie(c) } }
|
||||||
|
|
||||||
|
func (e *env) do(method, path string, body any, opts ...reqOpt) resp {
|
||||||
|
e.t.Helper()
|
||||||
|
var rd *bytes.Reader
|
||||||
|
if body != nil {
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
require.NoError(e.t, err)
|
||||||
|
rd = bytes.NewReader(raw)
|
||||||
|
} else {
|
||||||
|
rd = bytes.NewReader(nil)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, path, rd)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.RemoteAddr = "192.0.2.10:5555"
|
||||||
|
for _, o := range opts {
|
||||||
|
o(req)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.h.ServeHTTP(rec, req)
|
||||||
|
out := resp{ResponseRecorder: rec}
|
||||||
|
if rec.Body.Len() > 0 {
|
||||||
|
require.NoError(e.t, json.Unmarshal(rec.Body.Bytes(), &out.body), rec.Body.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) login(username, password, clientType string) resp {
|
||||||
|
e.t.Helper()
|
||||||
|
return e.do(http.MethodPost, "/api/v1/auth/login", map[string]any{
|
||||||
|
"username": username, "password": password, "client_type": clientType, "device_name": "Test device",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) mustLogin(username, password, clientType string) resp {
|
||||||
|
e.t.Helper()
|
||||||
|
r := e.login(username, password, clientType)
|
||||||
|
require.Equal(e.t, http.StatusOK, r.Code, r.Body.String())
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func access(r resp) string { return r.body["access_token"].(string) }
|
||||||
|
|
||||||
|
func user(r resp) map[string]any { return r.body["user"].(map[string]any) }
|
||||||
|
|
||||||
|
func TestBootstrapFlow(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, e.svc.Bootstrap(ctx))
|
||||||
|
require.NoError(t, e.svc.Bootstrap(ctx), "second bootstrap is a no-op")
|
||||||
|
require.Len(t, e.store.Users, 1)
|
||||||
|
|
||||||
|
info := e.do(http.MethodGet, "/api/v1/server-info", nil)
|
||||||
|
assert.Equal(t, true, info.body["bootstrap_required"])
|
||||||
|
|
||||||
|
// Сгенерированный пароль неизвестен тесту — подменяем хэш известным паролем.
|
||||||
|
var admin auth.User
|
||||||
|
for _, u := range e.store.Users {
|
||||||
|
admin = u
|
||||||
|
}
|
||||||
|
assert.Equal(t, "admin", admin.Username)
|
||||||
|
assert.Equal(t, auth.RoleAdmin, admin.Role)
|
||||||
|
assert.True(t, admin.MustChangeCredentials)
|
||||||
|
admin.PasswordHash, _ = e.hasher.Hash("initial-secret-pass")
|
||||||
|
e.store.Users[admin.ID] = admin
|
||||||
|
|
||||||
|
r := e.mustLogin("admin", "initial-secret-pass", "web")
|
||||||
|
assert.Equal(t, true, user(r)["must_change_credentials"])
|
||||||
|
tok := access(r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).Code)
|
||||||
|
blocked := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok))
|
||||||
|
assert.Equal(t, http.StatusForbidden, blocked.Code)
|
||||||
|
assert.Equal(t, auth.CodeCredentialsChangeRequired, blocked.code())
|
||||||
|
assert.Equal(t, auth.CodeCredentialsChangeRequired,
|
||||||
|
e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "whatever-long-pass"}, bearer(tok)).code())
|
||||||
|
|
||||||
|
short := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
|
||||||
|
map[string]any{"new_username": "owner", "new_password": "short"}, bearer(tok))
|
||||||
|
assert.Equal(t, http.StatusUnprocessableEntity, short.Code)
|
||||||
|
assert.Equal(t, auth.CodePasswordTooShort, short.code())
|
||||||
|
assert.EqualValues(t, 12, short.body["params"].(map[string]any)["min"])
|
||||||
|
|
||||||
|
same := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
|
||||||
|
map[string]any{"new_username": "owner", "new_password": "initial-secret-pass"}, bearer(tok))
|
||||||
|
assert.Equal(t, auth.CodePasswordUnchanged, same.code())
|
||||||
|
|
||||||
|
badName := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
|
||||||
|
map[string]any{"new_username": "x", "new_password": "a-much-better-secret"}, bearer(tok))
|
||||||
|
assert.Equal(t, auth.CodeUsernameInvalid, badName.code())
|
||||||
|
|
||||||
|
ok := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
|
||||||
|
map[string]any{"new_username": " Owner ", "new_password": "a-much-better-secret"}, bearer(tok))
|
||||||
|
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
|
||||||
|
assert.Equal(t, "owner", user(ok)["username"])
|
||||||
|
assert.Equal(t, false, user(ok)["must_change_credentials"])
|
||||||
|
assert.NotNil(t, ok.cookie(auth.RefreshCookie))
|
||||||
|
|
||||||
|
old := e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok))
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, old.Code)
|
||||||
|
assert.Equal(t, auth.CodeTokenRevoked, old.code())
|
||||||
|
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(ok))).Code)
|
||||||
|
|
||||||
|
assert.Equal(t, false, e.do(http.MethodGet, "/api/v1/server-info", nil).body["bootstrap_required"])
|
||||||
|
again := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
|
||||||
|
map[string]any{"new_username": "owner", "new_password": "another-better-secret"}, bearer(access(ok)))
|
||||||
|
assert.Equal(t, http.StatusConflict, again.Code)
|
||||||
|
|
||||||
|
actions := []string{}
|
||||||
|
for _, a := range e.store.Audit {
|
||||||
|
actions = append(actions, a.Action)
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{"user.bootstrap_create", "user.bootstrap_credentials"}, actions)
|
||||||
|
|
||||||
|
// После завершения первого запуска удаление всех админов не создаёт нового автоматически.
|
||||||
|
delete(e.store.Users, admin.ID)
|
||||||
|
require.NoError(t, e.svc.Bootstrap(ctx))
|
||||||
|
assert.Empty(t, e.store.Users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapWithConfiguredPassword(t *testing.T) {
|
||||||
|
e := newEnv(t, func(c *auth.Config) {
|
||||||
|
c.BootstrapUsername = "Root"
|
||||||
|
c.BootstrapPassword = "configured-admin-pass"
|
||||||
|
})
|
||||||
|
require.NoError(t, e.svc.Bootstrap(context.Background()))
|
||||||
|
r := e.mustLogin("root", "configured-admin-pass", "android")
|
||||||
|
assert.Equal(t, "root", user(r)["username"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapSkippedWhenAdminExists(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("boss", "boss-password-123", auth.RoleAdmin)
|
||||||
|
require.NoError(t, e.svc.Bootstrap(context.Background()))
|
||||||
|
assert.Len(t, e.store.Users, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginErrorsAreIndistinguishable(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||||
|
|
||||||
|
wrong := e.login("alice", "nope-nope-nope", "web")
|
||||||
|
unknown := e.login("bob", "nope-nope-nope", "web")
|
||||||
|
for _, r := range []resp{wrong, unknown} {
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, r.Code)
|
||||||
|
assert.Equal(t, auth.CodeInvalidCredentials, r.code())
|
||||||
|
}
|
||||||
|
assert.Equal(t, http.StatusOK, e.login("ALICE", "alice-password", "web").Code, "username is case-insensitive")
|
||||||
|
|
||||||
|
bad := e.do(http.MethodPost, "/api/v1/auth/login",
|
||||||
|
map[string]any{"username": "alice", "password": "alice-password", "client_type": "ios"})
|
||||||
|
assert.Equal(t, http.StatusUnprocessableEntity, bad.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginLockout(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
u := e.addUser("alice", "alice-password", auth.RoleUser)
|
||||||
|
|
||||||
|
for range 5 {
|
||||||
|
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code())
|
||||||
|
e.limiter.Reset()
|
||||||
|
}
|
||||||
|
locked := e.store.Users[u.ID]
|
||||||
|
require.NotNil(t, locked.LockedUntil)
|
||||||
|
assert.Equal(t, e.clock.Now().Add(5*time.Minute), *locked.LockedUntil)
|
||||||
|
|
||||||
|
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "alice-password", "web").code(),
|
||||||
|
"correct password is rejected while locked")
|
||||||
|
assert.Equal(t, 5, e.store.Users[u.ID].FailedLoginCount, "attempts while locked are not counted")
|
||||||
|
|
||||||
|
e.clock.Advance(5 * time.Minute)
|
||||||
|
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code())
|
||||||
|
assert.Equal(t, e.clock.Now().Add(10*time.Minute), *e.store.Users[u.ID].LockedUntil, "lock doubles")
|
||||||
|
|
||||||
|
e.clock.Advance(10 * time.Minute)
|
||||||
|
e.limiter.Reset()
|
||||||
|
e.mustLogin("alice", "alice-password", "web")
|
||||||
|
assert.Zero(t, e.store.Users[u.ID].FailedLoginCount)
|
||||||
|
assert.Nil(t, e.store.Users[u.ID].LockedUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockDurationIsCapped(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
u := e.addUser("alice", "alice-password", auth.RoleUser, func(u *auth.User) { u.FailedLoginCount = 40 })
|
||||||
|
e.login("alice", "wrong-password", "web")
|
||||||
|
assert.Equal(t, e.clock.Now().Add(time.Hour), *e.store.Users[u.ID].LockedUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRateLimit(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||||
|
for range 5 {
|
||||||
|
e.login("alice", "wrong-password", "web")
|
||||||
|
}
|
||||||
|
r := e.login("alice", "alice-password", "web")
|
||||||
|
assert.Equal(t, http.StatusTooManyRequests, r.Code)
|
||||||
|
assert.Equal(t, auth.CodeTooManyAttempts, r.code())
|
||||||
|
assert.Equal(t, "60", r.Header().Get("Retry-After"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockedUserCannotLoginOrUseTokens(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
u := e.addUser("alice", "alice-password", auth.RoleUser)
|
||||||
|
tok := access(e.mustLogin("alice", "alice-password", "web"))
|
||||||
|
|
||||||
|
blocked := e.store.Users[u.ID]
|
||||||
|
blocked.IsBlocked = true
|
||||||
|
e.store.Users[u.ID] = blocked
|
||||||
|
|
||||||
|
assert.Equal(t, auth.CodeUserBlocked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code())
|
||||||
|
r := e.login("alice", "alice-password", "web")
|
||||||
|
assert.Equal(t, http.StatusForbidden, r.Code)
|
||||||
|
assert.Equal(t, auth.CodeUserBlocked, r.code())
|
||||||
|
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code(),
|
||||||
|
"blocked status is not revealed without the password")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebRefreshRotation(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||||
|
r := e.mustLogin("alice", "alice-password", "web")
|
||||||
|
assert.Nil(t, r.body["refresh_token"], "web never gets refresh token in the body")
|
||||||
|
c1 := r.cookie(auth.RefreshCookie)
|
||||||
|
require.NotNil(t, c1)
|
||||||
|
assert.True(t, c1.HttpOnly)
|
||||||
|
assert.True(t, c1.Secure)
|
||||||
|
assert.Equal(t, http.SameSiteStrictMode, c1.SameSite)
|
||||||
|
assert.Equal(t, "/api/v1/auth", c1.Path)
|
||||||
|
|
||||||
|
e.clock.Advance(time.Minute)
|
||||||
|
r2 := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1))
|
||||||
|
require.Equal(t, http.StatusOK, r2.Code, r2.Body.String())
|
||||||
|
c2 := r2.cookie(auth.RefreshCookie)
|
||||||
|
require.NotNil(t, c2)
|
||||||
|
assert.NotEqual(t, c1.Value, c2.Value)
|
||||||
|
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(r2))).Code)
|
||||||
|
|
||||||
|
// Вторая вкладка прислала старую cookie сразу после ротации — это гонка, а не кража.
|
||||||
|
race := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1))
|
||||||
|
assert.Equal(t, auth.CodeRefreshRace, race.code())
|
||||||
|
assert.Nil(t, race.cookie(auth.RefreshCookie), "race keeps the fresh cookie")
|
||||||
|
|
||||||
|
// Позже старый токен — признак кражи: отзывается вся семья, в том числе новый токен.
|
||||||
|
e.clock.Advance(time.Minute)
|
||||||
|
reuse := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1))
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, reuse.Code)
|
||||||
|
assert.Equal(t, auth.CodeRefreshReused, reuse.code())
|
||||||
|
cleared := reuse.cookie(auth.RefreshCookie)
|
||||||
|
require.NotNil(t, cleared)
|
||||||
|
assert.Equal(t, -1, cleared.MaxAge)
|
||||||
|
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c2)).code())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAndroidRefreshInBody(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||||
|
r := e.mustLogin("alice", "alice-password", "android")
|
||||||
|
assert.Nil(t, r.cookie(auth.RefreshCookie))
|
||||||
|
rt, ok := r.body["refresh_token"].(string)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
r2 := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt})
|
||||||
|
require.Equal(t, http.StatusOK, r2.Code, r2.Body.String())
|
||||||
|
assert.NotEqual(t, rt, r2.body["refresh_token"])
|
||||||
|
|
||||||
|
assert.Equal(t, auth.CodeRefreshInvalid,
|
||||||
|
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": "garbage"}).code())
|
||||||
|
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh", nil).code())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshExpiry(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||||
|
rt := e.mustLogin("alice", "alice-password", "android").body["refresh_token"].(string)
|
||||||
|
|
||||||
|
// Скользящий срок: каждые 29 дней ротация продлевает, но не дальше 180 дней от входа.
|
||||||
|
for range 6 {
|
||||||
|
e.clock.Advance(29 * 24 * time.Hour)
|
||||||
|
r := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt})
|
||||||
|
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||||
|
rt = r.body["refresh_token"].(string)
|
||||||
|
}
|
||||||
|
e.clock.Advance(7 * 24 * time.Hour) // 181 день от входа
|
||||||
|
assert.Equal(t, auth.CodeRefreshInvalid,
|
||||||
|
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt}).code())
|
||||||
|
|
||||||
|
rt2 := e.mustLogin("alice", "alice-password", "android").body["refresh_token"].(string)
|
||||||
|
e.clock.Advance(31 * 24 * time.Hour)
|
||||||
|
assert.Equal(t, auth.CodeRefreshInvalid,
|
||||||
|
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt2}).code())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessTokenErrors(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||||
|
tok := access(e.mustLogin("alice", "alice-password", "web"))
|
||||||
|
|
||||||
|
assert.Equal(t, auth.CodeUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil).code())
|
||||||
|
assert.Equal(t, auth.CodeTokenInvalid, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok+"x")).code())
|
||||||
|
assert.Equal(t, auth.CodeUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil,
|
||||||
|
func(r *http.Request) { r.Header.Set("Authorization", "Basic abc") }).code())
|
||||||
|
|
||||||
|
e.clock.Advance(16 * time.Minute)
|
||||||
|
expired := e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok))
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, expired.Code)
|
||||||
|
assert.Equal(t, auth.CodeTokenExpired, expired.code())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenSignedByPreviousKey(t *testing.T) {
|
||||||
|
clock := &authtest.Clock{T: time.Now()}
|
||||||
|
oldKey, _ := auth.GenerateKey()
|
||||||
|
newKey, _ := auth.GenerateKey()
|
||||||
|
old := auth.NewTokenIssuer(oldKey, nil, time.Minute, clock.Now)
|
||||||
|
rotated := auth.NewTokenIssuer(newKey, []ed25519.PrivateKey{oldKey}, time.Minute, clock.Now)
|
||||||
|
other := auth.NewTokenIssuer(newKey, nil, time.Minute, clock.Now)
|
||||||
|
|
||||||
|
tok, err := old.Issue(uuid.New(), auth.RoleUser, uuid.New(), 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
c, err := rotated.Parse(tok)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 3, c.TokenVersion)
|
||||||
|
_, err = other.Parse(tok)
|
||||||
|
assert.ErrorIs(t, err, auth.ErrTokenInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangePassword(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||||
|
web := e.mustLogin("alice", "alice-password", "web")
|
||||||
|
phone := e.mustLogin("alice", "alice-password", "android")
|
||||||
|
tok := access(web)
|
||||||
|
|
||||||
|
missing := e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "brand-new-pass"}, bearer(tok))
|
||||||
|
assert.Equal(t, auth.CodeCurrentPasswordRequired, missing.code())
|
||||||
|
wrong := e.do(http.MethodPost, "/api/v1/me/password",
|
||||||
|
map[string]any{"current_password": "nope", "new_password": "brand-new-pass"}, bearer(tok))
|
||||||
|
assert.Equal(t, auth.CodeCurrentPasswordInvalid, wrong.code())
|
||||||
|
common := e.do(http.MethodPost, "/api/v1/me/password",
|
||||||
|
map[string]any{"current_password": "alice-password", "new_password": "Password123"}, bearer(tok))
|
||||||
|
assert.Equal(t, auth.CodePasswordTooCommon, common.code())
|
||||||
|
|
||||||
|
ok := e.do(http.MethodPost, "/api/v1/me/password",
|
||||||
|
map[string]any{"current_password": "alice-password", "new_password": "brand-new-pass"}, bearer(tok))
|
||||||
|
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
|
||||||
|
assert.NotNil(t, ok.cookie(auth.RefreshCookie), "current web device keeps a session")
|
||||||
|
|
||||||
|
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code())
|
||||||
|
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh",
|
||||||
|
map[string]any{"refresh_token": phone.body["refresh_token"]}).code(), "other devices are signed out")
|
||||||
|
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(ok))).Code)
|
||||||
|
|
||||||
|
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "alice-password", "web").code())
|
||||||
|
e.mustLogin("alice", "brand-new-pass", "web")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemporaryPasswordMustBeChanged(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("bob", "temp-pass-123", auth.RoleUser, func(u *auth.User) { u.MustChangePassword = true })
|
||||||
|
r := e.mustLogin("bob", "temp-pass-123", "android")
|
||||||
|
tok := access(r)
|
||||||
|
assert.Equal(t, true, user(r)["must_change_password"])
|
||||||
|
|
||||||
|
denied := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok))
|
||||||
|
assert.Equal(t, http.StatusForbidden, denied.Code)
|
||||||
|
assert.Equal(t, auth.CodePasswordChangeRequired, denied.code())
|
||||||
|
|
||||||
|
ok := e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "bobs-own-pass"}, bearer(tok))
|
||||||
|
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
|
||||||
|
assert.Equal(t, false, user(ok)["must_change_password"])
|
||||||
|
assert.NotEmpty(t, ok.body["refresh_token"], "android device gets refresh token in the body")
|
||||||
|
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(ok))).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrivilegedPasswordLength(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
|
||||||
|
tok := access(e.mustLogin("mod", "moderator-pass-1", "web"))
|
||||||
|
r := e.do(http.MethodPost, "/api/v1/me/password",
|
||||||
|
map[string]any{"current_password": "moderator-pass-1", "new_password": "eleven-char"}, bearer(tok))
|
||||||
|
assert.Equal(t, auth.CodePasswordTooShort, r.code())
|
||||||
|
assert.EqualValues(t, 12, r.body["params"].(map[string]any)["min"])
|
||||||
|
assert.Equal(t, []any{map[string]any{"field": "new_password", "code": auth.CodePasswordTooShort}}, r.body["errors"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateMe(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||||
|
tok := access(e.mustLogin("alice", "alice-password", "web"))
|
||||||
|
|
||||||
|
r := e.do(http.MethodPatch, "/api/v1/me", map[string]any{
|
||||||
|
"display_name": " Alice ", "locale": "en", "ui_prefs": map[string]any{"theme": "light", "crossfade_s": 3},
|
||||||
|
}, bearer(tok))
|
||||||
|
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||||
|
assert.Equal(t, "Alice", r.body["display_name"])
|
||||||
|
assert.Equal(t, "en", r.body["locale"])
|
||||||
|
|
||||||
|
r = e.do(http.MethodPatch, "/api/v1/me", map[string]any{"ui_prefs": map[string]any{"wave_variant": "liquid"}}, bearer(tok))
|
||||||
|
assert.Equal(t, map[string]any{"theme": "light", "crossfade_s": float64(3), "wave_variant": "liquid"}, r.body["ui_prefs"],
|
||||||
|
"ui_prefs are merged")
|
||||||
|
assert.Equal(t, "Alice", r.body["display_name"], "absent fields are unchanged")
|
||||||
|
|
||||||
|
r = e.do(http.MethodPatch, "/api/v1/me", map[string]any{"display_name": ""}, bearer(tok))
|
||||||
|
assert.Nil(t, r.body["display_name"])
|
||||||
|
|
||||||
|
assert.Equal(t, auth.CodeLocaleUnsupported,
|
||||||
|
e.do(http.MethodPatch, "/api/v1/me", map[string]any{"locale": "de"}, bearer(tok)).code())
|
||||||
|
assert.Equal(t, auth.CodeUIPrefsInvalid,
|
||||||
|
e.do(http.MethodPatch, "/api/v1/me", map[string]any{"ui_prefs": map[string]any{"crossfade_s": 9}}, bearer(tok)).code())
|
||||||
|
assert.Equal(t, auth.CodeDisplayNameTooLong,
|
||||||
|
e.do(http.MethodPatch, "/api/v1/me", map[string]any{"display_name": strings.Repeat("я", 65)}, bearer(tok)).code())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessions(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||||
|
web := e.mustLogin("alice", "alice-password", "web")
|
||||||
|
e.clock.Advance(time.Second)
|
||||||
|
phone := e.mustLogin("alice", "alice-password", "android")
|
||||||
|
tok := access(web)
|
||||||
|
|
||||||
|
list := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok))
|
||||||
|
items := list.body["items"].([]any)
|
||||||
|
require.Len(t, items, 2)
|
||||||
|
first, second := items[0].(map[string]any), items[1].(map[string]any)
|
||||||
|
assert.Equal(t, "android", first["client_type"], "newest first")
|
||||||
|
assert.Equal(t, false, first["current"])
|
||||||
|
assert.Equal(t, true, second["current"])
|
||||||
|
assert.Equal(t, "Test device", second["device_name"])
|
||||||
|
|
||||||
|
assert.Equal(t, auth.CodeSessionNotFound,
|
||||||
|
e.do(http.MethodDelete, "/api/v1/me/sessions/"+uuid.NewString(), nil, bearer(tok)).code())
|
||||||
|
del := e.do(http.MethodDelete, "/api/v1/me/sessions/"+first["id"].(string), nil, bearer(tok))
|
||||||
|
assert.Equal(t, http.StatusNoContent, del.Code)
|
||||||
|
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh",
|
||||||
|
map[string]any{"refresh_token": phone.body["refresh_token"]}).code())
|
||||||
|
assert.Len(t, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok)).body["items"], 1)
|
||||||
|
|
||||||
|
// Чужую сессию завершить нельзя.
|
||||||
|
e.addUser("bob", "bob-password", auth.RoleUser)
|
||||||
|
bob := access(e.mustLogin("bob", "bob-password", "web"))
|
||||||
|
assert.Equal(t, auth.CodeSessionNotFound,
|
||||||
|
e.do(http.MethodDelete, "/api/v1/me/sessions/"+second["id"].(string), nil, bearer(bob)).code())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogout(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("alice", "alice-password", auth.RoleUser)
|
||||||
|
web := e.mustLogin("alice", "alice-password", "web")
|
||||||
|
phone := e.mustLogin("alice", "alice-password", "android")
|
||||||
|
|
||||||
|
out := e.do(http.MethodPost, "/api/v1/auth/logout", nil, bearer(access(web)))
|
||||||
|
assert.Equal(t, http.StatusNoContent, out.Code)
|
||||||
|
require.NotNil(t, out.cookie(auth.RefreshCookie))
|
||||||
|
assert.Equal(t, -1, out.cookie(auth.RefreshCookie).MaxAge)
|
||||||
|
assert.Equal(t, auth.CodeRefreshInvalid,
|
||||||
|
e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(web.cookie(auth.RefreshCookie))).code())
|
||||||
|
|
||||||
|
rt := phone.body["refresh_token"]
|
||||||
|
ok := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt})
|
||||||
|
require.Equal(t, http.StatusOK, ok.Code, "logout touches only the current session")
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNoContent, e.do(http.MethodPost, "/api/v1/auth/logout-all", nil, bearer(access(ok))).Code)
|
||||||
|
assert.Equal(t, auth.CodeRefreshInvalid,
|
||||||
|
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": ok.body["refresh_token"]}).code())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicRoutes(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
|
||||||
|
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
// Package authtest — тестовые реализации зависимостей auth: хранилище в памяти, часы, лимитер.
|
||||||
|
package authtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"pipu/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MemStore — auth.Store в памяти. WithTx откатывает изменения, если fn вернула ошибку.
|
||||||
|
type MemStore struct {
|
||||||
|
mu *sync.Mutex
|
||||||
|
inTx bool
|
||||||
|
Users map[uuid.UUID]auth.User
|
||||||
|
Tokens map[uuid.UUID]auth.RefreshToken
|
||||||
|
Audit []auth.AuditEntry
|
||||||
|
AuditIDs []uuid.UUID
|
||||||
|
Settings map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemStore создаёт пустое хранилище.
|
||||||
|
func NewMemStore() *MemStore {
|
||||||
|
return &MemStore{
|
||||||
|
mu: &sync.Mutex{},
|
||||||
|
Users: map[uuid.UUID]auth.User{},
|
||||||
|
Tokens: map[uuid.UUID]auth.RefreshToken{},
|
||||||
|
Settings: map[string]any{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemStore) lock() func() {
|
||||||
|
if s.inTx {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
return s.mu.Unlock
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTx реализует auth.Store.
|
||||||
|
func (s *MemStore) WithTx(_ context.Context, fn func(auth.Store) error) error {
|
||||||
|
unlock := s.lock()
|
||||||
|
defer unlock()
|
||||||
|
users, tokens, settings := maps.Clone(s.Users), maps.Clone(s.Tokens), maps.Clone(s.Settings)
|
||||||
|
audit, auditIDs := slices.Clone(s.Audit), slices.Clone(s.AuditIDs)
|
||||||
|
tx := &MemStore{
|
||||||
|
mu: s.mu, inTx: true, Users: s.Users, Tokens: s.Tokens, Audit: s.Audit, AuditIDs: s.AuditIDs, Settings: s.Settings,
|
||||||
|
}
|
||||||
|
if err := fn(tx); err != nil {
|
||||||
|
s.Users, s.Tokens, s.Audit, s.AuditIDs, s.Settings = users, tokens, audit, auditIDs, settings
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Users, s.Tokens, s.Audit, s.AuditIDs, s.Settings = tx.Users, tx.Tokens, tx.Audit, tx.AuditIDs, tx.Settings
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountAdmins реализует auth.Store.
|
||||||
|
func (s *MemStore) CountAdmins(context.Context) (int, error) {
|
||||||
|
defer s.lock()()
|
||||||
|
n := 0
|
||||||
|
for _, u := range s.Users {
|
||||||
|
if u.Role == auth.RoleAdmin && !u.IsBlocked {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemStore) usernameTaken(username string, except uuid.UUID) bool {
|
||||||
|
for _, u := range s.Users {
|
||||||
|
if u.ID != except && strings.EqualFold(u.Username, username) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser реализует auth.Store.
|
||||||
|
func (s *MemStore) CreateUser(_ context.Context, u auth.User, _ *uuid.UUID) error {
|
||||||
|
defer s.lock()()
|
||||||
|
if s.usernameTaken(u.Username, uuid.Nil) {
|
||||||
|
return auth.ErrUsernameTaken
|
||||||
|
}
|
||||||
|
s.Users[u.ID] = cloneUser(u)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneUser копирует ui_prefs через JSON, чтобы тест не делил указатели с хранилищем.
|
||||||
|
func cloneUser(u auth.User) auth.User {
|
||||||
|
raw, _ := json.Marshal(u.UIPrefs)
|
||||||
|
u.UIPrefs = auth.UIPrefs{}
|
||||||
|
_ = json.Unmarshal(raw, &u.UIPrefs)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID реализует auth.Store.
|
||||||
|
func (s *MemStore) GetUserByID(_ context.Context, id uuid.UUID) (auth.User, error) {
|
||||||
|
defer s.lock()()
|
||||||
|
u, ok := s.Users[id]
|
||||||
|
if !ok {
|
||||||
|
return auth.User{}, auth.ErrNotFound
|
||||||
|
}
|
||||||
|
return cloneUser(u), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByUsername реализует auth.Store.
|
||||||
|
func (s *MemStore) GetUserByUsername(_ context.Context, username string) (auth.User, error) {
|
||||||
|
defer s.lock()()
|
||||||
|
for _, u := range s.Users {
|
||||||
|
if strings.EqualFold(u.Username, username) {
|
||||||
|
return cloneUser(u), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return auth.User{}, auth.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemStore) update(id uuid.UUID, fn func(*auth.User)) error {
|
||||||
|
defer s.lock()()
|
||||||
|
u, ok := s.Users[id]
|
||||||
|
if !ok {
|
||||||
|
return auth.ErrNotFound
|
||||||
|
}
|
||||||
|
fn(&u)
|
||||||
|
s.Users[id] = u
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordLoginFailure реализует auth.Store.
|
||||||
|
func (s *MemStore) RecordLoginFailure(_ context.Context, id uuid.UUID, failed int, lockedUntil *time.Time) error {
|
||||||
|
return s.update(id, func(u *auth.User) { u.FailedLoginCount, u.LockedUntil = failed, lockedUntil })
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordLoginSuccess реализует auth.Store.
|
||||||
|
func (s *MemStore) RecordLoginSuccess(_ context.Context, id uuid.UUID, at time.Time, newHash *string) error {
|
||||||
|
return s.update(id, func(u *auth.User) {
|
||||||
|
u.FailedLoginCount, u.LockedUntil, u.LastLoginAt = 0, nil, &at
|
||||||
|
if newHash != nil {
|
||||||
|
u.PasswordHash = *newHash
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPassword реализует auth.Store.
|
||||||
|
func (s *MemStore) SetPassword(_ context.Context, id uuid.UUID, hash string) error {
|
||||||
|
return s.update(id, func(u *auth.User) {
|
||||||
|
u.PasswordHash, u.MustChangePassword = hash, false
|
||||||
|
u.TokenVersion++
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCredentials реализует auth.Store.
|
||||||
|
func (s *MemStore) SetCredentials(_ context.Context, id uuid.UUID, username, hash string) error {
|
||||||
|
unlock := s.lock()
|
||||||
|
taken := s.usernameTaken(username, id)
|
||||||
|
unlock()
|
||||||
|
if taken {
|
||||||
|
return auth.ErrUsernameTaken
|
||||||
|
}
|
||||||
|
return s.update(id, func(u *auth.User) {
|
||||||
|
u.Username, u.PasswordHash = username, hash
|
||||||
|
u.MustChangeCredentials, u.MustChangePassword = false, false
|
||||||
|
u.TokenVersion++
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfile реализует auth.Store.
|
||||||
|
func (s *MemStore) UpdateProfile(ctx context.Context, id uuid.UUID, upd auth.ProfileUpdate) (auth.User, error) {
|
||||||
|
err := s.update(id, func(u *auth.User) {
|
||||||
|
if upd.DisplayName != nil {
|
||||||
|
u.DisplayName = *upd.DisplayName
|
||||||
|
}
|
||||||
|
if upd.Locale != nil {
|
||||||
|
u.Locale = *upd.Locale
|
||||||
|
}
|
||||||
|
if upd.UIPrefs != nil {
|
||||||
|
u.UIPrefs = *upd.UIPrefs
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
return s.GetUserByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertRefreshToken реализует auth.Store.
|
||||||
|
func (s *MemStore) InsertRefreshToken(_ context.Context, t auth.RefreshToken) error {
|
||||||
|
defer s.lock()()
|
||||||
|
s.Tokens[t.ID] = t
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRefreshTokenForUpdate реализует auth.Store.
|
||||||
|
func (s *MemStore) GetRefreshTokenForUpdate(_ context.Context, hash []byte) (auth.RefreshToken, error) {
|
||||||
|
defer s.lock()()
|
||||||
|
for _, t := range s.Tokens {
|
||||||
|
if bytes.Equal(t.TokenHash, hash) {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return auth.RefreshToken{}, auth.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkRefreshTokenUsed реализует auth.Store.
|
||||||
|
func (s *MemStore) MarkRefreshTokenUsed(_ context.Context, id uuid.UUID, at time.Time) error {
|
||||||
|
defer s.lock()()
|
||||||
|
t := s.Tokens[id]
|
||||||
|
t.UsedAt = &at
|
||||||
|
s.Tokens[id] = t
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemStore) revoke(at time.Time, match func(auth.RefreshToken) bool) {
|
||||||
|
defer s.lock()()
|
||||||
|
for id, t := range s.Tokens {
|
||||||
|
if t.RevokedAt == nil && match(t) {
|
||||||
|
t.RevokedAt = &at
|
||||||
|
s.Tokens[id] = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeFamily реализует auth.Store.
|
||||||
|
func (s *MemStore) RevokeFamily(_ context.Context, familyID uuid.UUID, at time.Time) error {
|
||||||
|
s.revoke(at, func(t auth.RefreshToken) bool { return t.FamilyID == familyID })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeUserSessions реализует auth.Store.
|
||||||
|
func (s *MemStore) RevokeUserSessions(_ context.Context, userID uuid.UUID, at time.Time) error {
|
||||||
|
s.revoke(at, func(t auth.RefreshToken) bool { return t.UserID == userID })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSessions реализует auth.Store.
|
||||||
|
func (s *MemStore) ListSessions(_ context.Context, userID uuid.UUID, now time.Time) ([]auth.Session, error) {
|
||||||
|
defer s.lock()()
|
||||||
|
var out []auth.Session
|
||||||
|
for _, t := range s.Tokens {
|
||||||
|
if t.UserID == userID && t.UsedAt == nil && t.RevokedAt == nil && t.ExpiresAt.After(now) {
|
||||||
|
out = append(out, auth.Session{
|
||||||
|
ID: t.FamilyID, DeviceName: t.DeviceName, ClientType: t.ClientType,
|
||||||
|
CreatedAt: t.FamilyStartedAt, LastUsedAt: t.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b auth.Session) int { return b.LastUsedAt.Compare(a.LastUsedAt) })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers реализует auth.Store.
|
||||||
|
func (s *MemStore) ListUsers(_ context.Context, f auth.UserFilter) ([]auth.User, error) {
|
||||||
|
defer s.lock()()
|
||||||
|
q := strings.ToLower(f.Query)
|
||||||
|
var out []auth.User
|
||||||
|
for _, u := range s.Users {
|
||||||
|
name := ""
|
||||||
|
if u.DisplayName != nil {
|
||||||
|
name = strings.ToLower(*u.DisplayName)
|
||||||
|
}
|
||||||
|
if q != "" && !strings.Contains(strings.ToLower(u.Username), q) && !strings.Contains(name, q) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f.Role != nil && u.Role != *f.Role {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f.After != "" && strings.ToLower(u.Username) <= strings.ToLower(f.After) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, cloneUser(u))
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b auth.User) int {
|
||||||
|
return strings.Compare(strings.ToLower(a.Username), strings.ToLower(b.Username))
|
||||||
|
})
|
||||||
|
if len(out) > f.Limit {
|
||||||
|
out = out[:f.Limit]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockActiveAdmins реализует auth.Store.
|
||||||
|
func (s *MemStore) LockActiveAdmins(ctx context.Context) (int, error) { return s.CountAdmins(ctx) }
|
||||||
|
|
||||||
|
// UpdateUser реализует auth.Store.
|
||||||
|
func (s *MemStore) UpdateUser(ctx context.Context, id uuid.UUID, upd auth.UserUpdate) (auth.User, error) {
|
||||||
|
err := s.update(id, func(u *auth.User) {
|
||||||
|
if upd.DisplayName != nil {
|
||||||
|
u.DisplayName = *upd.DisplayName
|
||||||
|
}
|
||||||
|
if upd.Role != nil {
|
||||||
|
u.Role = *upd.Role
|
||||||
|
}
|
||||||
|
if upd.IsBlocked != nil {
|
||||||
|
u.IsBlocked = *upd.IsBlocked
|
||||||
|
}
|
||||||
|
if upd.RevokeTokens {
|
||||||
|
u.TokenVersion++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
return s.GetUserByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword реализует auth.Store.
|
||||||
|
func (s *MemStore) ResetPassword(_ context.Context, id uuid.UUID, hash string) error {
|
||||||
|
return s.update(id, func(u *auth.User) {
|
||||||
|
u.PasswordHash, u.MustChangePassword = hash, true
|
||||||
|
u.FailedLoginCount, u.LockedUntil = 0, nil
|
||||||
|
u.TokenVersion++
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser реализует auth.Store: токены удаляются, ссылки в журнале обнуляются, как в PostgreSQL.
|
||||||
|
func (s *MemStore) DeleteUser(_ context.Context, id uuid.UUID) error {
|
||||||
|
defer s.lock()()
|
||||||
|
delete(s.Users, id)
|
||||||
|
for tid, t := range s.Tokens {
|
||||||
|
if t.UserID == id {
|
||||||
|
delete(s.Tokens, tid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range s.Audit {
|
||||||
|
if s.Audit[i].ActorID != nil && *s.Audit[i].ActorID == id {
|
||||||
|
s.Audit[i].ActorID = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertAudit реализует auth.Store. ID сохраняется в AuditIDs параллельно Audit.
|
||||||
|
func (s *MemStore) InsertAudit(_ context.Context, id uuid.UUID, e auth.AuditEntry) error {
|
||||||
|
defer s.lock()()
|
||||||
|
s.Audit = append(s.Audit, e)
|
||||||
|
s.AuditIDs = append(s.AuditIDs, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAudit реализует auth.Store.
|
||||||
|
func (s *MemStore) ListAudit(_ context.Context, before uuid.UUID, limit int) ([]auth.AuditRecord, error) {
|
||||||
|
defer s.lock()()
|
||||||
|
var out []auth.AuditRecord
|
||||||
|
for i := len(s.Audit) - 1; i >= 0 && len(out) < limit; i-- {
|
||||||
|
id, e := s.AuditIDs[i], s.Audit[i]
|
||||||
|
if before != uuid.Nil && id.String() >= before.String() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r := auth.AuditRecord{ID: id, ActorID: e.ActorID, Action: e.Action, TargetID: e.TargetID, Payload: e.Payload}
|
||||||
|
if e.TargetType != "" {
|
||||||
|
tt := e.TargetType
|
||||||
|
r.TargetType = &tt
|
||||||
|
}
|
||||||
|
if e.ActorID != nil {
|
||||||
|
if u, ok := s.Users[*e.ActorID]; ok {
|
||||||
|
name := u.Username
|
||||||
|
r.ActorUsername = &name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSettingBool реализует auth.Store.
|
||||||
|
func (s *MemStore) GetSettingBool(_ context.Context, key string) (bool, error) {
|
||||||
|
defer s.lock()()
|
||||||
|
v, _ := s.Settings[key].(bool)
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSetting реализует auth.Store.
|
||||||
|
func (s *MemStore) SetSetting(_ context.Context, key string, value any) error {
|
||||||
|
defer s.lock()()
|
||||||
|
s.Settings[key] = value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ auth.Store = (*MemStore)(nil)
|
||||||
|
|
||||||
|
// Clock — управляемые часы.
|
||||||
|
type Clock struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
T time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now реализует auth.Clock.
|
||||||
|
func (c *Clock) Now() time.Time {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.T
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance сдвигает часы вперёд.
|
||||||
|
func (c *Clock) Advance(d time.Duration) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.T = c.T.Add(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter — счётчик попыток в памяти без учёта окна (окно сбрасывает Reset).
|
||||||
|
type Limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
counts map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow реализует auth.Limiter.
|
||||||
|
func (l *Limiter) Allow(_ context.Context, key string, limit int, window time.Duration) (bool, int, error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if l.counts == nil {
|
||||||
|
l.counts = map[string]int{}
|
||||||
|
}
|
||||||
|
l.counts[key]++
|
||||||
|
if l.counts[key] > limit {
|
||||||
|
return false, int(window / time.Second), nil
|
||||||
|
}
|
||||||
|
return true, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset обнуляет счётчики (как истечение окна).
|
||||||
|
func (l *Limiter) Reset() {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.counts = nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Частые пароли длиной от 8 символов (короче отсекает правило длины). Сравнение без учёта регистра.
|
||||||
|
# Неполный список: расширяется добавлением строк, по одной на строку.
|
||||||
|
12345678
|
||||||
|
123456789
|
||||||
|
1234567890
|
||||||
|
12345678910
|
||||||
|
123123123
|
||||||
|
1234512345
|
||||||
|
11111111
|
||||||
|
111111111
|
||||||
|
1111111111
|
||||||
|
00000000
|
||||||
|
0000000000
|
||||||
|
22222222
|
||||||
|
55555555
|
||||||
|
66666666
|
||||||
|
77777777
|
||||||
|
88888888
|
||||||
|
99999999
|
||||||
|
87654321
|
||||||
|
987654321
|
||||||
|
0987654321
|
||||||
|
11223344
|
||||||
|
12341234
|
||||||
|
12121212
|
||||||
|
13131313
|
||||||
|
147258369
|
||||||
|
123456789a
|
||||||
|
123456789q
|
||||||
|
1q2w3e4r
|
||||||
|
1q2w3e4r5t
|
||||||
|
1q2w3e4r5t6y
|
||||||
|
1qaz2wsx
|
||||||
|
1qaz2wsx3edc
|
||||||
|
zaq12wsx
|
||||||
|
qwertyui
|
||||||
|
qwertyuiop
|
||||||
|
qwerty123
|
||||||
|
qwerty1234
|
||||||
|
qwerty12345
|
||||||
|
qwerty123456
|
||||||
|
qwertyqwerty
|
||||||
|
asdfghjkl
|
||||||
|
asdfasdf
|
||||||
|
zxcvbnm123
|
||||||
|
zxcvbnmm
|
||||||
|
password
|
||||||
|
password1
|
||||||
|
password12
|
||||||
|
password123
|
||||||
|
password1234
|
||||||
|
password!
|
||||||
|
passw0rd
|
||||||
|
p@ssw0rd
|
||||||
|
p@ssword
|
||||||
|
pa55word
|
||||||
|
iloveyou
|
||||||
|
iloveyou1
|
||||||
|
iloveyou2
|
||||||
|
sunshine
|
||||||
|
princess
|
||||||
|
football
|
||||||
|
baseball
|
||||||
|
basketball
|
||||||
|
superman
|
||||||
|
batman123
|
||||||
|
starwars
|
||||||
|
whatever
|
||||||
|
trustno1
|
||||||
|
letmein1
|
||||||
|
welcome1
|
||||||
|
welcome123
|
||||||
|
changeme
|
||||||
|
changeme123
|
||||||
|
administrator
|
||||||
|
admin123
|
||||||
|
admin1234
|
||||||
|
admin12345
|
||||||
|
adminadmin
|
||||||
|
administrator1
|
||||||
|
rootroot
|
||||||
|
root1234
|
||||||
|
master123
|
||||||
|
dragon123
|
||||||
|
monkey123
|
||||||
|
shadow123
|
||||||
|
michael1
|
||||||
|
jennifer
|
||||||
|
computer
|
||||||
|
internet
|
||||||
|
abcd1234
|
||||||
|
abc12345
|
||||||
|
abc123456
|
||||||
|
abcdefgh
|
||||||
|
aaaaaaaa
|
||||||
|
aa123456
|
||||||
|
a1234567
|
||||||
|
a12345678
|
||||||
|
q1w2e3r4
|
||||||
|
q1w2e3r4t5
|
||||||
|
asdf1234
|
||||||
|
1234qwer
|
||||||
|
qwer1234
|
||||||
|
q1234567
|
||||||
|
myspace1
|
||||||
|
charlie1
|
||||||
|
freedom1
|
||||||
|
mustang1
|
||||||
|
liverpool
|
||||||
|
chelsea1
|
||||||
|
arsenal1
|
||||||
|
fuckyou1
|
||||||
|
secret123
|
||||||
|
samsung1
|
||||||
|
pokemon1
|
||||||
|
minecraft
|
||||||
|
loveyou1
|
||||||
|
hello123
|
||||||
|
helloworld
|
||||||
|
yankees1
|
||||||
|
jordan23
|
||||||
|
access14
|
||||||
|
cheese12
|
||||||
|
michelle
|
||||||
|
nicole12
|
||||||
|
jessica1
|
||||||
|
ashley12
|
||||||
|
killer12
|
||||||
|
soccer12
|
||||||
|
hunter12
|
||||||
|
ranger12
|
||||||
|
buster12
|
||||||
|
thomas12
|
||||||
|
tigger12
|
||||||
|
summer12
|
||||||
|
spring12
|
||||||
|
winter12
|
||||||
|
autumn12
|
||||||
|
qazwsxedc
|
||||||
|
1234abcd
|
||||||
|
pipu1234
|
||||||
|
pipumusic
|
||||||
|
music123
|
||||||
|
music1234
|
||||||
|
musiclover
|
||||||
|
parol123
|
||||||
|
parol1234
|
||||||
|
privet123
|
||||||
|
qwertyuiop123
|
||||||
|
йцукенгш
|
||||||
|
пароль123
|
||||||
|
пароль1234
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pipu/api/internal/api/gen"
|
||||||
|
"pipu/api/internal/platform/apperr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RefreshCookie — имя cookie с refresh-токеном веб-клиента.
|
||||||
|
const RefreshCookie = "pipu_rt"
|
||||||
|
|
||||||
|
const refreshCookiePath = "/api/v1/auth"
|
||||||
|
|
||||||
|
// Handler реализует auth- и me-части gen.StrictServerInterface.
|
||||||
|
type Handler struct {
|
||||||
|
svc *Service
|
||||||
|
cookieSecure bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler создаёт обработчик. cookieSecure = false допустим только для разработки по http.
|
||||||
|
func NewHandler(svc *Service, cookieSecure bool) *Handler {
|
||||||
|
return &Handler{svc: svc, cookieSecure: cookieSecure}
|
||||||
|
}
|
||||||
|
|
||||||
|
func principal(ctx context.Context) (Principal, error) {
|
||||||
|
p, ok := PrincipalFrom(ctx)
|
||||||
|
if !ok {
|
||||||
|
return Principal{}, apperr.Unauthorized(CodeUnauthorized, "not authenticated")
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) Login(ctx context.Context, req gen.LoginRequestObject) (gen.LoginResponseObject, error) {
|
||||||
|
in := LoginInput{
|
||||||
|
Username: req.Body.Username,
|
||||||
|
Password: req.Body.Password,
|
||||||
|
ClientType: ClientType(req.Body.ClientType),
|
||||||
|
IP: clientIP(ctx),
|
||||||
|
}
|
||||||
|
if req.Body.DeviceName != nil {
|
||||||
|
in.DeviceName = *req.Body.DeviceName
|
||||||
|
}
|
||||||
|
t, err := h.svc.Login(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.Login200JSONResponse(h.tokensDTO(ctx, t)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshTokens реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) RefreshTokens(ctx context.Context, req gen.RefreshTokensRequestObject) (gen.RefreshTokensResponseObject, error) {
|
||||||
|
var token string
|
||||||
|
if req.Body != nil && req.Body.RefreshToken != nil {
|
||||||
|
token = *req.Body.RefreshToken
|
||||||
|
} else if req.Params.PipuRt != nil {
|
||||||
|
token = *req.Params.PipuRt
|
||||||
|
}
|
||||||
|
t, err := h.svc.Refresh(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
var ae *apperr.Error
|
||||||
|
// Токен больше не годится — убираем cookie, чтобы браузер не присылал его снова.
|
||||||
|
// При гонке двух вкладок cookie уже новая, её не трогаем.
|
||||||
|
if req.Params.PipuRt != nil && errors.As(err, &ae) && ae.Code != CodeRefreshRace {
|
||||||
|
h.clearCookie(ctx)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.RefreshTokens200JSONResponse(h.tokensDTO(ctx, t)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) Logout(ctx context.Context, _ gen.LogoutRequestObject) (gen.LogoutResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := h.svc.Logout(ctx, p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h.clearCookie(ctx)
|
||||||
|
return gen.Logout204Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogoutAll реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) LogoutAll(ctx context.Context, _ gen.LogoutAllRequestObject) (gen.LogoutAllResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := h.svc.LogoutAll(ctx, p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h.clearCookie(ctx)
|
||||||
|
return gen.LogoutAll204Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMe реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) GetMe(ctx context.Context, _ gen.GetMeRequestObject) (gen.GetMeResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u, err := h.svc.Me(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.GetMe200JSONResponse(MeDTO(u)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMe реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) UpdateMe(ctx context.Context, req gen.UpdateMeRequestObject) (gen.UpdateMeResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
patch := MePatch{DisplayName: req.Body.DisplayName, Locale: req.Body.Locale}
|
||||||
|
if req.Body.UiPrefs != nil {
|
||||||
|
prefs := uiPrefsFromDTO(*req.Body.UiPrefs)
|
||||||
|
patch.UIPrefs = &prefs
|
||||||
|
}
|
||||||
|
u, err := h.svc.UpdateMe(ctx, p, patch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.UpdateMe200JSONResponse(MeDTO(u)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) ChangePassword(ctx context.Context, req gen.ChangePasswordRequestObject) (gen.ChangePasswordResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t, err := h.svc.ChangePassword(ctx, p, req.Body.CurrentPassword, req.Body.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.ChangePassword200JSONResponse(h.tokensDTO(ctx, t)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBootstrapCredentials реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) SetBootstrapCredentials(ctx context.Context, req gen.SetBootstrapCredentialsRequestObject) (gen.SetBootstrapCredentialsResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t, err := h.svc.SetBootstrapCredentials(ctx, p, req.Body.NewUsername, req.Body.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.SetBootstrapCredentials200JSONResponse(h.tokensDTO(ctx, t)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMySessions реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) ListMySessions(ctx context.Context, _ gen.ListMySessionsRequestObject) (gen.ListMySessionsResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sessions, err := h.svc.ListSessions(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]gen.Session, 0, len(sessions))
|
||||||
|
for _, s := range sessions {
|
||||||
|
items = append(items, gen.Session{
|
||||||
|
Id: s.ID,
|
||||||
|
DeviceName: s.DeviceName,
|
||||||
|
ClientType: gen.ClientType(s.ClientType),
|
||||||
|
CreatedAt: s.CreatedAt,
|
||||||
|
LastUsedAt: s.LastUsedAt,
|
||||||
|
Current: s.ID == p.SessionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return gen.ListMySessions200JSONResponse{Items: items}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeMySession реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) RevokeMySession(ctx context.Context, req gen.RevokeMySessionRequestObject) (gen.RevokeMySessionResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := h.svc.RevokeSession(ctx, p, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.RevokeMySession204Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokensDTO собирает ответ с токенами: веб получает refresh в cookie, Android — в теле.
|
||||||
|
func (h *Handler) tokensDTO(ctx context.Context, t Tokens) gen.AuthTokens {
|
||||||
|
out := gen.AuthTokens{
|
||||||
|
AccessToken: t.Access,
|
||||||
|
TokenType: gen.Bearer,
|
||||||
|
ExpiresIn: int(t.ExpiresIn / time.Second),
|
||||||
|
User: MeDTO(t.User),
|
||||||
|
}
|
||||||
|
if t.ClientType == ClientWeb {
|
||||||
|
setCookie(ctx, &http.Cookie{
|
||||||
|
Name: RefreshCookie,
|
||||||
|
Value: t.Refresh,
|
||||||
|
Path: refreshCookiePath,
|
||||||
|
MaxAge: int(h.svc.cfg.RefreshTTL / time.Second),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: h.cookieSecure,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
refresh := t.Refresh
|
||||||
|
out.RefreshToken = &refresh
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) clearCookie(ctx context.Context) {
|
||||||
|
setCookie(ctx, &http.Cookie{
|
||||||
|
Name: RefreshCookie, Path: refreshCookiePath, MaxAge: -1,
|
||||||
|
HttpOnly: true, Secure: h.cookieSecure, SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeDTO переводит пользователя в схему Me.
|
||||||
|
func MeDTO(u User) gen.Me {
|
||||||
|
return gen.Me{
|
||||||
|
Id: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
DisplayName: u.DisplayName,
|
||||||
|
Role: gen.Role(u.Role),
|
||||||
|
Locale: u.Locale,
|
||||||
|
UiPrefs: uiPrefsDTO(u.UIPrefs),
|
||||||
|
MustChangePassword: u.MustChangePassword,
|
||||||
|
MustChangeCredentials: u.MustChangeCredentials,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uiPrefsDTO(p UIPrefs) gen.UiPrefs {
|
||||||
|
return gen.UiPrefs{
|
||||||
|
Theme: (*gen.UiPrefsTheme)(p.Theme),
|
||||||
|
WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant),
|
||||||
|
WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer),
|
||||||
|
MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer),
|
||||||
|
Ambient: p.Ambient,
|
||||||
|
CrossfadeS: p.CrossfadeS,
|
||||||
|
Normalize: p.Normalize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
|
||||||
|
return UIPrefs{
|
||||||
|
Theme: (*string)(p.Theme),
|
||||||
|
WaveVariant: (*string)(p.WaveVariant),
|
||||||
|
WebPlayer: (*string)(p.WebPlayer),
|
||||||
|
MobilePlayer: (*string)(p.MobilePlayer),
|
||||||
|
Ambient: p.Ambient,
|
||||||
|
CrossfadeS: p.CrossfadeS,
|
||||||
|
Normalize: p.Normalize,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RedisLimiter — счётчик попыток с фиксированным окном в Redis.
|
||||||
|
type RedisLimiter struct{ rdb redis.Cmdable }
|
||||||
|
|
||||||
|
// NewRedisLimiter создаёт лимитер.
|
||||||
|
func NewRedisLimiter(rdb redis.Cmdable) *RedisLimiter { return &RedisLimiter{rdb: rdb} }
|
||||||
|
|
||||||
|
// Первый INCR ставит срок окна; ответ — {счётчик, оставшийся TTL в мс}.
|
||||||
|
var incrWindow = redis.NewScript(`
|
||||||
|
local n = redis.call('INCR', KEYS[1])
|
||||||
|
if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
||||||
|
return {n, redis.call('PTTL', KEYS[1])}
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Allow реализует Limiter.
|
||||||
|
func (l *RedisLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, int, error) {
|
||||||
|
res, err := incrWindow.Run(ctx, l.rdb, []string{"rl:" + key}, window.Milliseconds()).Int64Slice()
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, fmt.Errorf("auth: rate limit: %w", err)
|
||||||
|
}
|
||||||
|
if len(res) != 2 {
|
||||||
|
return false, 0, fmt.Errorf("auth: rate limit: unexpected reply %v", res)
|
||||||
|
}
|
||||||
|
if res[0] <= int64(limit) {
|
||||||
|
return true, 0, nil
|
||||||
|
}
|
||||||
|
retry := int((res[1] + 999) / 1000)
|
||||||
|
if retry < 1 {
|
||||||
|
retry = 1
|
||||||
|
}
|
||||||
|
return false, retry, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"pipu/api/internal/platform/apperr"
|
||||||
|
"pipu/api/internal/platform/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
principalKey ctxKey = iota
|
||||||
|
requestKey
|
||||||
|
)
|
||||||
|
|
||||||
|
// requestInfo — то, что strict-обработчикам нужно от HTTP-запроса, но не входит в контракт:
|
||||||
|
// адрес клиента и cookie, которые нужно выставить в ответе.
|
||||||
|
type requestInfo struct {
|
||||||
|
ip string
|
||||||
|
cookies []*http.Cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrincipalFrom возвращает пользователя запроса (ok = false для публичных эндпоинтов).
|
||||||
|
func PrincipalFrom(ctx context.Context) (Principal, bool) {
|
||||||
|
p, ok := ctx.Value(principalKey).(Principal)
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrincipal кладёт пользователя в контекст (для тестов других модулей).
|
||||||
|
func WithPrincipal(ctx context.Context, p Principal) context.Context {
|
||||||
|
return context.WithValue(ctx, principalKey, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(ctx context.Context) string {
|
||||||
|
if ri, ok := ctx.Value(requestKey).(*requestInfo); ok {
|
||||||
|
return ri.ip
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCookie(ctx context.Context, c *http.Cookie) {
|
||||||
|
if ri, ok := ctx.Value(requestKey).(*requestInfo); ok {
|
||||||
|
ri.cookies = append(ri.cookies, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Публичные эндпоинты /api/v1 — без access-токена.
|
||||||
|
var publicRoutes = map[string]bool{
|
||||||
|
"GET /api/v1/server-info": true,
|
||||||
|
"POST /api/v1/auth/login": true,
|
||||||
|
"POST /api/v1/auth/refresh": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
|
||||||
|
var (
|
||||||
|
credentialsChangeRoutes = map[string]bool{
|
||||||
|
"GET /api/v1/me": true,
|
||||||
|
"POST /api/v1/me/bootstrap-credentials": true,
|
||||||
|
"POST /api/v1/auth/logout": true,
|
||||||
|
}
|
||||||
|
passwordChangeRoutes = map[string]bool{
|
||||||
|
"GET /api/v1/me": true,
|
||||||
|
"POST /api/v1/me/password": true,
|
||||||
|
"POST /api/v1/auth/logout": true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Middleware аутентифицирует запросы к /api/v1 по Bearer-токену и ограничивает пользователей,
|
||||||
|
// обязанных сменить пароль или учётные данные (docs/auth-and-roles.md).
|
||||||
|
func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ri := &requestInfo{ip: hostOnly(r.RemoteAddr)}
|
||||||
|
ctx := context.WithValue(r.Context(), requestKey, ri)
|
||||||
|
w = &cookieWriter{ResponseWriter: w, ri: ri}
|
||||||
|
|
||||||
|
route := r.Method + " " + strings.TrimSuffix(r.URL.Path, "/")
|
||||||
|
if route == "POST /api/v1/auth/refresh" && r.ContentLength == 0 {
|
||||||
|
// Веб шлёт refresh без тела (токен в cookie), а strict-сервер oapi-codegen
|
||||||
|
// декодирует необязательное тело всё равно и отвечает 400 на пустое.
|
||||||
|
r.Body = io.NopCloser(strings.NewReader("{}"))
|
||||||
|
r.ContentLength = 2
|
||||||
|
}
|
||||||
|
// Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись.
|
||||||
|
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
|
||||||
|
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||||
|
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/"))
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, ok := bearer(r.Header.Get("Authorization"))
|
||||||
|
if !ok {
|
||||||
|
httpx.WriteError(log, w, r, apperr.Unauthorized(CodeUnauthorized, "bearer token required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := svc.Authenticate(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteError(log, w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case p.MustChangeCredentials && !credentialsChangeRoutes[route]:
|
||||||
|
httpx.WriteError(log, w, r, apperr.Forbidden(CodeCredentialsChangeRequired, "change bootstrap credentials first"))
|
||||||
|
return
|
||||||
|
case p.MustChangePassword && !passwordChangeRoutes[route]:
|
||||||
|
httpx.WriteError(log, w, r, apperr.Forbidden(CodePasswordChangeRequired, "change the temporary password first"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r.WithContext(WithPrincipal(ctx, p)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearer(h string) (string, bool) {
|
||||||
|
const prefix = "bearer "
|
||||||
|
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
t := strings.TrimSpace(h[len(prefix):])
|
||||||
|
return t, t != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostOnly(addr string) string {
|
||||||
|
if host, _, err := net.SplitHostPort(addr); err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookieWriter добавляет накопленные обработчиком cookie перед записью заголовков ответа.
|
||||||
|
type cookieWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
ri *requestInfo
|
||||||
|
wrote bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *cookieWriter) flush() {
|
||||||
|
if w.wrote {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.wrote = true
|
||||||
|
for _, c := range w.ri.cookies {
|
||||||
|
http.SetCookie(w.ResponseWriter, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *cookieWriter) WriteHeader(code int) {
|
||||||
|
w.flush()
|
||||||
|
w.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *cookieWriter) Write(b []byte) (int, error) {
|
||||||
|
w.flush()
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap нужен http.ResponseController (flush, дедлайны) для обёрнутого writer.
|
||||||
|
func (w *cookieWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// Package auth — пользователи, вход, токены, политика прав (docs/auth-and-roles.md).
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role — роль пользователя.
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
// Роли по возрастанию прав.
|
||||||
|
const (
|
||||||
|
RoleUser Role = "user"
|
||||||
|
RoleModerator Role = "moderator"
|
||||||
|
RoleAdmin Role = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid сообщает, известна ли роль.
|
||||||
|
func (r Role) Valid() bool { return r == RoleUser || r == RoleModerator || r == RoleAdmin }
|
||||||
|
|
||||||
|
// ClientType — тип клиента, от него зависит доставка refresh-токена.
|
||||||
|
type ClientType string
|
||||||
|
|
||||||
|
// Типы клиентов.
|
||||||
|
const (
|
||||||
|
ClientWeb ClientType = "web"
|
||||||
|
ClientAndroid ClientType = "android"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid сообщает, известен ли тип клиента.
|
||||||
|
func (c ClientType) Valid() bool { return c == ClientWeb || c == ClientAndroid }
|
||||||
|
|
||||||
|
// UIPrefs — настройки интерфейса пользователя (users.ui_prefs). Пустые поля — значения по умолчанию клиента.
|
||||||
|
type UIPrefs struct {
|
||||||
|
Theme *string `json:"theme,omitempty"`
|
||||||
|
WaveVariant *string `json:"wave_variant,omitempty"`
|
||||||
|
WebPlayer *string `json:"web_player,omitempty"`
|
||||||
|
MobilePlayer *string `json:"mobile_player,omitempty"`
|
||||||
|
Ambient *bool `json:"ambient,omitempty"`
|
||||||
|
CrossfadeS *int `json:"crossfade_s,omitempty"`
|
||||||
|
Normalize *bool `json:"normalize,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// User — учётная запись.
|
||||||
|
type User struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Username string
|
||||||
|
DisplayName *string
|
||||||
|
PasswordHash string
|
||||||
|
Role Role
|
||||||
|
Locale string
|
||||||
|
UIPrefs UIPrefs
|
||||||
|
MustChangePassword bool
|
||||||
|
MustChangeCredentials bool
|
||||||
|
IsBlocked bool
|
||||||
|
TokenVersion int
|
||||||
|
FailedLoginCount int
|
||||||
|
LockedUntil *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastLoginAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken — строка refresh_tokens. Сам токен в БД не хранится, только SHA-256.
|
||||||
|
type RefreshToken struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
UserID uuid.UUID
|
||||||
|
FamilyID uuid.UUID
|
||||||
|
FamilyStartedAt time.Time
|
||||||
|
TokenHash []byte
|
||||||
|
DeviceName *string
|
||||||
|
ClientType ClientType
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
UsedAt *time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session — активная семья refresh-токенов (одно устройство).
|
||||||
|
type Session struct {
|
||||||
|
ID uuid.UUID // family_id
|
||||||
|
DeviceName *string
|
||||||
|
ClientType ClientType
|
||||||
|
CreatedAt time.Time // начало семьи (вход)
|
||||||
|
LastUsedAt time.Time // последняя ротация
|
||||||
|
}
|
||||||
|
|
||||||
|
// Principal — аутентифицированный пользователь запроса (из access-токена и актуальной строки users).
|
||||||
|
type Principal struct {
|
||||||
|
UserID uuid.UUID
|
||||||
|
Role Role
|
||||||
|
SessionID uuid.UUID
|
||||||
|
MustChangePassword bool
|
||||||
|
MustChangeCredentials bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditRecord — прочитанная запись audit_log с логином автора.
|
||||||
|
type AuditRecord struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
ActorID *uuid.UUID
|
||||||
|
ActorUsername *string
|
||||||
|
Action string
|
||||||
|
TargetType *string
|
||||||
|
TargetID *uuid.UUID
|
||||||
|
Payload map[string]any
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditEntry — запись audit_log.
|
||||||
|
type AuditEntry struct {
|
||||||
|
ActorID *uuid.UUID
|
||||||
|
Action string
|
||||||
|
TargetType string
|
||||||
|
TargetID *uuid.UUID
|
||||||
|
Payload map[string]any
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Argon2Params — параметры argon2id. Хранятся в самом хэше (формат PHC), поэтому их можно менять:
|
||||||
|
// старые хэши проверяются со своими параметрами и пересчитываются при следующем входе.
|
||||||
|
type Argon2Params struct {
|
||||||
|
MemoryKiB uint32
|
||||||
|
Time uint32
|
||||||
|
Threads uint8
|
||||||
|
SaltLen int
|
||||||
|
KeyLen uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultArgon2Params — m=64 MiB, t=3, p=2 (docs/auth-and-roles.md).
|
||||||
|
var DefaultArgon2Params = Argon2Params{MemoryKiB: 64 * 1024, Time: 3, Threads: 2, SaltLen: 16, KeyLen: 32}
|
||||||
|
|
||||||
|
// Hasher хэширует и проверяет пароли.
|
||||||
|
type Hasher struct{ p Argon2Params }
|
||||||
|
|
||||||
|
// NewHasher создаёт Hasher с заданными параметрами.
|
||||||
|
func NewHasher(p Argon2Params) *Hasher { return &Hasher{p: p} }
|
||||||
|
|
||||||
|
// Hash возвращает PHC-строку `$argon2id$v=19$m=…,t=…,p=…$salt$key`.
|
||||||
|
func (h *Hasher) Hash(password string) (string, error) {
|
||||||
|
salt := make([]byte, h.p.SaltLen)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", fmt.Errorf("auth: salt: %w", err)
|
||||||
|
}
|
||||||
|
key := argon2.IDKey([]byte(password), salt, h.p.Time, h.p.MemoryKiB, h.p.Threads, h.p.KeyLen)
|
||||||
|
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, h.p.MemoryKiB, h.p.Time, h.p.Threads,
|
||||||
|
b64.EncodeToString(salt), b64.EncodeToString(key)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify проверяет пароль. needsRehash = true, если хэш посчитан с устаревшими параметрами.
|
||||||
|
func (h *Hasher) Verify(password, encoded string) (ok, needsRehash bool, err error) {
|
||||||
|
p, salt, key, err := decodePHC(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return false, false, err
|
||||||
|
}
|
||||||
|
got := argon2.IDKey([]byte(password), salt, p.Time, p.MemoryKiB, p.Threads, uint32(len(key)))
|
||||||
|
if subtle.ConstantTimeCompare(got, key) != 1 {
|
||||||
|
return false, false, nil
|
||||||
|
}
|
||||||
|
needsRehash = p.MemoryKiB != h.p.MemoryKiB || p.Time != h.p.Time || p.Threads != h.p.Threads ||
|
||||||
|
len(salt) != h.p.SaltLen || uint32(len(key)) != h.p.KeyLen
|
||||||
|
return true, needsRehash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var b64 = base64.RawStdEncoding
|
||||||
|
|
||||||
|
var errBadHash = errors.New("auth: malformed password hash")
|
||||||
|
|
||||||
|
func decodePHC(s string) (Argon2Params, []byte, []byte, error) {
|
||||||
|
var p Argon2Params
|
||||||
|
parts := strings.Split(s, "$")
|
||||||
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||||
|
return p, nil, nil, errBadHash
|
||||||
|
}
|
||||||
|
var v int
|
||||||
|
if _, err := fmt.Sscanf(parts[2], "v=%d", &v); err != nil || v != argon2.Version {
|
||||||
|
return p, nil, nil, errBadHash
|
||||||
|
}
|
||||||
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.MemoryKiB, &p.Time, &p.Threads); err != nil {
|
||||||
|
return p, nil, nil, errBadHash
|
||||||
|
}
|
||||||
|
salt, err := b64.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return p, nil, nil, errBadHash
|
||||||
|
}
|
||||||
|
key, err := b64.DecodeString(parts[5])
|
||||||
|
if err != nil || len(key) == 0 {
|
||||||
|
return p, nil, nil, errBadHash
|
||||||
|
}
|
||||||
|
p.SaltLen, p.KeyLen = len(salt), uint32(len(key))
|
||||||
|
return p, salt, key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Алфавит временных паролей: без похожих символов (0/O, 1/l/I), чтобы пароль легко продиктовать.
|
||||||
|
const tempPasswordAlphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
// GeneratePassword возвращает случайный пароль длины n из tempPasswordAlphabet.
|
||||||
|
func GeneratePassword(n int) (string, error) {
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", fmt.Errorf("auth: random password: %w", err)
|
||||||
|
}
|
||||||
|
// 256 % 56 != 0: отбрасываем байты из «хвоста», чтобы распределение было равномерным.
|
||||||
|
const limit = 256 - 256%len(tempPasswordAlphabet)
|
||||||
|
out := make([]byte, 0, n)
|
||||||
|
for len(out) < n {
|
||||||
|
for _, b := range buf {
|
||||||
|
if int(b) < limit && len(out) < n {
|
||||||
|
out = append(out, tempPasswordAlphabet[int(b)%len(tempPasswordAlphabet)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", fmt.Errorf("auth: random password: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"pipu/api/internal/platform/apperr"
|
||||||
|
)
|
||||||
|
|
||||||
|
var fastArgon = Argon2Params{MemoryKiB: 64, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32}
|
||||||
|
|
||||||
|
func TestHasher(t *testing.T) {
|
||||||
|
h := NewHasher(fastArgon)
|
||||||
|
enc, err := h.Hash("correct horse")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, strings.HasPrefix(enc, "$argon2id$v=19$m=64,t=1,p=1$"), enc)
|
||||||
|
|
||||||
|
ok, rehash, err := h.Verify("correct horse", enc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.False(t, rehash)
|
||||||
|
|
||||||
|
ok, _, err = h.Verify("wrong horse", enc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, ok)
|
||||||
|
|
||||||
|
other, _ := h.Hash("correct horse")
|
||||||
|
assert.NotEqual(t, enc, other, "salt is random")
|
||||||
|
|
||||||
|
stronger := NewHasher(Argon2Params{MemoryKiB: 128, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32})
|
||||||
|
ok, rehash, err = stronger.Verify("correct horse", enc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, ok, "old params are read from the hash")
|
||||||
|
assert.True(t, rehash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasherRejectsMalformed(t *testing.T) {
|
||||||
|
h := NewHasher(fastArgon)
|
||||||
|
for _, s := range []string{"", "plain", "$argon2i$v=19$m=64,t=1,p=1$c2FsdA$a2V5", "$argon2id$v=18$m=64,t=1,p=1$c2FsdA$a2V5",
|
||||||
|
"$argon2id$v=19$m=x,t=1,p=1$c2FsdA$a2V5", "$argon2id$v=19$m=64,t=1,p=1$!!$a2V5", "$argon2id$v=19$m=64,t=1,p=1$c2FsdA$"} {
|
||||||
|
_, _, err := h.Verify("x", s)
|
||||||
|
assert.Error(t, err, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePassword(t *testing.T) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for range 50 {
|
||||||
|
p, err := GeneratePassword(12)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, p, 12)
|
||||||
|
for _, r := range p {
|
||||||
|
assert.Contains(t, tempPasswordAlphabet, string(r))
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
}
|
||||||
|
assert.Len(t, seen, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePassword(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, password, username string
|
||||||
|
role Role
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"user ok", "eightchr", "alice", RoleUser, ""},
|
||||||
|
{"user short", "sevench", "alice", RoleUser, CodePasswordTooShort},
|
||||||
|
{"moderator needs 12", "elevenchars", "mod", RoleModerator, CodePasswordTooShort},
|
||||||
|
{"admin ok", "twelve-chars", "root", RoleAdmin, ""},
|
||||||
|
{"runes not bytes", "пароль-ок", "alice", RoleUser, ""},
|
||||||
|
{"same as username", "Alexander", "alexander", RoleUser, CodePasswordSameAsUsername},
|
||||||
|
{"common", "Password123", "alice", RoleUser, CodePasswordTooCommon},
|
||||||
|
{"common cyrillic", "пароль123", "alice", RoleUser, CodePasswordTooCommon},
|
||||||
|
{"too long", strings.Repeat("a", MaxPassword+1), "alice", RoleUser, CodePasswordTooLong},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := ValidatePassword("new_password", tc.password, tc.username, tc.role)
|
||||||
|
if tc.code == "" {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ae *apperr.Error
|
||||||
|
require.ErrorAs(t, err, &ae)
|
||||||
|
assert.Equal(t, tc.code, ae.Code)
|
||||||
|
assert.Equal(t, "new_password", ae.Fields[0].Field)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateUsername(t *testing.T) {
|
||||||
|
for _, ok := range []string{"abc", "john.doe", "a_b-c", strings.Repeat("a", 32)} {
|
||||||
|
assert.NoError(t, ValidateUsername("username", ok), ok)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"ab", "John", "имя", "a b", "a@b", strings.Repeat("a", 33)} {
|
||||||
|
assert.Error(t, ValidateUsername("username", bad), bad)
|
||||||
|
}
|
||||||
|
assert.Equal(t, "john", NormalizeUsername(" John "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePrivateKey(t *testing.T) {
|
||||||
|
seed := "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||||
|
k1, err := ParsePrivateKey(seed)
|
||||||
|
require.NoError(t, err)
|
||||||
|
k2, err := ParsePrivateKey(strings.TrimRight(strings.NewReplacer("+", "-", "/", "_").Replace(seed), "="))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, k1, k2)
|
||||||
|
k3, err := ParsePrivateKey(b64std(k1))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, k1, k3, "64-byte key form")
|
||||||
|
_, err = ParsePrivateKey("c2hvcnQ=")
|
||||||
|
assert.Error(t, err)
|
||||||
|
_, err = ParsePrivateKey("not base64 !!")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func b64std(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// querier — общее у пула и транзакции.
|
||||||
|
type querier interface {
|
||||||
|
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||||
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
// PgStore — Store поверх PostgreSQL.
|
||||||
|
type PgStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
q querier
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPgStore создаёт Store на пуле соединений.
|
||||||
|
func NewPgStore(pool *pgxpool.Pool) *PgStore { return &PgStore{pool: pool, q: pool} }
|
||||||
|
|
||||||
|
// WithTx реализует Store. Вложенный вызов выполняется в уже открытой транзакции.
|
||||||
|
func (s *PgStore) WithTx(ctx context.Context, fn func(Store) error) error {
|
||||||
|
if s.pool == nil {
|
||||||
|
return fn(s)
|
||||||
|
}
|
||||||
|
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { return fn(&PgStore{q: tx}) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userColumns = `id, username, display_name, password_hash, role, locale, ui_prefs, must_change_password,
|
||||||
|
must_change_credentials, is_blocked, token_version, failed_login_count, locked_until, created_at, last_login_at`
|
||||||
|
|
||||||
|
func scanUser(row pgx.Row) (User, error) {
|
||||||
|
var (
|
||||||
|
u User
|
||||||
|
role string
|
||||||
|
prefs []byte
|
||||||
|
)
|
||||||
|
err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &role, &u.Locale, &prefs,
|
||||||
|
&u.MustChangePassword, &u.MustChangeCredentials, &u.IsBlocked, &u.TokenVersion, &u.FailedLoginCount,
|
||||||
|
&u.LockedUntil, &u.CreatedAt, &u.LastLoginAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return User{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("auth: scan user: %w", err)
|
||||||
|
}
|
||||||
|
u.Role = Role(role)
|
||||||
|
if err := json.Unmarshal(prefs, &u.UIPrefs); err != nil {
|
||||||
|
return User{}, fmt.Errorf("auth: decode ui_prefs: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountAdmins реализует Store. Заблокированные админы не считаются: войти они не могут.
|
||||||
|
func (s *PgStore) CountAdmins(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.q.QueryRow(ctx, `SELECT count(*) FROM users WHERE role = 'admin' AND NOT is_blocked`).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("auth: count admins: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser реализует Store.
|
||||||
|
func (s *PgStore) CreateUser(ctx context.Context, u User, createdBy *uuid.UUID) error {
|
||||||
|
prefs, err := json.Marshal(u.UIPrefs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.q.Exec(ctx, `
|
||||||
|
INSERT INTO users (id, username, display_name, password_hash, role, locale, ui_prefs,
|
||||||
|
must_change_password, must_change_credentials, created_by, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)`,
|
||||||
|
u.ID, u.Username, u.DisplayName, u.PasswordHash, string(u.Role), u.Locale, prefs,
|
||||||
|
u.MustChangePassword, u.MustChangeCredentials, createdBy, u.CreatedAt)
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return ErrUsernameTaken
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth: create user: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUniqueViolation(err error) bool {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID реализует Store.
|
||||||
|
func (s *PgStore) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) {
|
||||||
|
return scanUser(s.q.QueryRow(ctx, `SELECT `+userColumns+` FROM users WHERE id = $1`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByUsername реализует Store (username — citext, регистр не важен).
|
||||||
|
func (s *PgStore) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
||||||
|
return scanUser(s.q.QueryRow(ctx, `SELECT `+userColumns+` FROM users WHERE username = $1`, username))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordLoginFailure реализует Store.
|
||||||
|
func (s *PgStore) RecordLoginFailure(ctx context.Context, id uuid.UUID, failedCount int, lockedUntil *time.Time) error {
|
||||||
|
_, err := s.q.Exec(ctx, `UPDATE users SET failed_login_count = $2, locked_until = $3 WHERE id = $1`,
|
||||||
|
id, failedCount, lockedUntil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth: record login failure: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordLoginSuccess реализует Store.
|
||||||
|
func (s *PgStore) RecordLoginSuccess(ctx context.Context, id uuid.UUID, at time.Time, newHash *string) error {
|
||||||
|
_, err := s.q.Exec(ctx, `
|
||||||
|
UPDATE users SET failed_login_count = 0, locked_until = NULL, last_login_at = $2,
|
||||||
|
password_hash = COALESCE($3, password_hash)
|
||||||
|
WHERE id = $1`, id, at, newHash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth: record login success: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPassword реализует Store.
|
||||||
|
func (s *PgStore) SetPassword(ctx context.Context, id uuid.UUID, hash string) error {
|
||||||
|
_, err := s.q.Exec(ctx, `
|
||||||
|
UPDATE users SET password_hash = $2, must_change_password = false,
|
||||||
|
token_version = token_version + 1, updated_at = now()
|
||||||
|
WHERE id = $1`, id, hash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth: set password: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCredentials реализует Store.
|
||||||
|
func (s *PgStore) SetCredentials(ctx context.Context, id uuid.UUID, username, hash string) error {
|
||||||
|
_, err := s.q.Exec(ctx, `
|
||||||
|
UPDATE users SET username = $2, password_hash = $3, must_change_credentials = false,
|
||||||
|
must_change_password = false, token_version = token_version + 1, updated_at = now()
|
||||||
|
WHERE id = $1`, id, username, hash)
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return ErrUsernameTaken
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth: set credentials: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfile реализует Store.
|
||||||
|
func (s *PgStore) UpdateProfile(ctx context.Context, id uuid.UUID, upd ProfileUpdate) (User, error) {
|
||||||
|
var (
|
||||||
|
setName bool
|
||||||
|
name *string
|
||||||
|
prefs []byte
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if upd.DisplayName != nil {
|
||||||
|
setName, name = true, *upd.DisplayName
|
||||||
|
}
|
||||||
|
if upd.UIPrefs != nil {
|
||||||
|
if prefs, err = json.Marshal(upd.UIPrefs); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scanUser(s.q.QueryRow(ctx, `
|
||||||
|
UPDATE users SET
|
||||||
|
display_name = CASE WHEN $2 THEN $3 ELSE display_name END,
|
||||||
|
locale = COALESCE($4, locale),
|
||||||
|
ui_prefs = COALESCE($5::jsonb, ui_prefs),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING `+userColumns, id, setName, name, upd.Locale, prefs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertRefreshToken реализует Store.
|
||||||
|
func (s *PgStore) InsertRefreshToken(ctx context.Context, t RefreshToken) error {
|
||||||
|
_, err := s.q.Exec(ctx, `
|
||||||
|
INSERT INTO refresh_tokens (id, user_id, family_id, family_started_at, token_hash, device_name,
|
||||||
|
client_type, created_at, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
t.ID, t.UserID, t.FamilyID, t.FamilyStartedAt, t.TokenHash, t.DeviceName, string(t.ClientType),
|
||||||
|
t.CreatedAt, t.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth: insert refresh token: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRefreshTokenForUpdate реализует Store.
|
||||||
|
func (s *PgStore) GetRefreshTokenForUpdate(ctx context.Context, hash []byte) (RefreshToken, error) {
|
||||||
|
var (
|
||||||
|
t RefreshToken
|
||||||
|
ct string
|
||||||
|
)
|
||||||
|
err := s.q.QueryRow(ctx, `
|
||||||
|
SELECT id, user_id, family_id, family_started_at, token_hash, device_name, client_type,
|
||||||
|
created_at, expires_at, used_at, revoked_at
|
||||||
|
FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE`, hash).
|
||||||
|
Scan(&t.ID, &t.UserID, &t.FamilyID, &t.FamilyStartedAt, &t.TokenHash, &t.DeviceName, &ct,
|
||||||
|
&t.CreatedAt, &t.ExpiresAt, &t.UsedAt, &t.RevokedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return RefreshToken{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return RefreshToken{}, fmt.Errorf("auth: get refresh token: %w", err)
|
||||||
|
}
|
||||||
|
t.ClientType = ClientType(ct)
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkRefreshTokenUsed реализует Store.
|
||||||
|
func (s *PgStore) MarkRefreshTokenUsed(ctx context.Context, id uuid.UUID, at time.Time) error {
|
||||||
|
_, err := s.q.Exec(ctx, `UPDATE refresh_tokens SET used_at = $2 WHERE id = $1`, id, at)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth: mark refresh token used: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeFamily реализует Store.
|
||||||
|
func (s *PgStore) RevokeFamily(ctx context.Context, familyID uuid.UUID, at time.Time) error {
|
||||||
|
_, err := s.q.Exec(ctx, `UPDATE refresh_tokens SET revoked_at = $2 WHERE family_id = $1 AND revoked_at IS NULL`,
|
||||||
|
familyID, at)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth: revoke family: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeUserSessions реализует Store.
|
||||||
|
func (s *PgStore) RevokeUserSessions(ctx context.Context, userID uuid.UUID, at time.Time) error {
|
||||||
|
_, err := s.q.Exec(ctx, `UPDATE refresh_tokens SET revoked_at = $2 WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||||
|
userID, at)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth: revoke user sessions: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSessions реализует Store.
|
||||||
|
func (s *PgStore) ListSessions(ctx context.Context, userID uuid.UUID, now time.Time) ([]Session, error) {
|
||||||
|
rows, err := s.q.Query(ctx, `
|
||||||
|
SELECT family_id, device_name, client_type, family_started_at, created_at
|
||||||
|
FROM refresh_tokens
|
||||||
|
WHERE user_id = $1 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > $2
|
||||||
|
ORDER BY created_at DESC`, userID, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("auth: list sessions: %w", err)
|
||||||
|
}
|
||||||
|
out, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (Session, error) {
|
||||||
|
var (
|
||||||
|
ss Session
|
||||||
|
ct string
|
||||||
|
)
|
||||||
|
err := row.Scan(&ss.ID, &ss.DeviceName, &ct, &ss.CreatedAt, &ss.LastUsedAt)
|
||||||
|
ss.ClientType = ClientType(ct)
|
||||||
|
return ss, err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("auth: list sessions: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers реализует Store.
|
||||||
|
func (s *PgStore) ListUsers(ctx context.Context, f UserFilter) ([]User, error) {
|
||||||
|
var role *string
|
||||||
|
if f.Role != nil {
|
||||||
|
r := string(*f.Role)
|
||||||
|
role = &r
|
||||||
|
}
|
||||||
|
// Спецсимволы LIKE в запросе экранируются: ищем подстроку буквально.
|
||||||
|
pattern := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(f.Query) + "%"
|
||||||
|
rows, err := s.q.Query(ctx, `
|
||||||
|
SELECT `+userColumns+` FROM users
|
||||||
|
WHERE ($1 = '%%' OR username ILIKE $1 OR display_name ILIKE $1)
|
||||||
|
AND ($2::user_role IS NULL OR role = $2::user_role)
|
||||||
|
AND ($3 = '' OR username > $3::citext)
|
||||||
|
ORDER BY username
|
||||||
|
LIMIT $4`, pattern, role, f.After, f.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("auth: list users: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []User
|
||||||
|
for rows.Next() {
|
||||||
|
u, err := scanUser(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("auth: list users: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockActiveAdmins реализует Store.
|
||||||
|
func (s *PgStore) LockActiveAdmins(ctx context.Context) (int, error) {
|
||||||
|
rows, err := s.q.Query(ctx, `SELECT id FROM users WHERE role = 'admin' AND NOT is_blocked FOR UPDATE`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("auth: lock admins: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
n := 0
|
||||||
|
for rows.Next() {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, fmt.Errorf("auth: lock admins: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser реализует Store.
|
||||||
|
func (s *PgStore) UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate) (User, error) {
|
||||||
|
var (
|
||||||
|
setName bool
|
||||||
|
name *string
|
||||||
|
role *string
|
||||||
|
)
|
||||||
|
if upd.DisplayName != nil {
|
||||||
|
setName, name = true, *upd.DisplayName
|
||||||
|
}
|
||||||
|
if upd.Role != nil {
|
||||||
|
r := string(*upd.Role)
|
||||||
|
role = &r
|
||||||
|
}
|
||||||
|
return scanUser(s.q.QueryRow(ctx, `
|
||||||
|
UPDATE users SET
|
||||||
|
display_name = CASE WHEN $2 THEN $3 ELSE display_name END,
|
||||||
|
role = COALESCE($4::user_role, role),
|
||||||
|
is_blocked = COALESCE($5, is_blocked),
|
||||||
|
token_version = token_version + CASE WHEN $6 THEN 1 ELSE 0 END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword реализует Store.
|
||||||
|
func (s *PgStore) ResetPassword(ctx context.Context, id uuid.UUID, hash string) error {
|
||||||
|
_, err := s.q.Exec(ctx, `
|
||||||
|
UPDATE users SET password_hash = $2, must_change_password = true, failed_login_count = 0,
|
||||||
|
locked_until = NULL, token_version = token_version + 1, updated_at = now()
|
||||||
|
WHERE id = $1`, id, hash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth: reset password: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser реализует Store. Токены удаляются каскадно, ссылки в audit_log и created_by обнуляются.
|
||||||
|
func (s *PgStore) DeleteUser(ctx context.Context, id uuid.UUID) error {
|
||||||
|
if _, err := s.q.Exec(ctx, `DELETE FROM users WHERE id = $1`, id); err != nil {
|
||||||
|
return fmt.Errorf("auth: delete user: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAudit реализует Store.
|
||||||
|
func (s *PgStore) ListAudit(ctx context.Context, before uuid.UUID, limit int) ([]AuditRecord, error) {
|
||||||
|
rows, err := s.q.Query(ctx, `
|
||||||
|
SELECT a.id, a.actor_id, u.username::text, a.action, a.target_type, a.target_id, a.payload, a.created_at
|
||||||
|
FROM audit_log a LEFT JOIN users u ON u.id = a.actor_id
|
||||||
|
WHERE $1 = '00000000-0000-0000-0000-000000000000'::uuid OR a.id < $1
|
||||||
|
ORDER BY a.id DESC
|
||||||
|
LIMIT $2`, before, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("auth: list audit: %w", err)
|
||||||
|
}
|
||||||
|
out, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (AuditRecord, error) {
|
||||||
|
var (
|
||||||
|
r AuditRecord
|
||||||
|
payload []byte
|
||||||
|
)
|
||||||
|
if err := row.Scan(&r.ID, &r.ActorID, &r.ActorUsername, &r.Action, &r.TargetType, &r.TargetID, &payload,
|
||||||
|
&r.CreatedAt); err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
if payload != nil {
|
||||||
|
if err := json.Unmarshal(payload, &r.Payload); err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("auth: list audit: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertAudit реализует Store.
|
||||||
|
func (s *PgStore) InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error {
|
||||||
|
var payload []byte
|
||||||
|
if e.Payload != nil {
|
||||||
|
var err error
|
||||||
|
if payload, err = json.Marshal(e.Payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := s.q.Exec(ctx, `
|
||||||
|
INSERT INTO audit_log (id, actor_id, action, target_type, target_id, payload)
|
||||||
|
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6)`, id, e.ActorID, e.Action, e.TargetType, e.TargetID, payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth: insert audit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSettingBool реализует Store: отсутствующий ключ — false.
|
||||||
|
func (s *PgStore) GetSettingBool(ctx context.Context, key string) (bool, error) {
|
||||||
|
var v bool
|
||||||
|
err := s.q.QueryRow(ctx, `SELECT value::boolean FROM app_settings WHERE key = $1`, key).Scan(&v)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("auth: get setting %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSetting реализует Store.
|
||||||
|
func (s *PgStore) SetSetting(ctx context.Context, key string, value any) error {
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.q.Exec(ctx, `
|
||||||
|
INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, now())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, key, raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth: set setting %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Store = (*PgStore)(nil)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
|
// Матрица прав из docs/auth-and-roles.md. Проверяется в сервисном слое, а не только в middleware.
|
||||||
|
|
||||||
|
// Permission — право, не зависящее от целевого пользователя.
|
||||||
|
type Permission int
|
||||||
|
|
||||||
|
// Глобальные права.
|
||||||
|
const (
|
||||||
|
PermAdminPanel Permission = iota // открыть админку
|
||||||
|
PermListUsers // видеть список пользователей
|
||||||
|
PermManageLibrary // хранилища, сканирование, переиндексация
|
||||||
|
PermViewLibraryStatus // статус библиотеки (просмотр)
|
||||||
|
PermSuggestTracks // предлагать треки, видеть свои предложения
|
||||||
|
PermReviewUploads // очередь, одобрение, правка метаданных, лимиты
|
||||||
|
PermSystemStatus // статус рекомендательной системы, аудит
|
||||||
|
)
|
||||||
|
|
||||||
|
var permissions = map[Permission][]Role{
|
||||||
|
PermAdminPanel: {RoleModerator, RoleAdmin},
|
||||||
|
PermListUsers: {RoleModerator, RoleAdmin},
|
||||||
|
PermManageLibrary: {RoleAdmin},
|
||||||
|
PermViewLibraryStatus: {RoleModerator, RoleAdmin},
|
||||||
|
PermSuggestTracks: {RoleModerator, RoleAdmin},
|
||||||
|
PermReviewUploads: {RoleAdmin},
|
||||||
|
PermSystemStatus: {RoleAdmin},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can сообщает, есть ли у роли глобальное право.
|
||||||
|
func Can(r Role, p Permission) bool {
|
||||||
|
for _, allowed := range permissions[p] {
|
||||||
|
if allowed == r {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserAction — действие над учётной записью другого пользователя.
|
||||||
|
type UserAction int
|
||||||
|
|
||||||
|
// Действия над пользователями.
|
||||||
|
const (
|
||||||
|
UserCreate UserAction = iota // создать пользователя с ролью target
|
||||||
|
UserEdit // изменить отображаемое имя
|
||||||
|
UserResetPassword // сбросить пароль
|
||||||
|
UserBlock // заблокировать / разблокировать
|
||||||
|
UserChangeRole // сменить роль (target — текущая роль)
|
||||||
|
UserDelete // удалить
|
||||||
|
)
|
||||||
|
|
||||||
|
// Actor — кто действует (или над кем). ID нужен, чтобы запретить действия над собой.
|
||||||
|
type Actor struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Role Role
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanManageUser — может ли actor выполнить action над target.
|
||||||
|
// Модератор управляет только обычными пользователями; роли и удаление — только админ.
|
||||||
|
// Блокировать, удалять себя и менять себе роль нельзя никому: так не потерять доступ случайно.
|
||||||
|
// Правило «последний админ» зависит от данных и проверяется в сервисе.
|
||||||
|
func CanManageUser(actor, target Actor, action UserAction) bool {
|
||||||
|
self := actor.ID != uuid.Nil && actor.ID == target.ID
|
||||||
|
if self && (action == UserBlock || action == UserDelete || action == UserChangeRole) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch actor.Role {
|
||||||
|
case RoleAdmin:
|
||||||
|
return true
|
||||||
|
case RoleModerator:
|
||||||
|
switch action {
|
||||||
|
case UserCreate, UserEdit, UserResetPassword, UserBlock:
|
||||||
|
return target.Role == RoleUser
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanAssignRole — может ли actor выдать роль newRole (при создании или смене роли).
|
||||||
|
func CanAssignRole(actor Role, newRole Role) bool {
|
||||||
|
switch actor {
|
||||||
|
case RoleAdmin:
|
||||||
|
return newRole.Valid()
|
||||||
|
case RoleModerator:
|
||||||
|
return newRole == RoleUser
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCan(t *testing.T) {
|
||||||
|
// Строки матрицы прав из docs/auth-and-roles.md: user, moderator, admin.
|
||||||
|
matrix := []struct {
|
||||||
|
perm Permission
|
||||||
|
name string
|
||||||
|
want [3]bool
|
||||||
|
}{
|
||||||
|
{PermAdminPanel, "admin panel", [3]bool{false, true, true}},
|
||||||
|
{PermListUsers, "list users", [3]bool{false, true, true}},
|
||||||
|
{PermManageLibrary, "manage library", [3]bool{false, false, true}},
|
||||||
|
{PermViewLibraryStatus, "library status", [3]bool{false, true, true}},
|
||||||
|
{PermSuggestTracks, "suggest tracks", [3]bool{false, true, true}},
|
||||||
|
{PermReviewUploads, "review uploads", [3]bool{false, false, true}},
|
||||||
|
{PermSystemStatus, "system status", [3]bool{false, false, true}},
|
||||||
|
}
|
||||||
|
roles := [3]Role{RoleUser, RoleModerator, RoleAdmin}
|
||||||
|
for _, row := range matrix {
|
||||||
|
for i, r := range roles {
|
||||||
|
t.Run(fmt.Sprintf("%s/%s", row.name, r), func(t *testing.T) {
|
||||||
|
assert.Equal(t, row.want[i], Can(r, row.perm))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.False(t, Can(Role("guest"), PermAdminPanel))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanManageUser(t *testing.T) {
|
||||||
|
actions := map[UserAction]string{
|
||||||
|
UserCreate: "create", UserEdit: "edit", UserResetPassword: "reset", UserBlock: "block",
|
||||||
|
UserChangeRole: "role", UserDelete: "delete",
|
||||||
|
}
|
||||||
|
// want[actor][target] для каждого действия; порядок ролей — user, moderator, admin.
|
||||||
|
cases := map[UserAction][3][3]bool{
|
||||||
|
UserCreate: {{false, false, false}, {true, false, false}, {true, true, true}},
|
||||||
|
UserEdit: {{false, false, false}, {true, false, false}, {true, true, true}},
|
||||||
|
UserResetPassword: {{false, false, false}, {true, false, false}, {true, true, true}},
|
||||||
|
UserBlock: {{false, false, false}, {true, false, false}, {true, true, true}},
|
||||||
|
UserChangeRole: {{false, false, false}, {false, false, false}, {true, true, true}},
|
||||||
|
UserDelete: {{false, false, false}, {false, false, false}, {true, true, true}},
|
||||||
|
}
|
||||||
|
roles := [3]Role{RoleUser, RoleModerator, RoleAdmin}
|
||||||
|
for action, want := range cases {
|
||||||
|
for ai, ar := range roles {
|
||||||
|
for ti, tr := range roles {
|
||||||
|
t.Run(fmt.Sprintf("%s/%s->%s", actions[action], ar, tr), func(t *testing.T) {
|
||||||
|
actor := Actor{ID: uuid.New(), Role: ar}
|
||||||
|
target := Actor{ID: uuid.New(), Role: tr}
|
||||||
|
assert.Equal(t, want[ai][ti], CanManageUser(actor, target, action))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanManageUserSelf(t *testing.T) {
|
||||||
|
admin := Actor{ID: uuid.New(), Role: RoleAdmin}
|
||||||
|
for _, a := range []UserAction{UserBlock, UserDelete, UserChangeRole} {
|
||||||
|
assert.False(t, CanManageUser(admin, admin, a), "action %d on self", a)
|
||||||
|
}
|
||||||
|
assert.True(t, CanManageUser(admin, admin, UserEdit))
|
||||||
|
assert.True(t, CanManageUser(admin, admin, UserResetPassword))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanAssignRole(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
actor, role Role
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{RoleAdmin, RoleAdmin, true},
|
||||||
|
{RoleAdmin, RoleModerator, true},
|
||||||
|
{RoleAdmin, RoleUser, true},
|
||||||
|
{RoleAdmin, Role("root"), false},
|
||||||
|
{RoleModerator, RoleUser, true},
|
||||||
|
{RoleModerator, RoleModerator, false},
|
||||||
|
{RoleModerator, RoleAdmin, false},
|
||||||
|
{RoleUser, RoleUser, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
assert.Equal(t, tc.want, CanAssignRole(tc.actor, tc.role), "%s assigns %s", tc.actor, tc.role)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"pipu/api/internal/platform/apperr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Коды ошибок аутентификации.
|
||||||
|
const (
|
||||||
|
CodeInvalidCredentials = "auth.invalid_credentials"
|
||||||
|
CodeTooManyAttempts = "auth.too_many_attempts"
|
||||||
|
CodeUserBlocked = "auth.user_blocked"
|
||||||
|
CodeUnauthorized = "auth.unauthorized"
|
||||||
|
CodeTokenExpired = "auth.token_expired"
|
||||||
|
CodeTokenInvalid = "auth.token_invalid"
|
||||||
|
CodeTokenRevoked = "auth.token_revoked"
|
||||||
|
CodeRefreshInvalid = "auth.refresh_invalid"
|
||||||
|
CodeRefreshReused = "auth.refresh_reused"
|
||||||
|
CodeRefreshRace = "auth.refresh_race"
|
||||||
|
CodeCredentialsChangeRequired = "auth.credentials_change_required"
|
||||||
|
CodePasswordChangeRequired = "auth.password_change_required"
|
||||||
|
CodeCurrentPasswordRequired = "auth.current_password_required"
|
||||||
|
CodeCurrentPasswordInvalid = "auth.current_password_invalid"
|
||||||
|
CodeBootstrapNotRequired = "auth.bootstrap_not_required"
|
||||||
|
CodeSessionNotFound = "auth.session_not_found"
|
||||||
|
CodeUsernameTaken = "validation.username_taken"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ключ app_settings: первый вход администратора завершён.
|
||||||
|
const settingBootstrapCompleted = "bootstrap.completed"
|
||||||
|
|
||||||
|
// Config — параметры сервиса.
|
||||||
|
type Config struct {
|
||||||
|
RefreshTTL time.Duration // скользящий срок refresh-токена
|
||||||
|
RefreshMaxAge time.Duration // абсолютный срок семьи токенов
|
||||||
|
// RefreshRaceWindow — сколько после ротации повтор старого токена считается гонкой
|
||||||
|
// (две вкладки обновили токен одновременно), а не кражей.
|
||||||
|
RefreshRaceWindow time.Duration
|
||||||
|
|
||||||
|
LockThreshold int // неудачных входов до блокировки
|
||||||
|
LockBase time.Duration // первая блокировка, дальше удваивается
|
||||||
|
LockMax time.Duration
|
||||||
|
|
||||||
|
LoginLimitPerUser int // попыток в минуту на IP + логин
|
||||||
|
LoginLimitPerIP int // попыток в минуту на IP
|
||||||
|
|
||||||
|
BootstrapUsername string
|
||||||
|
BootstrapPassword string // пусто — сгенерировать
|
||||||
|
|
||||||
|
SupportedLocales []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig — значения из docs/auth-and-roles.md.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
RefreshTTL: 30 * 24 * time.Hour,
|
||||||
|
RefreshMaxAge: 180 * 24 * time.Hour,
|
||||||
|
RefreshRaceWindow: 10 * time.Second,
|
||||||
|
LockThreshold: 5,
|
||||||
|
LockBase: 5 * time.Minute,
|
||||||
|
LockMax: time.Hour,
|
||||||
|
LoginLimitPerUser: 5,
|
||||||
|
LoginLimitPerIP: 30,
|
||||||
|
BootstrapUsername: "admin",
|
||||||
|
SupportedLocales: []string{"ru", "en"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service — бизнес-логика входа, токенов и профиля.
|
||||||
|
type Service struct {
|
||||||
|
store Store
|
||||||
|
hasher *Hasher
|
||||||
|
tokens *TokenIssuer
|
||||||
|
limiter Limiter
|
||||||
|
clock Clock
|
||||||
|
ids IDGen
|
||||||
|
log *slog.Logger
|
||||||
|
cfg Config
|
||||||
|
|
||||||
|
// dummyHash проверяется для несуществующих логинов, чтобы время ответа не выдавало их отсутствие.
|
||||||
|
dummyHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService создаёт сервис.
|
||||||
|
func NewService(store Store, hasher *Hasher, tokens *TokenIssuer, limiter Limiter, clock Clock, ids IDGen,
|
||||||
|
log *slog.Logger, cfg Config,
|
||||||
|
) (*Service, error) {
|
||||||
|
if log == nil {
|
||||||
|
log = slog.New(slog.DiscardHandler)
|
||||||
|
}
|
||||||
|
dummy, err := hasher.Hash("pipu-dummy-password")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Service{
|
||||||
|
store: store, hasher: hasher, tokens: tokens, limiter: limiter, clock: clock, ids: ids, log: log, cfg: cfg,
|
||||||
|
dummyHash: dummy,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tokens — результат входа или ротации.
|
||||||
|
type Tokens struct {
|
||||||
|
Access string
|
||||||
|
ExpiresIn time.Duration
|
||||||
|
Refresh string
|
||||||
|
ClientType ClientType
|
||||||
|
User User
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginInput — данные формы входа.
|
||||||
|
type LoginInput struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
ClientType ClientType
|
||||||
|
DeviceName string
|
||||||
|
IP string
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidCredentials() error {
|
||||||
|
return apperr.Unauthorized(CodeInvalidCredentials, "invalid username or password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login проверяет логин и пароль и открывает новую сессию.
|
||||||
|
func (s *Service) Login(ctx context.Context, in LoginInput) (Tokens, error) {
|
||||||
|
if !in.ClientType.Valid() {
|
||||||
|
return Tokens{}, apperr.Validation("client_type", "validation.client_type_invalid", "unknown client type", nil)
|
||||||
|
}
|
||||||
|
username := NormalizeUsername(in.Username)
|
||||||
|
if err := s.checkRateLimit(ctx, in.IP, username); err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
if len(in.Password) > MaxPassword*4 {
|
||||||
|
return Tokens{}, invalidCredentials()
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.store.GetUserByUsername(ctx, username)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
_, _, _ = s.hasher.Verify(in.Password, s.dummyHash)
|
||||||
|
return Tokens{}, invalidCredentials()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := s.clock.Now()
|
||||||
|
if u.LockedUntil != nil && now.Before(*u.LockedUntil) {
|
||||||
|
_, _, _ = s.hasher.Verify(in.Password, s.dummyHash)
|
||||||
|
return Tokens{}, invalidCredentials()
|
||||||
|
}
|
||||||
|
ok, rehash, err := s.hasher.Verify(in.Password, u.PasswordHash)
|
||||||
|
if err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
failed := u.FailedLoginCount + 1
|
||||||
|
var lockedUntil *time.Time
|
||||||
|
if failed >= s.cfg.LockThreshold {
|
||||||
|
until := now.Add(s.lockDuration(failed))
|
||||||
|
lockedUntil = &until
|
||||||
|
}
|
||||||
|
if err := s.store.RecordLoginFailure(ctx, u.ID, failed, lockedUntil); err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
return Tokens{}, invalidCredentials()
|
||||||
|
}
|
||||||
|
if u.IsBlocked {
|
||||||
|
return Tokens{}, apperr.Forbidden(CodeUserBlocked, "user is blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
var newHash *string
|
||||||
|
if rehash {
|
||||||
|
h, err := s.hasher.Hash(in.Password)
|
||||||
|
if err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
newHash = &h
|
||||||
|
}
|
||||||
|
var out Tokens
|
||||||
|
err = s.store.WithTx(ctx, func(tx Store) error {
|
||||||
|
if err := tx.RecordLoginSuccess(ctx, u.ID, now, newHash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err = s.openSession(ctx, tx, u, in.ClientType, in.DeviceName)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockDuration — блокировка после failed неудачных попыток: LockBase, затем вдвое больше за каждую
|
||||||
|
// следующую неудачу, но не более LockMax.
|
||||||
|
func (s *Service) lockDuration(failed int) time.Duration {
|
||||||
|
d := s.cfg.LockBase
|
||||||
|
for i := s.cfg.LockThreshold; i < failed && d < s.cfg.LockMax; i++ {
|
||||||
|
d *= 2
|
||||||
|
}
|
||||||
|
return min(d, s.cfg.LockMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) checkRateLimit(ctx context.Context, ip, username string) error {
|
||||||
|
if s.limiter == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
checks := []struct {
|
||||||
|
key string
|
||||||
|
limit int
|
||||||
|
}{
|
||||||
|
{"login:ip:" + ip, s.cfg.LoginLimitPerIP},
|
||||||
|
{"login:user:" + ip + ":" + username, s.cfg.LoginLimitPerUser},
|
||||||
|
}
|
||||||
|
for _, c := range checks {
|
||||||
|
ok, retry, err := s.limiter.Allow(ctx, c.key, c.limit, time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
// Redis недоступен — не запираем всех снаружи: остаётся блокировка учётки по счётчику в БД.
|
||||||
|
s.log.WarnContext(ctx, "login rate limiter unavailable", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return apperr.TooManyRequests(CodeTooManyAttempts, retry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openSession создаёт новую семью refresh-токенов и выдаёт пару.
|
||||||
|
func (s *Service) openSession(ctx context.Context, st Store, u User, ct ClientType, device string) (Tokens, error) {
|
||||||
|
now := s.clock.Now()
|
||||||
|
return s.issue(ctx, st, u, RefreshToken{
|
||||||
|
FamilyID: s.ids.New(),
|
||||||
|
FamilyStartedAt: now,
|
||||||
|
DeviceName: deviceName(device),
|
||||||
|
ClientType: ct,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// issue записывает новый refresh-токен семьи prev и подписывает access-токен.
|
||||||
|
func (s *Service) issue(ctx context.Context, st Store, u User, prev RefreshToken) (Tokens, error) {
|
||||||
|
now := s.clock.Now()
|
||||||
|
refresh, hash, err := NewRefreshToken()
|
||||||
|
if err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
expires := now.Add(s.cfg.RefreshTTL)
|
||||||
|
if hardLimit := prev.FamilyStartedAt.Add(s.cfg.RefreshMaxAge); expires.After(hardLimit) {
|
||||||
|
expires = hardLimit
|
||||||
|
}
|
||||||
|
rt := RefreshToken{
|
||||||
|
ID: s.ids.New(),
|
||||||
|
UserID: u.ID,
|
||||||
|
FamilyID: prev.FamilyID,
|
||||||
|
FamilyStartedAt: prev.FamilyStartedAt,
|
||||||
|
TokenHash: hash,
|
||||||
|
DeviceName: prev.DeviceName,
|
||||||
|
ClientType: prev.ClientType,
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: expires,
|
||||||
|
}
|
||||||
|
if err := st.InsertRefreshToken(ctx, rt); err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
access, err := s.tokens.Issue(u.ID, u.Role, rt.FamilyID, u.TokenVersion)
|
||||||
|
if err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
return Tokens{Access: access, ExpiresIn: s.tokens.TTL(), Refresh: refresh, ClientType: rt.ClientType, User: u}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceName(s string) *string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(s) > 100 {
|
||||||
|
s = string([]rune(s)[:100])
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh ротирует refresh-токен: старый помечается использованным, выдаётся новый в той же семье.
|
||||||
|
func (s *Service) Refresh(ctx context.Context, token string) (Tokens, error) {
|
||||||
|
if token == "" {
|
||||||
|
return Tokens{}, apperr.Unauthorized(CodeRefreshInvalid, "refresh token missing")
|
||||||
|
}
|
||||||
|
now := s.clock.Now()
|
||||||
|
var (
|
||||||
|
out Tokens
|
||||||
|
reused bool
|
||||||
|
familyID uuid.UUID
|
||||||
|
)
|
||||||
|
err := s.store.WithTx(ctx, func(tx Store) error {
|
||||||
|
rt, err := tx.GetRefreshTokenForUpdate(ctx, HashRefreshToken(token))
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return apperr.Unauthorized(CodeRefreshInvalid, "unknown refresh token")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rt.RevokedAt != nil {
|
||||||
|
return apperr.Unauthorized(CodeRefreshInvalid, "refresh token revoked")
|
||||||
|
}
|
||||||
|
if rt.UsedAt != nil {
|
||||||
|
if now.Sub(*rt.UsedAt) <= s.cfg.RefreshRaceWindow {
|
||||||
|
return apperr.Unauthorized(CodeRefreshRace, "refresh token was just rotated")
|
||||||
|
}
|
||||||
|
reused, familyID = true, rt.FamilyID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !now.Before(rt.ExpiresAt) {
|
||||||
|
return apperr.Unauthorized(CodeRefreshInvalid, "refresh token expired")
|
||||||
|
}
|
||||||
|
u, err := tx.GetUserByID(ctx, rt.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.IsBlocked {
|
||||||
|
return apperr.Forbidden(CodeUserBlocked, "user is blocked")
|
||||||
|
}
|
||||||
|
if err := tx.MarkRefreshTokenUsed(ctx, rt.ID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err = s.issue(ctx, tx, u, rt)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
if reused {
|
||||||
|
// Токен уже обменяли — его украли или скопировали. Отзываем всю семью вне транзакции чтения.
|
||||||
|
s.log.WarnContext(ctx, "refresh token reuse detected, family revoked", "family_id", familyID)
|
||||||
|
if err := s.store.RevokeFamily(ctx, familyID, now); err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
return Tokens{}, apperr.Unauthorized(CodeRefreshReused, "refresh token reuse")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate проверяет access-токен и актуальное состояние пользователя.
|
||||||
|
func (s *Service) Authenticate(ctx context.Context, access string) (Principal, error) {
|
||||||
|
claims, err := s.tokens.Parse(access)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrTokenExpired):
|
||||||
|
return Principal{}, apperr.Unauthorized(CodeTokenExpired, "access token expired")
|
||||||
|
case err != nil:
|
||||||
|
return Principal{}, apperr.Unauthorized(CodeTokenInvalid, "access token invalid")
|
||||||
|
}
|
||||||
|
u, err := s.store.GetUserByID(ctx, claims.UserID)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return Principal{}, apperr.Unauthorized(CodeTokenRevoked, "user not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, err
|
||||||
|
}
|
||||||
|
if u.IsBlocked {
|
||||||
|
return Principal{}, apperr.Unauthorized(CodeUserBlocked, "user is blocked")
|
||||||
|
}
|
||||||
|
if u.TokenVersion != claims.TokenVersion {
|
||||||
|
return Principal{}, apperr.Unauthorized(CodeTokenRevoked, "token version changed")
|
||||||
|
}
|
||||||
|
return Principal{
|
||||||
|
UserID: u.ID,
|
||||||
|
Role: u.Role,
|
||||||
|
SessionID: claims.SessionID,
|
||||||
|
MustChangePassword: u.MustChangePassword,
|
||||||
|
MustChangeCredentials: u.MustChangeCredentials,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout отзывает текущую сессию.
|
||||||
|
func (s *Service) Logout(ctx context.Context, p Principal) error {
|
||||||
|
return s.store.RevokeFamily(ctx, p.SessionID, s.clock.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogoutAll отзывает все сессии пользователя.
|
||||||
|
func (s *Service) LogoutAll(ctx context.Context, p Principal) error {
|
||||||
|
return s.store.RevokeUserSessions(ctx, p.UserID, s.clock.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me возвращает профиль текущего пользователя.
|
||||||
|
func (s *Service) Me(ctx context.Context, p Principal) (User, error) {
|
||||||
|
return s.store.GetUserByID(ctx, p.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MePatch — изменения профиля из PATCH /me. nil — не менять; DisplayName "" — сбросить.
|
||||||
|
type MePatch struct {
|
||||||
|
DisplayName *string
|
||||||
|
Locale *string
|
||||||
|
UIPrefs *UIPrefs
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMe меняет имя, язык и настройки интерфейса.
|
||||||
|
func (s *Service) UpdateMe(ctx context.Context, p Principal, patch MePatch) (User, error) {
|
||||||
|
var upd ProfileUpdate
|
||||||
|
if patch.DisplayName != nil {
|
||||||
|
name := strings.TrimSpace(*patch.DisplayName)
|
||||||
|
if utf8.RuneCountInString(name) > MaxDisplayName {
|
||||||
|
return User{}, apperr.Validation("display_name", CodeDisplayNameTooLong, "display name too long",
|
||||||
|
map[string]any{"max": MaxDisplayName})
|
||||||
|
}
|
||||||
|
var v *string
|
||||||
|
if name != "" {
|
||||||
|
v = &name
|
||||||
|
}
|
||||||
|
upd.DisplayName = &v
|
||||||
|
}
|
||||||
|
if patch.Locale != nil {
|
||||||
|
if !contains(s.cfg.SupportedLocales, *patch.Locale) {
|
||||||
|
return User{}, apperr.Validation("locale", CodeLocaleUnsupported, "unsupported locale",
|
||||||
|
map[string]any{"supported": s.cfg.SupportedLocales})
|
||||||
|
}
|
||||||
|
upd.Locale = patch.Locale
|
||||||
|
}
|
||||||
|
if patch.UIPrefs != nil {
|
||||||
|
if err := ValidateUIPrefs(*patch.UIPrefs); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
u, err := s.store.GetUserByID(ctx, p.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
merged := MergeUIPrefs(u.UIPrefs, *patch.UIPrefs)
|
||||||
|
upd.UIPrefs = &merged
|
||||||
|
}
|
||||||
|
return s.store.UpdateProfile(ctx, p.UserID, upd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, v string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword меняет пароль, отзывает все сессии и открывает новую на текущем устройстве.
|
||||||
|
// current не нужен, если пароль временный (must_change_password).
|
||||||
|
func (s *Service) ChangePassword(ctx context.Context, p Principal, current *string, newPassword string) (Tokens, error) {
|
||||||
|
u, err := s.store.GetUserByID(ctx, p.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
if u.MustChangeCredentials {
|
||||||
|
return Tokens{}, apperr.Forbidden(CodeCredentialsChangeRequired, "use bootstrap credentials endpoint")
|
||||||
|
}
|
||||||
|
if !u.MustChangePassword {
|
||||||
|
if current == nil || *current == "" {
|
||||||
|
return Tokens{}, apperr.Validation("current_password", CodeCurrentPasswordRequired, "current password required", nil)
|
||||||
|
}
|
||||||
|
ok, _, err := s.hasher.Verify(*current, u.PasswordHash)
|
||||||
|
if err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return Tokens{}, apperr.Validation("current_password", CodeCurrentPasswordInvalid, "current password is wrong", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.checkNewPassword(u, u.Username, newPassword); err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
hash, err := s.hasher.Hash(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
return s.replaceSessions(ctx, p, u, func(tx Store) error { return tx.SetPassword(ctx, u.ID, hash) }, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNewPassword — политика паролей плюс запрет оставить прежний пароль.
|
||||||
|
func (s *Service) checkNewPassword(u User, username, newPassword string) error {
|
||||||
|
if err := ValidatePassword("new_password", newPassword, username, u.Role); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
same, _, err := s.hasher.Verify(newPassword, u.PasswordHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if same {
|
||||||
|
return apperr.Validation("new_password", CodePasswordUnchanged, "new password equals current", nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceSessions в одной транзакции применяет change, отзывает все сессии пользователя
|
||||||
|
// и открывает новую с тем же устройством, что у текущей.
|
||||||
|
func (s *Service) replaceSessions(ctx context.Context, p Principal, u User, change func(Store) error,
|
||||||
|
audit *AuditEntry,
|
||||||
|
) (Tokens, error) {
|
||||||
|
now := s.clock.Now()
|
||||||
|
var out Tokens
|
||||||
|
err := s.store.WithTx(ctx, func(tx Store) error {
|
||||||
|
sessions, err := tx.ListSessions(ctx, u.ID, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ct, device := ClientWeb, ""
|
||||||
|
for _, ss := range sessions {
|
||||||
|
if ss.ID == p.SessionID {
|
||||||
|
ct = ss.ClientType
|
||||||
|
if ss.DeviceName != nil {
|
||||||
|
device = *ss.DeviceName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := change(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.RevokeUserSessions(ctx, u.ID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if audit != nil {
|
||||||
|
if err := tx.InsertAudit(ctx, s.ids.New(), *audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Перечитываем: change увеличил token_version, новый access-токен должен нести его.
|
||||||
|
fresh, err := tx.GetUserByID(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err = s.openSession(ctx, tx, fresh, ct, device)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBootstrapCredentials — первый вход администратора: новые логин и пароль.
|
||||||
|
func (s *Service) SetBootstrapCredentials(ctx context.Context, p Principal, newUsername, newPassword string) (Tokens, error) {
|
||||||
|
u, err := s.store.GetUserByID(ctx, p.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
if !u.MustChangeCredentials {
|
||||||
|
return Tokens{}, apperr.Conflict(CodeBootstrapNotRequired, "credentials already changed")
|
||||||
|
}
|
||||||
|
username := NormalizeUsername(newUsername)
|
||||||
|
if err := ValidateUsername("new_username", username); err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
if err := s.checkNewPassword(u, username, newPassword); err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
hash, err := s.hasher.Hash(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return Tokens{}, err
|
||||||
|
}
|
||||||
|
audit := &AuditEntry{
|
||||||
|
ActorID: &u.ID, Action: "user.bootstrap_credentials", TargetType: "user", TargetID: &u.ID,
|
||||||
|
Payload: map[string]any{"username": username},
|
||||||
|
}
|
||||||
|
out, err := s.replaceSessions(ctx, p, u, func(tx Store) error {
|
||||||
|
if err := tx.SetCredentials(ctx, u.ID, username, hash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.SetSetting(ctx, settingBootstrapCompleted, true)
|
||||||
|
}, audit)
|
||||||
|
if errors.Is(err, ErrUsernameTaken) {
|
||||||
|
return Tokens{}, apperr.Validation("new_username", CodeUsernameTaken, "username is taken", nil)
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSessions — активные сессии пользователя, новые сверху.
|
||||||
|
func (s *Service) ListSessions(ctx context.Context, p Principal) ([]Session, error) {
|
||||||
|
return s.store.ListSessions(ctx, p.UserID, s.clock.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeSession завершает одну свою сессию.
|
||||||
|
func (s *Service) RevokeSession(ctx context.Context, p Principal, id uuid.UUID) error {
|
||||||
|
now := s.clock.Now()
|
||||||
|
sessions, err := s.store.ListSessions(ctx, p.UserID, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, ss := range sessions {
|
||||||
|
if ss.ID == id {
|
||||||
|
return s.store.RevokeFamily(ctx, id, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return apperr.NotFound(CodeSessionNotFound, "session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BootstrapRequired — первый вход администратора ещё не завершён.
|
||||||
|
func (s *Service) BootstrapRequired(ctx context.Context) (bool, error) {
|
||||||
|
done, err := s.store.GetSettingBool(ctx, settingBootstrapCompleted)
|
||||||
|
return !done, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap создаёт первого администратора, если его нет и первый запуск не завершён.
|
||||||
|
// Сгенерированный пароль печатается в лог один раз.
|
||||||
|
func (s *Service) Bootstrap(ctx context.Context) error {
|
||||||
|
done, err := s.store.GetSettingBool(ctx, settingBootstrapCompleted)
|
||||||
|
if err != nil || done {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
admins, err := s.store.CountAdmins(ctx)
|
||||||
|
if err != nil || admins > 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
username := NormalizeUsername(s.cfg.BootstrapUsername)
|
||||||
|
if err := ValidateUsername("BOOTSTRAP_ADMIN_USERNAME", username); err != nil {
|
||||||
|
return fmt.Errorf("auth: bootstrap: %w", err)
|
||||||
|
}
|
||||||
|
password, generated := s.cfg.BootstrapPassword, false
|
||||||
|
if password == "" {
|
||||||
|
if password, err = GeneratePassword(20); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
generated = true
|
||||||
|
}
|
||||||
|
hash, err := s.hasher.Hash(password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u := User{
|
||||||
|
ID: s.ids.New(), Username: username, PasswordHash: hash, Role: RoleAdmin, Locale: "ru",
|
||||||
|
MustChangeCredentials: true, CreatedAt: s.clock.Now(),
|
||||||
|
}
|
||||||
|
err = s.store.WithTx(ctx, func(tx Store) error {
|
||||||
|
if err := tx.CreateUser(ctx, u, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
||||||
|
Action: "user.bootstrap_create", TargetType: "user", TargetID: &u.ID,
|
||||||
|
Payload: map[string]any{"username": username},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth: bootstrap: %w", err)
|
||||||
|
}
|
||||||
|
if generated {
|
||||||
|
// Единственное место, где пароль попадает в лог: админ увидит его в `docker compose logs api`.
|
||||||
|
s.log.Warn("bootstrap admin created: sign in and change the credentials",
|
||||||
|
"username", username, "password", password)
|
||||||
|
} else {
|
||||||
|
s.log.Warn("bootstrap admin created with BOOTSTRAP_ADMIN_PASSWORD: sign in and change the credentials",
|
||||||
|
"username", username)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ошибки хранилища.
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("auth: not found")
|
||||||
|
ErrUsernameTaken = errors.New("auth: username taken")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProfileUpdate — изменяемые пользователем поля профиля. nil — не менять.
|
||||||
|
type ProfileUpdate struct {
|
||||||
|
DisplayName **string // *nil — сбросить имя
|
||||||
|
Locale *string
|
||||||
|
UIPrefs *UIPrefs
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserFilter — параметры списка пользователей.
|
||||||
|
type UserFilter struct {
|
||||||
|
Query string // подстрока логина или имени
|
||||||
|
Role *Role
|
||||||
|
After string // логин последнего элемента предыдущей страницы
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserUpdate — изменения из админки. nil — не менять.
|
||||||
|
type UserUpdate struct {
|
||||||
|
DisplayName **string
|
||||||
|
Role *Role
|
||||||
|
IsBlocked *bool
|
||||||
|
RevokeTokens bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store — доступ к users, refresh_tokens, audit_log и app_settings.
|
||||||
|
type Store interface {
|
||||||
|
// WithTx выполняет fn в транзакции; Store внутри fn работает в ней же.
|
||||||
|
WithTx(ctx context.Context, fn func(Store) error) error
|
||||||
|
|
||||||
|
CountAdmins(ctx context.Context) (int, error)
|
||||||
|
CreateUser(ctx context.Context, u User, createdBy *uuid.UUID) error
|
||||||
|
GetUserByID(ctx context.Context, id uuid.UUID) (User, error)
|
||||||
|
GetUserByUsername(ctx context.Context, username string) (User, error)
|
||||||
|
RecordLoginFailure(ctx context.Context, id uuid.UUID, failedCount int, lockedUntil *time.Time) error
|
||||||
|
// RecordLoginSuccess сбрасывает счётчик неудач; newHash != nil — пересчитанный хэш пароля.
|
||||||
|
RecordLoginSuccess(ctx context.Context, id uuid.UUID, at time.Time, newHash *string) error
|
||||||
|
// SetPassword меняет хэш, снимает must_change_password и увеличивает token_version.
|
||||||
|
SetPassword(ctx context.Context, id uuid.UUID, hash string) error
|
||||||
|
// SetCredentials меняет логин и хэш, снимает must_change_credentials и увеличивает token_version.
|
||||||
|
SetCredentials(ctx context.Context, id uuid.UUID, username, hash string) error
|
||||||
|
UpdateProfile(ctx context.Context, id uuid.UUID, upd ProfileUpdate) (User, error)
|
||||||
|
|
||||||
|
InsertRefreshToken(ctx context.Context, t RefreshToken) error
|
||||||
|
// GetRefreshTokenForUpdate находит токен по хэшу и блокирует строку до конца транзакции.
|
||||||
|
GetRefreshTokenForUpdate(ctx context.Context, hash []byte) (RefreshToken, error)
|
||||||
|
MarkRefreshTokenUsed(ctx context.Context, id uuid.UUID, at time.Time) error
|
||||||
|
RevokeFamily(ctx context.Context, familyID uuid.UUID, at time.Time) error
|
||||||
|
// RevokeUserSessions отзывает все семьи пользователя.
|
||||||
|
RevokeUserSessions(ctx context.Context, userID uuid.UUID, at time.Time) error
|
||||||
|
// ListSessions — активные семьи пользователя: есть неиспользованный, неотозванный, не истёкший токен.
|
||||||
|
ListSessions(ctx context.Context, userID uuid.UUID, now time.Time) ([]Session, error)
|
||||||
|
|
||||||
|
// ListUsers — страница пользователей по возрастанию логина, после логина f.After.
|
||||||
|
ListUsers(ctx context.Context, f UserFilter) ([]User, error)
|
||||||
|
// LockActiveAdmins блокирует строки активных админов до конца транзакции и возвращает их число:
|
||||||
|
// два параллельных понижения не оставят систему без админа.
|
||||||
|
LockActiveAdmins(ctx context.Context) (int, error)
|
||||||
|
// UpdateUser применяет изменения админки; RevokeTokens увеличивает token_version.
|
||||||
|
UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate) (User, error)
|
||||||
|
// ResetPassword ставит временный пароль: must_change_password, сброс блокировки входа, token_version + 1.
|
||||||
|
ResetPassword(ctx context.Context, id uuid.UUID, hash string) error
|
||||||
|
DeleteUser(ctx context.Context, id uuid.UUID) error
|
||||||
|
|
||||||
|
InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error
|
||||||
|
// ListAudit — записи журнала новее-первыми, с id меньше before (uuid.Nil — с начала).
|
||||||
|
ListAudit(ctx context.Context, before uuid.UUID, limit int) ([]AuditRecord, error)
|
||||||
|
GetSettingBool(ctx context.Context, key string) (bool, error)
|
||||||
|
SetSetting(ctx context.Context, key string, value any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter ограничивает частоту попыток входа.
|
||||||
|
type Limiter interface {
|
||||||
|
// Allow учитывает попытку по ключу. retryAfter — секунды до сброса окна, если попытка отклонена.
|
||||||
|
Allow(ctx context.Context, key string, limit int, window time.Duration) (ok bool, retryAfter int, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock — источник времени (подменяется в тестах).
|
||||||
|
type Clock interface{ Now() time.Time }
|
||||||
|
|
||||||
|
// IDGen — генератор UUIDv7.
|
||||||
|
type IDGen interface{ New() uuid.UUID }
|
||||||
|
|
||||||
|
// SystemClock — реальное время в UTC.
|
||||||
|
type SystemClock struct{}
|
||||||
|
|
||||||
|
// Now возвращает текущее время.
|
||||||
|
func (SystemClock) Now() time.Time { return time.Now().UTC() }
|
||||||
|
|
||||||
|
// UUIDv7 — генератор сортируемых по времени UUID.
|
||||||
|
type UUIDv7 struct{}
|
||||||
|
|
||||||
|
// New возвращает новый UUIDv7.
|
||||||
|
func (UUIDv7) New() uuid.UUID { return uuid.Must(uuid.NewV7()) }
|
||||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user