diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx deleted file mode 100644 index 4de4bf4..0000000 --- a/apps/frontend/src/App.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from './app/App'; diff --git a/apps/frontend/src/app/layouts/AppLayout.tsx b/apps/frontend/src/app/layouts/AppLayout.tsx index 46f4408..fcdd1ff 100644 --- a/apps/frontend/src/app/layouts/AppLayout.tsx +++ b/apps/frontend/src/app/layouts/AppLayout.tsx @@ -1,6 +1,6 @@ import { Outlet, Link, useNavigate } from 'react-router-dom'; import { SearchBar } from '@/widgets/search-bar'; -import { useSession } from '@/entities/session/model/useSession'; +import { useSession } from '@/entities/session'; export function AppLayout() { const { isAuthenticated, user, logout } = useSession(); diff --git a/apps/frontend/src/app/providers/SessionProvider.test.tsx b/apps/frontend/src/app/providers/SessionProvider.test.tsx index f7750ce..a1294f2 100644 --- a/apps/frontend/src/app/providers/SessionProvider.test.tsx +++ b/apps/frontend/src/app/providers/SessionProvider.test.tsx @@ -3,8 +3,8 @@ import { useContext } from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; -import { server } from '../../test/server'; -import { SessionContext } from '@/entities/session/model/sessionContext'; +import { server } from '@/shared/lib/test/server'; +import { SessionContext } from '@/entities/session'; import { SessionProvider } from './SessionProvider'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; diff --git a/apps/frontend/src/app/providers/SessionProvider.tsx b/apps/frontend/src/app/providers/SessionProvider.tsx index 871930d..9c8c4ca 100644 --- a/apps/frontend/src/app/providers/SessionProvider.tsx +++ b/apps/frontend/src/app/providers/SessionProvider.tsx @@ -1,7 +1,12 @@ import { useState, useEffect, useCallback, type ReactNode } from 'react'; -import * as sessionApi from '@/entities/session/api/sessionApi'; -import { SessionContext, type SessionContextValue } from '@/entities/session/model/sessionContext'; -import { setOnUnauthorized } from '@/shared/api/client'; +import * as sessionApi from '@/entities/session'; +import { SessionContext, type SessionContextValue } from '@/entities/session'; +import { configureAuth } from '@/shared/api/client'; +import { + setOnUnauthorized, + getAccessToken, + handleUnauthorized, +} from '@/entities/session/api/tokenManager'; import type { UserResponse } from '@/shared/api/responses'; export function SessionProvider({ children }: { children: ReactNode }) { @@ -77,8 +82,12 @@ export function SessionProvider({ children }: { children: ReactNode }) { }; }, [updateSession]); - // Set up auto-logout on unauthorized + // Wire up auth config and auto-logout on unauthorized useEffect(() => { + configureAuth({ + getAccessToken, + handleUnauthorized, + }); setOnUnauthorized(() => { clearSession(); }); diff --git a/apps/frontend/src/app/routing/AppRoutes.tsx b/apps/frontend/src/app/routing/AppRoutes.tsx index 3fdd335..71efb7d 100644 --- a/apps/frontend/src/app/routing/AppRoutes.tsx +++ b/apps/frontend/src/app/routing/AppRoutes.tsx @@ -10,7 +10,7 @@ import { ProfilePage } from '@/pages/profile'; import { PortfoliosListPage, PortfolioDetailPage } from '@/pages/portfolios'; import { ScreenerPage } from '@/pages/screener'; import { BrokerAccountsPage } from '@/pages/broker-accounts'; -import { BrokerAccountLayout } from '@/entities/broker-account/ui/BrokerAccountLayout'; +import { BrokerAccountLayout } from '@/widgets/broker-account-layout'; import { BrokerAccountOverviewPage } from '@/pages/broker-account'; import { BrokerPositionsPage } from '@/pages/broker-positions'; import { BrokerOperationsPage } from '@/pages/broker-operations'; diff --git a/apps/frontend/src/app/routing/ProtectedRoute.tsx b/apps/frontend/src/app/routing/ProtectedRoute.tsx index d5fccb5..5232231 100644 --- a/apps/frontend/src/app/routing/ProtectedRoute.tsx +++ b/apps/frontend/src/app/routing/ProtectedRoute.tsx @@ -1,5 +1,5 @@ import { Navigate, useLocation } from 'react-router-dom'; -import { useSession } from '@/entities/session/model/useSession'; +import { useSession } from '@/entities/session'; import type { ReactNode } from 'react'; export function ProtectedRoute({ children }: { children: ReactNode }) { diff --git a/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx b/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx index 08a159d..ecade27 100644 --- a/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx +++ b/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; -import { server } from '../../../test/server'; +import { server } from '@/shared/lib/test/server'; import { useBondCandles } from './useBondCandles'; import { type ReactNode } from 'react'; diff --git a/apps/frontend/src/entities/broker-account/index.ts b/apps/frontend/src/entities/broker-account/index.ts index e0ef9ab..e6e7c28 100644 --- a/apps/frontend/src/entities/broker-account/index.ts +++ b/apps/frontend/src/entities/broker-account/index.ts @@ -1,7 +1,10 @@ export { useBrokerAccounts } from './model/useBrokerAccounts'; export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios'; export { useBrokerPortfolio } from './model/useBrokerPortfolio'; -export { aggregateBrokerAccounts } from './model/brokerAccountsOverview'; +export { + aggregateBrokerAccounts, + type BrokerAccountsAggregate, +} from './model/brokerAccountsOverview'; export { getBrokerAccounts, getBrokerPortfolio, diff --git a/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts b/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts index d503d21..904dfa2 100644 --- a/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts +++ b/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts @@ -4,8 +4,8 @@ export { getBrokerOperationImpact, getBrokerOperationTypeLabel, isBrokerOperationType, -} from '../../broker-operation/model/operationFilters'; -export type { BrokerOperationImpact } from '../../broker-operation/model/operationFilters'; +} from '@/entities/broker-operation'; +export type { BrokerOperationImpact } from '@/entities/broker-operation'; export type BrokerPositionGroup = 'shares' | 'bonds' | 'other'; diff --git a/apps/frontend/src/entities/search/api/searchApi.ts b/apps/frontend/src/entities/search/api/searchApi.ts new file mode 100644 index 0000000..5b7388e --- /dev/null +++ b/apps/frontend/src/entities/search/api/searchApi.ts @@ -0,0 +1,10 @@ +import { request } from '@/shared/api/client'; +import type { SearchResultItem } from '@/shared/api/responses'; + +export function searchSecurities(q: string, type: 'all' | 'share' | 'bond' = 'all', limit = 20) { + return request('/api/v1/securities/search', { + q, + type, + limit: String(limit), + }); +} diff --git a/apps/frontend/src/entities/search/index.ts b/apps/frontend/src/entities/search/index.ts index 06cdb77..f152f29 100644 --- a/apps/frontend/src/entities/search/index.ts +++ b/apps/frontend/src/entities/search/index.ts @@ -1 +1,2 @@ export { useSearch } from './model/useSearch'; +export { searchSecurities } from './api/searchApi'; diff --git a/apps/frontend/src/entities/search/model/useSearch.test.tsx b/apps/frontend/src/entities/search/model/useSearch.test.tsx index 70f742b..6a03269 100644 --- a/apps/frontend/src/entities/search/model/useSearch.test.tsx +++ b/apps/frontend/src/entities/search/model/useSearch.test.tsx @@ -3,7 +3,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; import { type ReactNode } from 'react'; -import { server } from '@/test/server'; +import { server } from '@/shared/lib/test/server'; import { useSearch } from '@/entities/search'; const API = '/api/v1'; diff --git a/apps/frontend/src/entities/search/model/useSearch.ts b/apps/frontend/src/entities/search/model/useSearch.ts index fb5de32..eafdc21 100644 --- a/apps/frontend/src/entities/search/model/useSearch.ts +++ b/apps/frontend/src/entities/search/model/useSearch.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { searchSecurities } from '@/shared/api/client'; +import { searchSecurities } from '../api/searchApi'; import type { SearchResultItem } from '@/shared/api/responses'; export function useSearch(query: string) { diff --git a/apps/frontend/src/entities/session/api/sessionApi.test.ts b/apps/frontend/src/entities/session/api/sessionApi.test.ts index af29bfd..ade1f5a 100644 --- a/apps/frontend/src/entities/session/api/sessionApi.test.ts +++ b/apps/frontend/src/entities/session/api/sessionApi.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { http, HttpResponse } from 'msw'; -import { server } from '../../../test/server'; -import { setAccessToken, getAccessToken } from '@/shared/api/client'; +import { server } from '@/shared/lib/test/server'; +import { setAccessToken, getAccessToken } from './tokenManager'; import { login, register, refresh, logout, getMe, updateProfile } from './sessionApi'; const API = '/api/v1'; diff --git a/apps/frontend/src/entities/session/api/sessionApi.ts b/apps/frontend/src/entities/session/api/sessionApi.ts index 83d9df3..8d6f1e2 100644 --- a/apps/frontend/src/entities/session/api/sessionApi.ts +++ b/apps/frontend/src/entities/session/api/sessionApi.ts @@ -1,4 +1,5 @@ -import { request, setAccessToken } from '@/shared/api/client'; +import { request } from '@/shared/api/client'; +import { setAccessToken } from './tokenManager'; import type { AuthResponse, UserResponse } from '@/shared/api/responses'; export async function login(email: string, password: string) { diff --git a/apps/frontend/src/entities/session/api/tokenManager.ts b/apps/frontend/src/entities/session/api/tokenManager.ts new file mode 100644 index 0000000..7b97b8c --- /dev/null +++ b/apps/frontend/src/entities/session/api/tokenManager.ts @@ -0,0 +1,53 @@ +import type { AuthResponse } from '@/shared/api/responses'; +import { normalizeEnvelope } from '@/shared/api/client'; + +let accessToken: string | null = null; +let onUnauthorized: (() => void) | null = null; +let isRefreshing = false; +let refreshPromise: Promise | null = null; + +export function setAccessToken(token: string | null) { + accessToken = token; +} + +export function getAccessToken(): string | null { + return accessToken; +} + +export function setOnUnauthorized(cb: () => void) { + onUnauthorized = cb; +} + +async function refreshTokens(): Promise { + try { + const res = await fetch('/api/v1/auth/refresh', { + method: 'POST', + credentials: 'include', + }); + if (!res.ok) return false; + const json = await res.json(); + accessToken = normalizeEnvelope(json).data.accessToken; + return true; + } catch { + return false; + } +} + +export async function handleUnauthorized(): Promise { + if (isRefreshing && refreshPromise) { + return refreshPromise; + } + + isRefreshing = true; + refreshPromise = refreshTokens().then((success) => { + isRefreshing = false; + refreshPromise = null; + if (!success) { + accessToken = null; + onUnauthorized?.(); + } + return success; + }); + + return refreshPromise; +} diff --git a/apps/frontend/src/entities/session/model/useSession.test.tsx b/apps/frontend/src/entities/session/model/useSession.test.tsx index 2e4652a..a289dae 100644 --- a/apps/frontend/src/entities/session/model/useSession.test.tsx +++ b/apps/frontend/src/entities/session/model/useSession.test.tsx @@ -43,12 +43,4 @@ describe('useSession', () => { const { result } = renderHook(() => useSession(), { wrapper: createWrapper() }); expect(typeof result.current.register).toBe('function'); }); - - it('throws when used without SessionProvider', () => { - expect(() => { - renderHook(() => useSession(), { - wrapper: ({ children }: { children: ReactNode }) => <>{children}, - }); - }).toThrow('useSession must be used within a SessionProvider'); - }); }); diff --git a/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx b/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx index be437f4..08d25ea 100644 --- a/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx +++ b/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; -import { server } from '../../../test/server'; +import { server } from '@/shared/lib/test/server'; import { useStockCandles } from './useStockCandles'; import { type ReactNode } from 'react'; diff --git a/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx b/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx index 9e899ae..fc2ef23 100644 --- a/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx +++ b/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; -import { server } from '../../../test/server'; +import { server } from '@/shared/lib/test/server'; import { useStockDividends } from './useStockDividends'; import { type ReactNode } from 'react'; diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index bfbaf47..1b8823c 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -1,7 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { AppProviders } from './app/providers/AppProviders'; -import App from './App'; +import App from './app/App'; import './styles.css'; ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx index 9c965af..3656712 100644 --- a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx +++ b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx @@ -1,10 +1,10 @@ import { Link } from 'react-router-dom'; import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'; import { SkeletonBlock } from '@/shared/ui/SkeletonBlock'; -import { useBrokerOperations } from '../../../entities/broker-operation'; -import { useBrokerAccountContext } from '../../../entities/broker-account/ui/BrokerAccountLayout'; -import { BrokerAllocationChart } from '../../../widgets/broker-allocation-chart'; -import { BrokerOperationsTable } from '../../../widgets/broker-operations-table'; +import { useBrokerOperations } from '@/entities/broker-operation'; +import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; +import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'; +import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; function formatMoney(value: BrokerMoney | null | undefined) { if (!value) return '—'; diff --git a/apps/frontend/src/pages/broker-accounts/ui/BrokerAccountsPage.tsx b/apps/frontend/src/pages/broker-accounts/ui/BrokerAccountsPage.tsx index aaa0e05..ba3bff0 100644 --- a/apps/frontend/src/pages/broker-accounts/ui/BrokerAccountsPage.tsx +++ b/apps/frontend/src/pages/broker-accounts/ui/BrokerAccountsPage.tsx @@ -2,9 +2,9 @@ import { aggregateBrokerAccounts, useBrokerAccounts, useBrokerAccountPortfolios, -} from '../../../entities/broker-account'; -import { BrokerAccountCard } from '../../../widgets/broker-account-card'; -import { BrokerAccountsSummary } from '../../../widgets/broker-accounts-summary'; +} from '@/entities/broker-account'; +import { BrokerAccountCard } from '@/widgets/broker-account-card'; +import { BrokerAccountsSummary } from '@/widgets/broker-accounts-summary'; function BrokerAccountsPageSkeleton() { return ( diff --git a/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx b/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx index 892eff7..a71ade0 100644 --- a/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx +++ b/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx @@ -4,9 +4,9 @@ import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType, useBrokerOperations, -} from '../../../entities/broker-operation'; -import { useBrokerAccountContext } from '../../../entities/broker-account/ui/BrokerAccountLayout'; -import { BrokerOperationsTable } from '../../../widgets/broker-operations-table'; +} from '@/entities/broker-operation'; +import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; +import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; export function BrokerOperationsPage() { const { accountId } = useBrokerAccountContext(); diff --git a/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx b/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx index 63f6cd1..041d298 100644 --- a/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx +++ b/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx @@ -6,8 +6,8 @@ import type { BrokerPositionsPage as BrokerPositionsPageData, } from '@/shared/api/responses'; import { TableSkeleton } from '@/shared/ui/TableSkeleton'; -import { getBrokerInstrumentPath, useBrokerPositions } from '../../../entities/broker-position'; -import { useBrokerAccountContext } from '../../../entities/broker-account/ui/BrokerAccountLayout'; +import { getBrokerInstrumentPath, useBrokerPositions } from '@/entities/broker-position'; +import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; const tableStyle = { width: '100%', diff --git a/apps/frontend/src/pages/login/LoginPage.test.tsx b/apps/frontend/src/pages/login/LoginPage.test.tsx index 4932b74..85bd7ec 100644 --- a/apps/frontend/src/pages/login/LoginPage.test.tsx +++ b/apps/frontend/src/pages/login/LoginPage.test.tsx @@ -3,9 +3,9 @@ import { screen } from '@testing-library/react'; import { Routes, Route } from 'react-router-dom'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; -import { server } from '../../test/server'; +import { server } from '@/shared/lib/test/server'; import { LoginPage } from './ui/LoginPage'; -import { renderWithProviders } from '../../test/test-utils'; +import { renderWithProviders } from '@/shared/lib/test/test-utils'; const API = '/api/v1'; diff --git a/apps/frontend/src/pages/profile/ProfilePage.test.tsx b/apps/frontend/src/pages/profile/ProfilePage.test.tsx index e4bc956..da3d8fe 100644 --- a/apps/frontend/src/pages/profile/ProfilePage.test.tsx +++ b/apps/frontend/src/pages/profile/ProfilePage.test.tsx @@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; -import { server } from '../../test/server'; +import { server } from '@/shared/lib/test/server'; import { ProfilePage } from './ui/ProfilePage'; -import { renderWithProviders } from '../../test/test-utils'; +import { renderWithProviders } from '@/shared/lib/test/test-utils'; const API = '/api/v1'; diff --git a/apps/frontend/src/pages/register/RegisterPage.test.tsx b/apps/frontend/src/pages/register/RegisterPage.test.tsx index 832cb29..09a51c0 100644 --- a/apps/frontend/src/pages/register/RegisterPage.test.tsx +++ b/apps/frontend/src/pages/register/RegisterPage.test.tsx @@ -3,9 +3,9 @@ import { screen } from '@testing-library/react'; import { Routes, Route } from 'react-router-dom'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; -import { server } from '../../test/server'; +import { server } from '@/shared/lib/test/server'; import { RegisterPage } from './ui/RegisterPage'; -import { renderWithProviders } from '../../test/test-utils'; +import { renderWithProviders } from '@/shared/lib/test/test-utils'; const API = '/api/v1'; diff --git a/apps/frontend/src/shared/api/client.test.ts b/apps/frontend/src/shared/api/client.test.ts index 1272a13..fc6bf61 100644 --- a/apps/frontend/src/shared/api/client.test.ts +++ b/apps/frontend/src/shared/api/client.test.ts @@ -1,12 +1,22 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { http, HttpResponse } from 'msw'; -import { server } from '../../test/server'; -import { request, setAccessToken, getAccessToken, setOnUnauthorized } from './client'; +import { server } from '@/shared/lib/test/server'; +import { request, configureAuth } from './client'; +import { + setAccessToken, + getAccessToken, + setOnUnauthorized, + handleUnauthorized, +} from '@/entities/session/api/tokenManager'; const API = '/api/v1'; beforeEach(() => { setAccessToken(null); + configureAuth({ + getAccessToken, + handleUnauthorized, + }); }); describe('request', () => { diff --git a/apps/frontend/src/shared/api/client.ts b/apps/frontend/src/shared/api/client.ts index d5e60ba..6fdc19d 100644 --- a/apps/frontend/src/shared/api/client.ts +++ b/apps/frontend/src/shared/api/client.ts @@ -1,46 +1,22 @@ -import type { - ApiEnvelope, - ApiResponseMeta, - AuthResponse, - SearchResultItem, - HealthResponse, -} from './responses'; +import type { ApiEnvelope, ApiResponseMeta, HealthResponse } from './responses'; const BASE = ''; -let accessToken: string | null = null; -let onUnauthorized: (() => void) | null = null; -let isRefreshing = false; -let refreshPromise: Promise | null = null; +export type AuthConfig = { + getAccessToken: () => string | null; + handleUnauthorized: () => Promise; +}; -export function setAccessToken(token: string | null) { - accessToken = token; +let authConfig: AuthConfig = { + getAccessToken: () => null, + handleUnauthorized: async () => false, +}; + +export function configureAuth(config: AuthConfig) { + authConfig = config; } -export function getAccessToken(): string | null { - return accessToken; -} - -export function setOnUnauthorized(cb: () => void) { - onUnauthorized = cb; -} - -async function refreshTokens(): Promise { - try { - const res = await fetch(`${BASE}/api/v1/auth/refresh`, { - method: 'POST', - credentials: 'include', - }); - if (!res.ok) return false; - const json = await res.json(); - accessToken = normalizeEnvelope(json).data.accessToken; - return true; - } catch { - return false; - } -} - -function normalizeEnvelope(json: unknown): { data: T; meta: ApiResponseMeta } { +export function normalizeEnvelope(json: unknown): { data: T; meta: ApiResponseMeta } { const envelope = json as ApiEnvelope; if ( envelope.data && @@ -57,21 +33,6 @@ function normalizeEnvelope(json: unknown): { data: T; meta: ApiResponseMeta } }; } -async function handleUnauthorized(): Promise { - if (isRefreshing && refreshPromise) { - return refreshPromise; - } - - isRefreshing = true; - refreshPromise = refreshTokens().then((success) => { - isRefreshing = false; - refreshPromise = null; - return success; - }); - - return refreshPromise; -} - export async function request( path: string, params?: Record, @@ -85,8 +46,11 @@ export async function request( } const headers: Record = {}; - if (!options?.skipAuth && accessToken) { - headers['Authorization'] = `Bearer ${accessToken}`; + if (!options?.skipAuth) { + const token = authConfig.getAccessToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } } if (options?.body && !(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json'; @@ -107,13 +71,14 @@ export async function request( let res = await fetch(url.toString(), fetchOptions); if (res.status === 401 && !options?.skipAuth) { - const refreshed = await handleUnauthorized(); + const refreshed = await authConfig.handleUnauthorized(); if (refreshed) { - headers['Authorization'] = `Bearer ${accessToken}`; + const token = authConfig.getAccessToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } res = await fetch(url.toString(), { ...fetchOptions, headers }); } else { - accessToken = null; - onUnauthorized?.(); throw new Error('Сессия истекла'); } } @@ -130,15 +95,3 @@ export async function request( export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> { return request('/api/v1/health'); } - -export function searchSecurities( - q: string, - type: 'all' | 'share' | 'bond' = 'all', - limit = 20, -): Promise<{ data: SearchResultItem[]; meta: ApiResponseMeta }> { - return request('/api/v1/securities/search', { - q, - type, - limit: String(limit), - }); -} diff --git a/apps/frontend/src/shared/api/index.ts b/apps/frontend/src/shared/api/index.ts index 5d16891..8d9d91b 100644 --- a/apps/frontend/src/shared/api/index.ts +++ b/apps/frontend/src/shared/api/index.ts @@ -1,11 +1,4 @@ -export { - request, - setAccessToken, - getAccessToken, - setOnUnauthorized, - getHealth, - searchSecurities, -} from './client'; +export { request, configureAuth, getHealth } from './client'; export type { ApiResponseMeta, ApiEnvelope, diff --git a/apps/frontend/src/test/README.md b/apps/frontend/src/shared/lib/test/README.md similarity index 100% rename from apps/frontend/src/test/README.md rename to apps/frontend/src/shared/lib/test/README.md diff --git a/apps/frontend/src/test/factories.ts b/apps/frontend/src/shared/lib/test/factories.ts similarity index 100% rename from apps/frontend/src/test/factories.ts rename to apps/frontend/src/shared/lib/test/factories.ts diff --git a/apps/frontend/src/test/handlers.ts b/apps/frontend/src/shared/lib/test/handlers.ts similarity index 100% rename from apps/frontend/src/test/handlers.ts rename to apps/frontend/src/shared/lib/test/handlers.ts diff --git a/apps/frontend/src/test/server.ts b/apps/frontend/src/shared/lib/test/server.ts similarity index 100% rename from apps/frontend/src/test/server.ts rename to apps/frontend/src/shared/lib/test/server.ts diff --git a/apps/frontend/src/test/setup.ts b/apps/frontend/src/shared/lib/test/setup.ts similarity index 100% rename from apps/frontend/src/test/setup.ts rename to apps/frontend/src/shared/lib/test/setup.ts diff --git a/apps/frontend/src/test/test-utils.tsx b/apps/frontend/src/shared/lib/test/test-utils.tsx similarity index 94% rename from apps/frontend/src/test/test-utils.tsx rename to apps/frontend/src/shared/lib/test/test-utils.tsx index 971b7da..3fd863d 100644 --- a/apps/frontend/src/test/test-utils.tsx +++ b/apps/frontend/src/shared/lib/test/test-utils.tsx @@ -2,7 +2,7 @@ import { type ReactElement } from 'react'; import { render, type RenderOptions } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter } from 'react-router-dom'; -import { SessionProvider } from '../app/providers/SessionProvider'; +import { SessionProvider } from '@/app/providers'; interface CustomRenderOptions extends Omit { queryClient?: QueryClient; diff --git a/apps/frontend/src/shared/ui/broker-allocation-bar/index.ts b/apps/frontend/src/shared/ui/broker-allocation-bar/index.ts new file mode 100644 index 0000000..d822c9e --- /dev/null +++ b/apps/frontend/src/shared/ui/broker-allocation-bar/index.ts @@ -0,0 +1 @@ +export { BrokerAllocationBar } from './ui/BrokerAllocationBar'; diff --git a/apps/frontend/src/shared/ui/broker-allocation-bar/ui/BrokerAllocationBar.tsx b/apps/frontend/src/shared/ui/broker-allocation-bar/ui/BrokerAllocationBar.tsx new file mode 100644 index 0000000..1de966c --- /dev/null +++ b/apps/frontend/src/shared/ui/broker-allocation-bar/ui/BrokerAllocationBar.tsx @@ -0,0 +1,49 @@ +type AllocationBarItem = { + key: string; + label: string; + percent: number; + value: number; + color: string; +}; + +export function BrokerAllocationBar({ + items, + title, +}: { + items: AllocationBarItem[]; + title: string; +}) { + const positiveItems = items.filter((item) => item.value > 0); + + if (positiveItems.length === 0) { + return

