diff --git a/apps/web/package.json b/apps/web/package.json index c2333ec..ab95acf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 061d3fe..c09e56e 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -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( - - - + , ); + 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); }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0d96c5f..c85a44b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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; + queryClient?: QueryClient; +}) { + const [client] = useState(() => queryClient ?? createQueryClient()); return ( -
-
-
- - {t('app.name')} -
-
- {SUPPORTED_LOCALES.map((l) => ( - - ))} -
-
-
-
-

{t('app.name')}

-

{t('app.tagline')}

- {server.data?.status === 'ok' ? ( -
- {t('server.online', { version: server.data.info.version })} -
- ) : server.data ? ( -
- {t('server.offline')} -
- ) : ( -
- {t('server.checking')} -
- )} -
-
-
+ + + + + ); } diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx new file mode 100644 index 0000000..709a33f --- /dev/null +++ b/apps/web/src/app/router.tsx @@ -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()({ + component: Outlet, + errorComponent: RouteError, +}); + +const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/login', + validateSearch: (search: Record): { 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: () => , +}); + +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: () => , +}); + +/** Каркас приложения: только для вошедших, прошедших обязательные шаги. */ +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[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; + } +} diff --git a/apps/web/src/app/session.tsx b/apps/web/src/app/session.tsx new file mode 100644 index 0000000..17d0e68 --- /dev/null +++ b/apps/web/src/app/session.tsx @@ -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(null); + +export function SessionProvider({ session, children }: { session: AuthSession; children: ReactNode }) { + return ( + + + {children} + + ); +} + +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'; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index ebec9a4..9911d14 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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( - - - + , ); diff --git a/apps/web/src/pages/AuthLayout.tsx b/apps/web/src/pages/AuthLayout.tsx new file mode 100644 index 0000000..3707ac4 --- /dev/null +++ b/apps/web/src/pages/AuthLayout.tsx @@ -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 ( +
+
+ + + +
+
+ + +
+
+
{children}
+ {footer &&
{footer}
} +
+
+ ); +} diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..9c0224e --- /dev/null +++ b/apps/web/src/pages/HomePage.tsx @@ -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 ( +
+

{t('home.greeting', { name: user.display_name ?? user.username })}

+
+ + {t('home.wave')} +
+

{t('home.wave_soon')}

+
+ ); +} diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..2826335 --- /dev/null +++ b/apps/web/src/pages/LoginPage.tsx @@ -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(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 ( + +

{t('login.title')}

+

{t('login.subtitle')}

+ {info.data?.bootstrap_required &&
{t('login.bootstrap_hint')}
} +
void submit(e)} noValidate> + + + {error ? errorText(error) : null} + + +
+ ); +} diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..388932f --- /dev/null +++ b/apps/web/src/pages/SettingsPage.tsx @@ -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; + +export function SettingsPage() { + const { t } = useTranslation(); + return ( +
+
+

{t('settings.title')}

+
+ + + + +
+ ); +} + +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 ( +
+

{t('settings.account')}

+
{ + e.preventDefault(); + save.mutate(); + }} + > +
+ { + setName(v); + save.reset(); + }} + maxLength={64} + hint={`@${user.username}`} + /> + +
+ {save.isSuccess && {t('settings.saved')}} + {save.error ? errorText(save.error) : null} +
+
+ ); +} + +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 ( +
+

{t('settings.appearance')}

+
+ {t('settings.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') }, + ]} + /> +
+
+ {t('language.label')} + +
+
+ ); +} + +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 ( +
+

{t('settings.password')}

+
+ + + + {change.isSuccess && {t('settings.password_changed')}} + {err && !currentError && !nextError ? errorText(err) : null} +
+ +
+ +
+ ); +} + +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 ( +
+

{t('settings.sessions')}

+
+ {sessions.data?.map((ss) => ( +
+
+
{ss.device_name ?? ss.client_type}
+
+ {ss.current + ? t('settings.session_current') + : t('settings.session_last_used', { date: formatDateTime(ss.last_used_at, i18n.language) })} +
+
+ {!ss.current && ( + + )} +
+ ))} +
+ {sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null} +
+ +
+
+ ); +} diff --git a/apps/web/src/pages/SetupPage.tsx b/apps/web/src/pages/SetupPage.tsx new file mode 100644 index 0000000..84cc1e3 --- /dev/null +++ b/apps/web/src/pages/SetupPage.tsx @@ -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' ? : 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(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 ( + + + + {t(admin ? 'setup.admin_badge' : 'setup.password_badge')} + +

{t(admin ? 'setup.admin_title' : 'setup.password_title')}

+

{t(admin ? 'setup.admin_text' : 'setup.password_text')}

