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'],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user