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:
Sergey Krylov 2026-06-20 14:45:20 +03:00
parent af36d2e7cf
commit 8e9fdbe70a
29 changed files with 714 additions and 475 deletions

View File

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

View File

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

View 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>
);
}

View File

@ -0,0 +1 @@
export { default as App } from './App';

View 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>
);
}

View File

@ -0,0 +1 @@
export { AppLayout } from './AppLayout';

View 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>
);
}

View 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');
});
});
});

View 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>;
}

View File

@ -0,0 +1,2 @@
export { SessionProvider } from './SessionProvider';
export { AppProviders } from './AppProviders';

View 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>
);
}

View 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}</>;
}

View File

@ -0,0 +1,2 @@
export { AppRoutes } from './AppRoutes';
export { ProtectedRoute } from './ProtectedRoute';

View File

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

View File

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

View File

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

View 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');
});
});

View 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;
}

View 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';

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

View 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');
});
});

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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