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); 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') @Get('config.json')
@ApiOperation({ summary: 'FedCM provider config', description: 'Конфигурация Identity Provider для Federated Credential Management API.' }) @ApiOperation({ summary: 'FedCM provider config', description: 'Конфигурация Identity Provider для Federated Credential Management API.' })
async config(@Req() req: Request, @Res({ passthrough: true }) res: Response) { 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); const status = await fetchAccountDeletionStatus(userId, token);
setDeletionStatus(status); setDeletionStatus(status);
} catch { } catch {
setDeletionStatus(null); // Фоновый сбой gateway не должен сбрасывать уже показанный статус удаления.
} }
}, [isPinLocked, token, userId]); }, [isPinLocked, token, userId]);

View File

@@ -19,23 +19,24 @@ export default function HomePage() {
const router = useRouter(); const router = useRouter();
const { user, token, isPinLocked } = useAuth(); const { user, token, isPinLocked } = useAuth();
const { isReady } = useRequireAuth(); const { isReady } = useRequireAuth();
const userId = user?.id;
const contactLine = [user?.phone, user?.username ?? user?.email].filter(Boolean).join(' · '); const contactLine = [user?.phone, user?.username ?? user?.email].filter(Boolean).join(' · ');
const [documents, setDocuments] = useState<UserDocument[]>([]); const [documents, setDocuments] = useState<UserDocument[]>([]);
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null); const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
const loadDocuments = useCallback(async () => { const loadDocuments = useCallback(async () => {
if (!user || !token || isPinLocked) return; if (!userId || !token || isPinLocked) return;
try { 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 ?? []); setDocuments(response.documents ?? []);
} catch { } catch {
setDocuments([]); // Фоновая ошибка не должна очищать уже показанные документы.
} }
}, [isPinLocked, token, user]); }, [isPinLocked, token, userId]);
useEffect(() => { useEffect(() => {
if (isReady && user && !isPinLocked) void loadDocuments(); if (isReady && userId && !isPinLocked) void loadDocuments();
}, [isPinLocked, isReady, loadDocuments, user]); }, [isPinLocked, isReady, loadDocuments, userId]);
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]); const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);

View File