+
void submit(e)} noValidate> + {admin && ( + + )} + + + {generalError} + + + +
+ ); +} diff --git a/apps/web/src/pages/admin/AuditPage.tsx b/apps/web/src/pages/admin/AuditPage.tsx new file mode 100644 index 0000000..2ada437 --- /dev/null +++ b/apps/web/src/pages/admin/AuditPage.tsx @@ -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; + const roleName = (r: string) => tr(`common:roles.${r}`, { defaultValue: r }); + + return ( +
+
+
+

{t('audit.title')}

+

{t('audit.subtitle')}

+
+
+ {list.error ? errorText(list.error) : null} +
+ + + + + + + + + + + {entries.map((e) => ( + + + + + + + ))} + +
{t('audit.col_when')}{t('audit.col_who')}{t('audit.col_action')}{t('audit.col_details')}
{formatDateTime(e.created_at, i18n.language)}{e.actor ? `@${e.actor.username}` : {t('audit.system')}}{tr(`admin:audit.actions.${e.action}`, { defaultValue: e.action })}{details(e, roleName)}
+ {list.isSuccess && entries.length === 0 &&
{t('audit.empty')}
} + {list.isPending &&
{t('common:loading')}
} +
+ {list.hasNextPage && ( + + )} +
+ ); +} diff --git a/apps/web/src/pages/admin/UsersPage.tsx b/apps/web/src/pages/admin/UsersPage.tsx new file mode 100644 index 0000000..a1ed773 --- /dev/null +++ b/apps/web/src/pages/admin/UsersPage.tsx @@ -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(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(null); + const query = useDebounced(q.trim(), 200); + const [creating, setCreating] = useState(false); + const [confirm, setConfirm] = useState(null); + const [secret, setSecret] = useState<{ username: string; password: string } | null>(null); + const [actionError, setActionError] = useState(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[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 ( +
+
+
+

{t('users.title')}

+

{t('users.subtitle')}

+
+ +
+ +
+ +
+ + {ROLES.map((r) => ( + + ))} +
+
+ + {actionError || list.error ? errorText(actionError ?? list.error) : null} + +
+ + + + + + + + + + + {users.map((u) => { + const items = actionsFor(u); + const st = status(u); + return ( + + + + + + + + ); + })} + +
{t('users.col_user')}{t('users.col_role')}{t('users.col_status')}{t('users.col_last_login')} +
+
+ +
+
+ {u.display_name ?? u.username} + {u.id === me.id && ({t('users.you')})} +
+
@{u.username}
+
+
+
+ {t(`common:roles.${u.role}`)} + + {t(`users.status_${st}`)} + + {u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')} + + {items.length > 0 && ( + } items={items} /> + )} +
+ {list.isSuccess && users.length === 0 &&
{t('users.empty')}
} + {list.isPending &&
{t('common:loading')}
} +
+ {list.hasNextPage && ( + + )} + + {creating && ( + setCreating(false)} + onCreated={(username, password) => { + setCreating(false); + setSecret({ username, password }); + void refresh(); + }} + /> + )} + + {confirm && ( + setConfirm(null)} + footer={ + <> + + + + } + > +

{t(`users.${confirm.kind}_confirm`, { username: confirm.user.username })}

+
+ )} + + {secret && setSecret(null)} />} +
+ ); +} + +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('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 ( + + + + + } + > +
+ + + {roles.length > 1 && ( + + )} +

{t('create.password_note')}

+ {create.error && !usernameError && !nameError ? errorText(create.error) : null} + +
+ ); +} + +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 ( + + {t('temp_password.done')} + + } + > +

{t('temp_password.text', { username })}

