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

+139
View File
@@ -0,0 +1,139 @@
import type { AuthSession, AuthUser } from '@pipu/core';
import {
createRootRouteWithContext,
createRoute,
createRouter,
Outlet,
redirect,
type ParsedLocation,
} from '@tanstack/react-router';
import { AuditPage } from '../pages/admin/AuditPage';
import { UsersPage } from '../pages/admin/UsersPage';
import { HomePage } from '../pages/HomePage';
import { LoginPage } from '../pages/LoginPage';
import { SettingsPage } from '../pages/SettingsPage';
import { SetupPage } from '../pages/SetupPage';
import { AppShell } from '../widgets/AppShell';
import { RouteError } from '../widgets/RouteError';
export interface RouterContext {
session: AuthSession;
}
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
export function homeFor(user: AuthUser): '/setup/admin' | '/setup/password' | '/' {
if (user.must_change_credentials) return '/setup/admin';
if (user.must_change_password) return '/setup/password';
return '/';
}
function signedInUser(session: AuthSession): AuthUser | null {
return session.state.status === 'signed_in' ? session.state.user : null;
}
/** Внутренний путь из ?redirect=; внешние адреса игнорируются. */
export function safeRedirect(target: unknown): string {
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
}
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
const user = signedInUser(session);
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
return user;
}
const rootRoute = createRootRouteWithContext<RouterContext>()({
component: Outlet,
errorComponent: RouteError,
});
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
beforeLoad: ({ context, search }) => {
const user = signedInUser(context.session);
if (!user) return;
const home = homeFor(user);
if (home !== '/') throw redirect({ to: home });
throw redirect({ href: safeRedirect(search.redirect) });
},
component: LoginPage,
});
const setupAdminRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup/admin',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
},
component: () => <SetupPage variant="admin" />,
});
const setupPasswordRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup/password',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
},
component: () => <SetupPage variant="password" />,
});
/** Каркас приложения: только для вошедших, прошедших обязательные шаги. */
const appRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'app',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
const home = homeFor(user);
if (home !== '/') throw redirect({ to: home });
return { user };
},
component: AppShell,
});
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
const usersRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/users',
beforeLoad: ({ context }) => {
if (context.user.role === 'user') throw redirect({ to: '/' });
},
component: UsersPage,
});
const auditRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/audit',
beforeLoad: ({ context }) => {
if (context.user.role !== 'admin') throw redirect({ to: '/' });
},
component: AuditPage,
});
const routeTree = rootRoute.addChildren([
loginRoute,
setupAdminRoute,
setupPasswordRoute,
appRoute.addChildren([homeRoute, settingsRoute, usersRoute, auditRoute]),
]);
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
session.subscribe(() => void router.invalidate());
return router;
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createAppRouter>;
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { AuthSession, AuthState, AuthUser } from '@pipu/core';
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import { createContext, useContext, useEffect, useSyncExternalStore, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
const SessionContext = createContext<AuthSession | null>(null);
export function SessionProvider({ session, children }: { session: AuthSession; children: ReactNode }) {
return (
<SessionContext.Provider value={session}>
<UserEffects session={session} />
{children}
</SessionContext.Provider>
);
}
export function useSession(): AuthSession {
const s = useContext(SessionContext);
if (!s) throw new Error('useSession outside SessionProvider');
return s;
}
export function useAuthState(): AuthState {
const session = useSession();
return useSyncExternalStore(
(fn) => session.subscribe(fn),
() => session.state,
);
}
/** Текущий пользователь; вызывать только на защищённых страницах. */
export function useUser(): AuthUser {
const state = useAuthState();
if (state.status !== 'signed_in') throw new Error('useUser: not signed in');
return state.user;
}
const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly string[]).includes(l);
/** Применяет настройки вошедшего пользователя: язык и тему. */
function UserEffects({ session }: { session: AuthSession }) {
const { i18n } = useTranslation();
const state = useSyncExternalStore(
(fn) => session.subscribe(fn),
() => session.state,
);
const user = state.status === 'signed_in' ? state.user : null;
const locale = user?.locale;
const theme = user?.ui_prefs.theme;
useEffect(() => {
if (locale && isLocale(locale) && locale !== i18n.language) void i18n.changeLanguage(locale);
}, [locale, i18n]);
useEffect(() => {
applyTheme(theme);
}, [theme]);
return null;
}
/** Тёмная тема по умолчанию (docs/design.md); system — по ОС через prefers-color-scheme (tokens.css). */
export function applyTheme(theme: string | undefined) {
const root = document.documentElement;
if (theme === 'system') delete root.dataset.theme;
else root.dataset.theme = theme === 'light' ? 'light' : 'dark';
}