const API_ORIGIN_PROXY_PREFIX = '/idp-api'; const configuredApiUrl = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000').replace(/\/$/, ''); const configuredWsUrl = (process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8085/ws').replace(/\/$/, ''); function resolveBrowserApiBaseUrl(): string { if (typeof window === 'undefined') { return configuredApiUrl; } try { const configured = new URL(configuredApiUrl); if (configured.origin !== window.location.origin) { return `${window.location.origin}${API_ORIGIN_PROXY_PREFIX}`; } } catch { // keep configured URL } return configuredApiUrl; } function resolveBrowserWsBaseUrl(): string { if (typeof window === 'undefined') { return configuredWsUrl; } try { const configured = new URL(configuredWsUrl); if (configured.origin !== window.location.origin) { const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`; } } catch { // keep configured URL } return configuredWsUrl; } export function getApiUrl(): string { return resolveBrowserApiBaseUrl(); } export function getWsUrl(): string { return resolveBrowserWsBaseUrl(); } /** @deprecated Используйте getApiUrl() — URL может переключаться на same-origin /idp-api в браузере */ export const API_URL = configuredApiUrl; export interface PublicUser { id: string; email?: string | null; phone?: string | null; backupEmail?: string | null; backupPhone?: string | null; displayName: string; username?: string | null; avatarUrl?: string | null; hasAvatar?: boolean; isSuperAdmin: boolean; status: string; roles?: string[]; permissions?: string[]; canAccessAdmin?: boolean; canManageRoles?: boolean; canManageOAuth?: boolean; canManageUsers?: boolean; canManageSettings?: boolean; canViewUsers?: boolean; canViewUserDocuments?: boolean; } export interface AdminUser { id: string; email?: string | null; phone?: string | null; displayName: string; username?: string | null; isSuperAdmin: boolean; status: string; createdAt: string; roles: string[]; } export interface AdminRole { id: string; slug: string; name: string; description?: string; permissions: { id: string; slug: string; name: string; description?: string }[]; } export interface AdminPermission { id: string; slug: string; name: string; description?: string; } export interface OAuthScope { id: string; slug: string; name: string; description?: string; } export interface OAuthClient { id: string; name: string; clientId: string; redirectUris: string[]; scopes: string[]; isActive: boolean; type: string; clientSecret?: string; } export interface AuthTokens { accessToken: string; refreshToken: string; expiresAt: string; pinVerified: boolean; user: PublicUser; sessionId: string; } export interface PasswordlessAuthResponse { requiresPassword: boolean; tempAuthToken?: string; auth?: AuthTokens; } export interface LoginMethod { kind: 'password' | 'otp'; channel: string; masked: string; } export interface IdentifyResponse { exists: boolean; hasPassword: boolean; isPinEnabled: boolean; methods?: LoginMethod[]; } export interface OtpSendResponse { sent: boolean; expiresAt: string; maskedTarget: string; } export interface PinVerificationResponse { accessToken: string; pinVerified: boolean; } export interface RefreshSessionResponse { requiresPin: boolean; accessToken?: string; sessionId?: string; pinVerified: boolean; user?: PublicUser; } export interface AuthSessionResponse { requiresPin: boolean; sessionId?: string | null; pinVerified: boolean; user: PublicUser; } export const AUTH_TOKEN_KEY = 'lendry_access_token'; export const AUTH_REFRESH_KEY = 'lendry_refresh_token'; export const AUTH_SESSION_KEY = 'lendry_session_id'; export const AUTH_USER_CACHE_KEY = 'lendry_cached_user'; export class ApiError extends Error { status: number; code?: string; sessionId?: string; constructor(message: string, status: number, code?: string, sessionId?: string) { super(message); this.name = 'ApiError'; this.status = status; this.code = code; this.sessionId = sessionId; } } function mapFetchError(error: unknown): ApiError { if (error instanceof TypeError) { const message = error.message.toLowerCase(); if (message.includes('failed to fetch') || message.includes('networkerror')) { return new ApiError( 'Нет связи с сервером. При самоподписанном HTTPS примите сертификат сайта или выполните ./install.sh --fix-proxy на сервере.', 0, 'NETWORK_ERROR' ); } } if (error instanceof ApiError) { return error; } return new ApiError(error instanceof Error ? error.message : 'Не удалось выполнить запрос', 0, 'UNKNOWN'); } type PinRequiredHandler = (sessionId: string) => void; export function setPinRequiredHandler(handler: PinRequiredHandler | null) { pinRequiredHandler = handler; if (!handler) { pinNotificationSent = false; } } interface ErrorBody { message?: string | string[]; error?: string; code?: string; sessionId?: string; } async function parseErrorResponse(response: Response): Promise { try { const body = (await response.clone().json()) as ErrorBody; const message = Array.isArray(body.message) ? body.message.join('\n') : (body.message ?? body.error ?? 'Не удалось выполнить запрос'); const pinRequired = body.code === 'PIN_REQUIRED' || (response.status === 403 && typeof message === 'string' && message.toLowerCase().includes('pin')); return new ApiError(message, response.status, pinRequired ? 'PIN_REQUIRED' : body.code, body.sessionId); } catch { return new ApiError('Не удалось выполнить запрос', response.status); } } export function isPinRequiredError(error: unknown): error is ApiError { return error instanceof ApiError && error.code === 'PIN_REQUIRED'; } export function getApiErrorMessage(error: unknown, fallback: string): string | null { if (isPinRequiredError(error)) return null; if (error instanceof ApiError && error.status === 403 && error.message.toLowerCase().includes('pin')) { return null; } return error instanceof Error ? error.message : fallback; } let pinRequiredHandler: PinRequiredHandler | null = null; let pinNotificationSent = false; export function resetPinRequiredNotification() { pinNotificationSent = false; } function notifyPinRequired(sessionId: string) { if (!pinRequiredHandler || pinNotificationSent) return; pinNotificationSent = true; pinRequiredHandler(sessionId); } export async function fetchAuthSession(token?: string | null): Promise { return apiFetch('/auth/session', {}, token); } export async function refreshAuthSession(): Promise { if (typeof window === 'undefined') { throw new ApiError('Refresh token недоступен на сервере', 401, 'NO_REFRESH'); } const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY); const sessionId = window.localStorage.getItem(AUTH_SESSION_KEY); if (!refreshToken) { throw new ApiError('Refresh token отсутствует', 401, 'NO_REFRESH'); } const response = await fetch(`${getApiUrl()}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken, sessionId: sessionId ?? undefined }) }); if (!response.ok) { throw await parseErrorResponse(response); } return response.json() as Promise; } async function trySilentTokenRefresh(): Promise { try { const refreshed = await refreshAuthSession(); if (refreshed.requiresPin) { notifyPinRequired(refreshed.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); return refreshed; } if (refreshed.accessToken) { window.localStorage.setItem(AUTH_TOKEN_KEY, refreshed.accessToken); if (refreshed.sessionId) { window.localStorage.setItem(AUTH_SESSION_KEY, refreshed.sessionId); } if (refreshed.user) { window.dispatchEvent(new CustomEvent('lendry:auth-refreshed', { detail: refreshed })); } } return refreshed; } catch { return null; } } export interface UserDocument { id: string; userId: string; type: string; number: string; issuedAt?: string; expiresAt?: string; metadataJson?: string; } export interface SystemSetting { id: string; key: string; value: string; description?: string | null; isSecret: boolean; } export interface SocialProvider { id: string; providerName: string; clientId: string; isEnabled: boolean; } export interface ActiveDevice { id: string; name: string; type: string; lastIp?: string | null; lastSeenAt: string; trusted: boolean; } export interface SignInEvent { id: string; success: boolean; reason?: string | null; ipAddress?: string | null; userAgent?: string | null; createdAt: string; } export interface ActiveSession { id: string; userId: string; deviceId?: string | null; pinVerified: boolean; status: string; ipAddress?: string | null; userAgent?: string | null; expiresAt: string; createdAt: string; updatedAt: string; } async function parseError(response: Response): Promise { const error = await parseErrorResponse(response); return error.message; } export function buildSystemSettingPayload(setting: Pick) { return { key: setting.key, value: setting.value, description: setting.description ?? undefined, isSecret: setting.isSecret }; } export async function apiFetch(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise { const resolvedToken = token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null); let response: Response; try { response = await fetch(`${getApiUrl()}${path}`, { ...options, headers: { 'Content-Type': 'application/json', ...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}), ...options.headers } }); } catch (error) { throw mapFetchError(error); } if (!response.ok) { const error = await parseErrorResponse(response); if (error.code === 'PIN_REQUIRED') { notifyPinRequired(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); throw error; } if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') { const refreshed = await trySilentTokenRefresh(); if (refreshed?.accessToken) { return apiFetch(path, options, refreshed.accessToken, false); } if (refreshed?.requiresPin) { throw error; } } throw error; } return response.json() as Promise; } export function getDeviceFingerprint(): string { if (typeof window === 'undefined') return 'server'; const key = 'lendry_device_fingerprint'; const existing = window.localStorage.getItem(key); if (existing) return existing; const value = crypto.randomUUID(); window.localStorage.setItem(key, value); return value; } export interface MediaAccessResponse { accessUrl: string; expiresAt: string; } export async function fetchDocumentPhotoUrl(userId: string, storageKey: string, token?: string | null) { const query = new URLSearchParams({ storageKey }); return apiFetch(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token); } export async function softDeleteProfile(userId: string, token?: string | null) { return apiFetch<{ success: boolean }>(`/profile/users/${userId}/self-delete`, { method: 'POST' }, token); } export interface UserAddress { id: string; userId: string; label: 'HOME' | 'WORK' | 'OTHER'; city: string; street: string; house: string; apartment?: string; comment?: string; latitude?: number; longitude?: number; fullAddress?: string; createdAt: string; updatedAt: string; } export async function fetchUserAddresses(userId: string, token?: string | null) { return apiFetch<{ addresses?: UserAddress[] }>(`/addresses/users/${userId}`, {}, token); } export async function upsertUserAddress( userId: string, body: { addressId?: string; label: UserAddress['label']; city: string; street: string; house: string; apartment?: string; comment?: string; latitude?: number; longitude?: number; fullAddress?: string; }, token?: string | null ) { return apiFetch(`/addresses/users/${userId}`, { method: 'POST', body: JSON.stringify(body) }, token); } export async function deleteUserAddress(userId: string, addressId: string, token?: string | null) { return apiFetch<{ count: number }>(`/addresses/users/${userId}/${addressId}`, { method: 'DELETE' }, token); } export interface FamilyMember { id: string; userId: string; role: string; displayName: string; hasAvatar: boolean; } export interface FamilyGroup { id: string; ownerId: string; name: string; hasAvatar: boolean; members?: FamilyMember[]; } export interface FamilyInvite { id: string; groupId: string; groupName: string; inviterId: string; inviterName: string; inviteeUserId?: string; targetEmail?: string; targetPhone?: string; status: string; expiresAt: string; } export interface AppNotification { id: string; type: string; title: string; message: string; payloadJson?: string; isRead: boolean; createdAt: string; } export interface ChatPollOption { id: string; text: string; voteCount: number; votedByMe: boolean; } export interface ChatMessage { id: string; roomId: string; senderId: string; senderName: string; senderHasAvatar: boolean; type: string; content?: string; replyToId?: string; storageKey?: string; mimeType?: string; metadataJson?: string; createdAt: string; poll?: { id: string; question: string; allowsMultiple: boolean; isAnonymous: boolean; options: ChatPollOption[]; }; } export interface ChatRoom { id: string; groupId: string; type: string; name: string; hasAvatar: boolean; updatedAt: string; members: Array<{ id: string; userId: string; displayName: string; hasAvatar: boolean; notificationsMuted: boolean }>; lastMessage?: ChatMessage; } export async function fetchFamilyGroups(userId: string, token?: string | null) { return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, token); } export async function fetchFamilyGroup(groupId: string, token?: string | null) { return apiFetch(`/family/groups/${groupId}`, {}, token); } export async function updateFamilyGroup(groupId: string, name: string, token?: string | null) { return apiFetch(`/family/groups/${groupId}`, { method: 'PATCH', body: JSON.stringify({ name }) }, token); } export async function sendFamilyInvite(groupId: string, target: string, token?: string | null) { return apiFetch(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify({ target }) }, token); } export async function fetchFamilyInvites(token?: string | null) { return apiFetch<{ invites?: FamilyInvite[] }>('/family/invites', {}, token); } export async function respondFamilyInvite(inviteId: string, accept: boolean, token?: string | null) { return apiFetch(`/family/invites/${inviteId}/respond`, { method: 'POST', body: JSON.stringify({ accept }) }, token); } export async function fetchNotifications(token?: string | null, unreadOnly = false) { return apiFetch<{ notifications?: AppNotification[] }>(`/notifications?unreadOnly=${unreadOnly}`, {}, token); } export async function fetchUnreadNotificationCount(token?: string | null) { return apiFetch<{ count: number }>('/notifications/unread-count', {}, token); } export async function markNotificationRead(notificationId: string, token?: string | null) { return apiFetch(`/notifications/${notificationId}/read`, { method: 'PATCH', body: '{}' }, token); } export async function markAllNotificationsRead(token?: string | null) { return apiFetch('/notifications/read-all', { method: 'POST' }, token); } export async function fetchChatRooms(groupId: string, token?: string | null) { return apiFetch<{ rooms?: ChatRoom[] }>(`/chat/groups/${groupId}/rooms`, {}, token); } export async function createChatRoom(groupId: string, name: string, memberUserIds: string[], token?: string | null) { return apiFetch(`/chat/groups/${groupId}/rooms`, { method: 'POST', body: JSON.stringify({ name, memberUserIds }) }, token); } export async function fetchChatMessages(roomId: string, token?: string | null, beforeMessageId?: string) { const query = beforeMessageId ? `?beforeMessageId=${beforeMessageId}` : ''; return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${roomId}/messages${query}`, {}, token); } export async function sendChatMessage( roomId: string, body: { type: string; content?: string; replyToId?: string; storageKey?: string; mimeType?: string; metadataJson?: string; poll?: { question: string; options: string[]; allowsMultiple?: boolean; isAnonymous?: boolean }; }, token?: string | null ) { return apiFetch(`/chat/rooms/${roomId}/messages`, { method: 'POST', body: JSON.stringify(body) }, token); } export async function voteChatPoll(messageId: string, optionIds: string[], token?: string | null) { return apiFetch(`/chat/messages/${messageId}/vote`, { method: 'POST', body: JSON.stringify({ optionIds }) }, token); } export async function setChatRoomMuted(roomId: string, muted: boolean, token?: string | null) { return apiFetch(`/chat/rooms/${roomId}/mute`, { method: 'POST', body: JSON.stringify({ muted }) }, token); } /** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */ export const WS_URL = configuredWsUrl;