fix and update
This commit is contained in:
@@ -143,7 +143,7 @@ export default function DataPage() {
|
||||
const status = await fetchAccountDeletionStatus(userId, token);
|
||||
setDeletionStatus(status);
|
||||
} catch {
|
||||
setDeletionStatus(null);
|
||||
// Фоновый сбой gateway не должен сбрасывать уже показанный статус удаления.
|
||||
}
|
||||
}, [isPinLocked, token, userId]);
|
||||
|
||||
|
||||
@@ -19,23 +19,24 @@ export default function HomePage() {
|
||||
const router = useRouter();
|
||||
const { user, token, isPinLocked } = useAuth();
|
||||
const { isReady } = useRequireAuth();
|
||||
const userId = user?.id;
|
||||
const contactLine = [user?.phone, user?.username ?? user?.email].filter(Boolean).join(' · ');
|
||||
const [documents, setDocuments] = useState<UserDocument[]>([]);
|
||||
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
if (!userId || !token || isPinLocked) return;
|
||||
try {
|
||||
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${user.id}`, {}, token);
|
||||
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${userId}`, {}, token);
|
||||
setDocuments(response.documents ?? []);
|
||||
} catch {
|
||||
setDocuments([]);
|
||||
// Фоновая ошибка не должна очищать уже показанные документы.
|
||||
}
|
||||
}, [isPinLocked, token, user]);
|
||||
}, [isPinLocked, token, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && user && !isPinLocked) void loadDocuments();
|
||||
}, [isPinLocked, isReady, loadDocuments, user]);
|
||||
if (isReady && userId && !isPinLocked) void loadDocuments();
|
||||
}, [isPinLocked, isReady, loadDocuments, userId]);
|
||||
|
||||
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ export default function SecurityPage() {
|
||||
const { user, token, refreshProfile } = useAuth();
|
||||
const { isReady } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const userId = user?.id;
|
||||
const [devices, setDevices] = useState<ActiveDevice[]>([]);
|
||||
const [sessions, setSessions] = useState<ActiveSession[]>([]);
|
||||
const [history, setHistory] = useState<SignInEvent[]>([]);
|
||||
@@ -92,14 +93,14 @@ export default function SecurityPage() {
|
||||
const hasEmailContact = Boolean(user?.email || user?.backupEmail);
|
||||
|
||||
const loadSecurity = useCallback(async () => {
|
||||
if (!user || !token) return;
|
||||
if (!userId || !token) return;
|
||||
setIsSecurityLoading(true);
|
||||
try {
|
||||
const [devicesResponse, sessionsResponse, historyResponse, totpResponse] = await Promise.all([
|
||||
apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${user.id}/devices`, {}, token),
|
||||
apiFetch<{ sessions: ActiveSession[] }>(`/security/users/${user.id}/sessions`, {}, token),
|
||||
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token),
|
||||
apiFetch<TotpStatusResponse>(`/security/users/${user.id}/totp/status`, {}, token)
|
||||
apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${userId}/devices`, {}, token),
|
||||
apiFetch<{ sessions: ActiveSession[] }>(`/security/users/${userId}/sessions`, {}, token),
|
||||
apiFetch<{ events: SignInEvent[] }>(`/security/users/${userId}/sign-in-history`, {}, token),
|
||||
apiFetch<TotpStatusResponse>(`/security/users/${userId}/totp/status`, {}, token)
|
||||
]);
|
||||
setDevices(devicesResponse.devices ?? []);
|
||||
setSessions(sessionsResponse.sessions ?? []);
|
||||
@@ -107,16 +108,17 @@ export default function SecurityPage() {
|
||||
setCurrentSessionId(window.localStorage.getItem(AUTH_SESSION_KEY));
|
||||
setTotpEnabled(Boolean(totpResponse.isEnabled));
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность');
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить безопасность');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setIsSecurityLoading(false);
|
||||
}
|
||||
}, [showToast, token, user]);
|
||||
}, [showToast, token, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || !user) return;
|
||||
if (!isReady || !userId) return;
|
||||
void loadSecurity();
|
||||
}, [isReady, loadSecurity, user]);
|
||||
}, [isReady, loadSecurity, userId]);
|
||||
|
||||
async function revokeSession(sessionId: string) {
|
||||
if (!user || !token) return;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
apiFetch,
|
||||
ApiError,
|
||||
AUTH_SESSION_INVALID_EVENT,
|
||||
AUTH_REFRESH_KEY,
|
||||
AUTH_SESSION_KEY,
|
||||
AUTH_TOKEN_KEY,
|
||||
@@ -106,14 +107,12 @@ function applySessionState(
|
||||
}
|
||||
|
||||
function readInitialAuthState() {
|
||||
if (typeof window === 'undefined') {
|
||||
return { token: null as string | null, hasStoredSession: false };
|
||||
}
|
||||
const token = window.localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
const refresh = window.localStorage.getItem(AUTH_REFRESH_KEY);
|
||||
// Первичный render должен совпадать на сервере и в браузере. Токены из
|
||||
// localStorage читаются уже в refreshProfile(), иначе авторизованная вкладка
|
||||
// получает hydration mismatch (React #418) ещё до запуска эффектов.
|
||||
return {
|
||||
token,
|
||||
hasStoredSession: Boolean(refresh || token)
|
||||
token: null as string | null,
|
||||
hasStoredSession: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,6 +273,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
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;
|
||||
@@ -291,7 +300,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const task: Promise<void> = (async () => {
|
||||
const currentToken = window.localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY);
|
||||
setHasStoredSession(Boolean(refreshToken));
|
||||
setHasStoredSession(Boolean(currentToken || refreshToken));
|
||||
|
||||
if (!currentToken && !refreshToken) {
|
||||
window.localStorage.removeItem(AUTH_USER_CACHE_KEY);
|
||||
|
||||
@@ -598,7 +598,10 @@ async function trySilentTokenRefresh(): Promise<RefreshSessionResponse | null> {
|
||||
}
|
||||
}
|
||||
return refreshed;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
notifyAuthSessionInvalid();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -685,6 +688,8 @@ 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';
|
||||
export const AUTH_SESSION_INVALID_EVENT = 'idp-auth-session-invalid';
|
||||
const GATEWAY_CIRCUIT_COOLDOWN_MS = 8_000;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -694,6 +699,7 @@ let gatewayReadyResolved = false;
|
||||
let gatewayReadyPromise: Promise<void> | null = null;
|
||||
let stableGatewayPromise: Promise<void> | null = null;
|
||||
let gatewayReadyAt = 0;
|
||||
let gatewayCircuitOpenUntil = 0;
|
||||
let activeApiRequests = 0;
|
||||
const apiRequestWaiters: Array<() => void> = [];
|
||||
|
||||
@@ -701,6 +707,7 @@ export function resetApiGatewayWarmup() {
|
||||
gatewayReadyResolved = false;
|
||||
gatewayReadyPromise = null;
|
||||
stableGatewayPromise = null;
|
||||
gatewayCircuitOpenUntil = 0;
|
||||
}
|
||||
|
||||
export function invalidateApiGatewayReady() {
|
||||
@@ -712,6 +719,29 @@ export function invalidateApiGatewayReady() {
|
||||
}
|
||||
}
|
||||
|
||||
function openGatewayCircuit(): void {
|
||||
gatewayReadyResolved = false;
|
||||
gatewayReadyAt = 0;
|
||||
stableGatewayPromise = null;
|
||||
gatewayCircuitOpenUntil = Date.now() + GATEWAY_CIRCUIT_COOLDOWN_MS;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent(API_NOT_READY_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
function assertGatewayCircuitClosed(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (Date.now() < gatewayCircuitOpenUntil) {
|
||||
throw new ApiError('Сервер API временно недоступен. Запрос отложен, чтобы не перегружать консоль ошибками.', 503, 'GATEWAY_UNAVAILABLE');
|
||||
}
|
||||
gatewayCircuitOpenUntil = 0;
|
||||
}
|
||||
|
||||
function notifyAuthSessionInvalid(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent(AUTH_SESSION_INVALID_EVENT));
|
||||
}
|
||||
|
||||
export function isApiGatewayReady() {
|
||||
return gatewayReadyResolved;
|
||||
}
|
||||
@@ -720,6 +750,7 @@ export function markApiGatewayReady() {
|
||||
if (gatewayReadyResolved) return;
|
||||
gatewayReadyResolved = true;
|
||||
gatewayReadyAt = Date.now();
|
||||
gatewayCircuitOpenUntil = 0;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent(API_READY_EVENT));
|
||||
}
|
||||
@@ -816,6 +847,7 @@ export async function ensureApiGatewayReady(force = false): Promise<void> {
|
||||
gatewayReadyResolved = false;
|
||||
gatewayReadyAt = 0;
|
||||
stableGatewayPromise = null;
|
||||
gatewayCircuitOpenUntil = 0;
|
||||
}
|
||||
|
||||
const probes = force ? 2 : 1;
|
||||
@@ -835,6 +867,7 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
|
||||
let lastNetworkError: unknown;
|
||||
for (let attempt = 0; attempt < GATEWAY_RETRY_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
assertGatewayCircuitClosed();
|
||||
const response = await fetch(url, init);
|
||||
if (response.ok) {
|
||||
markApiGatewayReady();
|
||||
@@ -843,16 +876,24 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
|
||||
await sleep(gatewayRetryDelay(attempt));
|
||||
continue;
|
||||
}
|
||||
if (GATEWAY_RETRY_STATUS.has(response.status)) {
|
||||
openGatewayCircuit();
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastNetworkError = error;
|
||||
if (isGatewayUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
|
||||
await sleep(gatewayRetryDelay(attempt));
|
||||
continue;
|
||||
}
|
||||
openGatewayCircuit();
|
||||
throw mapFetchError(error);
|
||||
}
|
||||
}
|
||||
openGatewayCircuit();
|
||||
throw mapFetchError(lastNetworkError);
|
||||
}
|
||||
|
||||
@@ -871,6 +912,13 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
let apiSlotReleased = false;
|
||||
const releaseCurrentApiSlot = () => {
|
||||
if (apiSlotReleased) return;
|
||||
apiSlotReleased = true;
|
||||
releaseApiSlot();
|
||||
};
|
||||
|
||||
try {
|
||||
try {
|
||||
response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, {
|
||||
@@ -892,10 +940,15 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (resolvedToken && error.status === 401 && error.code !== 'TOKEN_EXPIRED') {
|
||||
notifyAuthSessionInvalid();
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') {
|
||||
const refreshed = await trySilentTokenRefresh();
|
||||
if (refreshed?.accessToken) {
|
||||
releaseApiSlot();
|
||||
releaseCurrentApiSlot();
|
||||
return apiFetch<T>(path, options, refreshed.accessToken, false);
|
||||
}
|
||||
if (refreshed?.requiresPin) {
|
||||
@@ -903,13 +956,17 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedToken && error.status === 401) {
|
||||
notifyAuthSessionInvalid();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
markApiGatewayReady();
|
||||
return response.json() as Promise<T>;
|
||||
} finally {
|
||||
releaseApiSlot();
|
||||
releaseCurrentApiSlot();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user