refactor(frontend): migrate app layer and auth to FSD
- Create app/ layer: App.tsx, providers, routing, layouts - Create entities/session/ for auth domain - Extract SessionProvider + AppProviders composition - Add ProtectedRoute, AppRoutes to app/routing/ - Add AppLayout to app/layouts/ - Convert legacy files to re-export shims - Update Login/Register/Profile pages to useSession
This commit is contained in:
parent
af36d2e7cf
commit
8e9fdbe70a
@ -1,10 +1 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
export { default } from './app/App';
|
||||||
import { AppRoutes } from './routes';
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return (
|
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
|
||||||
<AppRoutes />
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,52 +1,8 @@
|
|||||||
import { request, setAccessToken } from './client';
|
export {
|
||||||
import type { AuthResponse, UserResponse } from './responses';
|
login,
|
||||||
|
register,
|
||||||
export async function login(email: string, password: string) {
|
refresh,
|
||||||
const result = await request<AuthResponse>('/api/v1/auth/login', undefined, {
|
logout,
|
||||||
method: 'POST',
|
getMe,
|
||||||
body: { email, password },
|
updateProfile,
|
||||||
skipAuth: true,
|
} from '../entities/session/api/sessionApi';
|
||||||
});
|
|
||||||
setAccessToken(result.data.accessToken);
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function register(email: string, password: string, name?: string) {
|
|
||||||
const result = await request<AuthResponse>('/api/v1/auth/register', undefined, {
|
|
||||||
method: 'POST',
|
|
||||||
body: { email, password, name },
|
|
||||||
skipAuth: true,
|
|
||||||
});
|
|
||||||
setAccessToken(result.data.accessToken);
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refresh() {
|
|
||||||
const result = await request<AuthResponse>('/api/v1/auth/refresh', undefined, {
|
|
||||||
method: 'POST',
|
|
||||||
skipAuth: true,
|
|
||||||
});
|
|
||||||
setAccessToken(result.data.accessToken);
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout() {
|
|
||||||
const result = await request<{ message: string }>('/api/v1/auth/logout', undefined, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
setAccessToken(null);
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMe() {
|
|
||||||
const result = await request<UserResponse>('/api/v1/auth/me');
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateProfile(data: { name?: string }) {
|
|
||||||
const result = await request<UserResponse>('/api/v1/auth/me', undefined, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|||||||
10
apps/frontend/src/app/App.tsx
Normal file
10
apps/frontend/src/app/App.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { AppRoutes } from './routing/AppRoutes';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
|
<AppRoutes />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/frontend/src/app/index.ts
Normal file
1
apps/frontend/src/app/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as App } from './App';
|
||||||
142
apps/frontend/src/app/layouts/AppLayout.tsx
Normal file
142
apps/frontend/src/app/layouts/AppLayout.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { SearchBar } from '@/components/SearchBar';
|
||||||
|
import { useSession } from '@/entities/session/model/useSession';
|
||||||
|
|
||||||
|
export function AppLayout() {
|
||||||
|
const { isAuthenticated, user, logout } = useSession();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout();
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
borderBottom: '1px solid #e0e0e0',
|
||||||
|
padding: '12px 24px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
MoexVibe
|
||||||
|
</Link>
|
||||||
|
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/portfolios"
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Портфели
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/broker"
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Брокер
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/screener"
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Скринер
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/profile"
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.name || user?.email}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
style={{
|
||||||
|
padding: '6px 16px',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
style={{
|
||||||
|
padding: '6px 16px',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: '#fff',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: 'clamp(16px, 4vw, 24px)',
|
||||||
|
maxWidth: 1200,
|
||||||
|
width: '100%',
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/frontend/src/app/layouts/index.ts
Normal file
1
apps/frontend/src/app/layouts/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AppLayout } from './AppLayout';
|
||||||
21
apps/frontend/src/app/providers/AppProviders.tsx
Normal file
21
apps/frontend/src/app/providers/AppProviders.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { SessionProvider } from './SessionProvider';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 2,
|
||||||
|
staleTime: 900_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AppProviders({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SessionProvider>{children}</SessionProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/frontend/src/app/providers/SessionProvider.test.tsx
Normal file
74
apps/frontend/src/app/providers/SessionProvider.test.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
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 { SessionProvider } from './SessionProvider';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
function renderWithProviders(ui: React.ReactElement) {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SessionProvider>{ui}</SessionProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestConsumer() {
|
||||||
|
const ctx = useContext(SessionContext);
|
||||||
|
if (!ctx) return <div>no context</div>;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span data-testid="session">{ctx.isAuthenticated ? 'authenticated' : 'anonymous'}</span>
|
||||||
|
<span data-testid="email">{ctx.user?.email ?? ''}</span>
|
||||||
|
<button onClick={() => ctx.login('a@b.com', 'p')}>login</button>
|
||||||
|
<button onClick={() => ctx.register('a@b.com', 'p')}>register</button>
|
||||||
|
<button onClick={() => ctx.logout()}>logout</button>
|
||||||
|
<button onClick={() => ctx.updateProfile({ name: 'New' })}>updateProfile</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SessionProvider', () => {
|
||||||
|
it('starts unauthenticated when refresh fails', async () => {
|
||||||
|
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
|
||||||
|
renderWithProviders(<TestConsumer />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('session')).toHaveTextContent('anonymous');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores session on mount when refresh succeeds', async () => {
|
||||||
|
renderWithProviders(<TestConsumer />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('session')).toHaveTextContent('authenticated');
|
||||||
|
expect(screen.getByTestId('email')).toHaveTextContent('user@test.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates state after login', async () => {
|
||||||
|
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<TestConsumer />);
|
||||||
|
await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('anonymous'));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'login' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('session')).toHaveTextContent('authenticated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates state after logout', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<TestConsumer />);
|
||||||
|
await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('authenticated'));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'logout' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('session')).toHaveTextContent('anonymous');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
115
apps/frontend/src/app/providers/SessionProvider.tsx
Normal file
115
apps/frontend/src/app/providers/SessionProvider.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
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 type { UserResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<UserResponse | null>(null);
|
||||||
|
const [accessToken, setAccessTokenState] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => {
|
||||||
|
setUser(authData.user);
|
||||||
|
setAccessTokenState(authData.accessToken);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSession = useCallback(() => {
|
||||||
|
setUser(null);
|
||||||
|
setAccessTokenState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
async (email: string, password: string) => {
|
||||||
|
const result = await sessionApi.login(email, password);
|
||||||
|
updateSession(result);
|
||||||
|
},
|
||||||
|
[updateSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const register = useCallback(
|
||||||
|
async (email: string, password: string, name?: string) => {
|
||||||
|
const result = await sessionApi.register(email, password, name);
|
||||||
|
updateSession(result);
|
||||||
|
},
|
||||||
|
[updateSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await sessionApi.logout();
|
||||||
|
} catch {
|
||||||
|
// ignore network errors on logout
|
||||||
|
}
|
||||||
|
clearSession();
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
|
const updateProfileFn = useCallback(async (data: { name?: string }) => {
|
||||||
|
const result = await sessionApi.updateProfile(data);
|
||||||
|
setUser(result);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Try to restore session on mount
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const result = await sessionApi.refresh();
|
||||||
|
if (mounted) {
|
||||||
|
updateSession(result);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No valid session
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [updateSession]);
|
||||||
|
|
||||||
|
// Set up auto-logout on unauthorized
|
||||||
|
useEffect(() => {
|
||||||
|
setOnUnauthorized(() => {
|
||||||
|
clearSession();
|
||||||
|
});
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
|
if (!initialized && isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value: SessionContextValue = {
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
updateProfile: updateProfileFn,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||||
|
}
|
||||||
2
apps/frontend/src/app/providers/index.ts
Normal file
2
apps/frontend/src/app/providers/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { SessionProvider } from './SessionProvider';
|
||||||
|
export { AppProviders } from './AppProviders';
|
||||||
77
apps/frontend/src/app/routing/AppRoutes.tsx
Normal file
77
apps/frontend/src/app/routing/AppRoutes.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
import { AppLayout } from '../layouts/AppLayout';
|
||||||
|
import { ProtectedRoute } from './ProtectedRoute';
|
||||||
|
import { HomePage } from '@/pages/HomePage';
|
||||||
|
import { StockPage } from '@/pages/StockPage';
|
||||||
|
import { BondPage } from '@/pages/BondPage';
|
||||||
|
import { LoginPage } from '@/pages/LoginPage';
|
||||||
|
import { RegisterPage } from '@/pages/RegisterPage';
|
||||||
|
import { ProfilePage } from '@/pages/ProfilePage';
|
||||||
|
import { PortfoliosListPage } from '@/pages/portfolios/PortfoliosListPage';
|
||||||
|
import { PortfolioDetailPage } from '@/pages/portfolios/PortfolioDetailPage';
|
||||||
|
import { ScreenerPage } from '@/pages/screener/ScreenerPage';
|
||||||
|
import { BrokerAccountsPage } from '@/pages/broker-accounts';
|
||||||
|
import { BrokerAccountLayout } from '@/entities/broker-account/ui/BrokerAccountLayout';
|
||||||
|
import { BrokerAccountOverviewPage } from '@/pages/broker-account';
|
||||||
|
import { BrokerPositionsPage } from '@/pages/broker-positions';
|
||||||
|
import { BrokerOperationsPage } from '@/pages/broker-operations';
|
||||||
|
|
||||||
|
export function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppLayout />}>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/stocks/:secid" element={<StockPage />} />
|
||||||
|
<Route path="/bonds/:secid" element={<BondPage />} />
|
||||||
|
<Route path="/screener" element={<ScreenerPage />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route
|
||||||
|
path="/profile"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ProfilePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/portfolios"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PortfoliosListPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/portfolios/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PortfolioDetailPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/broker"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BrokerAccountsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/broker/:accountId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BrokerAccountLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<BrokerAccountOverviewPage />} />
|
||||||
|
<Route path="shares" element={<BrokerPositionsPage type="share" title="Акции" />} />
|
||||||
|
<Route path="bonds" element={<BrokerPositionsPage type="bond" title="Облигации" />} />
|
||||||
|
<Route path="operations" element={<BrokerOperationsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/frontend/src/app/routing/ProtectedRoute.tsx
Normal file
29
apps/frontend/src/app/routing/ProtectedRoute.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useSession } from '@/entities/session/model/useSession';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||||
|
const { isAuthenticated, isLoading } = useSession();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 40,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
2
apps/frontend/src/app/routing/index.ts
Normal file
2
apps/frontend/src/app/routing/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { AppRoutes } from './AppRoutes';
|
||||||
|
export { ProtectedRoute } from './ProtectedRoute';
|
||||||
@ -1,142 +1 @@
|
|||||||
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
export { AppLayout as Layout } from '../app/layouts/AppLayout';
|
||||||
import { SearchBar } from './SearchBar';
|
|
||||||
import { useAuth } from '../hooks/useAuth';
|
|
||||||
|
|
||||||
export function Layout() {
|
|
||||||
const { isAuthenticated, user, logout } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
await logout();
|
|
||||||
navigate('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<header
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
borderBottom: '1px solid #e0e0e0',
|
|
||||||
padding: '12px 24px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
style={{
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
MoexVibe
|
|
||||||
</Link>
|
|
||||||
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
|
|
||||||
<SearchBar />
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to="/portfolios"
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Портфели
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/broker"
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Брокер
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/screener"
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Скринер
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginLeft: 'auto',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
to="/profile"
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user?.name || user?.email}
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
style={{
|
|
||||||
padding: '6px 16px',
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
border: '1px solid #e0e0e0',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
fontSize: 14,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Выйти
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
to="/login"
|
|
||||||
style={{
|
|
||||||
padding: '6px 16px',
|
|
||||||
background: 'var(--color-primary)',
|
|
||||||
color: '#fff',
|
|
||||||
textDecoration: 'none',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Войти
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: 'clamp(16px, 4vw, 24px)',
|
|
||||||
maxWidth: 1200,
|
|
||||||
width: '100%',
|
|
||||||
margin: '0 auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,29 +1 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
export { ProtectedRoute } from '../app/routing/ProtectedRoute';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 40,
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Загрузка...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,131 +1,5 @@
|
|||||||
import { createContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
export {
|
||||||
import * as authApi from '../api/auth';
|
SessionContext as AuthContext,
|
||||||
import { setOnUnauthorized } from '@/shared/api/client';
|
type SessionContextValue as AuthContextValue,
|
||||||
import type { UserResponse } from '@/shared/api/responses';
|
} from '@/entities/session/model/sessionContext';
|
||||||
|
export { SessionProvider as AuthProvider } from '@/app/providers/SessionProvider';
|
||||||
export interface AuthContextValue {
|
|
||||||
user: UserResponse | null;
|
|
||||||
accessToken: string | null;
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
login: (email: string, password: string) => Promise<void>;
|
|
||||||
register: (email: string, password: string, name?: string) => Promise<void>;
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
updateProfile: (data: { name?: string }) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [user, setUser] = useState<UserResponse | null>(null);
|
|
||||||
const [accessToken, setAccessTokenState] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [initialized, setInitialized] = useState(false);
|
|
||||||
|
|
||||||
const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => {
|
|
||||||
setUser(authData.user);
|
|
||||||
setAccessTokenState(authData.accessToken);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearSession = useCallback(() => {
|
|
||||||
setUser(null);
|
|
||||||
setAccessTokenState(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const login = useCallback(
|
|
||||||
async (email: string, password: string) => {
|
|
||||||
const result = await authApi.login(email, password);
|
|
||||||
updateSession(result);
|
|
||||||
},
|
|
||||||
[updateSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
const register = useCallback(
|
|
||||||
async (email: string, password: string, name?: string) => {
|
|
||||||
const result = await authApi.register(email, password, name);
|
|
||||||
updateSession(result);
|
|
||||||
},
|
|
||||||
[updateSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await authApi.logout();
|
|
||||||
} catch {
|
|
||||||
// ignore network errors on logout
|
|
||||||
}
|
|
||||||
clearSession();
|
|
||||||
}, [clearSession]);
|
|
||||||
|
|
||||||
const updateProfileFn = useCallback(async (data: { name?: string }) => {
|
|
||||||
const result = await authApi.updateProfile(data);
|
|
||||||
setUser(result);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Try to restore session on mount
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
try {
|
|
||||||
const result = await authApi.refresh();
|
|
||||||
if (mounted) {
|
|
||||||
updateSession(result);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// No valid session
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
setInitialized(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [updateSession]);
|
|
||||||
|
|
||||||
// Set up auto-logout on unauthorized
|
|
||||||
useEffect(() => {
|
|
||||||
setOnUnauthorized(() => {
|
|
||||||
clearSession();
|
|
||||||
});
|
|
||||||
}, [clearSession]);
|
|
||||||
|
|
||||||
if (!initialized && isLoading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
minHeight: '100vh',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Загрузка...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider
|
|
||||||
value={{
|
|
||||||
user,
|
|
||||||
accessToken,
|
|
||||||
isAuthenticated: !!user,
|
|
||||||
isLoading,
|
|
||||||
login,
|
|
||||||
register,
|
|
||||||
logout,
|
|
||||||
updateProfile: updateProfileFn,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
68
apps/frontend/src/entities/session/api/sessionApi.test.ts
Normal file
68
apps/frontend/src/entities/session/api/sessionApi.test.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
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 { login, register, refresh, logout, getMe, updateProfile } from './sessionApi';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setAccessToken(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('returns auth data and sets access token', async () => {
|
||||||
|
const result = await login('user@test.com', 'password');
|
||||||
|
expect(result.user.email).toBe('user@test.com');
|
||||||
|
expect(result.accessToken).toBe('mock-access-token');
|
||||||
|
expect(getAccessToken()).toBe('mock-access-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on invalid credentials', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(
|
||||||
|
`${API}/auth/login`,
|
||||||
|
() => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(login('wrong@test.com', 'wrong')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('register', () => {
|
||||||
|
it('returns auth data and sets access token', async () => {
|
||||||
|
const result = await register('new@test.com', 'password', 'New User');
|
||||||
|
expect(result.user.email).toBe('user@test.com');
|
||||||
|
expect(getAccessToken()).toBe('mock-access-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refresh', () => {
|
||||||
|
it('returns auth data and sets access token', async () => {
|
||||||
|
const result = await refresh();
|
||||||
|
expect(result.accessToken).toBe('mock-access-token');
|
||||||
|
expect(getAccessToken()).toBe('mock-access-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('clears access token', async () => {
|
||||||
|
setAccessToken('test-token');
|
||||||
|
await logout();
|
||||||
|
expect(getAccessToken()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMe', () => {
|
||||||
|
it('returns current user', async () => {
|
||||||
|
const result = await getMe();
|
||||||
|
expect(result.email).toBe('user@test.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateProfile', () => {
|
||||||
|
it('updates and returns user', async () => {
|
||||||
|
const result = await updateProfile({ name: 'Updated' });
|
||||||
|
expect(result.name).toBe('Updated');
|
||||||
|
});
|
||||||
|
});
|
||||||
52
apps/frontend/src/entities/session/api/sessionApi.ts
Normal file
52
apps/frontend/src/entities/session/api/sessionApi.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { request, setAccessToken } from '@/shared/api/client';
|
||||||
|
import type { AuthResponse, UserResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export async function login(email: string, password: string) {
|
||||||
|
const result = await request<AuthResponse>('/api/v1/auth/login', undefined, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password },
|
||||||
|
skipAuth: true,
|
||||||
|
});
|
||||||
|
setAccessToken(result.data.accessToken);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(email: string, password: string, name?: string) {
|
||||||
|
const result = await request<AuthResponse>('/api/v1/auth/register', undefined, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password, name },
|
||||||
|
skipAuth: true,
|
||||||
|
});
|
||||||
|
setAccessToken(result.data.accessToken);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
const result = await request<AuthResponse>('/api/v1/auth/refresh', undefined, {
|
||||||
|
method: 'POST',
|
||||||
|
skipAuth: true,
|
||||||
|
});
|
||||||
|
setAccessToken(result.data.accessToken);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
const result = await request<{ message: string }>('/api/v1/auth/logout', undefined, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
setAccessToken(null);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe() {
|
||||||
|
const result = await request<UserResponse>('/api/v1/auth/me');
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(data: { name?: string }) {
|
||||||
|
const result = await request<UserResponse>('/api/v1/auth/me', undefined, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
3
apps/frontend/src/entities/session/index.ts
Normal file
3
apps/frontend/src/entities/session/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { login, register, refresh, logout, getMe, updateProfile } from './api/sessionApi';
|
||||||
|
export { SessionContext, type SessionContextValue } from './model/sessionContext';
|
||||||
|
export { useSession } from './model/useSession';
|
||||||
15
apps/frontend/src/entities/session/model/sessionContext.ts
Normal file
15
apps/frontend/src/entities/session/model/sessionContext.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
import type { UserResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export interface SessionContextValue {
|
||||||
|
user: UserResponse | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (email: string, password: string, name?: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
updateProfile: (data: { name?: string }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SessionContext = createContext<SessionContextValue | null>(null);
|
||||||
63
apps/frontend/src/entities/session/model/useSession.test.tsx
Normal file
63
apps/frontend/src/entities/session/model/useSession.test.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { AuthProvider } from '../../../context/AuthContext';
|
||||||
|
import { useSession } from './useSession';
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useSession', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns session context with user after mount', async () => {
|
||||||
|
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isAuthenticated).toBe(true);
|
||||||
|
});
|
||||||
|
expect(result.current.user?.email).toBe('user@test.com');
|
||||||
|
expect(result.current.accessToken).toBe('mock-access-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides login function', async () => {
|
||||||
|
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
|
||||||
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
|
||||||
|
expect(typeof result.current.login).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides logout function', async () => {
|
||||||
|
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
|
||||||
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
|
||||||
|
expect(typeof result.current.logout).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides register function', async () => {
|
||||||
|
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
|
||||||
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
|
||||||
|
expect(typeof result.current.register).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when used without AuthProvider', () => {
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useSession(), {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}).toThrow('useSession must be used within a SessionProvider');
|
||||||
|
});
|
||||||
|
});
|
||||||
10
apps/frontend/src/entities/session/model/useSession.ts
Normal file
10
apps/frontend/src/entities/session/model/useSession.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { SessionContext, type SessionContextValue } from './sessionContext';
|
||||||
|
|
||||||
|
export function useSession(): SessionContextValue {
|
||||||
|
const ctx = useContext(SessionContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useSession must be used within a SessionProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@ -58,6 +58,6 @@ describe('useAuth', () => {
|
|||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}).toThrow('useAuth must be used within an AuthProvider');
|
}).toThrow('useSession must be used within a SessionProvider');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,10 +1 @@
|
|||||||
import { useContext } from 'react';
|
export { useSession as useAuth } from '../entities/session/model/useSession';
|
||||||
import { AuthContext, type AuthContextValue } from '../context/AuthContext';
|
|
||||||
|
|
||||||
export function useAuth(): AuthContextValue {
|
|
||||||
const ctx = useContext(AuthContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,26 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { AppProviders } from './app/providers/AppProviders';
|
||||||
import { AuthProvider } from './context/AuthContext';
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: 2,
|
|
||||||
staleTime: 900_000,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<AppProviders>
|
||||||
<AuthProvider>
|
|
||||||
<App />
|
<App />
|
||||||
</AuthProvider>
|
</AppProviders>
|
||||||
</QueryClientProvider>
|
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useSession } from '@/entities/session';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { login } = useAuth();
|
const { login } = useSession();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useSession } from '@/entities/session';
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
const { user, updateProfile } = useAuth();
|
const { user, updateProfile } = useSession();
|
||||||
const [name, setName] = useState(user?.name || '');
|
const [name, setName] = useState(user?.name || '');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useSession } from '@/entities/session';
|
||||||
|
|
||||||
export function RegisterPage() {
|
export function RegisterPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { register } = useAuth();
|
const { register } = useSession();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
|||||||
@ -1,77 +1 @@
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
export { AppRoutes } from './app/routing/AppRoutes';
|
||||||
import { Layout } from './components/Layout';
|
|
||||||
import { HomePage } from './pages/HomePage';
|
|
||||||
import { StockPage } from './pages/StockPage';
|
|
||||||
import { BondPage } from './pages/BondPage';
|
|
||||||
import { LoginPage } from './pages/LoginPage';
|
|
||||||
import { RegisterPage } from './pages/RegisterPage';
|
|
||||||
import { ProfilePage } from './pages/ProfilePage';
|
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
|
||||||
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
|
|
||||||
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
|
|
||||||
import { ScreenerPage } from './pages/screener/ScreenerPage';
|
|
||||||
import { BrokerAccountsPage } from './pages/broker-accounts';
|
|
||||||
import { BrokerAccountLayout } from './entities/broker-account/ui/BrokerAccountLayout';
|
|
||||||
import { BrokerAccountOverviewPage } from './pages/broker-account';
|
|
||||||
import { BrokerPositionsPage } from './pages/broker-positions';
|
|
||||||
import { BrokerOperationsPage } from './pages/broker-operations';
|
|
||||||
|
|
||||||
export function AppRoutes() {
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route element={<Layout />}>
|
|
||||||
<Route path="/" element={<HomePage />} />
|
|
||||||
<Route path="/stocks/:secid" element={<StockPage />} />
|
|
||||||
<Route path="/bonds/:secid" element={<BondPage />} />
|
|
||||||
<Route path="/screener" element={<ScreenerPage />} />
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
|
||||||
<Route
|
|
||||||
path="/profile"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<ProfilePage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/portfolios"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<PortfoliosListPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/portfolios/:id"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<PortfolioDetailPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/broker"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<BrokerAccountsPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/broker/:accountId"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<BrokerAccountLayout />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route index element={<BrokerAccountOverviewPage />} />
|
|
||||||
<Route path="shares" element={<BrokerPositionsPage type="share" title="Акции" />} />
|
|
||||||
<Route path="bonds" element={<BrokerPositionsPage type="bond" title="Облигации" />} />
|
|
||||||
<Route path="operations" element={<BrokerOperationsPage />} />
|
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user