@@ -67,6 +67,7 @@ export default function SecurityPage() {
const { user, token, refreshProfile } = useAuth(); const { user, token, refreshProfile } = useAuth();
const { isReady } = useRequireAuth(); const { isReady } = useRequireAuth();
const { showToast } = useToast(); const { showToast } = useToast();
const userId = user?.id;
const [devices, setDevices] = useState<ActiveDevice[]>([]); const [devices, setDevices] = useState<ActiveDevice[]>([]);
const [sessions, setSessions] = useState<ActiveSession[]>([]); const [sessions, setSessions] = useState<ActiveSession[]>([]);
const [history, setHistory] = useState<SignInEvent[]>([]); const [history, setHistory] = useState<SignInEvent[]>([]);
@@ -92,14 +93,14 @@ export default function SecurityPage() {
const hasEmailContact = Boolean(user?.email || user?.backupEmail); const hasEmailContact = Boolean(user?.email || user?.backupEmail);
const loadSecurity = useCallback(async () => { const loadSecurity = useCallback(async () => {
if (!user || !token) return; if (!userId || !token) return;
setIsSecurityLoading(true); setIsSecurityLoading(true);
try { try {
const [devicesResponse, sessionsResponse, historyResponse, totpResponse] = await Promise.all([ const [devicesResponse, sessionsResponse, historyResponse, totpResponse] = await Promise.all([
apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${user.id}/devices`, {}, token), apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${userId}/devices`, {}, token),
apiFetch<{ sessions: ActiveSession[] }>(`/security/users/${user.id}/sessions`, {}, token), apiFetch<{ sessions: ActiveSession[] }>(`/security/users/${userId}/sessions`, {}, token),
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token), apiFetch<{ events: SignInEvent[] }>(`/security/users/${userId}/sign-in-history`, {}, token),
apiFetch<TotpStatusResponse>(`/security/users/${user.id}/totp/status`, {}, token) apiFetch<TotpStatusResponse>(`/security/users/${userId}/totp/status`, {}, token)
]); ]);
setDevices(devicesResponse.devices ?? []); setDevices(devicesResponse.devices ?? []);
setSessions(sessionsResponse.sessions ?? []); setSessions(sessionsResponse.sessions ?? []);
@@ -107,16 +108,17 @@ export default function SecurityPage() {
setCurrentSessionId(window.localStorage.getItem(AUTH_SESSION_KEY)); setCurrentSessionId(window.localStorage.getItem(AUTH_SESSION_KEY));
setTotpEnabled(Boolean(totpResponse.isEnabled)); setTotpEnabled(Boolean(totpResponse.isEnabled));
} catch (error) { } catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность'); const message = getApiErrorMessage(error, 'Не удалось загрузить безопасность');
if (message) showToast(message);
} finally { } finally {
setIsSecurityLoading(false); setIsSecurityLoading(false);
} }
}, [showToast, token, user]); }, [showToast, token, userId]);
useEffect(() => { useEffect(() => {
if (!isReady || !user) return; if (!isReady || !userId) return;
void loadSecurity(); void loadSecurity();
}, [isReady, loadSecurity, user]); }, [isReady, loadSecurity, userId]);
async function revokeSession(sessionId: string) { async function revokeSession(sessionId: string) {
if (!user || !token) return; if (!user || !token) return;

View File

@@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation';
import { import {
apiFetch, apiFetch,
ApiError, ApiError,
AUTH_SESSION_INVALID_EVENT,
AUTH_REFRESH_KEY, AUTH_REFRESH_KEY,
AUTH_SESSION_KEY, AUTH_SESSION_KEY,
AUTH_TOKEN_KEY, AUTH_TOKEN_KEY,
@@ -106,14 +107,12 @@ function applySessionState(
} }
function readInitialAuthState() { function readInitialAuthState() {
if (typeof window === 'undefined') { // Первичный render должен совпадать на сервере и в браузере. Токены из
return { token: null as string | null, hasStoredSession: false }; // localStorage читаются уже в refreshProfile(), иначе авторизованная вкладка
} // получает hydration mismatch (React #418) ещё до запуска эффектов.
const token = window.localStorage.getItem(AUTH_TOKEN_KEY);
const refresh = window.localStorage.getItem(AUTH_REFRESH_KEY);
return { return {
token, token: null as string | null,
hasStoredSession: Boolean(refresh || token) hasStoredSession: false
}; };
} }
@@ -274,6 +273,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
router.push('/auth/login'); router.push('/auth/login');
}, [clearPinLock, router]); }, [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>) => { const applyUserPatch = React.useCallback((patch: Partial<PublicUser>) => {
setUser((current) => { setUser((current) => {
if (!current) return current; if (!current) return current;
@@ -291,7 +300,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const task: Promise<void> = (async () => { const task: Promise<void> = (async () => {
const currentToken = window.localStorage.getItem(AUTH_TOKEN_KEY); const currentToken = window.localStorage.getItem(AUTH_TOKEN_KEY);
const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY); const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY);
setHasStoredSession(Boolean(refreshToken)); setHasStoredSession(Boolean(currentToken || refreshToken));
if (!currentToken && !refreshToken) { if (!currentToken && !refreshToken) {
window.localStorage.removeItem(AUTH_USER_CACHE_KEY); window.localStorage.removeItem(AUTH_USER_CACHE_KEY);

View File

@@ -598,7 +598,10 @@ async function trySilentTokenRefresh(): Promise<RefreshSessionResponse | null> {
} }
} }
return refreshed; return refreshed;
} catch { } catch (error) {
if (error instanceof ApiError && error.status === 401) {
notifyAuthSessionInvalid();
}
return null; return null;
} }
} }
@@ -685,6 +688,8 @@ const API_BURST_MAX_CONCURRENT = 2;
const API_BURST_WINDOW_MS = 5000; 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'; 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> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
@@ -694,6 +699,7 @@ let gatewayReadyResolved = false;
let gatewayReadyPromise: Promise<void> | null = null; let gatewayReadyPromise: Promise<void> | null = null;
let stableGatewayPromise: Promise<void> | null = null; let stableGatewayPromise: Promise<void> | null = null;
let gatewayReadyAt = 0; let gatewayReadyAt = 0;
let gatewayCircuitOpenUntil = 0;
let activeApiRequests = 0; let activeApiRequests = 0;
const apiRequestWaiters: Array<() => void> = []; const apiRequestWaiters: Array<() => void> = [];
@@ -701,6 +707,7 @@ export function resetApiGatewayWarmup() {
gatewayReadyResolved = false; gatewayReadyResolved = false;
gatewayReadyPromise = null; gatewayReadyPromise = null;
stableGatewayPromise = null; stableGatewayPromise = null;
gatewayCircuitOpenUntil = 0;
} }
export function invalidateApiGatewayReady() { 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() { export function isApiGatewayReady() {
return gatewayReadyResolved; return gatewayReadyResolved;
} }
@@ -720,6 +750,7 @@ export function markApiGatewayReady() {
if (gatewayReadyResolved) return; if (gatewayReadyResolved) return;
gatewayReadyResolved = true; gatewayReadyResolved = true;
gatewayReadyAt = Date.now(); gatewayReadyAt = Date.now();
gatewayCircuitOpenUntil = 0;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(API_READY_EVENT)); window.dispatchEvent(new CustomEvent(API_READY_EVENT));
} }
@@ -816,6 +847,7 @@ export async function ensureApiGatewayReady(force = false): Promise<void> {
gatewayReadyResolved = false; gatewayReadyResolved = false;
gatewayReadyAt = 0; gatewayReadyAt = 0;
stableGatewayPromise = null; stableGatewayPromise = null;
gatewayCircuitOpenUntil = 0;
} }
const probes = force ? 2 : 1; const probes = force ? 2 : 1;
@@ -835,6 +867,7 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
let lastNetworkError: unknown; let lastNetworkError: unknown;
for (let attempt = 0; attempt < GATEWAY_RETRY_ATTEMPTS; attempt++) { for (let attempt = 0; attempt < GATEWAY_RETRY_ATTEMPTS; attempt++) {
try { try {
assertGatewayCircuitClosed();
const response = await fetch(url, init); const response = await fetch(url, init);
if (response.ok) { if (response.ok) {
markApiGatewayReady(); markApiGatewayReady();
@@ -843,16 +876,24 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
await sleep(gatewayRetryDelay(attempt)); await sleep(gatewayRetryDelay(attempt));
continue; continue;
} }
if (GATEWAY_RETRY_STATUS.has(response.status)) {
openGatewayCircuit();
}
return response; return response;
} catch (error) { } catch (error) {
lastNetworkError = error; lastNetworkError = error;
if (isGatewayUnavailableError(error)) {
throw error;
}
if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) { if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
await sleep(gatewayRetryDelay(attempt)); await sleep(gatewayRetryDelay(attempt));
continue; continue;
} }
openGatewayCircuit();
throw mapFetchError(error); throw mapFetchError(error);
} }
} }
openGatewayCircuit();
throw mapFetchError(lastNetworkError); throw mapFetchError(lastNetworkError);
} }
@@ -871,6 +912,13 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
}; };
let response: Response; let response: Response;
let apiSlotReleased = false;
const releaseCurrentApiSlot = () => {
if (apiSlotReleased) return;
apiSlotReleased = true;
releaseApiSlot();
};
try { try {
try { try {
response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, { response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, {
@@ -892,10 +940,15 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
throw error; throw error;
} }
if (resolvedToken && error.status === 401 && error.code !== 'TOKEN_EXPIRED') {
notifyAuthSessionInvalid();
throw error;
}
if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') { if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') {
const refreshed = await trySilentTokenRefresh(); const refreshed = await trySilentTokenRefresh();
if (refreshed?.accessToken) { if (refreshed?.accessToken) {
releaseApiSlot(); releaseCurrentApiSlot();
return apiFetch<T>(path, options, refreshed.accessToken, false); return apiFetch<T>(path, options, refreshed.accessToken, false);
} }
if (refreshed?.requiresPin) { 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; throw error;
} }
markApiGatewayReady(); markApiGatewayReady();
return response.json() as Promise<T>; return response.json() as Promise<T>;
} finally { } finally {
releaseApiSlot(); releaseCurrentApiSlot();
} }
} }

