Files
IdP/apps/frontend/components/id/auth-provider.tsx
2026-07-07 10:38:53 +03:00

832 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import * as React from 'react';
import { usePathname, useRouter } from 'next/navigation';
import {
apiFetch,
ApiError,
AUTH_SESSION_INVALID_EVENT,
AUTH_REFRESH_KEY,
AUTH_SESSION_KEY,
AUTH_TOKEN_KEY,
AUTH_USER_CACHE_KEY,
AuthSessionResponse,
AuthTokens,
fetchAuthSession,
buildAuthDevicePayload,
IdentifyResponse,
getApiErrorMessage,
ensureApiGatewayReady,
invalidateApiGatewayReady,
isApiGatewayReady,
isGatewayUnavailableError,
isPinRequiredError,
OtpSendResponse,
PasswordlessAuthResponse,
PinVerificationResponse,
PublicUser,
refreshAuthSession,
resetGatewayCircuit,
resetPinRequiredNotification,
scheduleFedcmSessionSync,
syncFedcmSession,
setPinRequiredHandler,
subscribeApiReady,
subscribeApiNotReady,
} from '@/lib/api';
import { resolvePostPinAuthRedirect } from '@/lib/auth-post-pin-redirect';
import { finalizeFedcmLogin, isFedcmLoginPopup, shouldFinalizeFedcmLogin } from '@/lib/fedcm-login-bridge';
import { FedcmApiLoginStatusBridge } from './fedcm-api-login-status';
import { usePublicSettings } from './public-settings-provider';
import { useToast } from './toast-provider';
import { PinLockModal } from './pin-lock-modal';
import { AppBootstrapScreen } from './app-bootstrap-screen';
import { EMBEDDED_AUTH_MESSAGE, type EmbeddedAuthPayload, useEmbeddedAuthFallback } from '@/lib/embedded-auth-bridge';
import { usePinIdleLock } from '@/hooks/use-pin-idle-lock';
import { clearPinIdleWatch, isPinIdleWatchActive, markPinIdleWatch } from '@/lib/pin-idle-storage';
interface AuthContextValue {
user: PublicUser | null;
token: string | null;
isLoading: boolean;
isApiReady: boolean;
/** true после стабильного /health и успешной проверки сессии */
isSessionReady: boolean;
/** @deprecated используйте isSessionReady */
isContentReady: boolean;
isPinLocked: boolean;
hasStoredSession: boolean;
login: (login: string, password: string) => Promise<AuthTokens>;
identifyLogin: (login: string) => Promise<IdentifyResponse>;
sendLoginOtp: (recipient: string, channel?: string) => Promise<OtpSendResponse>;
verifyLoginOtp: (recipient: string, code: string) => Promise<PasswordlessAuthResponse>;
loginWithPassword: (login: string, password: string) => Promise<AuthTokens>;
loginWithLdap: (username: string, password: string) => Promise<AuthTokens>;
beginTotpLogin: (recipient: string) => Promise<string>;
verifyTotpLogin: (totpChallengeToken: string, code: string) => Promise<AuthTokens>;
completePin: (sessionId: string, pin: string) => Promise<void>;
applyLoginAuth: (auth: AuthTokens) => Promise<AuthTokens>;
applyUserPatch: (patch: Partial<PublicUser>) => void;
register: (data: { displayName: string; login: string; password: string }) => Promise<void>;
refreshProfile: () => Promise<void>;
logout: () => void;
}
const AuthContext = React.createContext<AuthContextValue | null>(null);
function cacheUserProfile(user: PublicUser) {
window.localStorage.setItem(AUTH_USER_CACHE_KEY, JSON.stringify(user));
}
function readCachedUserProfile(): PublicUser | null {
const raw = window.localStorage.getItem(AUTH_USER_CACHE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as PublicUser;
} catch {
return null;
}
}
function persistPartialAuth(auth: AuthTokens) {
window.localStorage.setItem(AUTH_REFRESH_KEY, auth.refreshToken);
window.localStorage.setItem(AUTH_SESSION_KEY, auth.sessionId);
cacheUserProfile(auth.user);
}
function applySessionState(
session: AuthSessionResponse,
setters: {
setUser: React.Dispatch<React.SetStateAction<PublicUser | null>>;
setToken: React.Dispatch<React.SetStateAction<string | null>>;
activatePinLock: (sessionId: string) => void;
clearPinLock: () => void;
}
) {
setters.setUser(session.user);
cacheUserProfile(session.user);
if (session.sessionId) {
window.localStorage.setItem(AUTH_SESSION_KEY, session.sessionId);
}
if (session.requiresPin) {
setters.activatePinLock(session.sessionId ?? '');
} else {
setters.clearPinLock();
}
}
function readInitialAuthState() {
// Первичный render должен совпадать на сервере и в браузере. Токены из
// localStorage читаются уже в refreshProfile(), иначе авторизованная вкладка
// получает hydration mismatch (React #418) ещё до запуска эффектов.
return {
token: null as string | null,
hasStoredSession: false
};
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { showToast } = useToast();
const { publicApiUrl, pinLockTimeoutMinutes } = usePublicSettings();
const initialAuth = React.useMemo(() => readInitialAuthState(), []);
const [token, setToken] = React.useState<string | null>(initialAuth.token);
const [user, setUser] = React.useState<PublicUser | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [isApiReady, setIsApiReady] = React.useState(false);
const [isSessionReady, setIsSessionReady] = React.useState(false);
const [isPinLocked, setIsPinLocked] = React.useState(false);
const [hasStoredSession, setHasStoredSession] = React.useState(initialAuth.hasStoredSession);
const [lockedSessionId, setLockedSessionId] = React.useState<string | null>(null);
const [pinSubmitting, setPinSubmitting] = React.useState(false);
const [pinError, setPinError] = React.useState<string | null>(null);
const [bootstrapError, setBootstrapError] = React.useState<string | null>(null);
const isPinLockedRef = React.useRef(false);
const userIdRef = React.useRef<string | null | undefined>(null);
const refreshInFlightRef = React.useRef<Promise<void> | null>(null);
const initialBootstrapDoneRef = React.useRef(false);
useEmbeddedAuthFallback();
React.useEffect(() => {
if (isApiGatewayReady()) {
setIsApiReady(true);
}
const onReady = () => setIsApiReady(true);
const onNotReady = () => {
if (!initialBootstrapDoneRef.current) {
setIsApiReady(false);
}
};
const unsubReady = subscribeApiReady(onReady);
const unsubNotReady = subscribeApiNotReady(onNotReady);
return () => {
unsubReady();
unsubNotReady();
};
}, []);
React.useEffect(() => {
isPinLockedRef.current = isPinLocked;
}, [isPinLocked]);
React.useEffect(() => {
userIdRef.current = user?.id;
}, [user?.id]);
const clearPinLock = React.useCallback(() => {
setIsPinLocked(false);
setLockedSessionId(null);
setPinError(null);
resetPinRequiredNotification();
}, []);
React.useEffect(() => {
function handleEmbeddedAuth(event: Event) {
const detail = (event as CustomEvent<EmbeddedAuthPayload>).detail;
if (!detail?.token?.trim()) return;
setToken(detail.token.trim());
setHasStoredSession(true);
clearPinLock();
}
window.addEventListener(EMBEDDED_AUTH_MESSAGE, handleEmbeddedAuth as EventListener);
return () => window.removeEventListener(EMBEDDED_AUTH_MESSAGE, handleEmbeddedAuth as EventListener);
}, [clearPinLock]);
const activatePinLock = React.useCallback((sessionId: string) => {
const resolvedSessionId = sessionId || window.localStorage.getItem(AUTH_SESSION_KEY);
if (!resolvedSessionId) return;
markPinIdleWatch(userIdRef.current);
setLockedSessionId(resolvedSessionId);
setIsPinLocked(true);
setPinError(null);
setHasStoredSession(true);
const cachedUser = readCachedUserProfile();
if (cachedUser) {
setUser(cachedUser);
}
const accessToken = window.localStorage.getItem(AUTH_TOKEN_KEY);
if (accessToken) {
void syncFedcmSession(accessToken);
}
}, []);
const establishSession = React.useCallback(
async (auth: AuthTokens) => {
window.localStorage.setItem(AUTH_TOKEN_KEY, auth.accessToken);
window.localStorage.setItem(AUTH_REFRESH_KEY, auth.refreshToken);
window.localStorage.setItem(AUTH_SESSION_KEY, auth.sessionId);
setToken(auth.accessToken);
setUser(auth.user);
cacheUserProfile(auth.user);
setHasStoredSession(true);
clearPinLock();
setBootstrapError(null);
initialBootstrapDoneRef.current = true;
setIsApiReady(true);
setIsSessionReady(true);
setIsLoading(false);
void fetchAuthSession(auth.accessToken)
.then((session) => {
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
})
.catch((error) => {
if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? auth.sessionId);
return;
}
if (!isGatewayUnavailableError(error)) {
const message = getApiErrorMessage(error, 'Не удалось обновить профиль после входа');
if (message) showToast(message);
}
});
if (auth.pinVerified !== false) {
if (typeof window !== 'undefined' && shouldFinalizeFedcmLogin(window.location.pathname)) {
void finalizeFedcmLogin(auth.accessToken);
} else {
void syncFedcmSession(auth.accessToken);
scheduleFedcmSessionSync(auth.accessToken, 8000);
}
}
},
[activatePinLock, clearPinLock, showToast]
);
const logout = React.useCallback(() => {
window.localStorage.removeItem(AUTH_TOKEN_KEY);
window.localStorage.removeItem(AUTH_REFRESH_KEY);
window.localStorage.removeItem(AUTH_SESSION_KEY);
window.localStorage.removeItem(AUTH_USER_CACHE_KEY);
clearPinIdleWatch();
initialBootstrapDoneRef.current = false;
setToken(null);
setUser(null);
setHasStoredSession(false);
setIsSessionReady(false);
setIsApiReady(false);
invalidateApiGatewayReady();
clearPinLock();
router.push('/auth/login');
}, [clearPinLock, router]);
React.useEffect(() => {
function handleInvalidSession() {
if (!window.localStorage.getItem(AUTH_TOKEN_KEY) && !window.localStorage.getItem(AUTH_REFRESH_KEY)) return;
logout();
}
window.addEventListener(AUTH_SESSION_INVALID_EVENT, handleInvalidSession);
return () => window.removeEventListener(AUTH_SESSION_INVALID_EVENT, handleInvalidSession);
}, [logout]);
const applyUserPatch = React.useCallback((patch: Partial<PublicUser>) => {
setUser((current) => {
if (!current) return current;
const next = { ...current, ...patch };
cacheUserProfile(next);
return next;
});
}, []);
const refreshProfile = React.useCallback(async () => {
if (refreshInFlightRef.current) {
return refreshInFlightRef.current;
}
const task: Promise<void> = (async () => {
const currentToken = window.localStorage.getItem(AUTH_TOKEN_KEY);
const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY);
setHasStoredSession(Boolean(currentToken || refreshToken));
if (!currentToken && !refreshToken) {
window.localStorage.removeItem(AUTH_USER_CACHE_KEY);
setUser(null);
setToken(null);
initialBootstrapDoneRef.current = true;
setIsSessionReady(true);
setIsApiReady(true);
setIsLoading(false);
return;
}
const isInitialBootstrap = !initialBootstrapDoneRef.current;
if (isInitialBootstrap) {
setIsLoading(true);
setBootstrapError(null);
}
try {
for (let bootstrapAttempt = 0; bootstrapAttempt < 4; bootstrapAttempt += 1) {
if (isInitialBootstrap) {
try {
await ensureApiGatewayReady(bootstrapAttempt > 0);
} catch {
// цикл bootstrap повторит после паузы
}
}
try {
if (currentToken) {
setToken(currentToken);
try {
const session = await fetchAuthSession(currentToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
setIsApiReady(true);
setIsSessionReady(true);
return;
} catch (error) {
if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
setIsApiReady(true);
setIsSessionReady(true);
return;
}
if (!(error instanceof ApiError) || error.code !== 'TOKEN_EXPIRED') {
throw error;
}
}
}
if (refreshToken) {
const refreshed = await refreshAuthSession();
if (refreshed.accessToken) {
window.localStorage.setItem(AUTH_TOKEN_KEY, refreshed.accessToken);
setToken(refreshed.accessToken);
}
if (refreshed.sessionId) {
window.localStorage.setItem(AUTH_SESSION_KEY, refreshed.sessionId);
}
if (refreshed.requiresPin) {
if (refreshed.user) {
setUser(refreshed.user);
cacheUserProfile(refreshed.user);
}
activatePinLock(refreshed.sessionId ?? '');
setIsApiReady(true);
setIsSessionReady(true);
return;
}
if (refreshed.accessToken) {
const session = await fetchAuthSession(refreshed.accessToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
setIsApiReady(true);
setIsSessionReady(true);
return;
}
}
throw new ApiError('Сессия недействительна', 401);
} catch (error) {
if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 4) {
invalidateApiGatewayReady();
resetGatewayCircuit();
setBootstrapError(
'Не удалось подключиться к серверу API (502). Подождите несколько секунд и нажмите «Повторить».'
);
await new Promise((resolve) => setTimeout(resolve, 1200));
continue;
}
if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
setIsApiReady(true);
setIsSessionReady(true);
return;
}
if (refreshToken && error instanceof ApiError && error.status === 403) {
activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
setIsApiReady(true);
setIsSessionReady(true);
return;
}
if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) {
logout();
return;
}
const message = getApiErrorMessage(error, 'Не удалось загрузить профиль');
if (isGatewayUnavailableError(error) && isInitialBootstrap) {
setBootstrapError('Не удалось подключиться к серверу API. Подождите несколько секунд и нажмите «Повторить».');
setIsApiReady(false);
setIsSessionReady(false);
setIsLoading(false);
return;
}
if (message) showToast(message);
logout();
return;
}
}
} finally {
if (isInitialBootstrap) {
initialBootstrapDoneRef.current = true;
setIsLoading(false);
}
}
})();
refreshInFlightRef.current = task;
void task.finally(() => {
refreshInFlightRef.current = null;
});
return task;
}, [activatePinLock, clearPinLock, logout, showToast]);
React.useEffect(() => {
setPinRequiredHandler(activatePinLock);
return () => setPinRequiredHandler(null);
}, [activatePinLock]);
const refreshProfileRef = React.useRef(refreshProfile);
refreshProfileRef.current = refreshProfile;
React.useEffect(() => {
void refreshProfileRef.current();
}, []);
const pinIdleLockEnabled =
isSessionReady &&
!isPinLocked &&
!pathname.startsWith('/auth/') &&
Boolean(token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null)) &&
isPinIdleWatchActive(user?.id);
usePinIdleLock({
enabled: pinIdleLockEnabled,
timeoutMinutes: pinLockTimeoutMinutes,
accessToken: token,
onLockRequired: activatePinLock,
});
React.useEffect(() => {
function handleVisibilityChange() {
if (document.visibilityState !== 'visible') return;
if (isPinLockedRef.current) return;
if (!window.localStorage.getItem(AUTH_REFRESH_KEY)) return;
void refreshProfile();
}
function handleAuthRefreshed(event: Event) {
const detail = (event as CustomEvent<{ accessToken?: string; user?: PublicUser }>).detail;
if (detail.accessToken) {
setToken(detail.accessToken);
}
if (detail.user) {
setUser(detail.user);
cacheUserProfile(detail.user);
}
}
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('lendry:auth-refreshed', handleAuthRefreshed);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('lendry:auth-refreshed', handleAuthRefreshed);
};
}, [refreshProfile]);
const login = React.useCallback(
async (loginValue: string, password: string) => {
const auth = await apiFetch<AuthTokens>('/auth/login', {
method: 'POST',
body: JSON.stringify({
login: loginValue,
password,
...buildAuthDevicePayload(),
})
});
if (auth.pinVerified) {
await establishSession(auth);
} else {
persistPartialAuth(auth);
setUser(auth.user);
markPinIdleWatch(auth.user.id);
setHasStoredSession(true);
}
return auth;
},
[establishSession]
);
const identifyLogin = React.useCallback(async (loginValue: string) => {
return apiFetch<IdentifyResponse>('/auth/identify', {
method: 'POST',
body: JSON.stringify({ login: loginValue })
});
}, []);
const sendLoginOtp = React.useCallback(async (recipient: string, channel?: string) => {
return apiFetch<OtpSendResponse>('/auth/otp/send', {
method: 'POST',
body: JSON.stringify({ recipient, channel })
});
}, []);
const verifyLoginOtp = React.useCallback(
async (recipient: string, code: string) => {
const response = await apiFetch<PasswordlessAuthResponse>('/auth/otp/verify', {
method: 'POST',
body: JSON.stringify({
recipient,
code,
...buildAuthDevicePayload(),
})
});
if (response.auth?.pinVerified) {
await establishSession(response.auth);
} else if (response.auth) {
persistPartialAuth(response.auth);
setUser(response.auth.user);
markPinIdleWatch(response.auth.user.id);
setHasStoredSession(true);
}
return response;
},
[establishSession]
);
const loginWithPassword = React.useCallback(
async (loginValue: string, passwordValue: string) => {
const auth = await apiFetch<AuthTokens>('/auth/login/password', {
method: 'POST',
body: JSON.stringify({
login: loginValue,
password: passwordValue,
...buildAuthDevicePayload(),
})
});
if (auth.pinVerified) {
await establishSession(auth);
} else {
persistPartialAuth(auth);
setUser(auth.user);
markPinIdleWatch(auth.user.id);
setHasStoredSession(true);
}
return auth;
},
[establishSession]
);
const loginWithLdap = React.useCallback(
async (username: string, passwordValue: string) => {
const auth = await apiFetch<AuthTokens>('/auth/ldap/login', {
method: 'POST',
body: JSON.stringify({
username,
password: passwordValue,
...buildAuthDevicePayload(),
})
});
if (auth.pinVerified) {
await establishSession(auth);
} else {
persistPartialAuth(auth);
setUser(auth.user);
markPinIdleWatch(auth.user.id);
setHasStoredSession(true);
}
return auth;
},
[establishSession]
);
const beginTotpLogin = React.useCallback(async (recipient: string) => {
const response = await apiFetch<{ totpChallengeToken: string }>('/auth/totp/begin', {
method: 'POST',
body: JSON.stringify({
recipient,
...buildAuthDevicePayload(),
})
});
return response.totpChallengeToken;
}, []);
const verifyTotpLogin = React.useCallback(
async (totpChallengeToken: string, code: string) => {
const auth = await apiFetch<AuthTokens>('/auth/totp/verify', {
method: 'POST',
body: JSON.stringify({ totpChallengeToken, code })
});
if (auth.pinVerified) {
await establishSession(auth);
} else {
persistPartialAuth(auth);
setUser(auth.user);
markPinIdleWatch(auth.user.id);
setHasStoredSession(true);
}
return auth;
},
[establishSession]
);
const applyLoginAuth = React.useCallback(
async (auth: AuthTokens) => {
if (auth.pinVerified) {
await establishSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
activatePinLock(auth.sessionId);
}
return auth;
},
[activatePinLock, establishSession]
);
const completePin = React.useCallback(
async (sessionId: string, pin: string) => {
const response = await apiFetch<PinVerificationResponse>('/auth/pin/verify', {
method: 'POST',
body: JSON.stringify({ sessionId, pin })
});
window.localStorage.setItem(AUTH_TOKEN_KEY, response.accessToken);
window.localStorage.setItem(AUTH_SESSION_KEY, sessionId);
setToken(response.accessToken);
clearPinLock();
const cachedUser = readCachedUserProfile();
markPinIdleWatch(userIdRef.current ?? cachedUser?.id);
if (cachedUser) {
setUser(cachedUser);
}
setHasStoredSession(true);
setBootstrapError(null);
initialBootstrapDoneRef.current = true;
setIsApiReady(true);
setIsSessionReady(true);
setIsLoading(false);
void fetchAuthSession(response.accessToken)
.then((session) => {
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
})
.catch((error) => {
if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? sessionId);
return;
}
if (!isGatewayUnavailableError(error)) {
const message = getApiErrorMessage(error, 'Не удалось обновить профиль после PIN');
if (message) showToast(message);
}
});
if (isFedcmLoginPopup()) {
await finalizeFedcmLogin(response.accessToken);
return;
}
const onAuthEntryPage =
pathname.startsWith('/auth/login') || pathname.startsWith('/auth/register');
if (!onAuthEntryPage) {
const postPinRedirect =
typeof window !== 'undefined'
? resolvePostPinAuthRedirect(pathname, window.location.search)
: null;
if (postPinRedirect !== null) {
if (shouldFinalizeFedcmLogin(pathname)) {
await finalizeFedcmLogin(response.accessToken);
}
router.replace(postPinRedirect);
return;
}
if (shouldFinalizeFedcmLogin(pathname)) {
await finalizeFedcmLogin(response.accessToken);
} else {
void syncFedcmSession(response.accessToken);
scheduleFedcmSessionSync(response.accessToken, 8000);
}
}
},
[activatePinLock, clearPinLock, pathname, router, showToast]
);
const handlePinUnlock = React.useCallback(
async (pin: string) => {
const sessionId = lockedSessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY);
if (!sessionId) {
setPinError('Сессия не найдена. Войдите снова.');
return;
}
setPinSubmitting(true);
setPinError(null);
try {
await completePin(sessionId, pin);
} catch (error) {
setPinError(error instanceof Error ? error.message : 'Не удалось подтвердить PIN-код');
} finally {
setPinSubmitting(false);
}
},
[completePin, lockedSessionId]
);
const register = React.useCallback(
async (data: { displayName: string; login: string; password: string }) => {
const isEmail = data.login.includes('@');
await apiFetch<PublicUser>('/auth/register', {
method: 'POST',
body: JSON.stringify({
displayName: data.displayName,
password: data.password,
...(isEmail ? { email: data.login } : { phone: data.login })
})
});
await login(data.login, data.password);
},
[login]
);
const isBootstrapping =
!isPinLocked &&
Boolean(hasStoredSession || token) &&
(isLoading || !isApiReady || !isSessionReady);
React.useEffect(() => {
if (!isBootstrapping || bootstrapError) return;
const timer = window.setTimeout(() => {
setBootstrapError(
'Сервер API не отвечает. Если недавно перезапускали контейнеры — подождите 1020 секунд и нажмите «Повторить».'
);
}, 18000);
return () => window.clearTimeout(timer);
}, [bootstrapError, isBootstrapping]);
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
isApiReady,
isSessionReady,
isContentReady: isSessionReady && isApiReady,
isPinLocked,
hasStoredSession,
login,
identifyLogin,
sendLoginOtp,
verifyLoginOtp,
loginWithPassword,
loginWithLdap,
beginTotpLogin,
verifyTotpLogin,
completePin,
applyLoginAuth,
applyUserPatch,
register,
refreshProfile,
logout
}}
>
{isBootstrapping ? (
<AppBootstrapScreen
error={bootstrapError}
onRetry={() => {
setBootstrapError(null);
initialBootstrapDoneRef.current = false;
invalidateApiGatewayReady();
resetGatewayCircuit();
setIsLoading(true);
setIsApiReady(false);
setIsSessionReady(false);
void refreshProfile();
}}
/>
) : (
children
)}
<PinLockModal
open={isPinLocked}
isSubmitting={pinSubmitting}
error={pinError}
onSubmit={handlePinUnlock}
onLogout={logout}
/>
<FedcmApiLoginStatusBridge
active={isSessionReady && hasStoredSession && Boolean(token)}
accessToken={token}
publicApiUrl={publicApiUrl}
/>
</AuthContext.Provider>
);
}
export function useAuth() {
const context = React.useContext(AuthContext);
if (!context) {
throw new Error('useAuth должен использоваться внутри AuthProvider');
}
return context;
}