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>
68 lines
2.3 KiB
TypeScript
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';
|
|
}
|