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