feat(web): sign-in, first-run setup, app shell and user admin (M1)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 50s
ci / ts / ts (packages + web) (push) Successful in 43s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s

- core: AuthSession keeps the access token in memory, refreshes it
  once for all parallel requests (single-flight) and retries them;
  refresh races between tabs are retried, reuse signs out; ApiError
  carries problem+json codes and field errors
- web: TanStack Router with guards — guests go to /login, the bootstrap
  admin to /setup/admin, temporary passwords to /setup/password, admin
  pages by role
- login, setup screens, Classic shell (top bar, role-based sidebar,
  empty player), settings: name, theme, language, password, devices
- users admin: search, role filter, create with a one-time temporary
  password, reset, block, role change, delete as the policy allows;
  audit log for admins
- ru/en strings for auth, admin and error codes; language and theme
  are saved in the profile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:33:46 +03:00
1 parent f075863f15
commit ab092def54
44 files changed
+4070 -203

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
import { ApiError } from '@pipu/core';
import { useTranslation } from 'react-i18next';
type Translate = (key: string, options?: Record<string, unknown>) => string;
/** Текст ошибки для пользователя: перевод `errors:<code>` с параметрами, иначе общий текст. */
export function useErrorText(): (err: unknown) => string {
const { t, i18n } = useTranslation();
const tr = t as unknown as Translate;
return (err) => {
const code = err instanceof ApiError ? err.code : 'common.internal';
const params = err instanceof ApiError ? err.params : {};
const key = `errors:${code}`;
return i18n.exists(key) ? tr(key, params) : tr('errors:common.internal');
};
}
/** Ошибка конкретного поля (если сервер её указал) — для подписи под полем. */
export function useFieldError(): (err: unknown, field: string) => string | undefined {
const text = useErrorText();
return (err, field) => {
if (!(err instanceof ApiError)) return undefined;
return err.fieldCode(field) ? text(err) : undefined;
};
}
+23
View File
@@ -0,0 +1,23 @@
/** Дата и время в локали интерфейса: «20 окт. 2026 г., 14:05». */
export function formatDateTime(iso: string, locale: string): string {
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
}
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
['year', 365 * 24 * 3600],
['month', 30 * 24 * 3600],
['week', 7 * 24 * 3600],
['day', 24 * 3600],
['hour', 3600],
['minute', 60],
];
/** Относительное время: «12 минут назад», «вчера», «сейчас». */
export function formatRelative(iso: string, locale: string, now: Date = new Date()): string {
const diff = (new Date(iso).getTime() - now.getTime()) / 1000;
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
for (const [unit, sec] of UNITS) {
if (Math.abs(diff) >= sec) return rtf.format(Math.round(diff / sec), unit);
}
return rtf.format(0, 'minute');
}
+23
View File
@@ -0,0 +1,23 @@
import type { components } from '@pipu/api-client';
export type Role = components['schemas']['Role'];
export type UserAction = 'reset_password' | 'block' | 'change_role' | 'delete';
/**
* Копия матрицы прав (docs/auth-and-roles.md, services/api/internal/auth/policy.go) — только для UI:
* какие действия показывать. Решает всё равно сервер.
*/
export function canManageUser(actor: { id: string; role: Role }, target: { id: string; role: Role }, action: UserAction) {
const self = actor.id === target.id;
if (self && action !== 'reset_password') return false;
if (actor.role === 'admin') return true;
if (actor.role === 'moderator') return (action === 'reset_password' || action === 'block') && target.role === 'user';
return false;
}
/** Роли, которые actor может выдать при создании. */
export function assignableRoles(actor: Role): Role[] {
if (actor === 'admin') return ['user', 'moderator', 'admin'];
if (actor === 'moderator') return ['user'];
return [];
}
+21
View File
@@ -0,0 +1,21 @@
// Иконки Material Symbols Rounded (Apache 2.0). Импортируются по одной — в сборку попадают только используемые.
export { default as addIcon } from '@material-symbols/svg-400/rounded/add.svg';
export { default as checkIcon } from '@material-symbols/svg-400/rounded/check.svg';
export { default as closeIcon } from '@material-symbols/svg-400/rounded/close.svg';
export { default as copyIcon } from '@material-symbols/svg-400/rounded/content_copy.svg';
export { default as groupIcon } from '@material-symbols/svg-400/rounded/group.svg';
export { default as groupFillIcon } from '@material-symbols/svg-400/rounded/group-fill.svg';
export { default as homeIcon } from '@material-symbols/svg-400/rounded/home.svg';
export { default as homeFillIcon } from '@material-symbols/svg-400/rounded/home-fill.svg';
export { default as lockResetIcon } from '@material-symbols/svg-400/rounded/lock_reset.svg';
export { default as logoutIcon } from '@material-symbols/svg-400/rounded/logout.svg';
export { default as moreIcon } from '@material-symbols/svg-400/rounded/more_horiz.svg';
export { default as musicIcon } from '@material-symbols/svg-400/rounded/music_note.svg';
export { default as playIcon } from '@material-symbols/svg-400/rounded/play_arrow-fill.svg';
export { default as auditIcon } from '@material-symbols/svg-400/rounded/receipt_long.svg';
export { default as auditFillIcon } from '@material-symbols/svg-400/rounded/receipt_long-fill.svg';
export { default as searchIcon } from '@material-symbols/svg-400/rounded/search.svg';
export { default as settingsIcon } from '@material-symbols/svg-400/rounded/settings.svg';
export { default as shieldIcon } from '@material-symbols/svg-400/rounded/shield_person.svg';
export { default as visibilityIcon } from '@material-symbols/svg-400/rounded/visibility.svg';
export { default as visibilityOffIcon } from '@material-symbols/svg-400/rounded/visibility_off.svg';
+316
View File
@@ -0,0 +1,316 @@
.icon {
display: inline-block;
flex: none;
background-color: currentColor;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
}
.logo {
display: inline-flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text);
}
.logoDot {
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 44px;
padding: 0 18px;
border-radius: var(--radius-md);
font-weight: 600;
font-size: 14px;
white-space: nowrap;
transition:
background 0.15s,
box-shadow 0.15s,
opacity 0.15s;
}
.button:disabled {
opacity: 0.55;
cursor: default;
}
.button[aria-busy='true'] {
cursor: progress;
}
.primary {
background: var(--accent);
color: #fff;
box-shadow: var(--glow);
}
.primary:hover:not(:disabled) {
background: var(--accent2);
}
.secondary {
background: var(--surface3);
color: var(--text);
}
.secondary:hover:not(:disabled),
.ghost:hover:not(:disabled) {
background: var(--hover);
}
.ghost {
color: var(--text2);
}
.danger {
background: var(--danger-soft);
color: var(--danger);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.inputWrap {
position: relative;
display: flex;
}
.input {
width: 100%;
height: 48px;
padding: 0 14px;
border-radius: var(--radius-md);
border: 1px solid var(--line2);
background: var(--surface2);
color: var(--text);
font: inherit;
outline: none;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.inputWrap:has(.reveal) .input {
padding-right: 46px;
}
.mono {
font-family: var(--font-mono);
}
.invalid {
border-color: var(--danger);
}
.reveal {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 8px;
color: var(--text3);
}
.reveal:hover {
color: var(--text);
background: var(--hover);
}
.hint {
font-size: 12px;
color: var(--text3);
}
.error {
font-size: 12px;
color: var(--danger);
}
.formError {
padding: 10px 12px;
border-radius: 10px;
background: var(--danger-soft);
color: var(--danger);
font-size: 13px;
font-weight: 500;
}
.segmented {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: var(--surface2);
border: 1px solid var(--line);
}
.segmented button {
height: 32px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.segmented button[aria-pressed='true'] {
background: var(--accent);
color: #fff;
}
.overlay {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 16px;
background: var(--overlay);
animation: pipuIn 0.2s ease-out;
}
.dialog {
width: 440px;
max-width: 100%;
max-height: calc(100vh - 32px);
overflow: auto;
border-radius: var(--radius-xl);
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
border: 1px solid var(--line2);
box-shadow: var(--shadow);
outline: none;
}
.dialogHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 22px 24px 0;
}
.dialogHead h2 {
margin: 0;
font-family: var(--font-display);
font-size: 19px;
font-weight: 600;
letter-spacing: -0.01em;
}
.dialogBody {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px 24px;
}
.dialogFoot {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 0 24px 22px;
}
.iconButton {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 10px;
color: var(--text2);
}
.iconButton:hover,
.iconButton[aria-expanded='true'] {
background: var(--hover);
color: var(--text);
}
.menuWrap {
position: relative;
}
.menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 40;
min-width: 220px;
padding: 6px;
border-radius: var(--radius-lg);
background: var(--elev);
border: 1px solid var(--line2);
box-shadow: var(--shadow);
animation: pipuIn 0.15s ease-out;
}
.menuStart {
left: 0;
right: auto;
}
.menuItem {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 38px;
padding: 0 10px;
border-radius: 8px;
font-size: 14px;
text-align: left;
color: var(--text);
outline: none;
}
.menuItem:hover,
.menuItem:focus-visible {
background: var(--hover);
}
.menuDanger {
color: var(--danger);
}
.avatar {
display: inline-grid;
place-items: center;
flex: none;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--violet));
color: #fff;
font-weight: 700;
}
+282
View File
@@ -0,0 +1,282 @@
import {
useEffect,
useId,
useRef,
useState,
type ButtonHTMLAttributes,
type InputHTMLAttributes,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { closeIcon, visibilityIcon, visibilityOffIcon } from './icons';
import s from './ui.module.css';
const cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(' ');
/** Иконка-маска: цвет берётся из currentColor. */
export function Icon({ src, size = 20, className }: { src: string; size?: number; className?: string }) {
return (
<span
aria-hidden
className={cx(s.icon, className)}
style={{ width: size, height: size, maskImage: `url("${src}")`, WebkitMaskImage: `url("${src}")` }}
/>
);
}
export function Logo({ size = 28 }: { size?: number }) {
const { t } = useTranslation();
return (
<span className={s.logo} style={{ fontSize: size * 0.68 }}>
<span className={s.logoDot} style={{ width: size, height: size }} aria-hidden />
{t('app.name')}
</span>
);
}
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
icon?: string;
busy?: boolean;
};
export function Button({ variant = 'secondary', icon, busy, className, children, disabled, ...rest }: ButtonProps) {
return (
<button
type="button"
{...rest}
disabled={disabled || busy}
aria-busy={busy || undefined}
className={cx(s.button, s[variant], className)}
>
{icon && <Icon src={icon} size={18} />}
{children}
</button>
);
}
type FieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
label: string;
value: string;
onChange: (v: string) => void;
error?: string;
hint?: string;
mono?: boolean;
};
/** Поле ввода с подписью, ошибкой и (для паролей) кнопкой «показать». */
export function Field({ label, value, onChange, error, hint, mono, type = 'text', className, ...rest }: FieldProps) {
const { t } = useTranslation();
const id = useId();
const [shown, setShown] = useState(false);
const isPassword = type === 'password';
const note = error ?? hint;
return (
<div className={cx(s.field, className)}>
<label htmlFor={id} className={s.label}>
{label}
</label>
<div className={s.inputWrap}>
<input
id={id}
type={isPassword && shown ? 'text' : type}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-invalid={error ? true : undefined}
aria-describedby={note ? `${id}-note` : undefined}
className={cx(s.input, mono && s.mono, error && s.invalid)}
{...rest}
/>
{isPassword && (
<button
type="button"
className={s.reveal}
onClick={() => setShown((v) => !v)}
aria-label={shown ? t('form.hide_password') : t('form.show_password')}
>
<Icon src={shown ? visibilityOffIcon : visibilityIcon} size={18} />
</button>
)}
</div>
{note && (
<div id={`${id}-note`} className={error ? s.error : s.hint} role={error ? 'alert' : undefined}>
{note}
</div>
)}
</div>
);
}
/** Сегментированный переключатель. */
export function Segmented<T extends string>({
label,
value,
options,
onChange,
}: {
label: string;
value: T;
options: { value: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<div className={s.segmented} role="group" aria-label={label}>
{options.map((o) => (
<button key={o.value} type="button" aria-pressed={value === o.value} onClick={() => onChange(o.value)}>
{o.label}
</button>
))}
</div>
);
}
export function FormError({ children }: { children?: ReactNode }) {
if (!children) return null;
return (
<div className={s.formError} role="alert">
{children}
</div>
);
}
/** Модальный диалог: стеклянная карточка поверх подложки, Esc и клик по подложке закрывают. */
export function Dialog({
title,
onClose,
children,
footer,
}: {
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}) {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
const titleId = useId();
useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])');
(first ?? ref.current)?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
prev?.focus();
};
}, [onClose]);
return (
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
<div className={s.dialogHead}>
<h2 id={titleId}>{title}</h2>
<button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}>
<Icon src={closeIcon} />
</button>
</div>
<div className={s.dialogBody}>{children}</div>
{footer && <div className={s.dialogFoot}>{footer}</div>}
</div>
</div>
);
}
export type MenuItem = { label: string; icon?: string; danger?: boolean; onSelect: () => void };
/** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */
export function Menu({
label,
trigger,
items,
header,
align = 'end',
triggerClassName,
}: {
label: string;
trigger: ReactNode;
items: MenuItem[];
header?: ReactNode;
align?: 'start' | 'end';
triggerClassName?: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
ref.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const onMenuKey = (e: ReactKeyboardEvent) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const els = [...(ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
const i = els.indexOf(document.activeElement as HTMLElement);
const next = e.key === 'ArrowDown' ? (i + 1) % els.length : (i - 1 + els.length) % els.length;
els[next]?.focus();
};
return (
<div className={s.menuWrap} ref={ref}>
<button
type="button"
className={triggerClassName ?? s.iconButton}
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{trigger}
</button>
{open && (
<div className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
{header}
{items.map((it) => (
<button
key={it.label}
type="button"
role="menuitem"
className={cx(s.menuItem, it.danger && s.menuDanger)}
onClick={() => {
setOpen(false);
it.onSelect();
}}
>
{it.icon && <Icon src={it.icon} size={18} />}
{it.label}
</button>
))}
</div>
)}
</div>
);
}
export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
const initials = name
.split(/[\s._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]!.toUpperCase())
.join('');
return (
<span className={s.avatar} style={{ width: size, height: size, fontSize: size * 0.38 }} aria-hidden>
{initials || '?'}
</span>
);
}
export { cx };