fix and update

This commit is contained in:
lendry
2026-07-01 00:26:16 +03:00
parent a0c1722a8d
commit 115fc140af
7 changed files with 115 additions and 28 deletions

View File

@@ -76,6 +76,15 @@ export class FedcmController {
applyFedcmPreflightHeaders(res, origin);
}
@Options('discover.json')
@HttpCode(204)
discoverPreflight(@Res({ passthrough: true }) res: Response) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept');
res.setHeader('Access-Control-Max-Age', '86400');
}
@Get('config.json')
@ApiOperation({ summary: 'FedCM provider config', description: 'Конфигурация Identity Provider для Federated Credential Management API.' })
async config(@Req() req: Request, @Res({ passthrough: true }) res: Response) {

View File

@@ -143,7 +143,7 @@ export default function DataPage() {
const status = await fetchAccountDeletionStatus(userId, token);
setDeletionStatus(status);
} catch {
setDeletionStatus(null);
// Фоновый сбой gateway не должен сбрасывать уже показанный статус удаления.
}
}, [isPinLocked, token, userId]);

View File

@@ -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]);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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();
}
}