Files
pipu-music/apps/web/src/app/session.tsx
T
mrleo1nidandClaude Opus 5.5 ab092def54
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
feat(web): sign-in, first-run setup, app shell and user admin (M1)
- 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>
2026-10-20 06:33:46 +03:00

68 lines
2.3 KiB
TypeScript

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';
}