657 lines
21 KiB
TypeScript
657 lines
21 KiB
TypeScript
'use client';
|
||
|
||
import * as React from 'react';
|
||
import { usePathname, useRouter } from 'next/navigation';
|
||
import {
|
||
apiFetch,
|
||
ApiError,
|
||
AUTH_REFRESH_KEY,
|
||
AUTH_SESSION_KEY,
|
||
AUTH_TOKEN_KEY,
|
||
AUTH_USER_CACHE_KEY,
|
||
AuthSessionResponse,
|
||
AuthTokens,
|
||
fetchAuthSession,
|
||
buildAuthDevicePayload,
|
||
IdentifyResponse,
|
||
getApiErrorMessage,
|
||
invalidateApiGatewayReady,
|
||
isApiGatewayReady,
|
||
isGatewayUnavailableError,
|
||
isPinRequiredError,
|
||
OtpSendResponse,
|
||
PasswordlessAuthResponse,
|
||
PinVerificationResponse,
|
||
PublicUser,
|
||
refreshAuthSession,
|
||
resetPinRequiredNotification,
|
||
scheduleFedcmSessionSync,
|
||
setPinRequiredHandler,
|
||
subscribeApiReady,
|
||
subscribeApiNotReady,
|
||
waitForStableGateway,
|
||
} from '@/lib/api';
|
||
import { useToast } from './toast-provider';
|
||
import { PinLockModal } from './pin-lock-modal';
|
||
import { AppBootstrapScreen } from './app-bootstrap-screen';
|
||
import { EMBEDDED_AUTH_MESSAGE, type EmbeddedAuthPayload } from '@/lib/embedded-auth-bridge';
|
||
|
||
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>;
|
||
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();
|
||
}
|
||
}
|
||
|
||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||
const router = useRouter();
|
||
const pathname = usePathname();
|
||
const { showToast } = useToast();
|
||
const [token, setToken] = React.useState<string | null>(null);
|
||
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(false);
|
||
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 refreshInFlightRef = React.useRef<Promise<void> | null>(null);
|
||
const initialBootstrapDoneRef = React.useRef(false);
|
||
|
||
React.useEffect(() => {
|
||
if (isApiGatewayReady()) {
|
||
setIsApiReady(true);
|
||
}
|
||
const onReady = () => setIsApiReady(true);
|
||
const onNotReady = () => {
|
||
setIsApiReady(false);
|
||
setIsSessionReady(false);
|
||
};
|
||
const unsubReady = subscribeApiReady(onReady);
|
||
const unsubNotReady = subscribeApiNotReady(onNotReady);
|
||
return () => {
|
||
unsubReady();
|
||
unsubNotReady();
|
||
};
|
||
}, []);
|
||
|
||
React.useEffect(() => {
|
||
isPinLockedRef.current = isPinLocked;
|
||
}, [isPinLocked]);
|
||
|
||
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;
|
||
setLockedSessionId(resolvedSessionId);
|
||
setIsPinLocked(true);
|
||
setPinError(null);
|
||
setHasStoredSession(true);
|
||
const cachedUser = readCachedUserProfile();
|
||
if (cachedUser) {
|
||
setUser(cachedUser);
|
||
}
|
||
}, []);
|
||
|
||
const warmUpAndApplySession = React.useCallback(
|
||
async (accessToken: string) => {
|
||
setIsSessionReady(false);
|
||
setIsLoading(true);
|
||
setBootstrapError(null);
|
||
invalidateApiGatewayReady();
|
||
setIsApiReady(false);
|
||
|
||
try {
|
||
await waitForStableGateway(3);
|
||
const session = await fetchAuthSession(accessToken);
|
||
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
||
initialBootstrapDoneRef.current = true;
|
||
setIsApiReady(true);
|
||
setIsSessionReady(true);
|
||
} catch (error) {
|
||
setBootstrapError(
|
||
isGatewayUnavailableError(error)
|
||
? 'Не удалось подключиться к серверу API. Подождите несколько секунд и нажмите «Повторить».'
|
||
: (getApiErrorMessage(error, 'Не удалось подключиться к серверу API') ?? 'Ошибка подключения')
|
||
);
|
||
throw error;
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
},
|
||
[activatePinLock, clearPinLock]
|
||
);
|
||
|
||
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();
|
||
|
||
await warmUpAndApplySession(auth.accessToken);
|
||
|
||
if (auth.pinVerified !== false) {
|
||
scheduleFedcmSessionSync(auth.accessToken, 8000);
|
||
}
|
||
},
|
||
[clearPinLock, warmUpAndApplySession]
|
||
);
|
||
|
||
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);
|
||
initialBootstrapDoneRef.current = false;
|
||
setToken(null);
|
||
setUser(null);
|
||
setHasStoredSession(false);
|
||
setIsSessionReady(false);
|
||
setIsApiReady(false);
|
||
invalidateApiGatewayReady();
|
||
clearPinLock();
|
||
router.push('/auth/login');
|
||
}, [clearPinLock, router]);
|
||
|
||
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(refreshToken));
|
||
|
||
if (!currentToken && !refreshToken) {
|
||
initialBootstrapDoneRef.current = true;
|
||
setIsSessionReady(true);
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
|
||
const isInitialBootstrap = !initialBootstrapDoneRef.current;
|
||
if (isInitialBootstrap) {
|
||
setIsLoading(true);
|
||
setBootstrapError(null);
|
||
}
|
||
|
||
try {
|
||
for (let bootstrapAttempt = 0; bootstrapAttempt < 6; bootstrapAttempt += 1) {
|
||
try {
|
||
if (isInitialBootstrap) {
|
||
await waitForStableGateway(3);
|
||
}
|
||
|
||
if (currentToken) {
|
||
setToken(currentToken);
|
||
try {
|
||
const session = await fetchAuthSession(currentToken);
|
||
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
||
setIsSessionReady(true);
|
||
return;
|
||
} catch (error) {
|
||
if (isPinRequiredError(error)) {
|
||
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
||
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 ?? '');
|
||
return;
|
||
}
|
||
if (refreshed.accessToken) {
|
||
const session = await fetchAuthSession(refreshed.accessToken);
|
||
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
||
setIsSessionReady(true);
|
||
return;
|
||
}
|
||
}
|
||
|
||
throw new ApiError('Сессия недействительна', 401);
|
||
} catch (error) {
|
||
if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 6) {
|
||
invalidateApiGatewayReady();
|
||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||
continue;
|
||
}
|
||
if (isPinRequiredError(error)) {
|
||
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
||
return;
|
||
}
|
||
if (refreshToken && error instanceof ApiError && error.status === 403) {
|
||
activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
||
return;
|
||
}
|
||
if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) {
|
||
logout();
|
||
return;
|
||
}
|
||
const message = getApiErrorMessage(error, 'Не удалось загрузить профиль');
|
||
if (isGatewayUnavailableError(error) && isInitialBootstrap) {
|
||
setBootstrapError('Не удалось подключиться к серверу API. Подождите несколько секунд и нажмите «Повторить».');
|
||
return;
|
||
}
|
||
if (message) showToast(message);
|
||
logout();
|
||
return;
|
||
}
|
||
}
|
||
} finally {
|
||
if (isInitialBootstrap) {
|
||
initialBootstrapDoneRef.current = true;
|
||
setIsApiReady(isApiGatewayReady());
|
||
setIsSessionReady(isApiGatewayReady());
|
||
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]);
|
||
|
||
React.useEffect(() => {
|
||
void refreshProfile();
|
||
}, [refreshProfile]);
|
||
|
||
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);
|
||
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);
|
||
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);
|
||
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);
|
||
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);
|
||
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);
|
||
|
||
await warmUpAndApplySession(response.accessToken);
|
||
|
||
if (pathname.startsWith('/auth/')) {
|
||
router.replace('/');
|
||
}
|
||
},
|
||
[pathname, router, warmUpAndApplySession]
|
||
);
|
||
|
||
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 || user) &&
|
||
(isLoading || !isApiReady || !isSessionReady);
|
||
|
||
return (
|
||
<AuthContext.Provider
|
||
value={{
|
||
user,
|
||
token,
|
||
isLoading,
|
||
isApiReady,
|
||
isSessionReady,
|
||
isContentReady: isSessionReady,
|
||
isPinLocked,
|
||
hasStoredSession,
|
||
login,
|
||
identifyLogin,
|
||
sendLoginOtp,
|
||
verifyLoginOtp,
|
||
loginWithPassword,
|
||
loginWithLdap,
|
||
beginTotpLogin,
|
||
verifyTotpLogin,
|
||
completePin,
|
||
applyLoginAuth,
|
||
register,
|
||
refreshProfile,
|
||
logout
|
||
}}
|
||
>
|
||
{isBootstrapping ? (
|
||
<AppBootstrapScreen
|
||
error={bootstrapError}
|
||
onRetry={
|
||
bootstrapError
|
||
? () => {
|
||
setBootstrapError(null);
|
||
initialBootstrapDoneRef.current = false;
|
||
void refreshProfile();
|
||
}
|
||
: undefined
|
||
}
|
||
/>
|
||
) : (
|
||
children
|
||
)}
|
||
<PinLockModal open={isPinLocked} isSubmitting={pinSubmitting} error={pinError} onSubmit={handlePinUnlock} />
|
||
</AuthContext.Provider>
|
||
);
|
||
}
|
||
|
||
export function useAuth() {
|
||
const context = React.useContext(AuthContext);
|
||
if (!context) {
|
||
throw new Error('useAuth должен использоваться внутри AuthProvider');
|
||
}
|
||
return context;
|
||
}
|