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

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:33:46 +03:00
1 parent f075863f15
commit ab092def54
44 files changed
+4070 -203

No files matched your search

+4
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+139
View File
@@ -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>;
}
}
+67
View File
@@ -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
View File
@@ -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>,
);
+26
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+230
View File
@@ -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>
);
}
+108
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+362
View File
@@ -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>
);
}
+166
View File
@@ -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;
}
+63
View File
@@ -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;
}
+343
View File
@@ -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);
}
+25
View File
@@ -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;
};
}
+23
View File
@@ -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');
}
+23
View File
@@ -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 [];
}
+21
View File
@@ -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';
+316
View File
@@ -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;
}
+282
View File
@@ -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
View File
@@ -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;
}
}
+154
View File
@@ -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) };
}
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';
+202
View File
@@ -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;
}
}
+104
View File
@@ -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>
);
}
+33
View File
@@ -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)}
/>
);
}
+15
View File
@@ -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>
);
}
+1
View File
@@ -19,5 +19,6 @@ export default defineConfig({
},
test: {
environment: 'jsdom',
setupFiles: ['src/test/setup.ts'],
},
});