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

+202
View File
@@ -0,0 +1,202 @@
.shell {
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: 60px 1fr 88px;
grid-template-areas:
'top top'
'side content'
'player player';
height: 100%;
}
.top {
grid-area: top;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid var(--line);
background: var(--bar-a);
backdrop-filter: blur(20px);
}
.logoLink {
text-decoration: none;
}
.topRight {
display: flex;
align-items: center;
gap: 12px;
}
.profileButton {
border-radius: 50%;
outline-offset: 3px;
}
.profileButton[aria-expanded='true'] {
box-shadow: 0 0 0 3px var(--accent-soft);
}
.profileHead {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
align-items: center;
padding: 10px 10px 12px;
margin-bottom: 6px;
border-bottom: 1px solid var(--line);
}
.profileHead > :last-child {
grid-column: 1 / -1;
margin-top: 10px;
justify-self: start;
}
.profileName {
font-weight: 600;
}
.profileMeta {
font-size: 12px;
color: var(--text3);
}
.side {
grid-area: side;
display: flex;
flex-direction: column;
gap: 2px;
padding: 16px 12px;
border-right: 1px solid var(--line);
overflow-y: auto;
}
.navSection {
margin: 18px 12px 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
}
.navItem {
display: flex;
align-items: center;
gap: 12px;
height: 40px;
padding: 0 12px;
border-radius: 10px;
color: var(--text2);
font-weight: 500;
text-decoration: none;
}
.navItem:hover {
background: var(--hover);
color: var(--text);
}
.navItem[data-status='active'] {
background: var(--accent-soft);
color: var(--text);
}
.navIcon {
color: var(--text3);
}
.navItem[data-status='active'] .navIcon {
color: var(--accent2);
}
.content {
grid-area: content;
overflow-y: auto;
padding: 32px 36px;
animation: pipuIn 0.2s ease-out;
}
.player {
grid-area: player;
display: flex;
align-items: center;
gap: 14px;
padding: 0 20px;
border-top: 1px solid var(--line);
background: var(--bar-a);
backdrop-filter: blur(20px);
}
.cover {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 10px;
background: var(--surface2);
color: var(--text3);
}
.playerEmpty {
color: var(--text3);
font-size: 14px;
}
@media (max-width: 1023px) {
.shell {
grid-template-columns: 72px 1fr;
}
.navItem {
justify-content: center;
padding: 0;
font-size: 0;
}
.navSection {
margin: 14px 0 6px;
text-align: center;
font-size: 0;
border-top: 1px solid var(--line);
}
}
@media (max-width: 767px) {
.shell {
grid-template-columns: 1fr;
grid-template-rows: 60px 1fr 72px 64px;
grid-template-areas:
'top'
'content'
'player'
'side';
}
.side {
flex-direction: row;
justify-content: space-around;
padding: 6px 8px;
border-right: 0;
border-top: 1px solid var(--line);
}
.navSection {
display: none;
}
.navItem {
flex: 1;
}
.content {
padding: 20px 16px;
}
.player {
padding: 0 16px;
}
}
+104
View File
@@ -0,0 +1,104 @@
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session';
import {
auditFillIcon,
auditIcon,
groupFillIcon,
groupIcon,
homeFillIcon,
homeIcon,
logoutIcon,
musicIcon,
settingsIcon,
} from '../shared/ui/icons';
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch';
import s from './AppShell.module.css';
type NavPath = '/' | '/admin/users' | '/admin/audit';
function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) {
return (
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
{({ isActive }) => (
<>
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
{label}
</>
)}
</Link>
);
}
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
export function AppShell() {
return useAuthState().status === 'signed_in' ? <Shell /> : null;
}
function Shell() {
const { t } = useTranslation();
const user = useUser();
const session = useSession();
const navigate = useNavigate();
const name = user.display_name ?? user.username;
const staff = user.role !== 'user';
return (
<div className={s.shell}>
<header className={s.top}>
<Link to="/" className={s.logoLink}>
<Logo />
</Link>
<div className={s.topRight}>
<Menu
label={t('profile.menu')}
triggerClassName={s.profileButton}
trigger={<Avatar name={name} />}
header={
<div className={s.profileHead}>
<Avatar name={name} size={40} />
<div>
<div className={s.profileName}>{name}</div>
<div className={s.profileMeta}>
@{user.username} · {t(`roles.${user.role}`)}
</div>
</div>
<LanguageSwitch />
</div>
}
items={[
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
]}
/>
</div>
</header>
<nav className={s.side} aria-label={t('nav.main')}>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
{staff && (
<>
<div className={s.navSection}>{t('nav.admin')}</div>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
)}
</>
)}
</nav>
<main className={s.content}>
<Outlet />
</main>
<footer className={s.player} aria-label={t('player.empty')}>
<span className={s.cover}>
<Icon src={musicIcon} size={22} />
</span>
<span className={s.playerEmpty}>{t('player.empty')}</span>
</footer>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { unwrap } from '@pipu/core';
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession } from '../app/session';
import { Segmented } from '../shared/ui/ui';
/** Переключатель языка. Для вошедшего пользователя язык сохраняется в профиле (PATCH /me). */
export function LanguageSwitch() {
const { t, i18n } = useTranslation();
const session = useSession();
const auth = useAuthState();
const change = async (l: Locale) => {
document.documentElement.lang = l;
await i18n.changeLanguage(l);
if (auth.status !== 'signed_in') return;
try {
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } })));
} catch {
// Язык уже переключён локально; сохранится при следующей смене.
}
};
return (
<Segmented
label={t('language.label')}
value={(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language) ? (i18n.language as Locale) : 'en'}
options={SUPPORTED_LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }))}
onChange={(l) => void change(l)}
/>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { useTranslation } from 'react-i18next';
import { Button } from '../shared/ui/ui';
import s from '../pages/page.module.css';
/** Непредвиденная ошибка рендера страницы: короткое сообщение и перезагрузка. */
export function RouteError() {
const { t } = useTranslation(['common', 'errors']);
return (
<div className={s.empty} role="alert">
<p>{t('errors:common.internal')}</p>
<Button onClick={() => window.location.reload()}>{t('route_error.reload')}</Button>
</div>
);
}