{t('app.name')}
-{t('app.tagline')}
- {server.data?.status === 'ok' ? ( -{t('app.tagline')}
- {server.data?.status === 'ok' ? ( -{t('home.wave_soon')}
+{t('login.subtitle')}
+ {info.data?.bootstrap_required &&{t(admin ? 'setup.admin_text' : 'setup.password_text')}
+ +{t('audit.subtitle')}
+| {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)} | +
{t('users.subtitle')}
+| {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} /> + )} + | +
` с параметрами, иначе общий текст. */
+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() },
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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: {}