fix and update
This commit is contained in:
@@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { AdminPermission, AdminRole, AdminUser, adminDisableUserTotp, apiFetch, fetchUserTotpStatus } from '@/lib/api';
|
import { AdminPermission, AdminRole, AdminUser, adminDisableUserTotp, apiFetch, fetchUserTotpStatus, getApiErrorMessage } from '@/lib/api';
|
||||||
import { getAdminLandingPath } from '@/lib/admin-access';
|
import { getAdminLandingPath } from '@/lib/admin-access';
|
||||||
import { DEFAULT_VERIFICATION_ICON, VERIFICATION_ICON_OPTIONS } from '@/lib/verification-icons';
|
import { DEFAULT_VERIFICATION_ICON, VERIFICATION_ICON_OPTIONS } from '@/lib/verification-icons';
|
||||||
|
|
||||||
@@ -56,7 +56,8 @@ export default function AdminUsersPage() {
|
|||||||
const response = await apiFetch<{ users: AdminUser[] }>(`/admin/users${search ? `?search=${encodeURIComponent(search)}` : ''}`, {}, token);
|
const response = await apiFetch<{ users: AdminUser[] }>(`/admin/users${search ? `?search=${encodeURIComponent(search)}` : ''}`, {}, token);
|
||||||
setUsers(response.users ?? []);
|
setUsers(response.users ?? []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить пользователей');
|
const message = getApiErrorMessage(error, 'Не удалось загрузить пользователей');
|
||||||
|
if (message) showToast(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
buildAuthDevicePayload,
|
buildAuthDevicePayload,
|
||||||
IdentifyResponse,
|
IdentifyResponse,
|
||||||
getApiErrorMessage,
|
getApiErrorMessage,
|
||||||
|
ensureApiGatewayReady,
|
||||||
invalidateApiGatewayReady,
|
invalidateApiGatewayReady,
|
||||||
isApiGatewayReady,
|
isApiGatewayReady,
|
||||||
isGatewayUnavailableError,
|
isGatewayUnavailableError,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
PinVerificationResponse,
|
PinVerificationResponse,
|
||||||
PublicUser,
|
PublicUser,
|
||||||
refreshAuthSession,
|
refreshAuthSession,
|
||||||
|
resetGatewayCircuit,
|
||||||
resetPinRequiredNotification,
|
resetPinRequiredNotification,
|
||||||
scheduleFedcmSessionSync,
|
scheduleFedcmSessionSync,
|
||||||
setPinRequiredHandler,
|
setPinRequiredHandler,
|
||||||
@@ -200,6 +202,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setIsApiReady(false);
|
setIsApiReady(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await ensureApiGatewayReady();
|
||||||
const session = await fetchAuthSession(accessToken);
|
const session = await fetchAuthSession(accessToken);
|
||||||
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
||||||
initialBootstrapDoneRef.current = true;
|
initialBootstrapDoneRef.current = true;
|
||||||
@@ -321,6 +324,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (let bootstrapAttempt = 0; bootstrapAttempt < 4; bootstrapAttempt += 1) {
|
for (let bootstrapAttempt = 0; bootstrapAttempt < 4; bootstrapAttempt += 1) {
|
||||||
|
if (isInitialBootstrap) {
|
||||||
|
try {
|
||||||
|
await ensureApiGatewayReady(bootstrapAttempt > 0);
|
||||||
|
} catch {
|
||||||
|
// цикл bootstrap повторит после паузы
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (currentToken) {
|
if (currentToken) {
|
||||||
setToken(currentToken);
|
setToken(currentToken);
|
||||||
@@ -375,6 +386,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 4) {
|
if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 4) {
|
||||||
invalidateApiGatewayReady();
|
invalidateApiGatewayReady();
|
||||||
|
resetGatewayCircuit();
|
||||||
setBootstrapError(
|
setBootstrapError(
|
||||||
'Не удалось подключиться к серверу API (502). Подождите несколько секунд и нажмите «Повторить».'
|
'Не удалось подключиться к серверу API (502). Подождите несколько секунд и нажмите «Повторить».'
|
||||||
);
|
);
|
||||||
@@ -697,7 +709,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
isLoading,
|
isLoading,
|
||||||
isApiReady,
|
isApiReady,
|
||||||
isSessionReady,
|
isSessionReady,
|
||||||
isContentReady: isSessionReady,
|
isContentReady: isSessionReady && isApiReady,
|
||||||
isPinLocked,
|
isPinLocked,
|
||||||
hasStoredSession,
|
hasStoredSession,
|
||||||
login,
|
login,
|
||||||
@@ -723,6 +735,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setBootstrapError(null);
|
setBootstrapError(null);
|
||||||
initialBootstrapDoneRef.current = false;
|
initialBootstrapDoneRef.current = false;
|
||||||
invalidateApiGatewayReady();
|
invalidateApiGatewayReady();
|
||||||
|
resetGatewayCircuit();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setIsApiReady(false);
|
setIsApiReady(false);
|
||||||
setIsSessionReady(false);
|
setIsSessionReady(false);
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ function notifyPinRequired(sessionId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAuthSession(token?: string | null): Promise<AuthSessionResponse> {
|
export async function fetchAuthSession(token?: string | null): Promise<AuthSessionResponse> {
|
||||||
return apiFetch<AuthSessionResponse>('/auth/session', {}, token);
|
return apiFetch<AuthSessionResponse>('/auth/session', {}, token, true, { critical: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function approveOAuthAuthorization(params: URLSearchParams, token: string) {
|
export async function approveOAuthAuthorization(params: URLSearchParams, token: string) {
|
||||||
@@ -688,7 +688,7 @@ 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 = 3;
|
||||||
const GATEWAY_CRITICAL_RETRY_ATTEMPTS = 4;
|
const GATEWAY_CRITICAL_RETRY_ATTEMPTS = 4;
|
||||||
|
|
||||||
export interface ApiFetchBehavior {
|
export interface ApiFetchBehavior {
|
||||||
@@ -700,8 +700,8 @@ export interface ApiFetchBehavior {
|
|||||||
const GATEWAY_STABLE_PROBE_INTERVAL_MS = 800;
|
const GATEWAY_STABLE_PROBE_INTERVAL_MS = 800;
|
||||||
const GATEWAY_STABLE_MAX_ROUNDS = 8;
|
const GATEWAY_STABLE_MAX_ROUNDS = 8;
|
||||||
const API_MAX_CONCURRENT = 6;
|
const API_MAX_CONCURRENT = 6;
|
||||||
const API_BURST_MAX_CONCURRENT = 2;
|
const API_BURST_MAX_CONCURRENT = 4;
|
||||||
const API_BURST_WINDOW_MS = 5000;
|
const API_BURST_WINDOW_MS = 8000;
|
||||||
const API_READY_EVENT = 'idp-api-ready';
|
const API_READY_EVENT = 'idp-api-ready';
|
||||||
const API_NOT_READY_EVENT = 'idp-api-not-ready';
|
const API_NOT_READY_EVENT = 'idp-api-not-ready';
|
||||||
export const AUTH_SESSION_INVALID_EVENT = 'idp-auth-session-invalid';
|
export const AUTH_SESSION_INVALID_EVENT = 'idp-auth-session-invalid';
|
||||||
@@ -746,6 +746,8 @@ function shouldBypassGatewayCircuit(behavior?: ApiFetchBehavior): boolean {
|
|||||||
|
|
||||||
function openGatewayCircuit(behavior?: ApiFetchBehavior): void {
|
function openGatewayCircuit(behavior?: ApiFetchBehavior): void {
|
||||||
if (shouldBypassGatewayCircuit(behavior)) return;
|
if (shouldBypassGatewayCircuit(behavior)) return;
|
||||||
|
// До стабильного прогрева /health circuit не открываем — иначе каждая перезагрузка ловит ложный 502.
|
||||||
|
if (!gatewayReadyResolved) return;
|
||||||
gatewayReadyResolved = false;
|
gatewayReadyResolved = false;
|
||||||
gatewayReadyAt = 0;
|
gatewayReadyAt = 0;
|
||||||
stableGatewayPromise = null;
|
stableGatewayPromise = null;
|
||||||
@@ -757,6 +759,7 @@ function openGatewayCircuit(behavior?: ApiFetchBehavior): void {
|
|||||||
|
|
||||||
function assertGatewayCircuitClosed(behavior?: ApiFetchBehavior): void {
|
function assertGatewayCircuitClosed(behavior?: ApiFetchBehavior): void {
|
||||||
if (typeof window === 'undefined' || shouldBypassGatewayCircuit(behavior)) return;
|
if (typeof window === 'undefined' || shouldBypassGatewayCircuit(behavior)) return;
|
||||||
|
if (!gatewayReadyAt && !gatewayCircuitOpenUntil) return;
|
||||||
if (Date.now() < gatewayCircuitOpenUntil) {
|
if (Date.now() < gatewayCircuitOpenUntil) {
|
||||||
throw new ApiError('Сервер API временно недоступен. Запрос отложен, чтобы не перегружать консоль ошибками.', 503, 'GATEWAY_UNAVAILABLE');
|
throw new ApiError('Сервер API временно недоступен. Запрос отложен, чтобы не перегружать консоль ошибками.', 503, 'GATEWAY_UNAVAILABLE');
|
||||||
}
|
}
|
||||||
@@ -876,7 +879,7 @@ export async function ensureApiGatewayReady(force = false): Promise<void> {
|
|||||||
gatewayCircuitOpenUntil = 0;
|
gatewayCircuitOpenUntil = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const probes = force ? 2 : 1;
|
const probes = force ? 2 : 2;
|
||||||
gatewayReadyPromise = waitForStableGateway(probes).finally(() => {
|
gatewayReadyPromise = waitForStableGateway(probes).finally(() => {
|
||||||
gatewayReadyPromise = null;
|
gatewayReadyPromise = null;
|
||||||
});
|
});
|
||||||
@@ -900,9 +903,6 @@ async function fetchWithGatewayRetry(
|
|||||||
try {
|
try {
|
||||||
assertGatewayCircuitClosed(behavior);
|
assertGatewayCircuitClosed(behavior);
|
||||||
const response = await fetch(url, init);
|
const response = await fetch(url, init);
|
||||||
if (response.ok) {
|
|
||||||
markApiGatewayReady();
|
|
||||||
}
|
|
||||||
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < maxAttempts) {
|
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < maxAttempts) {
|
||||||
await sleep(gatewayRetryDelay(attempt));
|
await sleep(gatewayRetryDelay(attempt));
|
||||||
continue;
|
continue;
|
||||||
@@ -935,6 +935,14 @@ export async function apiFetch<T>(
|
|||||||
allowRetry = true,
|
allowRetry = true,
|
||||||
behavior: ApiFetchBehavior = {}
|
behavior: ApiFetchBehavior = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
if (!gatewayReadyResolved && !behavior.silent) {
|
||||||
|
try {
|
||||||
|
await ensureApiGatewayReady();
|
||||||
|
} catch {
|
||||||
|
// fetchWithGatewayRetry попробует сам после кратковременного 502
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await acquireApiSlot();
|
await acquireApiSlot();
|
||||||
|
|
||||||
const resolvedToken = resolveAccessToken(token);
|
const resolvedToken = resolveAccessToken(token);
|
||||||
@@ -1000,7 +1008,6 @@ export async function apiFetch<T>(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
markApiGatewayReady();
|
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
} finally {
|
} finally {
|
||||||
releaseCurrentApiSlot();
|
releaseCurrentApiSlot();
|
||||||
@@ -1263,11 +1270,11 @@ export async function fetchFamilyGroups(userId: string, token?: string | null) {
|
|||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
throw new ApiError('Не авторизован', 401, 'NO_TOKEN');
|
throw new ApiError('Не авторизован', 401, 'NO_TOKEN');
|
||||||
}
|
}
|
||||||
return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, accessToken);
|
return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, accessToken, true, { silent: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchFamilyGroup(groupId: string, token?: string | null) {
|
export async function fetchFamilyGroup(groupId: string, token?: string | null) {
|
||||||
return apiFetch<FamilyGroup>(`/family/groups/${groupId}`, {}, token);
|
return apiFetch<FamilyGroup>(`/family/groups/${groupId}`, {}, token, true, { silent: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateFamilyGroup(groupId: string, name: string, token?: string | null) {
|
export async function updateFamilyGroup(groupId: string, name: string, token?: string | null) {
|
||||||
@@ -1325,7 +1332,7 @@ export async function fetchNotifications(token?: string | null, unreadOnly = fal
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUnreadNotificationCount(token?: string | null) {
|
export async function fetchUnreadNotificationCount(token?: string | null) {
|
||||||
return apiFetch<{ count: number }>('/notifications/unread-count', {}, token);
|
return apiFetch<{ count: number }>('/notifications/unread-count', {}, token, true, { silent: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markNotificationRead(notificationId: string, token?: string | null) {
|
export async function markNotificationRead(notificationId: string, token?: string | null) {
|
||||||
@@ -1584,7 +1591,7 @@ export async function fetchFamilyPresence(groupId: string, token?: string | null
|
|||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
throw new ApiError('Не авторизован', 401, 'NO_TOKEN');
|
throw new ApiError('Не авторизован', 401, 'NO_TOKEN');
|
||||||
}
|
}
|
||||||
return apiFetch<FamilyPresence>(`/family/groups/${groupId}/presence`, {}, accessToken);
|
return apiFetch<FamilyPresence>(`/family/groups/${groupId}/presence`, {}, accessToken, true, { silent: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */
|
/** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */
|
||||||
|
|||||||
Reference in New Issue
Block a user