feat(web): sign-in, first-run setup, app shell and user admin (M1)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 50s
ci / ts / ts (packages + web) (push) Successful in 43s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 50s
ci / ts / ts (packages + web) (push) Successful in 43s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
- core: AuthSession keeps the access token in memory, refreshes it once for all parallel requests (single-flight) and retries them; refresh races between tabs are retried, reuse signs out; ApiError carries problem+json codes and field errors - web: TanStack Router with guards — guests go to /login, the bootstrap admin to /setup/admin, temporary passwords to /setup/password, admin pages by role - login, setup screens, Classic shell (top bar, role-based sidebar, empty player), settings: name, theme, language, password, devices - users admin: search, role filter, create with a one-time temporary password, reset, block, role change, delete as the policy allows; audit log for admins - ru/en strings for auth, admin and error codes; language and theme are saved in the profile Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f075863f15
commit
ab092def54
44 files changed
+4070
-203
No files matched your search
@@ -14,18 +14,22 @@
|
||||
"@fontsource/jetbrains-mono": "^5.2.6",
|
||||
"@fontsource/onest": "^5.2.6",
|
||||
"@fontsource/unbounded": "^5.2.6",
|
||||
"@material-symbols/svg-400": "^0.31.9",
|
||||
"@pipu/api-client": "workspace:*",
|
||||
"@pipu/core": "workspace:*",
|
||||
"@pipu/design-tokens": "workspace:*",
|
||||
"@pipu/i18n": "workspace:*",
|
||||
"@tanstack/react-query": "^5.80.7",
|
||||
"@tanstack/react-router": "^1.120.20",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
|
||||
+202
-29
@@ -1,51 +1,224 @@
|
||||
import { createApiClient } from '@pipu/api-client';
|
||||
import { AuthSession } from '@pipu/core';
|
||||
import { createI18n } from '@pipu/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
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 { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { App } from './App';
|
||||
import { createAppRouter, safeRedirect } from './app/router';
|
||||
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const info = {
|
||||
name: 'pipu-music',
|
||||
version: '0.1.0',
|
||||
api_version: 'v1',
|
||||
supported_locales: ['ru', 'en'],
|
||||
min_android_version_code: 100,
|
||||
};
|
||||
|
||||
async function renderApp(fetchImpl: typeof fetch) {
|
||||
async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) {
|
||||
const api = fakeApi(users);
|
||||
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(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<App client={client} />
|
||||
</QueryClientProvider>
|
||||
<App session={session} router={router} queryClient={queryClient} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
return { api, router, session, i18n, user: userEvent.setup() };
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
it('shows the server version when the server is online', async () => {
|
||||
await renderApp(async () => Response.json(info));
|
||||
expect(await screen.findByText('Сервер 0.1.0 на связи')).toBeTruthy();
|
||||
describe('sign-in', () => {
|
||||
it('sends a guest to the login page and back after signing in', async () => {
|
||||
const alice = makeUser('alice', 'user', { display_name: 'Алиса' });
|
||||
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 () => {
|
||||
await renderApp(async () => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
it('shows the bootstrap hint on first start', async () => {
|
||||
await renderApp([makeUser('admin', 'admin', { must_change_credentials: true })], '/login');
|
||||
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 () => {
|
||||
await renderApp(async () => Response.json(info));
|
||||
await screen.findByText('Сервер 0.1.0 на связи');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'EN' }));
|
||||
expect(await screen.findByText('Server 0.1.0 is online')).toBeTruthy();
|
||||
it('forces a temporary password to be replaced', async () => {
|
||||
const bob = makeUser('bob', 'user', { must_change_password: true });
|
||||
const { user } = await renderApp([bob], '/', { signedInAs: bob });
|
||||
await screen.findByRole('heading', { name: 'Задайте новый пароль' });
|
||||
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('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 { checkServer } from '@pipu/core';
|
||||
import { SUPPORTED_LOCALES } from '@pipu/i18n';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AuthSession } from '@pipu/core';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
const defaultClient = createApiClient();
|
||||
import type { createAppRouter } from './app/router';
|
||||
import { SessionProvider } from './app/session';
|
||||
|
||||
export function App({ client = defaultClient }: { client?: ApiClient }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const server = useQuery({ queryKey: ['server-info'], queryFn: () => checkServer(client) });
|
||||
export function createQueryClient() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } });
|
||||
}
|
||||
|
||||
export function App({
|
||||
session,
|
||||
router,
|
||||
queryClient,
|
||||
}: {
|
||||
session: AuthSession;
|
||||
router: ReturnType<typeof createAppRouter>;
|
||||
queryClient?: QueryClient;
|
||||
}) {
|
||||
const [client] = useState(() => queryClient ?? createQueryClient());
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="top">
|
||||
<div className="logo">
|
||||
<span className="logo-dot" aria-hidden />
|
||||
{t('app.name')}
|
||||
</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>
|
||||
<SessionProvider session={session}>
|
||||
<QueryClientProvider client={client}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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 { UsersPage } from '../pages/admin/UsersPage';
|
||||
import { HomePage } from '../pages/HomePage';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
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 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: ({ context }) => {
|
||||
if (context.user.role !== 'admin') throw redirect({ to: '/' });
|
||||
},
|
||||
component: AuditPage,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
loginRoute,
|
||||
setupAdminRoute,
|
||||
setupPasswordRoute,
|
||||
appRoute.addChildren([homeRoute, settingsRoute, usersRoute, auditRoute]),
|
||||
]);
|
||||
|
||||
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';
|
||||
}
|
||||
+36
-5
@@ -1,12 +1,14 @@
|
||||
import './styles.css';
|
||||
|
||||
import { AuthSession } from '@pipu/core';
|
||||
import { createI18n, pickLocale } from '@pipu/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
|
||||
import { App } from './App';
|
||||
import { createAppRouter } from './app/router';
|
||||
import { applyTheme } from './app/session';
|
||||
|
||||
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]));
|
||||
document.documentElement.lang = i18n.language;
|
||||
i18n.on('languageChanged', (l) => {
|
||||
document.documentElement.lang = l;
|
||||
try {
|
||||
localStorage.setItem(LOCALE_KEY, l);
|
||||
} 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(
|
||||
<StrictMode>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
<App session={session} router={router} />
|
||||
</I18nextProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -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,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,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,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,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,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,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,23 @@
|
||||
/** Дата и время в локали интерфейса: «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');
|
||||
}
|
||||
@@ -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,21 @@
|
||||
// Иконки 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';
|
||||
@@ -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 '@fontsource/onest/400.css';
|
||||
@import '@fontsource/onest/500.css';
|
||||
@import '@fontsource/onest/600.css';
|
||||
@import '@fontsource/onest/700.css';
|
||||
@import '@fontsource/unbounded/600.css';
|
||||
@import '@fontsource/jetbrains-mono/400.css';
|
||||
|
||||
@@ -23,6 +25,11 @@ body {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] body {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -34,118 +41,23 @@ button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 60px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
@keyframes pipuIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
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);
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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;
|
||||
|
||||
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 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 });
|
||||
}
|
||||
return problem(404, 'common.not_found');
|
||||
};
|
||||
|
||||
return { fetch: fetchImpl as typeof fetch, calls, users, signOut: () => (current = null) };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@@ -0,0 +1,202 @@
|
||||
.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;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--bar-a);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.logoLink {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.player {
|
||||
grid-area: player;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--bar-a);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.cover {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
background: var(--surface2);
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.playerEmpty {
|
||||
color: var(--text3);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
.player {
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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,
|
||||
logoutIcon,
|
||||
musicIcon,
|
||||
settingsIcon,
|
||||
} from '../shared/ui/icons';
|
||||
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from './LanguageSwitch';
|
||||
import s from './AppShell.module.css';
|
||||
|
||||
type NavPath = '/' | '/admin/users' | '/admin/audit';
|
||||
|
||||
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' ? <Shell /> : 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>
|
||||
<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} />
|
||||
{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/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<main className={s.content}>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className={s.player} aria-label={t('player.empty')}>
|
||||
<span className={s.cover}>
|
||||
<Icon src={musicIcon} size={22} />
|
||||
</span>
|
||||
<span className={s.playerEmpty}>{t('player.empty')}</span>
|
||||
</footer>
|
||||
</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,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>
|
||||
);
|
||||
}
|
||||
@@ -19,5 +19,6 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['src/test/setup.ts'],
|
||||
},
|
||||
});
|
||||
+3
-3
@@ -10,10 +10,10 @@
|
||||
| Серверное состояние | TanStack Query (кэш, инвалидация, бесконечные списки) |
|
||||
| Клиентское состояние | Zustand (плеер, очередь, UI) |
|
||||
| Стили | CSS Modules + CSS-переменные из `packages/design-tokens`; тёмная и светлая темы ([design.md](design.md#2-токены)) |
|
||||
| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono, сабсет Material Symbols Rounded — всё встроено в сборку |
|
||||
| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска) |
|
||||
| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono; иконки — SVG Material Symbols Rounded (`@material-symbols/svg-400`), импортируются поштучно в `shared/ui/icons.ts` и рисуются CSS-маской (`currentColor`), поэтому в сборку попадают только используемые — всё встроено в сборку |
|
||||
| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска); в M1 — собственные `Menu` / `Dialog` в `shared/ui`, переход на Radix вместе с поиском (M2) |
|
||||
| Анимация | Motion (переходы UI), Canvas 2D для кнопки волны (порт `wave.js` из прототипа) |
|
||||
| Формы | React Hook Form + Zod |
|
||||
| Формы | React Hook Form + Zod (в M1 формы простые — контролируемые поля, валидация на сервере с ошибками полей) |
|
||||
| i18n | i18next + react-i18next, словари из `packages/i18n` |
|
||||
| API | `packages/api-client` (сгенерирован из OpenAPI, `openapi-typescript` + `openapi-fetch`) |
|
||||
|
||||
|
||||
@@ -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,13 @@
|
||||
export {
|
||||
ApiError,
|
||||
AuthSession,
|
||||
toApiError,
|
||||
unwrap,
|
||||
type AuthSessionOptions,
|
||||
type AuthState,
|
||||
type AuthTokens,
|
||||
type AuthUser,
|
||||
type ClientType,
|
||||
type RefreshStore,
|
||||
} from './auth';
|
||||
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,67 @@
|
||||
"label": "Language",
|
||||
"ru": "Русский",
|
||||
"en": "English"
|
||||
},
|
||||
"nav": {
|
||||
"main": "Navigation",
|
||||
"home": "Home",
|
||||
"admin": "Admin",
|
||||
"users": "Users",
|
||||
"audit": "Audit log"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
"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"
|
||||
"method_not_allowed": "This action is not supported",
|
||||
"network": "Cannot reach the server. Check your network"
|
||||
},
|
||||
"auth": {
|
||||
"invalid_credentials": "Wrong username or password",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"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": "Пользователь удалён"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,67 @@
|
||||
"label": "Язык",
|
||||
"ru": "Русский",
|
||||
"en": "English"
|
||||
},
|
||||
"nav": {
|
||||
"main": "Навигация",
|
||||
"home": "Главная",
|
||||
"admin": "Админка",
|
||||
"users": "Пользователи",
|
||||
"audit": "Журнал"
|
||||
},
|
||||
"profile": {
|
||||
"menu": "Меню профиля",
|
||||
"settings": "Настройки",
|
||||
"logout": "Выйти"
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Администратор",
|
||||
"moderator": "Модератор",
|
||||
"user": "Пользователь"
|
||||
},
|
||||
"home": {
|
||||
"greeting": "Привет, {{name}}!",
|
||||
"wave": "Моя волна",
|
||||
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
|
||||
},
|
||||
"player": {
|
||||
"empty": "Ничего не играет"
|
||||
},
|
||||
"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": "Обновить страницу"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
"internal": "Что-то пошло не так на сервере. Попробуйте ещё раз",
|
||||
"bad_request": "Некорректный запрос",
|
||||
"not_found": "Не найдено",
|
||||
"method_not_allowed": "Действие не поддерживается"
|
||||
"method_not_allowed": "Действие не поддерживается",
|
||||
"network": "Нет связи с сервером. Проверьте сеть"
|
||||
},
|
||||
"auth": {
|
||||
"invalid_credentials": "Неверный логин или пароль",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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 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 ruErrors from '../locales/ru/errors.json';
|
||||
|
||||
@@ -11,8 +15,8 @@ export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: Locale = 'en';
|
||||
|
||||
export const resources = {
|
||||
ru: { common: ruCommon, errors: ruErrors },
|
||||
en: { common: enCommon, errors: enErrors },
|
||||
ru: { common: ruCommon, auth: ruAuth, admin: ruAdmin, errors: ruErrors },
|
||||
en: { common: enCommon, auth: enAuth, admin: enAdmin, errors: enErrors },
|
||||
} as const;
|
||||
|
||||
declare module 'i18next' {
|
||||
@@ -40,7 +44,7 @@ export async function createI18n(locale: Locale): Promise<I18n> {
|
||||
fallbackLng: DEFAULT_LOCALE,
|
||||
supportedLngs: [...SUPPORTED_LOCALES],
|
||||
defaultNS: 'common',
|
||||
ns: ['common', 'errors'],
|
||||
ns: ['common', 'auth', 'admin', 'errors'],
|
||||
resources,
|
||||
interpolation: { escapeValue: false },
|
||||
returnNull: false,
|
||||
|
||||
Generated
+155
@@ -69,6 +69,9 @@ importers:
|
||||
'@fontsource/unbounded':
|
||||
specifier: ^5.2.6
|
||||
version: 5.3.0
|
||||
'@material-symbols/svg-400':
|
||||
specifier: ^0.31.9
|
||||
version: 0.31.9
|
||||
'@pipu/api-client':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/api-client
|
||||
@@ -84,6 +87,9 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.80.7
|
||||
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:
|
||||
specifier: ^19.1.0
|
||||
version: 19.3.1
|
||||
@@ -97,9 +103,15 @@ importers:
|
||||
'@testing-library/dom':
|
||||
specifier: ^10.4.0
|
||||
version: 10.4.2
|
||||
'@testing-library/jest-dom':
|
||||
specifier: ^6.9.1
|
||||
version: 6.9.1
|
||||
'@testing-library/react':
|
||||
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)
|
||||
'@testing-library/user-event':
|
||||
specifier: ^14.6.1
|
||||
version: 14.6.1(@testing-library/dom@10.4.2)
|
||||
'@types/react':
|
||||
specifier: ^19.1.8
|
||||
version: 19.3.0
|
||||
@@ -157,6 +169,9 @@ packages:
|
||||
graphql:
|
||||
optional: true
|
||||
|
||||
'@adobe/css-tools@4.5.0':
|
||||
resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
|
||||
|
||||
'@asamuzakjp/css-color@3.2.0':
|
||||
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
||||
|
||||
@@ -985,6 +1000,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
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':
|
||||
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||
@@ -1215,6 +1233,10 @@ packages:
|
||||
'@sinonjs/fake-timers@10.3.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==}
|
||||
|
||||
@@ -1227,10 +1249,34 @@ packages:
|
||||
'@types/react':
|
||||
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':
|
||||
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
|
||||
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':
|
||||
resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1246,6 +1292,12 @@ packages:
|
||||
'@types/react-dom':
|
||||
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':
|
||||
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
||||
|
||||
@@ -1705,6 +1757,9 @@ packages:
|
||||
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
css.escape@1.5.1:
|
||||
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
|
||||
|
||||
cssstyle@4.6.0:
|
||||
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1783,6 +1838,9 @@ packages:
|
||||
dom-accessibility-api@0.5.16:
|
||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||
|
||||
dom-accessibility-api@0.6.3:
|
||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||
|
||||
dotenv-expand@11.0.7:
|
||||
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2121,6 +2179,10 @@ packages:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2487,6 +2549,10 @@ packages:
|
||||
resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
min-indent@1.0.1:
|
||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
|
||||
@@ -2838,6 +2904,10 @@ packages:
|
||||
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
redent@3.0.0:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
regenerate-unicode-properties@10.3.0:
|
||||
resolution: {integrity: sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3058,6 +3128,10 @@ packages:
|
||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
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:
|
||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3131,6 +3205,12 @@ packages:
|
||||
throat@5.0.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
@@ -3277,6 +3357,11 @@ packages:
|
||||
uri-js-replace@1.0.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
@@ -3528,6 +3613,8 @@ snapshots:
|
||||
|
||||
'@0no-co/graphql.web@1.3.4': {}
|
||||
|
||||
'@adobe/css-tools@4.5.0': {}
|
||||
|
||||
'@asamuzakjp/css-color@3.2.0':
|
||||
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)
|
||||
@@ -4590,6 +4677,8 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
'@material-symbols/svg-400@0.31.9': {}
|
||||
|
||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||
optional: true
|
||||
|
||||
@@ -4826,6 +4915,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@sinonjs/commons': 3.0.1
|
||||
|
||||
'@tanstack/history@1.120.17': {}
|
||||
|
||||
'@tanstack/query-core@5.104.4': {}
|
||||
|
||||
'@tanstack/react-query@5.104.4(@types/react@19.3.0)(react@19.3.1)':
|
||||
@@ -4835,6 +4926,32 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -4846,6 +4963,15 @@ snapshots:
|
||||
picocolors: 1.1.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)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.10
|
||||
@@ -4856,6 +4982,10 @@ snapshots:
|
||||
'@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/babel__core@7.20.5':
|
||||
@@ -5395,6 +5525,8 @@ snapshots:
|
||||
|
||||
crypto-random-string@2.0.0: {}
|
||||
|
||||
css.escape@1.5.1: {}
|
||||
|
||||
cssstyle@4.6.0:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 3.2.0
|
||||
@@ -5445,6 +5577,8 @@ snapshots:
|
||||
|
||||
dom-accessibility-api@0.5.16: {}
|
||||
|
||||
dom-accessibility-api@0.6.3: {}
|
||||
|
||||
dotenv-expand@11.0.7:
|
||||
dependencies:
|
||||
dotenv: 16.4.7
|
||||
@@ -5792,6 +5926,8 @@ snapshots:
|
||||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
indent-string@4.0.0: {}
|
||||
|
||||
index-to-position@1.2.0: {}
|
||||
|
||||
inflight@1.0.6:
|
||||
@@ -6269,6 +6405,8 @@ snapshots:
|
||||
|
||||
mimic-fn@1.2.0: {}
|
||||
|
||||
min-indent@1.0.1: {}
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.21
|
||||
@@ -6625,6 +6763,11 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
regenerate: 1.5.0
|
||||
@@ -6852,6 +6995,10 @@ snapshots:
|
||||
dependencies:
|
||||
ansi-regex: 6.4.0
|
||||
|
||||
strip-indent@3.0.0:
|
||||
dependencies:
|
||||
min-indent: 1.0.1
|
||||
|
||||
strip-json-comments@2.0.1: {}
|
||||
|
||||
strip-literal@3.1.0:
|
||||
@@ -6931,6 +7078,10 @@ snapshots:
|
||||
|
||||
throat@5.0.0: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tiny-warning@1.0.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
@@ -7036,6 +7187,10 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
uuid@7.0.3: {}
|
||||
|
||||
Reference in new issue
Block a user