View File

@@ -2336,12 +2336,13 @@ write_nginx_site_api() {
local domain="$1" local domain="$1"
local ssl_type="${2:-none}" local ssl_type="${2:-none}"
local ws_on_api="${3:-true}" local ws_on_api="${3:-true}"
local conf ws_block http_preamble ssl_block api_upstream api_location local conf ws_block http_preamble ssl_block api_upstream api_location server_dns
conf="$(nginx_conf_target api)" conf="$(nginx_conf_target api)"
ws_block="$(build_ws_block "$ws_on_api")" ws_block="$(build_ws_block "$ws_on_api")"
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")" http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
api_upstream="$(build_nginx_api_upstream_block)" api_upstream="$(build_nginx_api_upstream_block)"
api_location="$(build_nginx_api_location_block)" api_location="$(build_nginx_api_location_block)"
server_dns="$(nginx_docker_server_dns_block)"
if [[ "$ssl_type" == "none" ]]; then if [[ "$ssl_type" == "none" ]]; then
nginx_write_conf "$conf" <<EOF nginx_write_conf "$conf" <<EOF
@@ -2351,6 +2352,7 @@ server {
listen [::]:80; listen [::]:80;
server_name ${domain}; server_name ${domain};
client_max_body_size 100m; client_max_body_size 100m;
${server_dns}
${ws_block} ${ws_block}
${api_location} ${api_location}
} }
@@ -2365,6 +2367,7 @@ ${http_preamble}server {
server_name ${domain}; server_name ${domain};
${ssl_block} ${ssl_block}
client_max_body_size 100m; client_max_body_size 100m;
${server_dns}
${ws_block} ${ws_block}
${api_location} ${api_location}
} }
@@ -2713,6 +2716,9 @@ validate_docker_api_nginx_config() {
if grep -q 'proxy_pass.*\$request_uri' "$conf" 2>/dev/null; then if grep -q 'proxy_pass.*\$request_uri' "$conf" 2>/dev/null; then
fail "Конфиг ${conf} содержит proxy_pass \$request_uri — нужен ./install.sh --fix-all (502 на / без resolver)" fail "Конфиг ${conf} содержит proxy_pass \$request_uri — нужен ./install.sh --fix-all (502 на / без resolver)"
fi fi
if grep -q 'proxy_pass \$' "$conf" 2>/dev/null && ! grep -q 'resolver 127\.0\.0\.11' "$conf" 2>/dev/null; then
fail "Конфиг ${conf} использует variable proxy_pass без Docker resolver — будет 502 на api.idpmvk.lpr. Выполните ./install.sh --fix-all"
fi
ok "Конфиг API-домена: proxy_pass → api-gateway:3000" ok "Конфиг API-домена: proxy_pass → api-gateway:3000"
} }
@@ -2743,6 +2749,9 @@ validate_all_docker_nginx_proxy_pass() {
if grep -q 'proxy_pass.*\$request_uri' "$conf" 2>/dev/null; then if grep -q 'proxy_pass.*\$request_uri' "$conf" 2>/dev/null; then
fail "Конфиг $(basename "$conf") содержит proxy_pass \$request_uri — 502 «no resolver defined» на /. Выполните ./install.sh --fix-all" fail "Конфиг $(basename "$conf") содержит proxy_pass \$request_uri — 502 «no resolver defined» на /. Выполните ./install.sh --fix-all"
fi fi
if grep -q 'proxy_pass \$' "$conf" 2>/dev/null && ! grep -q 'resolver 127\.0\.0\.11' "$conf" 2>/dev/null; then
fail "Конфиг $(basename "$conf") использует variable proxy_pass без resolver 127.0.0.11 — после recreate контейнеров будут 502"
fi
done done
shopt -u nullglob shopt -u nullglob
} }