+
+ {password} + +
+
+ ); +} diff --git a/apps/web/src/pages/auth.module.css b/apps/web/src/pages/auth.module.css new file mode 100644 index 0000000..273dc93 --- /dev/null +++ b/apps/web/src/pages/auth.module.css @@ -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; +} diff --git a/apps/web/src/pages/home.module.css b/apps/web/src/pages/home.module.css new file mode 100644 index 0000000..7a64bfd --- /dev/null +++ b/apps/web/src/pages/home.module.css @@ -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; +} diff --git a/apps/web/src/pages/page.module.css b/apps/web/src/pages/page.module.css new file mode 100644 index 0000000..24bcbed --- /dev/null +++ b/apps/web/src/pages/page.module.css @@ -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); +} diff --git a/apps/web/src/shared/lib/errors.ts b/apps/web/src/shared/lib/errors.ts new file mode 100644 index 0000000..65f23a1 --- /dev/null +++ b/apps/web/src/shared/lib/errors.ts @@ -0,0 +1,25 @@ +import { ApiError } from '@pipu/core'; +import { useTranslation } from 'react-i18next'; + +type Translate = (key: string, options?: Record) => string; + +/** Текст ошибки для пользователя: перевод `errors:` с параметрами, иначе общий текст. */ +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; + }; +} diff --git a/apps/web/src/shared/lib/format.ts b/apps/web/src/shared/lib/format.ts new file mode 100644 index 0000000..ca89326 --- /dev/null +++ b/apps/web/src/shared/lib/format.ts @@ -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'); +} diff --git a/apps/web/src/shared/lib/policy.ts b/apps/web/src/shared/lib/policy.ts new file mode 100644 index 0000000..42083d4 --- /dev/null +++ b/apps/web/src/shared/lib/policy.ts @@ -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 []; +} diff --git a/apps/web/src/shared/ui/icons.ts b/apps/web/src/shared/ui/icons.ts new file mode 100644 index 0000000..ed24c25 --- /dev/null +++ b/apps/web/src/shared/ui/icons.ts @@ -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'; diff --git a/apps/web/src/shared/ui/ui.module.css b/apps/web/src/shared/ui/ui.module.css new file mode 100644 index 0000000..2e6b870 --- /dev/null +++ b/apps/web/src/shared/ui/ui.module.css @@ -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; +} diff --git a/apps/web/src/shared/ui/ui.tsx b/apps/web/src/shared/ui/ui.tsx new file mode 100644 index 0000000..fd7a1f2 --- /dev/null +++ b/apps/web/src/shared/ui/ui.tsx @@ -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 ( + + ); +} + +export function Logo({ size = 28 }: { size?: number }) { + const { t } = useTranslation(); + return ( + + + {t('app.name')} + + ); +} + +type ButtonProps = ButtonHTMLAttributes & { + variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; + icon?: string; + busy?: boolean; +}; + +export function Button({ variant = 'secondary', icon, busy, className, children, disabled, ...rest }: ButtonProps) { + return ( + + ); +} + +type FieldProps = Omit, '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 ( +
+ +
+ 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 && ( + + )} +
+ {note && ( +
+ {note} +
+ )} +
+ ); +} + +/** Сегментированный переключатель. */ +export function Segmented({ + label, + value, + options, + onChange, +}: { + label: string; + value: T; + options: { value: T; label: string }[]; + onChange: (v: T) => void; +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +export function FormError({ children }: { children?: ReactNode }) { + if (!children) return null; + return ( +
+ {children} +
+ ); +} + +/** Модальный диалог: стеклянная карточка поверх подложки, Esc и клик по подложке закрывают. */ +export function Dialog({ + title, + onClose, + children, + footer, +}: { + title: string; + onClose: () => void; + children: ReactNode; + footer?: ReactNode; +}) { + const { t } = useTranslation(); + const ref = useRef(null); + const titleId = useId(); + useEffect(() => { + const prev = document.activeElement as HTMLElement | null; + const first = ref.current?.querySelector('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 ( +
e.target === e.currentTarget && onClose()}> +
+
+

{title}

+ +
+
{children}
+ {footer &&
{footer}
} +
+
+ ); +} + +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(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('[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('[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 ( +
+ + {open && ( +
+ {header} + {items.map((it) => ( + + ))} +
+ )} +
+ ); +} + +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 ( + + {initials || '?'} + + ); +} + +export { cx }; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index aa051a2..f8a6a68 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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; + } } diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts new file mode 100644 index 0000000..e6d5227 --- /dev/null +++ b/apps/web/src/test/fakeApi.ts @@ -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 { + 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(); + 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 => { + 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) : 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) }; +} diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts new file mode 100644 index 0000000..bb02c60 --- /dev/null +++ b/apps/web/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; diff --git a/apps/web/src/widgets/AppShell.module.css b/apps/web/src/widgets/AppShell.module.css new file mode 100644 index 0000000..5ba115d --- /dev/null +++ b/apps/web/src/widgets/AppShell.module.css @@ -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; + } +} diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx new file mode 100644 index 0000000..521207e --- /dev/null +++ b/apps/web/src/widgets/AppShell.tsx @@ -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 ( + + {({ isActive }) => ( + <> + + {label} + + )} + + ); +} + +/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */ +export function AppShell() { + return useAuthState().status === 'signed_in' ? : 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 ( +
+
+ + + +
+ } + header={ +
+ +
+
{name}
+
+ @{user.username} · {t(`roles.${user.role}`)} +
+
+ +
+ } + items={[ + { label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) }, + { label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() }, + ]} + /> +
+
+ + + +
+ +
+ +
+ + + + {t('player.empty')} +
+
+ ); +} diff --git a/apps/web/src/widgets/LanguageSwitch.tsx b/apps/web/src/widgets/LanguageSwitch.tsx new file mode 100644 index 0000000..945ff38 --- /dev/null +++ b/apps/web/src/widgets/LanguageSwitch.tsx @@ -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 ( + ({ value: l, label: l.toUpperCase() }))} + onChange={(l) => void change(l)} + /> + ); +} diff --git a/apps/web/src/widgets/RouteError.tsx b/apps/web/src/widgets/RouteError.tsx new file mode 100644 index 0000000..529fd08 --- /dev/null +++ b/apps/web/src/widgets/RouteError.tsx @@ -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 ( +
+

{t('errors:common.internal')}

+ +
+ ); +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 31d4321..9297f6d 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -19,5 +19,6 @@ export default defineConfig({ }, test: { environment: 'jsdom', + setupFiles: ['src/test/setup.ts'], }, }); diff --git a/docs/web.md b/docs/web.md index ea953d0..c3573fe 100644 --- a/docs/web.md +++ b/docs/web.md @@ -10,10 +10,10 @@ | Серверное состояние | TanStack Query (кэш, инвалидация, бесконечные списки) | | Клиентское состояние | Zustand (плеер, очередь, UI) | | Стили | CSS Modules + CSS-переменные из `packages/design-tokens`; тёмная и светлая темы ([design.md](design.md#2-токены)) | -| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono, сабсет Material Symbols Rounded — всё встроено в сборку | -| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска) | +| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono; иконки — SVG Material Symbols Rounded (`@material-symbols/svg-400`), импортируются поштучно в `shared/ui/icons.ts` и рисуются CSS-маской (`currentColor`), поэтому в сборку попадают только используемые — всё встроено в сборку | +| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска); в M1 — собственные `Menu` / `Dialog` в `shared/ui`, переход на Radix вместе с поиском (M2) | | Анимация | Motion (переходы UI), Canvas 2D для кнопки волны (порт `wave.js` из прототипа) | -| Формы | React Hook Form + Zod | +| Формы | React Hook Form + Zod (в M1 формы простые — контролируемые поля, валидация на сервере с ошибками полей) | | i18n | i18next + react-i18next, словари из `packages/i18n` | | API | `packages/api-client` (сгенерирован из OpenAPI, `openapi-typescript` + `openapi-fetch`) | diff --git a/packages/core/src/auth.test.ts b/packages/core/src/auth.test.ts new file mode 100644 index 0000000..d18f7eb --- /dev/null +++ b/packages/core/src/auth.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ApiError, AuthSession, type AuthTokens, type RefreshStore } from './auth'; + +const user = { + id: '00000000-0000-0000-0000-000000000001', + username: 'alice', + display_name: null, + role: 'user' as const, + locale: 'ru', + ui_prefs: {}, + must_change_password: false, + must_change_credentials: false, + created_at: '2026-01-01T00:00:00Z', +}; + +const tokens = (access: string, refresh?: string): AuthTokens => ({ + access_token: access, + token_type: 'Bearer', + expires_in: 900, + refresh_token: refresh, + user, +}); + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' }, + }); + +const problem = (status: number, code: string, extra: object = {}) => json({ status, code, ...extra }, status); + +type Handler = (req: Request) => Response | Promise; + +/** Фейковый сервер: маршруты по «METHOD path», журнал запросов. */ +function server(routes: Record) { + const log: { route: string; auth: string | null; body: string }[] = []; + const fetchImpl = async (input: RequestInfo | URL) => { + const req = input as Request; + const route = `${req.method} ${new URL(req.url).pathname}`; + log.push({ route, auth: req.headers.get('Authorization'), body: await req.clone().text() }); + const h = routes[route]; + if (!h) return problem(404, 'common.not_found'); + return h(req); + }; + return { fetch: fetchImpl as typeof fetch, log }; +} + +const memoryStore = (initial: string | null = null): RefreshStore & { value: string | null } => ({ + value: initial, + async get() { + return this.value; + }, + async set(v) { + this.value = v; + }, + async clear() { + this.value = null; + }, +}); + +describe('AuthSession', () => { + it('logs in and sends the bearer token', async () => { + const s = server({ + 'POST /api/v1/auth/login': () => json(tokens('A1')), + 'GET /api/v1/me': (req) => + req.headers.get('Authorization') === 'Bearer A1' ? json(user) : problem(401, 'auth.unauthorized'), + }); + const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web', deviceName: 'Test' }); + const states: string[] = []; + auth.subscribe((st) => states.push(st.status)); + + await auth.login('alice', 'secret'); + expect(JSON.parse(s.log[0]!.body)).toEqual({ + username: 'alice', + password: 'secret', + client_type: 'web', + device_name: 'Test', + }); + const me = await auth.client.GET('/api/v1/me'); + expect(me.data?.username).toBe('alice'); + expect(states).toEqual(['signed_in']); + }); + + it('turns problem+json into ApiError with field errors', async () => { + const s = server({ + 'POST /api/v1/auth/login': () => problem(401, 'auth.invalid_credentials'), + }); + const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' }); + const err = await auth.login('alice', 'bad').catch((e: unknown) => e); + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).code).toBe('auth.invalid_credentials'); + expect(auth.state.status).toBe('unknown'); + + const e2 = new ApiError(422, 'validation.password_too_short', { min: 8 }, [ + { field: 'new_password', code: 'validation.password_too_short' }, + ]); + expect(e2.fieldCode('new_password')).toBe('validation.password_too_short'); + expect(e2.fieldCode('other')).toBeUndefined(); + }); + + it('refreshes once for parallel expired requests and retries them', async () => { + let refreshes = 0; + const s = server({ + 'POST /api/v1/auth/login': () => json(tokens('old')), + 'POST /api/v1/auth/refresh': async () => { + refreshes++; + await new Promise((r) => setTimeout(r, 10)); + return json(tokens('new')); + }, + 'GET /api/v1/me': (req) => + req.headers.get('Authorization') === 'Bearer new' ? json(user) : problem(401, 'auth.token_expired'), + }); + const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' }); + await auth.login('alice', 'secret'); + + const results = await Promise.all([1, 2, 3].map(() => auth.client.GET('/api/v1/me'))); + expect(results.every((r) => r.data?.username === 'alice')).toBe(true); + expect(refreshes).toBe(1); + }); + + it('does not refresh on other 401 codes and signs out a blocked user', async () => { + const s = server({ + 'POST /api/v1/auth/login': () => json(tokens('A')), + 'GET /api/v1/me': () => problem(401, 'auth.user_blocked'), + }); + const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' }); + await auth.login('alice', 'secret'); + const res = await auth.client.GET('/api/v1/me'); + expect(res.response.status).toBe(401); + expect(s.log.some((l) => l.route.includes('refresh'))).toBe(false); + expect(auth.state).toEqual({ status: 'signed_out', reason: 'auth.user_blocked' }); + }); + + it('signs out when refresh is rejected', async () => { + const s = server({ + 'POST /api/v1/auth/refresh': () => problem(401, 'auth.refresh_reused'), + }); + const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' }); + expect(await auth.restore()).toEqual({ status: 'signed_out', reason: 'auth.refresh_reused' }); + }); + + it('retries a refresh race once', async () => { + let n = 0; + const s = server({ + 'POST /api/v1/auth/refresh': () => (++n === 1 ? problem(401, 'auth.refresh_race') : json(tokens('A'))), + }); + const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' }); + expect((await auth.restore()).status).toBe('signed_in'); + expect(n).toBe(2); + }); + + it('keeps the session on network errors during refresh', async () => { + const auth = new AuthSession({ + baseUrl: 'http://x', + fetch: async () => { + throw new TypeError('Failed to fetch'); + }, + clientType: 'web', + }); + expect(await auth.refresh()).toBe(false); + expect(auth.state.status).toBe('unknown'); + }); + + it('android keeps the refresh token in the store', async () => { + const store = memoryStore('R0'); + const s = server({ + 'POST /api/v1/auth/refresh': async (req) => { + const body = (await req.json()) as { refresh_token: string }; + return body.refresh_token === 'R0' ? json(tokens('A1', 'R1')) : problem(401, 'auth.refresh_invalid'); + }, + 'POST /api/v1/auth/logout': () => new Response(null, { status: 204 }), + }); + const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'android', refreshStore: store }); + expect((await auth.restore()).status).toBe('signed_in'); + expect(store.value).toBe('R1'); + + await auth.logout(); + expect(store.value).toBeNull(); + expect(auth.state.status).toBe('signed_out'); + expect(s.log.at(-1)).toMatchObject({ route: 'POST /api/v1/auth/logout', auth: 'Bearer A1' }); + expect(await auth.refresh()).toBe(false); + }); + + it('requires a refresh store for android', () => { + expect(() => new AuthSession({ clientType: 'android' })).toThrow(/refreshStore/); + }); + + it('notifies subscribers about profile updates', async () => { + const s = server({ 'POST /api/v1/auth/login': () => json(tokens('A')) }); + const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' }); + const fn = vi.fn(); + auth.subscribe(fn); + await auth.login('alice', 'x'); + auth.setUser({ ...user, display_name: 'Alice' }); + expect(fn).toHaveBeenLastCalledWith({ status: 'signed_in', user: { ...user, display_name: 'Alice' } }); + }); +}); diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts new file mode 100644 index 0000000..2a2060d --- /dev/null +++ b/packages/core/src/auth.ts @@ -0,0 +1,219 @@ +import { createApiClient, type ApiClient, type components } from '@pipu/api-client'; + +export type AuthUser = components['schemas']['Me']; +export type AuthTokens = components['schemas']['AuthTokens']; +export type ClientType = components['schemas']['ClientType']; + +/** Ошибка API с машинным кодом (problem+json). Текст для пользователя — перевод `errors:`. */ +export class ApiError extends Error { + readonly status: number; + readonly code: string; + readonly params: Record; + readonly fields: { field: string; code: string }[]; + + constructor(status: number, code: string, params: Record = {}, fields: ApiError['fields'] = []) { + super(code); + this.name = 'ApiError'; + this.status = status; + this.code = code; + this.params = params; + this.fields = fields; + } + + /** Код ошибки конкретного поля формы, если сервер его указал. */ + fieldCode(field: string): string | undefined { + return this.fields.find((f) => f.field === field)?.code; + } +} + +/** Превращает ответ openapi-fetch с ошибкой в ApiError. Сетевые сбои — код `common.network`. */ +export function toApiError(error: unknown, response?: Response): ApiError { + if (error instanceof ApiError) return error; + const p = (error ?? {}) as Partial; + if (typeof p.code === 'string') { + return new ApiError(p.status ?? response?.status ?? 0, p.code, p.params ?? {}, p.errors ?? []); + } + if (response) return new ApiError(response.status, response.status >= 500 ? 'common.internal' : 'common.bad_request'); + return new ApiError(0, 'common.network'); +} + +/** Достаёт данные из результата openapi-fetch или бросает ApiError. */ +export function unwrap(res: { data?: T; error?: unknown; response: Response }): T { + if (res.error !== undefined || !res.response.ok) throw toApiError(res.error, res.response); + return res.data as T; +} + +/** Хранилище refresh-токена для клиентов без cookie (Android: expo-secure-store). */ +export interface RefreshStore { + get(): Promise; + set(token: string): Promise; + clear(): Promise; +} + +export type AuthState = + | { status: 'unknown' } + | { status: 'signed_out'; reason?: string } + | { status: 'signed_in'; user: AuthUser }; + +export interface AuthSessionOptions { + /** Адрес сервера; пусто — тот же origin (веб). */ + baseUrl?: string; + fetch?: typeof globalThis.fetch; + clientType: ClientType; + deviceName?: string; + /** Обязателен для android: веб хранит refresh в HttpOnly-cookie. */ + refreshStore?: RefreshStore; +} + +/** Коды 401, после которых имеет смысл обновить access-токен и повторить запрос. */ +const RETRYABLE = new Set(['auth.token_expired', 'auth.token_invalid', 'auth.token_revoked']); + +const RACE_RETRY_MS = 300; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * Сессия пользователя: access-токен в памяти, refresh — в cookie (веб) или RefreshStore (Android). + * `client` сам подставляет Bearer и при истёкшем токене делает **один** общий refresh на все + * параллельные запросы (single-flight), после чего повторяет их. + */ +export class AuthSession { + readonly client: ApiClient; + private readonly raw: ApiClient; + private readonly opts: AuthSessionOptions; + private readonly baseFetch: typeof globalThis.fetch; + private access: string | null = null; + private refreshing: Promise | null = null; + private current: AuthState = { status: 'unknown' }; + private readonly listeners = new Set<(s: AuthState) => void>(); + + constructor(opts: AuthSessionOptions) { + if (opts.clientType === 'android' && !opts.refreshStore) { + throw new Error('AuthSession: android client needs a refreshStore'); + } + this.opts = opts; + this.baseFetch = opts.fetch ?? ((...args) => globalThis.fetch(...args)); + this.raw = createApiClient({ baseUrl: opts.baseUrl, fetch: this.baseFetch }); + this.client = createApiClient({ baseUrl: opts.baseUrl, fetch: (req) => this.authFetch(req as Request) }); + } + + get state(): AuthState { + return this.current; + } + + /** Подписка на смену состояния; возвращает функцию отписки. */ + subscribe(fn: (s: AuthState) => void): () => void { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + + private setState(s: AuthState) { + this.current = s; + for (const fn of this.listeners) fn(s); + } + + /** Восстанавливает сессию после перезагрузки по refresh-токену. */ + async restore(): Promise { + if (!(await this.refresh())) { + if (this.current.status !== 'signed_out') this.setState({ status: 'signed_out' }); + } + return this.current; + } + + async login(username: string, password: string): Promise { + const res = await this.raw.POST('/api/v1/auth/login', { + body: { username, password, client_type: this.opts.clientType, device_name: this.opts.deviceName }, + }); + return this.apply(unwrap(res)); + } + + /** Принимает новую пару токенов (вход, смена пароля, смена учётных данных). */ + async apply(tokens: AuthTokens): Promise { + this.access = tokens.access_token; + if (tokens.refresh_token && this.opts.refreshStore) await this.opts.refreshStore.set(tokens.refresh_token); + this.setState({ status: 'signed_in', user: tokens.user }); + return tokens.user; + } + + /** Обновляет профиль в состоянии (после PATCH /me). */ + setUser(user: AuthUser) { + if (this.current.status === 'signed_in') this.setState({ status: 'signed_in', user }); + } + + async logout(all = false): Promise { + try { + if (this.access) await this.client.POST(all ? '/api/v1/auth/logout-all' : '/api/v1/auth/logout'); + } finally { + await this.signOut(); + } + } + + private async signOut(reason?: string) { + this.access = null; + await this.opts.refreshStore?.clear(); + this.setState({ status: 'signed_out', reason }); + } + + /** Single-flight: параллельные вызовы ждут один и тот же запрос. */ + refresh(): Promise { + this.refreshing ??= this.doRefresh().finally(() => { + this.refreshing = null; + }); + return this.refreshing; + } + + private async doRefresh(attempt = 0): Promise { + let body: { refresh_token?: string } | undefined; + if (this.opts.refreshStore) { + const token = await this.opts.refreshStore.get(); + if (!token) return false; + body = { refresh_token: token }; + } + let res; + try { + res = await this.raw.POST('/api/v1/auth/refresh', { body }); + } catch { + return false; // сеть недоступна: сессию не трогаем, следующий запрос попробует снова + } + if (res.data) { + await this.apply(res.data); + return true; + } + const err = toApiError(res.error, res.response); + if (err.code === 'auth.refresh_race' && attempt === 0) { + // Другая вкладка только что обновила cookie — повторяем уже с новой. + await sleep(RACE_RETRY_MS); + return this.doRefresh(attempt + 1); + } + if (res.response.status === 401 || res.response.status === 403) await this.signOut(err.code); + return false; + } + + private async authFetch(request: Request): Promise { + const retry = request.clone(); + const sent = this.access; + const res = await this.baseFetch(this.withAuth(request)); + if (res.status !== 401) return res; + + const code = await res + .clone() + .json() + .then((p: { code?: string }) => p.code) + .catch(() => undefined); + if (!code || !RETRYABLE.has(code)) { + if (code === 'auth.user_blocked') await this.signOut(code); + return res; + } + // Пока запрос был в пути, токен уже обновил кто-то другой — повторяем без новой ротации. + const fresh = this.access !== null && this.access !== sent; + if (!fresh && !(await this.refresh())) return res; + return this.baseFetch(this.withAuth(retry)); + } + + private withAuth(request: Request): Request { + if (!this.access) return request; + const headers = new Headers(request.headers); + headers.set('Authorization', `Bearer ${this.access}`); + return new Request(request, { headers }); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0e73f59..ec4ecaa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,13 @@ +export { + ApiError, + AuthSession, + toApiError, + unwrap, + type AuthSessionOptions, + type AuthState, + type AuthTokens, + type AuthUser, + type ClientType, + type RefreshStore, +} from './auth'; export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server'; diff --git a/packages/i18n/locales/en/admin.json b/packages/i18n/locales/en/admin.json new file mode 100644 index 0000000..322cf41 --- /dev/null +++ b/packages/i18n/locales/en/admin.json @@ -0,0 +1,66 @@ +{ + "users": { + "title": "Users", + "subtitle": "Create users and moderators, manage roles, reset passwords, block accounts", + "create": "Create user", + "search": "Name or username", + "all": "All", + "col_user": "User", + "col_role": "Role", + "col_status": "Status", + "col_last_login": "Last seen", + "status_active": "Active", + "status_blocked": "Blocked", + "status_must_change": "Must change password", + "never": "Never signed in", + "empty": "Nobody found", + "more": "Show more", + "actions": "Actions for {{username}}", + "reset_password": "Reset password", + "block": "Block", + "unblock": "Unblock", + "make_role": "Make {{role}}", + "delete": "Delete", + "delete_confirm": "Delete {{username}}? Their history and likes will be lost.", + "block_confirm": "Block {{username}}? All their sessions will end.", + "reset_confirm": "Reset {{username}}'s password? All their sessions will end.", + "confirm": "Confirm", + "you": "you" + }, + "create": { + "title": "New user", + "username": "Username", + "username_hint": "Latin letters, digits, \".\", \"_\", \"-\"", + "display_name": "Name", + "role": "Role", + "password_note": "The server will generate a password and show it once.", + "submit": "Create" + }, + "temp_password": { + "title": "Temporary password", + "text": "Give this password to {{username}}. It is shown only now; they will be asked to change it on sign-in.", + "done": "Done" + }, + "audit": { + "title": "Audit log", + "subtitle": "Actions of administrators and moderators", + "col_when": "When", + "col_who": "Who", + "col_action": "Action", + "col_details": "Details", + "system": "System", + "empty": "No entries yet", + "more": "Show more", + "actions": { + "user.bootstrap_create": "Initial administrator created", + "user.bootstrap_credentials": "Administrator credentials set", + "user.create": "User created", + "user.rename": "Name changed", + "user.role_change": "Role changed", + "user.block": "User blocked", + "user.unblock": "User unblocked", + "user.password_reset": "Password reset", + "user.delete": "User deleted" + } + } +} diff --git a/packages/i18n/locales/en/auth.json b/packages/i18n/locales/en/auth.json new file mode 100644 index 0000000..aaed601 --- /dev/null +++ b/packages/i18n/locales/en/auth.json @@ -0,0 +1,24 @@ +{ + "login": { + "title": "Sign in", + "subtitle": "Your music from your own server", + "username": "Username", + "password": "Password", + "submit": "Sign in", + "bootstrap_hint": "First start: sign in as the administrator. The password is set in BOOTSTRAP_ADMIN_PASSWORD or printed in the api service log." + }, + "setup": { + "admin_badge": "First administrator sign-in", + "admin_title": "Change the username and password", + "admin_text": "The server stays locked until you do: the initial administrator credentials are visible in logs and settings.", + "password_badge": "Password reset", + "password_title": "Set a new password", + "password_text": "You signed in with a temporary password. Choose your own — the temporary one will stop working.", + "new_username": "New username", + "new_password": "New password", + "repeat_password": "Repeat the password", + "min_length": "At least {{min}} characters", + "submit": "Save and continue", + "logout": "Sign out" + } +} diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 85ba38e..ae6d587 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -17,5 +17,67 @@ "label": "Language", "ru": "Русский", "en": "English" + }, + "nav": { + "main": "Navigation", + "home": "Home", + "admin": "Admin", + "users": "Users", + "audit": "Audit log" + }, + "profile": { + "menu": "Profile menu", + "settings": "Settings", + "logout": "Sign out" + }, + "roles": { + "admin": "Administrator", + "moderator": "Moderator", + "user": "User" + }, + "home": { + "greeting": "Hi, {{name}}!", + "wave": "My Wave", + "wave_soon": "An endless stream tuned to your taste will appear once the server has a library." + }, + "player": { + "empty": "Nothing is playing" + }, + "settings": { + "title": "Settings", + "account": "Account", + "display_name": "Display name", + "save": "Save", + "saved": "Saved", + "appearance": "Appearance", + "theme": "Theme", + "theme_dark": "Dark", + "theme_light": "Light", + "theme_system": "System", + "password": "Password", + "current_password": "Current password", + "new_password": "New password", + "repeat_password": "Repeat the password", + "change_password": "Change password", + "password_changed": "Password changed. Other devices have been signed out.", + "sessions": "Devices", + "session_current": "This device", + "session_last_used": "Active: {{date}}", + "session_end": "Sign out", + "logout_all": "Sign out everywhere" + }, + "form": { + "show_password": "Show password", + "hide_password": "Hide password", + "passwords_mismatch": "Passwords don't match", + "required": "Fill in this field", + "cancel": "Cancel", + "close": "Close", + "copy": "Copy", + "copied": "Copied" + }, + "loading": "Loading…", + "route_error": { + "reload": "Reload the page" } } diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json index c9c899f..d6945e7 100644 --- a/packages/i18n/locales/en/errors.json +++ b/packages/i18n/locales/en/errors.json @@ -3,7 +3,8 @@ "internal": "Something went wrong on the server. Please try again", "bad_request": "Invalid request", "not_found": "Not found", - "method_not_allowed": "This action is not supported" + "method_not_allowed": "This action is not supported", + "network": "Cannot reach the server. Check your network" }, "auth": { "invalid_credentials": "Wrong username or password", diff --git a/packages/i18n/locales/ru/admin.json b/packages/i18n/locales/ru/admin.json new file mode 100644 index 0000000..2464bf1 --- /dev/null +++ b/packages/i18n/locales/ru/admin.json @@ -0,0 +1,66 @@ +{ + "users": { + "title": "Пользователи", + "subtitle": "Создание пользователей и модераторов, роли, сброс пароля, блокировка", + "create": "Создать пользователя", + "search": "Имя или логин", + "all": "Все", + "col_user": "Пользователь", + "col_role": "Роль", + "col_status": "Статус", + "col_last_login": "Был в сети", + "status_active": "Активен", + "status_blocked": "Заблокирован", + "status_must_change": "Ждёт смены пароля", + "never": "Ещё не входил", + "empty": "Никого не нашли", + "more": "Показать ещё", + "actions": "Действия с {{username}}", + "reset_password": "Сбросить пароль", + "block": "Заблокировать", + "unblock": "Разблокировать", + "make_role": "Сделать: {{role}}", + "delete": "Удалить", + "delete_confirm": "Удалить {{username}}? Его история и лайки пропадут.", + "block_confirm": "Заблокировать {{username}}? Все его сессии завершатся.", + "reset_confirm": "Сбросить пароль {{username}}? Все его сессии завершатся.", + "confirm": "Подтвердить", + "you": "вы" + }, + "create": { + "title": "Новый пользователь", + "username": "Логин", + "username_hint": "Латинские буквы, цифры, «.», «_», «-»", + "display_name": "Имя", + "role": "Роль", + "password_note": "Пароль сгенерирует сервер и покажет один раз.", + "submit": "Создать" + }, + "temp_password": { + "title": "Временный пароль", + "text": "Передайте пароль пользователю {{username}}. Он виден только сейчас; при входе его попросят сменить.", + "done": "Готово" + }, + "audit": { + "title": "Журнал", + "subtitle": "Действия администраторов и модераторов", + "col_when": "Когда", + "col_who": "Кто", + "col_action": "Действие", + "col_details": "Подробности", + "system": "Система", + "empty": "Записей пока нет", + "more": "Показать ещё", + "actions": { + "user.bootstrap_create": "Создан стартовый администратор", + "user.bootstrap_credentials": "Заданы учётные данные администратора", + "user.create": "Создан пользователь", + "user.rename": "Изменено имя", + "user.role_change": "Изменена роль", + "user.block": "Пользователь заблокирован", + "user.unblock": "Пользователь разблокирован", + "user.password_reset": "Сброшен пароль", + "user.delete": "Пользователь удалён" + } + } +} diff --git a/packages/i18n/locales/ru/auth.json b/packages/i18n/locales/ru/auth.json new file mode 100644 index 0000000..55d6b50 --- /dev/null +++ b/packages/i18n/locales/ru/auth.json @@ -0,0 +1,24 @@ +{ + "login": { + "title": "Вход", + "subtitle": "Ваша музыка с вашего сервера", + "username": "Логин", + "password": "Пароль", + "submit": "Войти", + "bootstrap_hint": "Первый запуск: войдите как администратор. Пароль задан в BOOTSTRAP_ADMIN_PASSWORD или напечатан в логе сервиса api." + }, + "setup": { + "admin_badge": "Первый вход администратора", + "admin_title": "Смените логин и пароль", + "admin_text": "Без этого шага доступ к серверу закрыт: стартовые данные администратора видны в логах и настройках.", + "password_badge": "Пароль сброшен", + "password_title": "Задайте новый пароль", + "password_text": "Вы вошли по временному паролю. Придумайте свой — временный больше не подойдёт.", + "new_username": "Новый логин", + "new_password": "Новый пароль", + "repeat_password": "Повторите пароль", + "min_length": "Не короче {{min}} символов", + "submit": "Сохранить и продолжить", + "logout": "Выйти" + } +} diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index 32caed6..5043489 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -17,5 +17,67 @@ "label": "Язык", "ru": "Русский", "en": "English" + }, + "nav": { + "main": "Навигация", + "home": "Главная", + "admin": "Админка", + "users": "Пользователи", + "audit": "Журнал" + }, + "profile": { + "menu": "Меню профиля", + "settings": "Настройки", + "logout": "Выйти" + }, + "roles": { + "admin": "Администратор", + "moderator": "Модератор", + "user": "Пользователь" + }, + "home": { + "greeting": "Привет, {{name}}!", + "wave": "Моя волна", + "wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку." + }, + "player": { + "empty": "Ничего не играет" + }, + "settings": { + "title": "Настройки", + "account": "Аккаунт", + "display_name": "Отображаемое имя", + "save": "Сохранить", + "saved": "Сохранено", + "appearance": "Внешний вид", + "theme": "Тема", + "theme_dark": "Тёмная", + "theme_light": "Светлая", + "theme_system": "Как в системе", + "password": "Пароль", + "current_password": "Текущий пароль", + "new_password": "Новый пароль", + "repeat_password": "Повторите пароль", + "change_password": "Сменить пароль", + "password_changed": "Пароль изменён. Другие устройства вышли из аккаунта.", + "sessions": "Устройства", + "session_current": "Это устройство", + "session_last_used": "Активность: {{date}}", + "session_end": "Завершить", + "logout_all": "Выйти на всех устройствах" + }, + "form": { + "show_password": "Показать пароль", + "hide_password": "Скрыть пароль", + "passwords_mismatch": "Пароли не совпадают", + "required": "Заполните поле", + "cancel": "Отмена", + "close": "Закрыть", + "copy": "Копировать", + "copied": "Скопировано" + }, + "loading": "Загрузка…", + "route_error": { + "reload": "Обновить страницу" } } diff --git a/packages/i18n/locales/ru/errors.json b/packages/i18n/locales/ru/errors.json index 56fd24d..27ff09f 100644 --- a/packages/i18n/locales/ru/errors.json +++ b/packages/i18n/locales/ru/errors.json @@ -3,7 +3,8 @@ "internal": "Что-то пошло не так на сервере. Попробуйте ещё раз", "bad_request": "Некорректный запрос", "not_found": "Не найдено", - "method_not_allowed": "Действие не поддерживается" + "method_not_allowed": "Действие не поддерживается", + "network": "Нет связи с сервером. Проверьте сеть" }, "auth": { "invalid_credentials": "Неверный логин или пароль", diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 1447977..e6a473e 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -1,7 +1,11 @@ import i18next, { type i18n as I18n } from 'i18next'; +import enAdmin from '../locales/en/admin.json'; +import enAuth from '../locales/en/auth.json'; import enCommon from '../locales/en/common.json'; import enErrors from '../locales/en/errors.json'; +import ruAdmin from '../locales/ru/admin.json'; +import ruAuth from '../locales/ru/auth.json'; import ruCommon from '../locales/ru/common.json'; import ruErrors from '../locales/ru/errors.json'; @@ -11,8 +15,8 @@ export type Locale = (typeof SUPPORTED_LOCALES)[number]; export const DEFAULT_LOCALE: Locale = 'en'; export const resources = { - ru: { common: ruCommon, errors: ruErrors }, - en: { common: enCommon, errors: enErrors }, + ru: { common: ruCommon, auth: ruAuth, admin: ruAdmin, errors: ruErrors }, + en: { common: enCommon, auth: enAuth, admin: enAdmin, errors: enErrors }, } as const; declare module 'i18next' { @@ -40,7 +44,7 @@ export async function createI18n(locale: Locale): Promise { fallbackLng: DEFAULT_LOCALE, supportedLngs: [...SUPPORTED_LOCALES], defaultNS: 'common', - ns: ['common', 'errors'], + ns: ['common', 'auth', 'admin', 'errors'], resources, interpolation: { escapeValue: false }, returnNull: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8871c0..5d00294 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: '@fontsource/unbounded': specifier: ^5.2.6 version: 5.3.0 + '@material-symbols/svg-400': + specifier: ^0.31.9 + version: 0.31.9 '@pipu/api-client': specifier: workspace:* version: link:../../packages/api-client @@ -84,6 +87,9 @@ importers: '@tanstack/react-query': specifier: ^5.80.7 version: 5.104.4(@types/react@19.3.0)(react@19.3.1) + '@tanstack/react-router': + specifier: ^1.120.20 + version: 1.120.20(react-dom@19.3.1(react@19.3.1))(react@19.3.1) react: specifier: ^19.1.0 version: 19.3.1 @@ -97,9 +103,15 @@ importers: '@testing-library/dom': specifier: ^10.4.0 version: 10.4.2 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 '@testing-library/react': specifier: ^16.3.0 version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.2) '@types/react': specifier: ^19.1.8 version: 19.3.0 @@ -157,6 +169,9 @@ packages: graphql: optional: true + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -985,6 +1000,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@material-symbols/svg-400@0.31.9': + resolution: {integrity: sha512-LeQrY/qYdRdIlV6ZR+hUQDXjtkDYgP1LGnMQh/Grm6FsXz+6GR2Q+DrotEJjzDnmao/oHygHFGLnkKAR/PM1hQ==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -1215,6 +1233,10 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@tanstack/history@1.120.17': + resolution: {integrity: sha512-k07LFI4Qo074IIaWzT/XjD0KlkGx2w1V3fnNtclKx0oAl8z4O9kCh6za+FPEIRe98xLgNFEiddDbJeAYGSlPtw==} + engines: {node: '>=12'} + '@tanstack/query-core@5.104.4': resolution: {integrity: sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==} @@ -1227,10 +1249,34 @@ packages: '@types/react': optional: true + '@tanstack/react-router@1.120.20': + resolution: {integrity: sha512-+zNruUE9NsfGm9cHd22Xs7FRtBrBhDZe94pB69BEIjqjrEPZct6f5VhTV9WQ+bDZ6fRz8tUuxNFAgm/3Lm4AIg==} + engines: {node: '>=12'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.7.7': + resolution: {integrity: sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.120.19': + resolution: {integrity: sha512-5JUVgkxnIM3NxMwzKt0tfz2UopZVxwq6Kl7Rp33zlFJaPjpiRs46VuRjVeAvkpJd6samo1gcH1rWqmnPUmtGcw==} + engines: {node: '>=12'} + + '@tanstack/store@0.7.7': + resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} + '@testing-library/dom@10.4.2': resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} engines: {node: '>=18'} + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.3.3': resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} engines: {node: '>=18'} @@ -1246,6 +1292,12 @@ packages: '@types/react-dom': optional: true + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1705,6 +1757,9 @@ packages: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -1783,6 +1838,9 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} @@ -2121,6 +2179,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + index-to-position@1.2.0: resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} engines: {node: '>=18'} @@ -2487,6 +2549,10 @@ packages: resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} engines: {node: '>=4'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -2838,6 +2904,10 @@ packages: resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + regenerate-unicode-properties@10.3.0: resolution: {integrity: sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==} engines: {node: '>=4'} @@ -3058,6 +3128,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -3131,6 +3205,12 @@ packages: throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3277,6 +3357,11 @@ packages: uri-js-replace@1.0.1: resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -3528,6 +3613,8 @@ snapshots: '@0no-co/graphql.web@1.3.4': {} + '@adobe/css-tools@4.5.0': {} + '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -4590,6 +4677,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@material-symbols/svg-400@0.31.9': {} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true @@ -4826,6 +4915,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@tanstack/history@1.120.17': {} + '@tanstack/query-core@5.104.4': {} '@tanstack/react-query@5.104.4(@types/react@19.3.0)(react@19.3.1)': @@ -4835,6 +4926,32 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 + '@tanstack/react-router@1.120.20(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/history': 1.120.17 + '@tanstack/react-store': 0.7.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@tanstack/router-core': 1.120.19 + jsesc: 3.1.0 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/react-store@0.7.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/store': 0.7.7 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + '@tanstack/router-core@1.120.19': + dependencies: + '@tanstack/history': 1.120.17 + '@tanstack/store': 0.7.7 + tiny-invariant: 1.3.3 + + '@tanstack/store@0.7.7': {} + '@testing-library/dom@10.4.2': dependencies: '@babel/code-frame': 7.29.7 @@ -4846,6 +4963,15 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': dependencies: '@babel/runtime': 7.29.10 @@ -4856,6 +4982,10 @@ snapshots: '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -5395,6 +5525,8 @@ snapshots: crypto-random-string@2.0.0: {} + css.escape@1.5.1: {} + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -5445,6 +5577,8 @@ snapshots: dom-accessibility-api@0.5.16: {} + dom-accessibility-api@0.6.3: {} + dotenv-expand@11.0.7: dependencies: dotenv: 16.4.7 @@ -5792,6 +5926,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + index-to-position@1.2.0: {} inflight@1.0.6: @@ -6269,6 +6405,8 @@ snapshots: mimic-fn@1.2.0: {} + min-indent@1.0.1: {} + minimatch@3.1.5: dependencies: brace-expansion: 1.1.21 @@ -6625,6 +6763,11 @@ snapshots: react@19.3.1: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + regenerate-unicode-properties@10.3.0: dependencies: regenerate: 1.5.0 @@ -6852,6 +6995,10 @@ snapshots: dependencies: ansi-regex: 6.4.0 + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: {} strip-literal@3.1.0: @@ -6931,6 +7078,10 @@ snapshots: throat@5.0.0: {} + tiny-invariant@1.3.3: {} + + tiny-warning@1.0.3: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -7036,6 +7187,10 @@ snapshots: uri-js-replace@1.0.1: {} + use-sync-external-store@1.7.0(react@19.3.1): + dependencies: + react: 19.3.1 + utils-merge@1.0.1: {} uuid@7.0.3: {}