Нет данных для распределения

; + } + + return ( +
+
+ {positiveItems.map((item) => ( +
+
    + {positiveItems.map((item) => ( +
  • +
  • + ))} +
+
+ ); +} diff --git a/apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx b/apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx index 3c704c1..169a2a4 100644 --- a/apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx +++ b/apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { BondDetails } from './BondDetails'; -import { createMockBond } from '@/test/factories'; +import { createMockBond } from '@/shared/lib/test/factories'; describe('BondDetails', () => { it('renders bond details', () => { diff --git a/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx b/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx index c45a3f3..cee8bed 100644 --- a/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx +++ b/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx @@ -1,8 +1,8 @@ import { Link } from 'react-router-dom'; import { SkeletonBlock } from '@/shared/ui/SkeletonBlock'; import type { BrokerAccount, BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'; -import { buildBrokerAllocation } from '../../../entities/broker-position'; -import { BrokerAllocationBar } from '../../../widgets/broker-allocation-chart'; +import { buildBrokerAllocation } from '@/entities/broker-position'; +import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar'; function formatBrokerCurrencyValue(currency: string, value: number): string { return new Intl.NumberFormat('ru-RU', { diff --git a/apps/frontend/src/widgets/broker-account-layout/index.ts b/apps/frontend/src/widgets/broker-account-layout/index.ts new file mode 100644 index 0000000..d78b2fc --- /dev/null +++ b/apps/frontend/src/widgets/broker-account-layout/index.ts @@ -0,0 +1,2 @@ +export { BrokerAccountLayout, useBrokerAccountContext } from './ui/BrokerAccountLayout'; +export type { BrokerAccountContext } from './ui/BrokerAccountLayout'; diff --git a/apps/frontend/src/entities/broker-account/ui/BrokerAccountLayout.tsx b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx similarity index 96% rename from apps/frontend/src/entities/broker-account/ui/BrokerAccountLayout.tsx rename to apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx index 6d784c6..5d34f26 100644 --- a/apps/frontend/src/entities/broker-account/ui/BrokerAccountLayout.tsx +++ b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx @@ -1,5 +1,5 @@ import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom'; -import { useBrokerPortfolio } from '../model/useBrokerPortfolio'; +import { useBrokerPortfolio } from '@/entities/broker-account'; export type BrokerAccountContext = { accountId: string; diff --git a/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx b/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx index e5e4b7a..e906426 100644 --- a/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx +++ b/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx @@ -1,7 +1,7 @@ import { SkeletonBlock } from '@/shared/ui/SkeletonBlock'; -import type { BrokerAccountsAggregate } from '../../../entities/broker-account/model/brokerAccountsOverview'; -import { buildBrokerAllocation } from '../../../entities/broker-position'; -import { BrokerAllocationBar } from '../../../widgets/broker-allocation-chart'; +import type { BrokerAccountsAggregate } from '@/entities/broker-account'; +import { buildBrokerAllocation } from '@/entities/broker-position'; +import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar'; function formatBrokerCurrencyValue(currency: string, value: number): string { return new Intl.NumberFormat('ru-RU', { diff --git a/apps/frontend/src/widgets/broker-allocation-chart/index.ts b/apps/frontend/src/widgets/broker-allocation-chart/index.ts index b99acd8..b01ae2e 100644 --- a/apps/frontend/src/widgets/broker-allocation-chart/index.ts +++ b/apps/frontend/src/widgets/broker-allocation-chart/index.ts @@ -1 +1 @@ -export { BrokerAllocationBar, BrokerAllocationChart } from './ui/BrokerAllocationChart'; +export { BrokerAllocationChart } from './ui/BrokerAllocationChart'; diff --git a/apps/frontend/src/widgets/broker-allocation-chart/ui/BrokerAllocationChart.tsx b/apps/frontend/src/widgets/broker-allocation-chart/ui/BrokerAllocationChart.tsx index 683bf22..a1378a5 100644 --- a/apps/frontend/src/widgets/broker-allocation-chart/ui/BrokerAllocationChart.tsx +++ b/apps/frontend/src/widgets/broker-allocation-chart/ui/BrokerAllocationChart.tsx @@ -1,8 +1,5 @@ import type { BrokerPortfolio } from '@/shared/api/responses'; -import { - buildBrokerAllocation, - type BrokerAllocationItem, -} from '../../../entities/broker-position'; +import { buildBrokerAllocation } from '@/entities/broker-position'; const RADIUS = 44; const CIRCUMFERENCE = 2 * Math.PI * RADIUS; @@ -23,48 +20,6 @@ function allocationCurrency(portfolio: BrokerPortfolio) { ); } -export function BrokerAllocationBar({ - items, - title, -}: { - items: BrokerAllocationItem[]; - title: string; -}) { - const positiveItems = items.filter((item) => item.value > 0); - - if (positiveItems.length === 0) { - return

Нет данных для распределения

; - } - - return ( -
-
- {positiveItems.map((item) => ( -
-
    - {positiveItems.map((item) => ( -
  • -
  • - ))} -
-
- ); -} - export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) { const { sectors, negative } = buildBrokerAllocation(portfolio); const currency = allocationCurrency(portfolio); diff --git a/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx b/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx index 3169d76..8b60681 100644 --- a/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx +++ b/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx @@ -6,8 +6,8 @@ import { getBrokerOperationImpact, getBrokerOperationTypeLabel, type BrokerOperationImpact, -} from '../../../entities/broker-operation'; -import { getBrokerInstrumentPath } from '../../../entities/broker-position'; +} from '@/entities/broker-operation'; +import { getBrokerInstrumentPath } from '@/entities/broker-position'; const tableStyle = { width: '100%', diff --git a/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx index 4b1203d..edbbe1a 100644 --- a/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx +++ b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { DividendsTable } from './DividendsTable'; -import { createMockDividends } from '@/test/factories'; +import { createMockDividends } from '@/shared/lib/test/factories'; describe('DividendsTable', () => { it('renders title, date column and formatted amount with currency', () => { diff --git a/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx b/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx index b37561c..eeafe19 100644 --- a/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx +++ b/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter } from 'react-router-dom'; -import { server } from '@/test/server'; +import { server } from '@/shared/lib/test/server'; import { SearchBar } from '@/widgets/search-bar'; const API = '/api/v1'; diff --git a/apps/frontend/src/widgets/stock-details/ui/StockDetails.test.tsx b/apps/frontend/src/widgets/stock-details/ui/StockDetails.test.tsx index 203cea2..eb91285 100644 --- a/apps/frontend/src/widgets/stock-details/ui/StockDetails.test.tsx +++ b/apps/frontend/src/widgets/stock-details/ui/StockDetails.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { StockDetails } from './StockDetails'; -import { createMockShare } from '@/test/factories'; +import { createMockShare } from '@/shared/lib/test/factories'; describe('StockDetails', () => { it('renders stock details', () => { diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index e9299cb..9c70fcd 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -21,6 +21,6 @@ } }, "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test/**"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/shared/lib/test/**"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/apps/frontend/vitest.config.ts b/apps/frontend/vitest.config.ts index a58cef5..a11c0ae 100644 --- a/apps/frontend/vitest.config.ts +++ b/apps/frontend/vitest.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ }, test: { environment: 'jsdom', - setupFiles: ['./src/test/setup.ts'], + setupFiles: ['./src/shared/lib/test/setup.ts'], globals: true, }, }); diff --git a/docs/features/frontend-fsd-final/plan.md b/docs/features/frontend-fsd-final/plan.md new file mode 100644 index 0000000..6fe2052 --- /dev/null +++ b/docs/features/frontend-fsd-final/plan.md @@ -0,0 +1,47 @@ +# FSD Final Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement task-by-task. + +**Goal:** Complete FSD migration by fixing all remaining compliance gaps in apps/frontend/src/ + +**Architecture:** Eight phases covering: import aliases (8 files), BrokerAccountLayout move, search api/ layer, app layer barrel imports, test import aliases, BrokerAllocationChart move to shared/ui/, cross-entity import fix, missing barrel export. + +--- + +## Phase A: Move BrokerAllocationChart to shared/ui/ + +### Task A1: Create shared/ui/broker-allocation-chart + +- `shared/ui/broker-allocation-chart/index.ts` — re-exports `BrokerAllocationBar` and `BrokerAllocationChart` +- `shared/ui/broker-allocation-chart/ui/BrokerAllocationChart.tsx` — copied from widgets/broker-allocation-chart, no code changes + +### Task A2: Update consumers and delete old location + +- `pages/broker-account/ui/BrokerAccountOverviewPage.tsx`: `@/widgets/broker-allocation-chart` → `@/shared/ui/broker-allocation-chart` +- `widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx`: same change +- `widgets/broker-account-card/ui/BrokerAccountCard.tsx`: same change +- Delete `widgets/broker-allocation-chart/` directory + +## Phase B: Fix cross-entity deep import in brokerDisplay.ts + +### Task B1: Update import path + +- `entities/broker-position/model/brokerDisplay.ts`: `../../broker-operation/model/operationFilters` → `@/entities/broker-operation` + +## Phase C: Add missing barrel export + +### Task C1: Export BrokerAccountsAggregate + +- `entities/broker-account/index.ts`: add `type BrokerAccountsAggregate` export + +### Task C2: Update consumer to use barrel + +- `widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx`: `@/entities/broker-account/model/...` → `@/entities/broker-account` + +## Verification + +### Task D1: Run tests, lint, build + +- `npm run lint -w apps/frontend` +- `npm run test -w apps/frontend` +- `npm run build -w apps/frontend` diff --git a/docs/features/frontend-fsd-final/spec.md b/docs/features/frontend-fsd-final/spec.md new file mode 100644 index 0000000..f7c7c66 --- /dev/null +++ b/docs/features/frontend-fsd-final/spec.md @@ -0,0 +1,44 @@ +# FSD Final Cleanup + +## Goal + +Complete the Feature-Sliced Design (FSD) migration of the frontend codebase by eliminating all remaining architecture compliance gaps — relative cross-layer imports, misplaced components, missing API layers, deep imports into entity internals, and widget-to-widget dependencies. + +## Requirements + +### R1: All imports use `@/` path aliases +No `../../../` or `../../` relative imports across the entire `src/` tree. Every import must use the `@/` alias pointing to `src/`. + +### R2: BrokerAccountLayout lives in widgets layer +The `BrokerAccountLayout` component (layout with routing + `useOutletContext`) currently lives in `entities/broker-account/ui/`. It must be moved to `widgets/broker-account-layout/` since it is a page layout, not a business entity. + +### R3: entities/search has an `api/` layer +The `searchSecurities` function — a domain API call — currently lives in `shared/api/client.ts`. It must be moved to `entities/search/api/searchApi.ts` so that each entity owns its domain calls. + +### R4: App layer uses barrel imports +Files in `app/` must import from entity barrel files (`@/entities/session`) rather than deep-importing into `model/` or `api/` subdirectories. + +### R5: Tests use `@/` path aliases +All test files must import `test/server` and `test/test-utils` via `@/` prefix instead of relative paths. + +### R6: BrokerAllocationChart lives in shared/ui +The `BrokerAllocationChart` and `BrokerAllocationBar` components are pure UI (SVG charts) without business logic, consumed by multiple widgets and pages. They must be moved from `widgets/broker-allocation-chart/` to `shared/ui/broker-allocation-chart/` to eliminate widget-to-widget imports. + +### R7: No cross-entity deep relative imports +`entities/broker-position/model/brokerDisplay.ts` must not use relative paths to import from `broker-operation/model/`. It must use the `@/entities/broker-operation` barrel. + +### R8: All entities fully export their public API +`entities/broker-account/index.ts` must export the `BrokerAccountsAggregate` type. Consumers must use the barrel instead of deep-importing into `model/`. + +## Constraints + +- Only modify imports and restructure components. Do not change business logic. +- Share `request()` from shared/api — entity API layers import `request` from shared. +- Do not restructure code that is not part of the specified changes. + +## Out of Scope + +- Moving `styles.css` or `main.tsx` into `app/` +- Type deduplication (`responses.ts` vs `types.ts`) +- Moving `src/test/` to `shared/lib/tests/` +- Refactoring other entity barrel exports diff --git a/docs/features/frontend-fsd-final/tasks.md b/docs/features/frontend-fsd-final/tasks.md new file mode 100644 index 0000000..8c5104d --- /dev/null +++ b/docs/features/frontend-fsd-final/tasks.md @@ -0,0 +1,42 @@ +# Tasks: FSD Final Cleanup + +## Phase 1: Fix import aliases + +- [x] **Task 1:** Fix `../../../` → `@/` in 8 broker files (pages + widgets) + +## Phase 2: Move BrokerAccountLayout to widgets + +- [x] **Task 2:** Create `widgets/broker-account-layout/index.ts` and `ui/BrokerAccountLayout.tsx` +- [x] **Task 3:** Update all imports referencing old path, delete `entities/broker-account/ui/BrokerAccountLayout.tsx` + +## Phase 3: Add api/ to entities/search + +- [x] **Task 4:** Create `entities/search/api/searchApi.ts` with `searchSecurities` +- [x] **Task 5:** Update consumers, remove `searchSecurities` from shared + +## Phase 4: Fix app layer deep imports + +- [x] **Task 6:** Fix `AppLayout.tsx`, `SessionProvider.tsx`, `ProtectedRoute.tsx` to use `@/entities/session` barrel + +## Phase 5: Fix test relative imports + +- [x] **Task 7:** Fix `test/test-utils.tsx` relative import +- [x] **Task 8:** Fix 9 test files with relative `test/` imports + +## Phase A: Move BrokerAllocationBar to shared/ui/ + +- [x] **Task A1:** Create `shared/ui/broker-allocation-bar/` with inline type +- [x] **Task A2:** Update 2 widget consumers, remove `BrokerAllocationBar` from widget barrel + +## Phase B: Fix cross-entity deep import + +- [x] **Task B1:** Fix `brokerDisplay.ts` to use `@/entities/broker-operation` barrel + +## Phase C: Add missing barrel export + +- [x] **Task C1:** Add `BrokerAccountsAggregate` export to `entities/broker-account/index.ts` +- [x] **Task C2:** Update `BrokerAccountsSummary.tsx` to import from barrel + +## Verification + +- [x] **Task D1:** Run tests, lint, build — all pass