fix and update
This commit is contained in:
@@ -86,10 +86,10 @@ export default function LoginPage() {
|
|||||||
const redirectAfterLogin =
|
const redirectAfterLogin =
|
||||||
typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('redirect') : null;
|
typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('redirect') : null;
|
||||||
if (redirectAfterLogin && redirectAfterLogin.startsWith('/')) {
|
if (redirectAfterLogin && redirectAfterLogin.startsWith('/')) {
|
||||||
router.push(redirectAfterLogin);
|
router.replace(redirectAfterLogin);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push('/');
|
router.replace('/');
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, beginTotpLogin, verifyTotpLogin, completePin, applyLoginAuth } = useAuth();
|
const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, beginTotpLogin, verifyTotpLogin, completePin, applyLoginAuth } = useAuth();
|
||||||
@@ -195,11 +195,10 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
|
|
||||||
function finishQrAuth(auth: AuthTokens) {
|
function finishQrAuth(auth: AuthTokens) {
|
||||||
|
void (async () => {
|
||||||
const resolved = applyLoginAuth(auth);
|
const resolved = await applyLoginAuth(auth);
|
||||||
|
|
||||||
finishAuth(resolved.pinVerified, resolved.sessionId);
|
finishAuth(resolved.pinVerified, resolved.sessionId);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function AddressQuickSection({
|
|||||||
const [editingAddress, setEditingAddress] = useState<UserAddress | null>(null);
|
const [editingAddress, setEditingAddress] = useState<UserAddress | null>(null);
|
||||||
|
|
||||||
const loadAddresses = useCallback(async () => {
|
const loadAddresses = useCallback(async () => {
|
||||||
if (!user || !token || isPinLocked) return;
|
if (!user || !token || isPinLocked || !isReady) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetchUserAddresses(user.id, token);
|
const response = await fetchUserAddresses(user.id, token);
|
||||||
setAddresses(response.addresses ?? []);
|
setAddresses(response.addresses ?? []);
|
||||||
@@ -59,7 +59,7 @@ export function AddressQuickSection({
|
|||||||
const message = getApiErrorMessage(error, 'Не удалось загрузить адреса');
|
const message = getApiErrorMessage(error, 'Не удалось загрузить адреса');
|
||||||
if (message) showToast(message);
|
if (message) showToast(message);
|
||||||
}
|
}
|
||||||
}, [isPinLocked, showToast, token, user]);
|
}, [isPinLocked, isReady, showToast, token, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isReady && user && !isPinLocked) void loadAddresses();
|
if (isReady && user && !isPinLocked) void loadAddresses();
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
AUTH_USER_CACHE_KEY,
|
AUTH_USER_CACHE_KEY,
|
||||||
AuthSessionResponse,
|
AuthSessionResponse,
|
||||||
AuthTokens,
|
AuthTokens,
|
||||||
ensureApiGatewayReady,
|
|
||||||
fetchAuthSession,
|
fetchAuthSession,
|
||||||
buildAuthDevicePayload,
|
buildAuthDevicePayload,
|
||||||
IdentifyResponse,
|
IdentifyResponse,
|
||||||
@@ -29,6 +28,8 @@ import {
|
|||||||
scheduleFedcmSessionSync,
|
scheduleFedcmSessionSync,
|
||||||
setPinRequiredHandler,
|
setPinRequiredHandler,
|
||||||
subscribeApiReady,
|
subscribeApiReady,
|
||||||
|
subscribeApiNotReady,
|
||||||
|
waitForStableGateway,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { useToast } from './toast-provider';
|
import { useToast } from './toast-provider';
|
||||||
import { PinLockModal } from './pin-lock-modal';
|
import { PinLockModal } from './pin-lock-modal';
|
||||||
@@ -40,7 +41,9 @@ interface AuthContextValue {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isApiReady: boolean;
|
isApiReady: boolean;
|
||||||
/** false первые ~600 ms после bootstrap — блокирует шторм API сразу после логина */
|
/** true после стабильного /health и успешной проверки сессии */
|
||||||
|
isSessionReady: boolean;
|
||||||
|
/** @deprecated используйте isSessionReady */
|
||||||
isContentReady: boolean;
|
isContentReady: boolean;
|
||||||
isPinLocked: boolean;
|
isPinLocked: boolean;
|
||||||
hasStoredSession: boolean;
|
hasStoredSession: boolean;
|
||||||
@@ -53,7 +56,7 @@ interface AuthContextValue {
|
|||||||
beginTotpLogin: (recipient: string) => Promise<string>;
|
beginTotpLogin: (recipient: string) => Promise<string>;
|
||||||
verifyTotpLogin: (totpChallengeToken: string, code: string) => Promise<AuthTokens>;
|
verifyTotpLogin: (totpChallengeToken: string, code: string) => Promise<AuthTokens>;
|
||||||
completePin: (sessionId: string, pin: string) => Promise<void>;
|
completePin: (sessionId: string, pin: string) => Promise<void>;
|
||||||
applyLoginAuth: (auth: AuthTokens) => AuthTokens;
|
applyLoginAuth: (auth: AuthTokens) => Promise<AuthTokens>;
|
||||||
register: (data: { displayName: string; login: string; password: string }) => Promise<void>;
|
register: (data: { displayName: string; login: string; password: string }) => Promise<void>;
|
||||||
refreshProfile: () => Promise<void>;
|
refreshProfile: () => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
@@ -111,7 +114,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [user, setUser] = React.useState<PublicUser | null>(null);
|
const [user, setUser] = React.useState<PublicUser | null>(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
const [isApiReady, setIsApiReady] = React.useState(false);
|
const [isApiReady, setIsApiReady] = React.useState(false);
|
||||||
const [isContentReady, setIsContentReady] = React.useState(false);
|
const [isSessionReady, setIsSessionReady] = React.useState(false);
|
||||||
const [isPinLocked, setIsPinLocked] = React.useState(false);
|
const [isPinLocked, setIsPinLocked] = React.useState(false);
|
||||||
const [hasStoredSession, setHasStoredSession] = React.useState(false);
|
const [hasStoredSession, setHasStoredSession] = React.useState(false);
|
||||||
const [lockedSessionId, setLockedSessionId] = React.useState<string | null>(null);
|
const [lockedSessionId, setLockedSessionId] = React.useState<string | null>(null);
|
||||||
@@ -125,9 +128,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (isApiGatewayReady()) {
|
if (isApiGatewayReady()) {
|
||||||
setIsApiReady(true);
|
setIsApiReady(true);
|
||||||
}
|
}
|
||||||
return subscribeApiReady(() => {
|
const onReady = () => setIsApiReady(true);
|
||||||
setIsApiReady(true);
|
const onNotReady = () => {
|
||||||
});
|
setIsApiReady(false);
|
||||||
|
setIsSessionReady(false);
|
||||||
|
};
|
||||||
|
const unsubReady = subscribeApiReady(onReady);
|
||||||
|
const unsubNotReady = subscribeApiNotReady(onNotReady);
|
||||||
|
return () => {
|
||||||
|
unsubReady();
|
||||||
|
unsubNotReady();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -167,8 +178,29 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveSession = React.useCallback(
|
const warmUpAndApplySession = React.useCallback(
|
||||||
(auth: AuthTokens) => {
|
async (accessToken: string) => {
|
||||||
|
setIsSessionReady(false);
|
||||||
|
setIsLoading(true);
|
||||||
|
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);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activatePinLock, clearPinLock]
|
||||||
|
);
|
||||||
|
|
||||||
|
const establishSession = React.useCallback(
|
||||||
|
async (auth: AuthTokens) => {
|
||||||
window.localStorage.setItem(AUTH_TOKEN_KEY, auth.accessToken);
|
window.localStorage.setItem(AUTH_TOKEN_KEY, auth.accessToken);
|
||||||
window.localStorage.setItem(AUTH_REFRESH_KEY, auth.refreshToken);
|
window.localStorage.setItem(AUTH_REFRESH_KEY, auth.refreshToken);
|
||||||
window.localStorage.setItem(AUTH_SESSION_KEY, auth.sessionId);
|
window.localStorage.setItem(AUTH_SESSION_KEY, auth.sessionId);
|
||||||
@@ -177,15 +209,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
cacheUserProfile(auth.user);
|
cacheUserProfile(auth.user);
|
||||||
setHasStoredSession(true);
|
setHasStoredSession(true);
|
||||||
clearPinLock();
|
clearPinLock();
|
||||||
initialBootstrapDoneRef.current = true;
|
|
||||||
setIsApiReady(true);
|
await warmUpAndApplySession(auth.accessToken);
|
||||||
setIsLoading(false);
|
|
||||||
setIsContentReady(false);
|
|
||||||
if (auth.pinVerified !== false) {
|
if (auth.pinVerified !== false) {
|
||||||
scheduleFedcmSessionSync(auth.accessToken, 2500);
|
scheduleFedcmSessionSync(auth.accessToken, 2500);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clearPinLock]
|
[clearPinLock, warmUpAndApplySession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const logout = React.useCallback(() => {
|
const logout = React.useCallback(() => {
|
||||||
@@ -197,6 +228,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setHasStoredSession(false);
|
setHasStoredSession(false);
|
||||||
|
setIsSessionReady(false);
|
||||||
|
setIsApiReady(false);
|
||||||
|
invalidateApiGatewayReady();
|
||||||
clearPinLock();
|
clearPinLock();
|
||||||
router.push('/auth/login');
|
router.push('/auth/login');
|
||||||
}, [clearPinLock, router]);
|
}, [clearPinLock, router]);
|
||||||
@@ -213,6 +247,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (!currentToken && !refreshToken) {
|
if (!currentToken && !refreshToken) {
|
||||||
initialBootstrapDoneRef.current = true;
|
initialBootstrapDoneRef.current = true;
|
||||||
|
setIsSessionReady(true);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -226,7 +261,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
for (let bootstrapAttempt = 0; bootstrapAttempt < 6; bootstrapAttempt += 1) {
|
for (let bootstrapAttempt = 0; bootstrapAttempt < 6; bootstrapAttempt += 1) {
|
||||||
try {
|
try {
|
||||||
if (isInitialBootstrap) {
|
if (isInitialBootstrap) {
|
||||||
await ensureApiGatewayReady();
|
await waitForStableGateway(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentToken) {
|
if (currentToken) {
|
||||||
@@ -234,6 +269,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const session = await fetchAuthSession(currentToken);
|
const session = await fetchAuthSession(currentToken);
|
||||||
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
||||||
|
setIsSessionReady(true);
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isPinRequiredError(error)) {
|
if (isPinRequiredError(error)) {
|
||||||
@@ -266,6 +302,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (refreshed.accessToken) {
|
if (refreshed.accessToken) {
|
||||||
const session = await fetchAuthSession(refreshed.accessToken);
|
const session = await fetchAuthSession(refreshed.accessToken);
|
||||||
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
||||||
|
setIsSessionReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,6 +336,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (isInitialBootstrap) {
|
if (isInitialBootstrap) {
|
||||||
initialBootstrapDoneRef.current = true;
|
initialBootstrapDoneRef.current = true;
|
||||||
setIsApiReady(isApiGatewayReady());
|
setIsApiReady(isApiGatewayReady());
|
||||||
|
setIsSessionReady(isApiGatewayReady());
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,14 +397,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (auth.pinVerified) {
|
if (auth.pinVerified) {
|
||||||
saveSession(auth);
|
await establishSession(auth);
|
||||||
} else {
|
} else {
|
||||||
persistPartialAuth(auth);
|
persistPartialAuth(auth);
|
||||||
setHasStoredSession(true);
|
setHasStoredSession(true);
|
||||||
}
|
}
|
||||||
return auth;
|
return auth;
|
||||||
},
|
},
|
||||||
[saveSession]
|
[establishSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const identifyLogin = React.useCallback(async (loginValue: string) => {
|
const identifyLogin = React.useCallback(async (loginValue: string) => {
|
||||||
@@ -394,14 +432,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (response.auth?.pinVerified) {
|
if (response.auth?.pinVerified) {
|
||||||
saveSession(response.auth);
|
await establishSession(response.auth);
|
||||||
} else if (response.auth) {
|
} else if (response.auth) {
|
||||||
persistPartialAuth(response.auth);
|
persistPartialAuth(response.auth);
|
||||||
setHasStoredSession(true);
|
setHasStoredSession(true);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
[saveSession]
|
[establishSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const loginWithPassword = React.useCallback(
|
const loginWithPassword = React.useCallback(
|
||||||
@@ -415,14 +453,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (auth.pinVerified) {
|
if (auth.pinVerified) {
|
||||||
saveSession(auth);
|
await establishSession(auth);
|
||||||
} else {
|
} else {
|
||||||
persistPartialAuth(auth);
|
persistPartialAuth(auth);
|
||||||
setHasStoredSession(true);
|
setHasStoredSession(true);
|
||||||
}
|
}
|
||||||
return auth;
|
return auth;
|
||||||
},
|
},
|
||||||
[saveSession]
|
[establishSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const loginWithLdap = React.useCallback(
|
const loginWithLdap = React.useCallback(
|
||||||
@@ -436,14 +474,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (auth.pinVerified) {
|
if (auth.pinVerified) {
|
||||||
saveSession(auth);
|
await establishSession(auth);
|
||||||
} else {
|
} else {
|
||||||
persistPartialAuth(auth);
|
persistPartialAuth(auth);
|
||||||
setHasStoredSession(true);
|
setHasStoredSession(true);
|
||||||
}
|
}
|
||||||
return auth;
|
return auth;
|
||||||
},
|
},
|
||||||
[saveSession]
|
[establishSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const beginTotpLogin = React.useCallback(async (recipient: string) => {
|
const beginTotpLogin = React.useCallback(async (recipient: string) => {
|
||||||
@@ -464,20 +502,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
body: JSON.stringify({ totpChallengeToken, code })
|
body: JSON.stringify({ totpChallengeToken, code })
|
||||||
});
|
});
|
||||||
if (auth.pinVerified) {
|
if (auth.pinVerified) {
|
||||||
saveSession(auth);
|
await establishSession(auth);
|
||||||
} else {
|
} else {
|
||||||
persistPartialAuth(auth);
|
persistPartialAuth(auth);
|
||||||
setHasStoredSession(true);
|
setHasStoredSession(true);
|
||||||
}
|
}
|
||||||
return auth;
|
return auth;
|
||||||
},
|
},
|
||||||
[saveSession]
|
[establishSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const applyLoginAuth = React.useCallback(
|
const applyLoginAuth = React.useCallback(
|
||||||
(auth: AuthTokens) => {
|
async (auth: AuthTokens) => {
|
||||||
if (auth.pinVerified) {
|
if (auth.pinVerified) {
|
||||||
saveSession(auth);
|
await establishSession(auth);
|
||||||
} else {
|
} else {
|
||||||
persistPartialAuth(auth);
|
persistPartialAuth(auth);
|
||||||
setHasStoredSession(true);
|
setHasStoredSession(true);
|
||||||
@@ -485,7 +523,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
return auth;
|
return auth;
|
||||||
},
|
},
|
||||||
[activatePinLock, saveSession]
|
[activatePinLock, establishSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const completePin = React.useCallback(
|
const completePin = React.useCallback(
|
||||||
@@ -498,14 +536,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
window.localStorage.setItem(AUTH_SESSION_KEY, sessionId);
|
window.localStorage.setItem(AUTH_SESSION_KEY, sessionId);
|
||||||
setToken(response.accessToken);
|
setToken(response.accessToken);
|
||||||
|
|
||||||
const session = await fetchAuthSession(response.accessToken);
|
await warmUpAndApplySession(response.accessToken);
|
||||||
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
|
||||||
|
|
||||||
if (pathname.startsWith('/auth/')) {
|
if (pathname.startsWith('/auth/')) {
|
||||||
router.replace('/');
|
router.replace('/');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activatePinLock, clearPinLock, pathname, router]
|
[pathname, router, warmUpAndApplySession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePinUnlock = React.useCallback(
|
const handlePinUnlock = React.useCallback(
|
||||||
@@ -548,22 +585,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const isBootstrapping =
|
const isBootstrapping =
|
||||||
!isPinLocked &&
|
!isPinLocked &&
|
||||||
Boolean(hasStoredSession || token || user) &&
|
Boolean(hasStoredSession || token || user) &&
|
||||||
(isLoading || !isApiReady);
|
(isLoading || !isApiReady || !isSessionReady);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isBootstrapping) {
|
|
||||||
setIsContentReady(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
if (!cancelled) setIsContentReady(true);
|
|
||||||
}, 650);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [isBootstrapping]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
@@ -572,7 +594,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
token,
|
token,
|
||||||
isLoading,
|
isLoading,
|
||||||
isApiReady,
|
isApiReady,
|
||||||
isContentReady,
|
isSessionReady,
|
||||||
|
isContentReady: isSessionReady,
|
||||||
isPinLocked,
|
isPinLocked,
|
||||||
hasStoredSession,
|
hasStoredSession,
|
||||||
login,
|
login,
|
||||||
@@ -590,7 +613,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
logout
|
logout
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isBootstrapping || !isContentReady ? <AppBootstrapScreen /> : children}
|
{isBootstrapping ? <AppBootstrapScreen /> : children}
|
||||||
<PinLockModal open={isPinLocked} isSubmitting={pinSubmitting} error={pinError} onSubmit={handlePinUnlock} />
|
<PinLockModal open={isPinLocked} isSubmitting={pinSubmitting} error={pinError} onSubmit={handlePinUnlock} />
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch, ensureApiGatewayReady } from '@/lib/api';
|
||||||
|
|
||||||
interface PublicSettingsContextValue {
|
interface PublicSettingsContextValue {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
@@ -27,6 +27,7 @@ export function PublicSettingsProvider({ children }: { children: React.ReactNode
|
|||||||
|
|
||||||
const refreshPublicSettings = useCallback(async () => {
|
const refreshPublicSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
await ensureApiGatewayReady();
|
||||||
const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public');
|
const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public');
|
||||||
const map = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value]));
|
const map = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value]));
|
||||||
setSettings(map);
|
setSettings(map);
|
||||||
|
|||||||
@@ -7,20 +7,21 @@ import { useAuth } from '@/components/id/auth-provider';
|
|||||||
export function useRequireAuth() {
|
export function useRequireAuth() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { user, isLoading, isApiReady, isPinLocked, hasStoredSession } = useAuth();
|
const { user, isLoading, isApiReady, isSessionReady, isPinLocked, hasStoredSession } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || !isApiReady) return;
|
if (isLoading || !isApiReady || !isSessionReady) return;
|
||||||
if (user || isPinLocked || hasStoredSession) return;
|
if (user || isPinLocked || hasStoredSession) return;
|
||||||
if (pathname.startsWith('/auth/')) return;
|
if (pathname.startsWith('/auth/')) return;
|
||||||
router.replace('/auth/login');
|
router.replace('/auth/login');
|
||||||
}, [hasStoredSession, isApiReady, isLoading, isPinLocked, pathname, router, user]);
|
}, [hasStoredSession, isApiReady, isLoading, isPinLocked, isSessionReady, pathname, router, user]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
isLoading,
|
isLoading,
|
||||||
isPinLocked,
|
isPinLocked,
|
||||||
isApiReady,
|
isApiReady,
|
||||||
isReady: !isLoading && isApiReady && Boolean(user || isPinLocked || hasStoredSession)
|
isSessionReady,
|
||||||
|
isReady: !isLoading && isApiReady && isSessionReady && Boolean(user || isPinLocked || hasStoredSession)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -677,13 +677,15 @@ export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | '
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
|
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
|
||||||
const GATEWAY_RETRY_ATTEMPTS = 2;
|
const GATEWAY_RETRY_ATTEMPTS = 4;
|
||||||
const GATEWAY_PROBE_MAX_ATTEMPTS = 48;
|
const GATEWAY_PROBE_MAX_ATTEMPTS = 48;
|
||||||
const GATEWAY_PROBE_BASE_DELAY_MS = 450;
|
const GATEWAY_PROBE_BASE_DELAY_MS = 450;
|
||||||
const API_MAX_CONCURRENT = 8;
|
const GATEWAY_STABLE_PROBES = 3;
|
||||||
const API_BURST_MAX_CONCURRENT = 3;
|
const API_MAX_CONCURRENT = 6;
|
||||||
const API_BURST_WINDOW_MS = 3000;
|
const API_BURST_MAX_CONCURRENT = 2;
|
||||||
|
const API_BURST_WINDOW_MS = 5000;
|
||||||
const API_READY_EVENT = 'idp-api-ready';
|
const API_READY_EVENT = 'idp-api-ready';
|
||||||
|
const API_NOT_READY_EVENT = 'idp-api-not-ready';
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -703,6 +705,9 @@ export function resetApiGatewayWarmup() {
|
|||||||
export function invalidateApiGatewayReady() {
|
export function invalidateApiGatewayReady() {
|
||||||
gatewayReadyResolved = false;
|
gatewayReadyResolved = false;
|
||||||
gatewayReadyAt = 0;
|
gatewayReadyAt = 0;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new CustomEvent(API_NOT_READY_EVENT));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isApiGatewayReady() {
|
export function isApiGatewayReady() {
|
||||||
@@ -728,6 +733,37 @@ export function subscribeApiReady(listener: () => void): () => void {
|
|||||||
return () => window.removeEventListener(API_READY_EVENT, listener);
|
return () => window.removeEventListener(API_READY_EVENT, listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function subscribeApiNotReady(listener: () => void): () => void {
|
||||||
|
if (typeof window === 'undefined') return () => undefined;
|
||||||
|
window.addEventListener(API_NOT_READY_EVENT, listener);
|
||||||
|
return () => window.removeEventListener(API_NOT_READY_EVENT, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Несколько подряд успешных /health через nginx — защита от «один OK, потом 502». */
|
||||||
|
export async function waitForStableGateway(stableProbes = GATEWAY_STABLE_PROBES): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
gatewayReadyResolved = false;
|
||||||
|
gatewayReadyAt = 0;
|
||||||
|
|
||||||
|
let streak = 0;
|
||||||
|
for (let attempt = 0; attempt < 40 && streak < stableProbes; attempt += 1) {
|
||||||
|
if (await probeGatewayAlive()) {
|
||||||
|
streak += 1;
|
||||||
|
if (streak >= stableProbes) {
|
||||||
|
markApiGatewayReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(400);
|
||||||
|
} else {
|
||||||
|
streak = 0;
|
||||||
|
await sleep(Math.min(500 + attempt * 220, 2200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError('Сервер API временно недоступен. Попробуйте обновить страницу.', 503, 'GATEWAY_UNAVAILABLE');
|
||||||
|
}
|
||||||
|
|
||||||
async function acquireApiSlot(): Promise<void> {
|
async function acquireApiSlot(): Promise<void> {
|
||||||
const inBurst = gatewayReadyAt > 0 && Date.now() - gatewayReadyAt < API_BURST_WINDOW_MS;
|
const inBurst = gatewayReadyAt > 0 && Date.now() - gatewayReadyAt < API_BURST_WINDOW_MS;
|
||||||
const limit = inBurst ? API_BURST_MAX_CONCURRENT : API_MAX_CONCURRENT;
|
const limit = inBurst ? API_BURST_MAX_CONCURRENT : API_MAX_CONCURRENT;
|
||||||
@@ -807,14 +843,24 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
|
|||||||
markApiGatewayReady();
|
markApiGatewayReady();
|
||||||
}
|
}
|
||||||
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
|
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
|
||||||
|
invalidateApiGatewayReady();
|
||||||
|
try {
|
||||||
|
await waitForStableGateway(2);
|
||||||
|
} catch {
|
||||||
await sleep(gatewayRetryDelay(attempt));
|
await sleep(gatewayRetryDelay(attempt));
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastNetworkError = error;
|
lastNetworkError = error;
|
||||||
if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
|
if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
|
||||||
|
invalidateApiGatewayReady();
|
||||||
|
try {
|
||||||
|
await waitForStableGateway(2);
|
||||||
|
} catch {
|
||||||
await sleep(gatewayRetryDelay(attempt));
|
await sleep(gatewayRetryDelay(attempt));
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw mapFetchError(error);
|
throw mapFetchError(error);
|
||||||
|
|||||||
Reference in New Issue
Block a user