feat: add authentication system (JWT + RBAC) #3
@ -37,13 +37,20 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
|
|||||||
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
||||||
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
|
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
|
||||||
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
|
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
|
||||||
|
| `DATABASE_URL` | `file:./dev.db` | URL SQLite для Prisma |
|
||||||
|
| `JWT_SECRET` | `dev-jwt-secret-...` | Secret для access token |
|
||||||
|
| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret для refresh token |
|
||||||
|
| `JWT_ACCESS_EXPIRES` | `15m` | TTL access token |
|
||||||
|
| `JWT_REFRESH_EXPIRES` | `7d` | TTL refresh token |
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
- **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую.
|
- **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую.
|
||||||
- Feature-модули: `MoexClientModule` (глобальный), `CacheModule` (глобальный), `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `HealthModule`.
|
- Feature-модули: `PrismaModule` (глобальный), `MoexClientModule` (глобальный), `CacheModule` (глобальный), `AuthModule`, `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `HealthModule`.
|
||||||
- `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт).
|
- `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт).
|
||||||
- In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002).
|
- In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002).
|
||||||
|
- Аутентификация: JWT access token (15m, в памяти) + refresh token (7d, httpOnly cookie, bcrypt hash в БД). Глобальный `JwtAuthGuard` (`@Public()` для открытых эндпоинтов).
|
||||||
|
- БД: SQLite через Prisma ORM. Prisma client генерируется в `src/generated/prisma/`.
|
||||||
- Глобальный префикс NestJS: `/api/v1`. Swagger: `/api/docs`.
|
- Глобальный префикс NestJS: `/api/v1`. Swagger: `/api/docs`.
|
||||||
- Глобальный ValidationPipe (`transform: true, whitelist: true`), `HttpExceptionFilter`, `TransformInterceptor`, middleware логирования запросов.
|
- Глобальный ValidationPipe (`transform: true, whitelist: true`), `HttpExceptionFilter`, `TransformInterceptor`, middleware логирования запросов.
|
||||||
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
|
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
|
||||||
|
|||||||
10
apps/backend/.gitignore
vendored
Normal file
10
apps/backend/.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
# Keep environment variables out of version control
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Prisma generated client
|
||||||
|
node_modules/.prisma
|
||||||
|
|
||||||
|
# SQLite database
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
@ -11,17 +11,23 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@libsql/client": "^0.17.3",
|
||||||
"@nestjs/axios": "^3.0.0",
|
"@nestjs/axios": "^3.0.0",
|
||||||
"@nestjs/cache-manager": "^2.0.0",
|
"@nestjs/cache-manager": "^2.0.0",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.0.0",
|
"@nestjs/config": "^3.0.0",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/swagger": "^7.0.0",
|
"@nestjs/swagger": "^7.0.0",
|
||||||
|
"@prisma/adapter-libsql": "^7.8.0",
|
||||||
|
"@prisma/client": "^7.8.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"cache-manager": "^5.0.0",
|
"cache-manager": "^5.0.0",
|
||||||
"class-transformer": "^0.5.0",
|
"class-transformer": "^0.5.0",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"p-queue": "^7.3.0",
|
"p-queue": "^7.3.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
@ -32,11 +38,14 @@
|
|||||||
"@nestjs/schematics": "^10.0.0",
|
"@nestjs/schematics": "^10.0.0",
|
||||||
"@nestjs/testing": "^10.0.0",
|
"@nestjs/testing": "^10.0.0",
|
||||||
"@swc/core": "^1.15.41",
|
"@swc/core": "^1.15.41",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^4.17.0",
|
"@types/express": "^4.17.0",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
"@typescript-eslint/parser": "^7.0.0",
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
"eslint": "^8.0.0",
|
"eslint": "^8.0.0",
|
||||||
|
"prisma": "^7.8.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"unplugin-swc": "^1.5.9",
|
"unplugin-swc": "^1.5.9",
|
||||||
"vitest": "^1.0.0"
|
"vitest": "^1.0.0"
|
||||||
|
|||||||
14
apps/backend/prisma.config.ts
Normal file
14
apps/backend/prisma.config.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"role" TEXT NOT NULL DEFAULT 'user',
|
||||||
|
"refreshToken" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
3
apps/backend/prisma/migrations/migration_lock.toml
Normal file
3
apps/backend/prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
18
apps/backend/prisma/schema.prisma
Normal file
18
apps/backend/prisma/schema.prisma
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
name String?
|
||||||
|
role String @default("user")
|
||||||
|
refreshToken String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
@ -7,14 +7,18 @@ import { SecuritiesModule } from './modules/securities/securities.module';
|
|||||||
import { SharesModule } from './modules/shares/shares.module';
|
import { SharesModule } from './modules/shares/shares.module';
|
||||||
import { BondsModule } from './modules/bonds/bonds.module';
|
import { BondsModule } from './modules/bonds/bonds.module';
|
||||||
import { CandlesModule } from './modules/candles/candles.module';
|
import { CandlesModule } from './modules/candles/candles.module';
|
||||||
|
import { PrismaModule } from './modules/prisma/prisma.module';
|
||||||
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import configuration from './config/configuration';
|
import configuration from './config/configuration';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ load: [configuration], isGlobal: true }),
|
ConfigModule.forRoot({ load: [configuration], isGlobal: true, envFilePath: '.env' }),
|
||||||
|
PrismaModule,
|
||||||
CacheModule,
|
CacheModule,
|
||||||
MoexClientModule,
|
MoexClientModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
AuthModule,
|
||||||
SecuritiesModule,
|
SecuritiesModule,
|
||||||
SharesModule,
|
SharesModule,
|
||||||
BondsModule,
|
BondsModule,
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import { registerAs } from '@nestjs/config';
|
|||||||
|
|
||||||
export default registerAs('app', () => ({
|
export default registerAs('app', () => ({
|
||||||
port: parseInt(process.env.PORT || '3000', 10),
|
port: parseInt(process.env.PORT || '3000', 10),
|
||||||
|
database: {
|
||||||
|
url: process.env.DATABASE_URL || 'file:./dev.db',
|
||||||
|
},
|
||||||
moex: {
|
moex: {
|
||||||
baseUrl: process.env.MOEX_BASE_URL || 'https://iss.moex.com/iss',
|
baseUrl: process.env.MOEX_BASE_URL || 'https://iss.moex.com/iss',
|
||||||
rateLimit: parseInt(process.env.MOEX_RATE_LIMIT || '10', 10),
|
rateLimit: parseInt(process.env.MOEX_RATE_LIMIT || '10', 10),
|
||||||
@ -19,4 +22,10 @@ export default registerAs('app', () => ({
|
|||||||
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
|
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
|
||||||
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
|
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
|
||||||
},
|
},
|
||||||
|
auth: {
|
||||||
|
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
||||||
|
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-in-production',
|
||||||
|
jwtAccessExpires: process.env.JWT_ACCESS_EXPIRES || '15m',
|
||||||
|
jwtRefreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d',
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
|||||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||||
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
|
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
@ -15,12 +16,18 @@ async function bootstrap() {
|
|||||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.useGlobalInterceptors(new TransformInterceptor());
|
app.useGlobalInterceptors(new TransformInterceptor());
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
const reqLogMiddleware = new RequestLoggingMiddleware();
|
const reqLogMiddleware = new RequestLoggingMiddleware();
|
||||||
app.use(reqLogMiddleware.use.bind(reqLogMiddleware));
|
app.use(reqLogMiddleware.use.bind(reqLogMiddleware));
|
||||||
|
|
||||||
app.enableCors();
|
app.enableCors({ origin: true, credentials: true });
|
||||||
|
|
||||||
const config = new DocumentBuilder().setTitle('MoexVibe API').setVersion('1.0.0').build();
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('MoexVibe API')
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
|
|||||||
107
apps/backend/src/modules/auth/auth.controller.ts
Normal file
107
apps/backend/src/modules/auth/auth.controller.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { Controller, Post, Get, Patch, Body, Req, Res, HttpCode, HttpStatus } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
|
import { CurrentUser } from './decorators/current-user.decorator';
|
||||||
|
import { Public } from './decorators/public.decorator';
|
||||||
|
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||||
|
|
||||||
|
const REFRESH_COOKIE = 'refresh_token';
|
||||||
|
const COOKIE_OPTIONS = {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
path: '/api/v1/auth',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiTags('Auth')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('register')
|
||||||
|
@ApiOperation({ summary: 'Register new user' })
|
||||||
|
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const result = await this.authService.register(dto);
|
||||||
|
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
user: result.user,
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
},
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('login')
|
||||||
|
@ApiOperation({ summary: 'Login with email and password' })
|
||||||
|
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const result = await this.authService.login(dto);
|
||||||
|
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
user: result.user,
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
},
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('refresh')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Refresh access token' })
|
||||||
|
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const token = req.cookies?.[REFRESH_COOKIE];
|
||||||
|
const result = await this.authService.refresh(token);
|
||||||
|
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
user: result.user,
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
},
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Logout user' })
|
||||||
|
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
|
||||||
|
await this.authService.logout(user.sub);
|
||||||
|
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
|
||||||
|
return {
|
||||||
|
data: { message: 'Logged out successfully' },
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get current user profile' })
|
||||||
|
async getProfile(@CurrentUser() user: JwtPayload) {
|
||||||
|
const profile = await this.authService.getProfile(user.sub);
|
||||||
|
return {
|
||||||
|
data: profile,
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('me')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Update current user profile' })
|
||||||
|
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
|
||||||
|
const profile = await this.authService.updateProfile(user.sub, dto);
|
||||||
|
return {
|
||||||
|
data: profile,
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/backend/src/modules/auth/auth.module.ts
Normal file
20
apps/backend/src/modules/auth/auth.module.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from './guards/roles.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [JwtModule.register({})],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
JwtAuthGuard,
|
||||||
|
RolesGuard,
|
||||||
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
182
apps/backend/src/modules/auth/auth.service.spec.ts
Normal file
182
apps/backend/src/modules/auth/auth.service.spec.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
|
||||||
|
const testUser = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'testPass123',
|
||||||
|
name: 'Test User',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPrismaUser = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: 1,
|
||||||
|
email: testUser.email,
|
||||||
|
password: '',
|
||||||
|
name: testUser.name,
|
||||||
|
role: 'user',
|
||||||
|
refreshToken: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [
|
||||||
|
JwtModule.register({
|
||||||
|
secret: 'test-secret',
|
||||||
|
signOptions: { expiresIn: '15m' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: {
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
get: vi.fn((key: string) => {
|
||||||
|
const config: Record<string, string> = {
|
||||||
|
'app.auth.jwtSecret': 'test-secret',
|
||||||
|
'app.auth.jwtRefreshSecret': 'test-refresh-secret',
|
||||||
|
'app.auth.jwtAccessExpires': '15m',
|
||||||
|
'app.auth.jwtRefreshExpires': '7d',
|
||||||
|
};
|
||||||
|
return config[key];
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AuthService>(AuthService);
|
||||||
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('register', () => {
|
||||||
|
it('should register a new user and return tokens', async () => {
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||||
|
vi.mocked(prisma.user.create).mockResolvedValue(mockPrismaUser() as any);
|
||||||
|
|
||||||
|
const result = await service.register(testUser);
|
||||||
|
|
||||||
|
expect(result.user.id).toBe(1);
|
||||||
|
expect(result.user.email).toBe(testUser.email);
|
||||||
|
expect(result.user.name).toBe(testUser.name);
|
||||||
|
expect(result.user.role).toBe('user');
|
||||||
|
expect(result.accessToken).toBeDefined();
|
||||||
|
expect(result.refreshToken).toBeDefined();
|
||||||
|
expect(result.refreshToken).not.toBe(testUser.password);
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { email: testUser.email },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictException if email already exists', async () => {
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockPrismaUser() as any);
|
||||||
|
|
||||||
|
await expect(service.register(testUser)).rejects.toThrow(ConflictException);
|
||||||
|
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register a user without name', async () => {
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||||
|
vi.mocked(prisma.user.create).mockResolvedValue(
|
||||||
|
mockPrismaUser({ name: null, id: 2 }) as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.register({ email: testUser.email, password: testUser.password });
|
||||||
|
|
||||||
|
expect(result.user.name).toBeNull();
|
||||||
|
expect(result.accessToken).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('should login with valid credentials', async () => {
|
||||||
|
const passwordHash = await bcrypt.hash(testUser.password, 12);
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||||
|
mockPrismaUser({ password: passwordHash }) as any,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.user.update).mockResolvedValue(mockPrismaUser() as any);
|
||||||
|
|
||||||
|
const result = await service.login({ email: testUser.email, password: testUser.password });
|
||||||
|
|
||||||
|
expect(result.user.id).toBe(1);
|
||||||
|
expect(result.accessToken).toBeDefined();
|
||||||
|
expect(result.refreshToken).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException for wrong password', async () => {
|
||||||
|
const passwordHash = await bcrypt.hash(testUser.password, 12);
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||||
|
mockPrismaUser({ password: passwordHash }) as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.login({ email: testUser.email, password: 'wrongPassword' }),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException for non-existent email', async () => {
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.login({ email: 'nonexistent@example.com', password: 'pass' }),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('should clear refreshToken', async () => {
|
||||||
|
vi.mocked(prisma.user.update).mockResolvedValue(mockPrismaUser() as any);
|
||||||
|
|
||||||
|
await service.logout(1);
|
||||||
|
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 1 },
|
||||||
|
data: { refreshToken: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateProfile', () => {
|
||||||
|
it('should update user name', async () => {
|
||||||
|
vi.mocked(prisma.user.update).mockResolvedValue(
|
||||||
|
mockPrismaUser({ name: 'Updated Name' }) as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.updateProfile(1, { name: 'Updated Name' });
|
||||||
|
|
||||||
|
expect(result.name).toBe('Updated Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return user without changes if no fields provided', async () => {
|
||||||
|
vi.mocked(prisma.user.update).mockResolvedValue(mockPrismaUser() as any);
|
||||||
|
|
||||||
|
const result = await service.updateProfile(1, {});
|
||||||
|
|
||||||
|
expect(result.name).toBe(testUser.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
138
apps/backend/src/modules/auth/auth.service.ts
Normal file
138
apps/backend/src/modules/auth/auth.service.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { Injectable, ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
|
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(dto: RegisterDto) {
|
||||||
|
const existing = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('Email already registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(dto.password, SALT_ROUNDS);
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: dto.email,
|
||||||
|
password: passwordHash,
|
||||||
|
name: dto.name ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.generateTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(dto: LoginDto) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Invalid email or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(dto.password, user.password);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new UnauthorizedException('Invalid email or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.generateTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(refreshToken: string) {
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync<{ sub: number; jti: string }>(
|
||||||
|
refreshToken,
|
||||||
|
{
|
||||||
|
secret: this.configService.get('app.auth.jwtRefreshSecret'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
|
||||||
|
if (!user?.refreshToken) {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(refreshToken, user.refreshToken);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.generateTokens(user);
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(userId: number) {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { refreshToken: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfile(userId: number) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('User not found');
|
||||||
|
}
|
||||||
|
return this.sanitizeUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfile(userId: number, dto: UpdateProfileDto) {
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined && { name: dto.name }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.sanitizeUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateTokens(user: { id: number; email: string; name: string | null; role: string }) {
|
||||||
|
const accessPayload: JwtPayload = { sub: user.id, email: user.email, role: user.role };
|
||||||
|
const accessToken = await this.jwtService.signAsync(accessPayload, {
|
||||||
|
secret: this.configService.get('app.auth.jwtSecret'),
|
||||||
|
expiresIn: this.configService.get('app.auth.jwtAccessExpires'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const jti = crypto.randomUUID();
|
||||||
|
const refreshPayload = { sub: user.id, jti };
|
||||||
|
const refreshToken = await this.jwtService.signAsync(refreshPayload, {
|
||||||
|
secret: this.configService.get('app.auth.jwtRefreshSecret'),
|
||||||
|
expiresIn: this.configService.get('app.auth.jwtRefreshExpires'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshHash = await bcrypt.hash(refreshToken, SALT_ROUNDS);
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { refreshToken: refreshHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: this.sanitizeUser(user),
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeUser(user: { id: number; email: string; name: string | null; role: string }) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
return request.user;
|
||||||
|
});
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
12
apps/backend/src/modules/auth/dto/login.dto.ts
Normal file
12
apps/backend/src/modules/auth/dto/login.dto.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { IsEmail, IsString } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'securePass123' })
|
||||||
|
@IsString()
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
21
apps/backend/src/modules/auth/dto/register.dto.ts
Normal file
21
apps/backend/src/modules/auth/dto/register.dto.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { IsEmail, IsString, MinLength, MaxLength, IsOptional } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class RegisterDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'securePass123' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
@MaxLength(100)
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'John' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
11
apps/backend/src/modules/auth/dto/update-profile.dto.ts
Normal file
11
apps/backend/src/modules/auth/dto/update-profile.dto.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UpdateProfileDto {
|
||||||
|
@ApiPropertyOptional({ example: 'John Doe' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
50
apps/backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
50
apps/backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||||
|
import { JwtPayload } from '../interfaces/jwt-payload.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const token = this.extractToken(request);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
|
||||||
|
secret: this.configService.get('app.auth.jwtSecret'),
|
||||||
|
});
|
||||||
|
request.user = payload;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid or expired token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractToken(request: { headers?: Record<string, string> }): string | null {
|
||||||
|
const auth = request.headers?.authorization;
|
||||||
|
if (!auth) return null;
|
||||||
|
const [type, token] = auth.split(' ');
|
||||||
|
return type === 'Bearer' ? token : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/backend/src/modules/auth/guards/roles.guard.ts
Normal file
22
apps/backend/src/modules/auth/guards/roles.guard.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requiredRoles || requiredRoles.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
return requiredRoles.includes(request.user?.role);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
export interface JwtPayload {
|
||||||
|
sub: number;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
|
|
||||||
@ApiTags('Health')
|
@ApiTags('Health')
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
@Get()
|
@Get()
|
||||||
|
@Public()
|
||||||
@ApiOperation({ summary: 'Проверка состояния сервиса' })
|
@ApiOperation({ summary: 'Проверка состояния сервиса' })
|
||||||
check() {
|
check() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
9
apps/backend/src/modules/prisma/prisma.module.ts
Normal file
9
apps/backend/src/modules/prisma/prisma.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
21
apps/backend/src/modules/prisma/prisma.service.ts
Normal file
21
apps/backend/src/modules/prisma/prisma.service.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PrismaLibSql } from '@prisma/adapter-libsql';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
|
constructor() {
|
||||||
|
const url = process.env.DATABASE_URL || 'file:./dev.db';
|
||||||
|
const adapter = new PrismaLibSql({ url });
|
||||||
|
super({ adapter });
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import swc from 'unplugin-swc';
|
import swc from 'unplugin-swc';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
@ -19,4 +20,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
61
apps/docs/docs/adr/ADR-008-auth-system.md
Normal file
61
apps/docs/docs/adr/ADR-008-auth-system.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 14
|
||||||
|
title: ADR-008
|
||||||
|
---
|
||||||
|
|
||||||
|
# ADR-008: Authentication & Authorization System
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Приложение MoexVibe публичное, но для персонализации и будущих функций (избранное, уведомления, подписки) требуется система аутентификации. Необходимо решение, которое:
|
||||||
|
|
||||||
|
- Безопасно хранит учётные данные
|
||||||
|
- Поддерживает session management
|
||||||
|
- Позволяет расширять ролевую модель
|
||||||
|
- Интегрируется в существующий стек (NestJS + React)
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- **Database:** SQLite через Prisma ORM (без необходимости внешнего сервиса)
|
||||||
|
- **Auth strategy:** JWT access tokens (15m) + refresh tokens (7d) in httpOnly cookies
|
||||||
|
- **Password storage:** bcrypt with 12 salt rounds
|
||||||
|
- **Guard model:** Global `JwtAuthGuard` (все маршруты защищены по умолчанию, `@Public()` для открытых)
|
||||||
|
- **RBAC:** Роли `user` и `admin`, проверка через `RolesGuard`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- **State management:** React Context (`AuthContext`) для хранения пользователя и access token
|
||||||
|
- **Token storage:** Access token только в памяти (не localStorage), refresh token в httpOnly cookie
|
||||||
|
- **Auto-refresh:** При 401 → автоматический вызов `/auth/refresh` → повтор оригинального запроса
|
||||||
|
- **Route protection:** `ProtectedRoute` компонент, редирект на `/login` с return URL
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- httpOnly cookie защищает refresh token от XSS
|
||||||
|
- Access token в памяти защищён от кражи через localStorage
|
||||||
|
- Prisma + SQLite не требует внешнего сервиса (zero setup)
|
||||||
|
- Refresh token rotation повышает безопасность
|
||||||
|
- Глобальный guard — безопасность по умолчанию
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- Access token теряется при полной перезагрузке страницы (восстанавливается через refresh cookie)
|
||||||
|
- Для production требуется генерация надёжных JWT_SECRET
|
||||||
|
- Нет rate limiting на login endpoint (TODO)
|
||||||
|
- Нет верификации email (принятое упрощение)
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
Для перехода на PostgreSQL потребуется:
|
||||||
|
1. Изменить `provider` в schema.prisma на `postgresql`
|
||||||
|
2. Обновить `DATABASE_URL`
|
||||||
|
3. Выполнить `prisma migrate dev`
|
||||||
|
4. Никаких изменений кода не требуется (Prisma abstracts database)
|
||||||
@ -8,22 +8,30 @@ graph TD
|
|||||||
Backend["NestJS API<br/>:3000"]
|
Backend["NestJS API<br/>:3000"]
|
||||||
Cache["In-Memory Cache<br/>(cache-manager)"]
|
Cache["In-Memory Cache<br/>(cache-manager)"]
|
||||||
MOEX["MOEX ISS API<br/>iss.moex.com"]
|
MOEX["MOEX ISS API<br/>iss.moex.com"]
|
||||||
|
DB[("SQLite Database<br/>(Prisma)")]
|
||||||
|
|
||||||
Browser -->|"/api/v1/*"| Backend
|
Browser -->|"/api/v1/*"| Backend
|
||||||
|
Browser -->|"Cookie: refreshToken"| Backend
|
||||||
|
Browser -->|"Authorization: Bearer"| Backend
|
||||||
Backend -->|"getOrFetch()"| Cache
|
Backend -->|"getOrFetch()"| Cache
|
||||||
Backend -->|"GET /iss/*.json"| MOEX
|
Backend -->|"GET /iss/*.json"| MOEX
|
||||||
|
Backend -->|"Prisma ORM"| DB
|
||||||
Cache -->|"data"| Backend
|
Cache -->|"data"| Backend
|
||||||
|
DB -->|"users"| Backend
|
||||||
MOEX -->|"raw data"| Backend
|
MOEX -->|"raw data"| Backend
|
||||||
|
|
||||||
subgraph Backend_Internal["Backend (NestJS)"]
|
subgraph Backend_Internal["Backend (NestJS)"]
|
||||||
MoexClient["MoexClientModule<br/>p-queue + circuit breaker"]
|
MoexClient["MoexClientModule<br/>p-queue + circuit breaker"]
|
||||||
|
Auth["AuthModule<br/>JWT + bcrypt + Guards"]
|
||||||
Shares["SharesModule"]
|
Shares["SharesModule"]
|
||||||
Bonds["BondsModule"]
|
Bonds["BondsModule"]
|
||||||
Candles["CandlesModule"]
|
Candles["CandlesModule"]
|
||||||
Securities["SecuritiesModule"]
|
Securities["SecuritiesModule"]
|
||||||
Health["HealthModule"]
|
Health["HealthModule"]
|
||||||
CacheService["CacheService<br/>(global)"]
|
CacheService["CacheService<br/>(global)"]
|
||||||
|
Prisma["PrismaService<br/>(global)"]
|
||||||
|
|
||||||
|
Auth -->|"user CRUD"| Prisma
|
||||||
MoexClient -->|"fetches"| Shares
|
MoexClient -->|"fetches"| Shares
|
||||||
MoexClient -->|"fetches"| Bonds
|
MoexClient -->|"fetches"| Bonds
|
||||||
MoexClient -->|"fetches"| Candles
|
MoexClient -->|"fetches"| Candles
|
||||||
@ -35,7 +43,7 @@ graph TD
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
## Request Flow
|
## Request Flow (Authenticated)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@ -44,19 +52,22 @@ sequenceDiagram
|
|||||||
participant Backend as NestJS API
|
participant Backend as NestJS API
|
||||||
participant Cache as In-Memory Cache
|
participant Cache as In-Memory Cache
|
||||||
participant MOEX as MOEX ISS
|
participant MOEX as MOEX ISS
|
||||||
|
participant AuthDB as SQLite
|
||||||
|
|
||||||
User->>Frontend: Search / View instrument
|
User->>Frontend: View stock SBER
|
||||||
Frontend->>Backend: GET /api/v1/securities/search?q=SBER
|
Note over Frontend: Access token in memory
|
||||||
Backend->>Cache: getOrFetch('search:sber')
|
Frontend->>Backend: GET /securities/shares/SBER (Authorization: Bearer)
|
||||||
|
Backend->>Backend: JwtAuthGuard validates token
|
||||||
|
Backend->>Cache: getOrFetch('share:SBER')
|
||||||
alt Cache miss
|
alt Cache miss
|
||||||
Cache->>Backend: null
|
Cache->>Backend: null
|
||||||
Backend->>MOEX: GET /iss/securities?q=SBER
|
Backend->>MOEX: GET /iss/.../SBER.json
|
||||||
MOEX-->>Backend: raw data
|
MOEX-->>Backend: raw data
|
||||||
Backend->>Cache: set('search:sber', normalized, TTL=3600)
|
Backend->>Cache: set('share:SBER', ..., TTL=900)
|
||||||
else Cache hit
|
else Cache hit
|
||||||
Cache-->>Backend: cached data
|
Cache-->>Backend: cached data
|
||||||
end
|
end
|
||||||
Backend-->>Frontend: { data, meta: { fromCache, cachedAt } }
|
Backend-->>Frontend: { data, meta }
|
||||||
Frontend-->>User: rendered UI
|
Frontend-->>User: rendered UI
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -73,6 +84,7 @@ All architectural decisions are documented as ADR in `docs/architecture/adr/`:
|
|||||||
| [ADR-005](adr/ADR-005-openapi-codegen-frontend) | OpenAPI codegen for frontend |
|
| [ADR-005](adr/ADR-005-openapi-codegen-frontend) | OpenAPI codegen for frontend |
|
||||||
| [ADR-006](adr/ADR-006-no-cci) | No CCI (Custom Components Infrastructure) |
|
| [ADR-006](adr/ADR-006-no-cci) | No CCI (Custom Components Infrastructure) |
|
||||||
| [ADR-007](adr/ADR-007-two-level-caching) | Two-level caching strategy |
|
| [ADR-007](adr/ADR-007-two-level-caching) | Two-level caching strategy |
|
||||||
|
| [ADR-008](adr/ADR-008-auth-system) | Authentication & Authorization |
|
||||||
|
|
||||||
## Response Format
|
## Response Format
|
||||||
|
|
||||||
@ -107,4 +119,6 @@ All architectural decisions are documented as ADR in `docs/architecture/adr/`:
|
|||||||
- HttpExceptionFilter (catch-all)
|
- HttpExceptionFilter (catch-all)
|
||||||
- TransformInterceptor (авто-обёртка в `ApiResponse`)
|
- TransformInterceptor (авто-обёртка в `ApiResponse`)
|
||||||
- RequestLoggingMiddleware (логирует `METHOD /path STATUS DURATIONms`)
|
- RequestLoggingMiddleware (логирует `METHOD /path STATUS DURATIONms`)
|
||||||
- CORS: разрешён для всех origins
|
- JwtAuthGuard (глобально, `@Public()` для открытых эндпоинтов)
|
||||||
|
- RolesGuard (проверка ролей)
|
||||||
|
- CORS: разрешён для всех origins, `credentials: true`
|
||||||
|
|||||||
@ -2,6 +2,87 @@
|
|||||||
|
|
||||||
Все эндпоинты находятся под префиксом `/api/v1`. Swagger UI: `/api/docs`.
|
Все эндпоинты находятся под префиксом `/api/v1`. Swagger UI: `/api/docs`.
|
||||||
|
|
||||||
|
Защищённые эндпоинты требуют заголовок `Authorization: Bearer <accessToken>`.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
### `POST /auth/register`
|
||||||
|
|
||||||
|
Регистрация нового пользователя.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "securePass123",
|
||||||
|
"name": "John"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `{ data: { user, accessToken }, meta }` + `Set-Cookie: refresh_token`.
|
||||||
|
|
||||||
|
### `POST /auth/login`
|
||||||
|
|
||||||
|
Вход по email и паролю.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "securePass123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `{ data: { user, accessToken }, meta }` + `Set-Cookie: refresh_token`.
|
||||||
|
|
||||||
|
### `POST /auth/refresh`
|
||||||
|
|
||||||
|
Обновление access token через refresh token из cookie.
|
||||||
|
|
||||||
|
**Response:** `{ data: { user, accessToken }, meta }` + новый `Set-Cookie: refresh_token`.
|
||||||
|
|
||||||
|
### `POST /auth/logout`
|
||||||
|
|
||||||
|
Выход из системы. Удаляет refresh token из БД и чистит cookie.
|
||||||
|
|
||||||
|
**Headers:** `Authorization: Bearer <accessToken>`
|
||||||
|
|
||||||
|
**Response:** `{ data: { message }, meta }`.
|
||||||
|
|
||||||
|
### `GET /auth/me`
|
||||||
|
|
||||||
|
Информация о текущем пользователе.
|
||||||
|
|
||||||
|
**Headers:** `Authorization: Bearer <accessToken>`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"email": "user@example.com",
|
||||||
|
"name": "John",
|
||||||
|
"role": "user"
|
||||||
|
},
|
||||||
|
"meta": { "fromCache": false, "cachedAt": null }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PATCH /auth/me`
|
||||||
|
|
||||||
|
Обновление профиля текущего пользователя.
|
||||||
|
|
||||||
|
**Headers:** `Authorization: Bearer <accessToken>`
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "John Doe"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `{ data: { id, email, name, role }, meta }`.
|
||||||
|
|
||||||
## Health
|
## Health
|
||||||
|
|
||||||
### `GET /health`
|
### `GET /health`
|
||||||
|
|||||||
152
apps/docs/docs/backend/auth.md
Normal file
152
apps/docs/docs/backend/auth.md
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
# Authentication & Authorization
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Token-Based Authentication
|
||||||
|
|
||||||
|
Система использует два типа JWT-токенов:
|
||||||
|
|
||||||
|
| Token | Format | TTL | Storage | Purpose |
|
||||||
|
|-------|--------|-----|---------|---------|
|
||||||
|
| Access Token | JWT `{ sub, email, role }` | 15 min | Memory (React) + `Authorization: Bearer` | Authenticate API requests |
|
||||||
|
| Refresh Token | JWT `{ sub, jti }` | 7 days | httpOnly cookie + SHA-256 hash in DB | Issue new access tokens |
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Passwords hashed with **bcrypt** (12 salt rounds)
|
||||||
|
- Refresh tokens stored as **bcrypt hash** in database
|
||||||
|
- httpOnly, SameSite=Lax, Secure (production) cookies
|
||||||
|
- Access token never persisted to localStorage (XSS protection)
|
||||||
|
- Auto-refresh on 401 with automatic retry of failed request
|
||||||
|
- Refresh token rotation: each refresh invalidates the old token
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant Frontend as React SPA
|
||||||
|
participant Backend as NestJS API
|
||||||
|
participant DB as SQLite (Prisma)
|
||||||
|
|
||||||
|
User->>Frontend: Enter email & password
|
||||||
|
Frontend->>Backend: POST /auth/login { email, password }
|
||||||
|
Backend->>DB: Find user by email
|
||||||
|
Backend->>Backend: bcrypt.compare(password, hash)
|
||||||
|
Backend->>DB: Save refresh token hash
|
||||||
|
Backend-->>Frontend: { user, accessToken } + Set-Cookie (refreshToken)
|
||||||
|
Frontend->>Frontend: Store accessToken in memory
|
||||||
|
Frontend-->>User: Redirect to app
|
||||||
|
|
||||||
|
Note over Frontend,Backend: Later API request
|
||||||
|
Frontend->>Backend: GET /auth/me (Authorization: Bearer <accessToken>)
|
||||||
|
Backend->>Backend: Verify JWT signature & expiry
|
||||||
|
Backend-->>Frontend: { user }
|
||||||
|
|
||||||
|
Note over Frontend,Backend: Token refresh (auto on 401)
|
||||||
|
Frontend->>Backend: POST /auth/refresh (Cookie: refreshToken)
|
||||||
|
Backend->>Backend: Verify refresh JWT
|
||||||
|
Backend->>DB: Compare refresh token hash
|
||||||
|
Backend->>DB: Rotate: save new refresh token hash
|
||||||
|
Backend-->>Frontend: { user, newAccessToken } + Set-Cookie (newRefreshToken)
|
||||||
|
Frontend->>Frontend: Update accessToken in memory
|
||||||
|
Frontend->>Backend: Retry original request
|
||||||
|
|
||||||
|
Note over Frontend,Backend: Logout
|
||||||
|
Frontend->>Backend: POST /auth/logout
|
||||||
|
Backend->>DB: Clear refresh token hash
|
||||||
|
Backend-->>Frontend: Clear cookie
|
||||||
|
Frontend->>Frontend: Clear accessToken
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All endpoints are under `/api/v1/auth`.
|
||||||
|
|
||||||
|
### `POST /auth/register`
|
||||||
|
|
||||||
|
Register a new user.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "securePass123",
|
||||||
|
"name": "John"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `{ data: { user, accessToken }, meta }` + `Set-Cookie` with refresh token.
|
||||||
|
|
||||||
|
### `POST /auth/login`
|
||||||
|
|
||||||
|
Authenticate existing user.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "securePass123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `{ data: { user, accessToken }, meta }` + `Set-Cookie`.
|
||||||
|
|
||||||
|
### `POST /auth/refresh`
|
||||||
|
|
||||||
|
Refresh access token. Reads refresh token from cookie.
|
||||||
|
|
||||||
|
**Response:** `{ data: { user, accessToken }, meta }` + new `Set-Cookie`.
|
||||||
|
|
||||||
|
### `POST /auth/logout`
|
||||||
|
|
||||||
|
Invalidates refresh token. Requires `Authorization: Bearer`.
|
||||||
|
|
||||||
|
**Response:** `{ data: { message }, meta }` + cookie cleared.
|
||||||
|
|
||||||
|
### `GET /auth/me`
|
||||||
|
|
||||||
|
Returns current user profile. Requires `Authorization: Bearer`.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"email": "user@example.com",
|
||||||
|
"name": "John",
|
||||||
|
"role": "user"
|
||||||
|
},
|
||||||
|
"meta": { "fromCache": false, "cachedAt": null }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PATCH /auth/me`
|
||||||
|
|
||||||
|
Update current user profile. Requires `Authorization: Bearer`.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "John Doe"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authorization (RBAC)
|
||||||
|
|
||||||
|
| Role | Permissions |
|
||||||
|
|------|-------------|
|
||||||
|
| `user` | View/edit own profile |
|
||||||
|
| `admin` | All user permissions |
|
||||||
|
|
||||||
|
**Application level:** Global `JwtAuthGuard` protects all routes by default. Use `@Public()` decorator to bypass. `RolesGuard` checks required roles from `@Roles()` decorator.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `JWT_SECRET` | `dev-jwt-secret-...` | Secret for access token signing |
|
||||||
|
| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret for refresh token signing |
|
||||||
|
| `JWT_ACCESS_EXPIRES` | `15m` | Access token TTL |
|
||||||
|
| `JWT_REFRESH_EXPIRES` | `7d` | Refresh token TTL |
|
||||||
|
| `DATABASE_URL` | `file:./dev.db` | SQLite database URL (Prisma format) |
|
||||||
64
apps/docs/docs/backend/database.md
Normal file
64
apps/docs/docs/backend/database.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# Database Schema
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Используется **SQLite** через **Prisma ORM 7**. База данных находится в `apps/backend/dev.db`.
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
name String?
|
||||||
|
role String @default("user")
|
||||||
|
refreshToken String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|-------------|-------------|
|
||||||
|
| id | INTEGER | PK, AUTOINCREMENT | User ID |
|
||||||
|
| email | TEXT | UNIQUE, NOT NULL | Email address |
|
||||||
|
| password | TEXT | NOT NULL | bcrypt hash of password |
|
||||||
|
| name | TEXT | NULLABLE | Display name |
|
||||||
|
| role | TEXT | NOT NULL, DEFAULT 'user' | Role for RBAC |
|
||||||
|
| refreshToken | TEXT | NULLABLE | bcrypt hash of current refresh token |
|
||||||
|
| createdAt | DATETIME | NOT NULL | Timestamp |
|
||||||
|
| updatedAt | DATETIME | NOT NULL | Auto-updated timestamp |
|
||||||
|
|
||||||
|
## Prisma Client
|
||||||
|
|
||||||
|
The Prisma client is generated to `apps/backend/src/generated/prisma/` and imported via `@/generated/prisma/client`.
|
||||||
|
|
||||||
|
Key service: `PrismaService` (`src/modules/prisma/prisma.service.ts`) — extends `PrismaClient`, handles connection lifecycle (`onModuleInit`/`onModuleDestroy`). Registered as a global module.
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
Migrations are stored in `apps/backend/prisma/migrations/`. To create a new migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate dev --name <description>
|
||||||
|
```
|
||||||
|
|
||||||
|
To apply migrations in production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate deploy
|
||||||
|
```
|
||||||
@ -7,9 +7,11 @@ Backend — NestJS-приложение, единственная точка д
|
|||||||
`apps/backend/src/main.ts` — bootstrap:
|
`apps/backend/src/main.ts` — bootstrap:
|
||||||
|
|
||||||
- Глобальный префикс `/api/v1`
|
- Глобальный префикс `/api/v1`
|
||||||
- Swagger на `/api/docs` (title: "MoexVibe API", version: "1.0.0")
|
- Swagger на `/api/docs` (title: "MoexVibe API", version: "1.0.0") с Bearer auth
|
||||||
- ValidationPipe, HttpExceptionFilter, TransformInterceptor, RequestLoggingMiddleware
|
- ValidationPipe, HttpExceptionFilter, TransformInterceptor, RequestLoggingMiddleware
|
||||||
- CORS включён
|
- JwtAuthGuard (глобально, `@Public()` для открытых эндпоинтов)
|
||||||
|
- cookie-parser (для refresh token)
|
||||||
|
- CORS включён (`credentials: true`)
|
||||||
- Порт из `PORT` env (default: 3000)
|
- Порт из `PORT` env (default: 3000)
|
||||||
|
|
||||||
## Root Module
|
## Root Module
|
||||||
@ -17,7 +19,7 @@ Backend — NestJS-приложение, единственная точка д
|
|||||||
`apps/backend/src/app.module.ts` импортирует:
|
`apps/backend/src/app.module.ts` импортирует:
|
||||||
|
|
||||||
- `ConfigModule.forRoot` (глобальный, из `config/configuration.ts`)
|
- `ConfigModule.forRoot` (глобальный, из `config/configuration.ts`)
|
||||||
- Импортируются 6 feature-модулей (Cache + MoexClient — глобальные)
|
- Импортируются 8 feature-модулей (Prisma + Cache + MoexClient — глобальные)
|
||||||
|
|
||||||
## Source Layout
|
## Source Layout
|
||||||
|
|
||||||
@ -38,9 +40,11 @@ apps/backend/src/
|
|||||||
│ └── middleware/
|
│ └── middleware/
|
||||||
│ └── request-logging.middleware.ts
|
│ └── request-logging.middleware.ts
|
||||||
└── modules/
|
└── modules/
|
||||||
|
├── prisma/
|
||||||
├── moex-client/
|
├── moex-client/
|
||||||
├── cache/
|
├── cache/
|
||||||
├── health/
|
├── health/
|
||||||
|
├── auth/
|
||||||
├── securities/
|
├── securities/
|
||||||
├── shares/
|
├── shares/
|
||||||
├── bonds/
|
├── bonds/
|
||||||
|
|||||||
@ -12,6 +12,8 @@ const sidebars: SidebarsConfig = {
|
|||||||
'backend/overview',
|
'backend/overview',
|
||||||
'backend/modules',
|
'backend/modules',
|
||||||
'backend/api',
|
'backend/api',
|
||||||
|
'backend/auth',
|
||||||
|
'backend/database',
|
||||||
'backend/configuration',
|
'backend/configuration',
|
||||||
'backend/caching',
|
'backend/caching',
|
||||||
'backend/moex-client',
|
'backend/moex-client',
|
||||||
@ -56,6 +58,7 @@ const sidebars: SidebarsConfig = {
|
|||||||
'adr/ADR-005-openapi-codegen-frontend',
|
'adr/ADR-005-openapi-codegen-frontend',
|
||||||
'adr/ADR-006-no-cci',
|
'adr/ADR-006-no-cci',
|
||||||
'adr/ADR-007-two-level-caching',
|
'adr/ADR-007-two-level-caching',
|
||||||
|
'adr/ADR-008-auth-system',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
56
apps/frontend/src/api/auth.ts
Normal file
56
apps/frontend/src/api/auth.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { request, setAccessToken } from './client';
|
||||||
|
import type { AuthResponse, UserResponse } from './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;
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ApiEnvelope,
|
ApiEnvelope,
|
||||||
ApiResponseMeta,
|
ApiResponseMeta,
|
||||||
|
AuthResponse,
|
||||||
ShareResponse,
|
ShareResponse,
|
||||||
StockMarketData,
|
StockMarketData,
|
||||||
DividendItem,
|
DividendItem,
|
||||||
@ -15,9 +16,57 @@ import type {
|
|||||||
|
|
||||||
const BASE = '';
|
const BASE = '';
|
||||||
|
|
||||||
async function request<T>(
|
let accessToken: string | null = null;
|
||||||
|
let onUnauthorized: (() => void) | null = null;
|
||||||
|
let isRefreshing = false;
|
||||||
|
let refreshPromise: Promise<boolean> | 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<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/api/v1/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const json: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await res.json();
|
||||||
|
accessToken = json.data.data.accessToken;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnauthorized(): Promise<boolean> {
|
||||||
|
if (isRefreshing && refreshPromise) {
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true;
|
||||||
|
refreshPromise = refreshTokens().then((success) => {
|
||||||
|
isRefreshing = false;
|
||||||
|
refreshPromise = null;
|
||||||
|
return success;
|
||||||
|
});
|
||||||
|
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(
|
||||||
path: string,
|
path: string,
|
||||||
params?: Record<string, string>,
|
params?: Record<string, string | undefined>,
|
||||||
|
options?: { method?: string; body?: unknown; skipAuth?: boolean },
|
||||||
): Promise<{ data: T; meta: ApiResponseMeta }> {
|
): Promise<{ data: T; meta: ApiResponseMeta }> {
|
||||||
const url = new URL(`${BASE}${path}`, window.location.origin);
|
const url = new URL(`${BASE}${path}`, window.location.origin);
|
||||||
if (params) {
|
if (params) {
|
||||||
@ -25,8 +74,45 @@ async function request<T>(
|
|||||||
if (v !== undefined) url.searchParams.set(k, v);
|
if (v !== undefined) url.searchParams.set(k, v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const res = await fetch(url.toString());
|
|
||||||
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
const headers: Record<string, string> = {};
|
||||||
|
if (!options?.skipAuth && accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
if (options?.body && !(options.body instanceof FormData)) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers,
|
||||||
|
credentials: 'include' as RequestCredentials,
|
||||||
|
};
|
||||||
|
if (options?.method) {
|
||||||
|
fetchOptions.method = options.method;
|
||||||
|
}
|
||||||
|
if (options?.body !== undefined) {
|
||||||
|
fetchOptions.body = options.body instanceof FormData ? options.body : JSON.stringify(options.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = await fetch(url.toString(), fetchOptions);
|
||||||
|
|
||||||
|
if (res.status === 401 && !options?.skipAuth) {
|
||||||
|
const refreshed = await handleUnauthorized();
|
||||||
|
if (refreshed) {
|
||||||
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||||
|
res = await fetch(url.toString(), { ...fetchOptions, headers });
|
||||||
|
} else {
|
||||||
|
accessToken = null;
|
||||||
|
onUnauthorized?.();
|
||||||
|
throw new Error('Сессия истекла');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json();
|
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json();
|
||||||
return json.data;
|
return json.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -122,3 +122,15 @@ export interface HealthResponse {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
uptime: number;
|
uptime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserResponse {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
user: UserResponse;
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,16 @@
|
|||||||
import { Outlet, Link } from 'react-router-dom';
|
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
||||||
import { SearchBar } from './SearchBar';
|
import { SearchBar } from './SearchBar';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
|
const { isAuthenticated, user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout();
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
<header
|
<header
|
||||||
@ -26,6 +35,51 @@ export function Layout() {
|
|||||||
MoexVibe
|
MoexVibe
|
||||||
</Link>
|
</Link>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
{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>
|
</header>
|
||||||
<main style={{ flex: 1, padding: 24, maxWidth: 1200, width: '100%', margin: '0 auto' }}>
|
<main style={{ flex: 1, padding: 24, maxWidth: 1200, width: '100%', margin: '0 auto' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
29
apps/frontend/src/components/ProtectedRoute.tsx
Normal file
29
apps/frontend/src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
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}</>;
|
||||||
|
}
|
||||||
125
apps/frontend/src/context/AuthContext.tsx
Normal file
125
apps/frontend/src/context/AuthContext.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { createContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||||
|
import * as authApi from '../api/auth';
|
||||||
|
import { setOnUnauthorized } from '../api/client';
|
||||||
|
import type { UserResponse } from '../api/responses';
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/frontend/src/hooks/useAuth.ts
Normal file
10
apps/frontend/src/hooks/useAuth.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
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,6 +1,7 @@
|
|||||||
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
@ -17,7 +18,9 @@ const queryClient = new QueryClient({
|
|||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
96
apps/frontend/src/pages/LoginPage.tsx
Normal file
96
apps/frontend/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const redirect = searchParams.get('redirect') || '/';
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
navigate(redirect);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ошибка входа');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 400, margin: '60px auto' }}>
|
||||||
|
<h1 style={{ marginBottom: 24, fontSize: 24, fontWeight: 700 }}>Вход</h1>
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{error && (
|
||||||
|
<div style={{ color: 'var(--color-negative)', fontSize: 14 }}>{error}</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="email@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={loading} style={buttonStyle}>
|
||||||
|
{loading ? 'Вход...' : 'Войти'}
|
||||||
|
</button>
|
||||||
|
<p style={{ textAlign: 'center', fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||||
|
Нет аккаунта?{' '}
|
||||||
|
<Link to={`/register${redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} style={{ color: 'var(--color-primary)' }}>
|
||||||
|
Зарегистрироваться
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
fontSize: 16,
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonStyle: React.CSSProperties = {
|
||||||
|
padding: '12px 24px',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
97
apps/frontend/src/pages/ProfilePage.tsx
Normal file
97
apps/frontend/src/pages/ProfilePage.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const { user, updateProfile } = useAuth();
|
||||||
|
const [name, setName] = useState(user?.name || '');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
await updateProfile({ name: name || undefined });
|
||||||
|
setMessage('Профиль обновлён');
|
||||||
|
} catch {
|
||||||
|
setMessage('Не удалось обновить профиль');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 500, margin: '40px auto' }}>
|
||||||
|
<h1 style={{ marginBottom: 24, fontSize: 24, fontWeight: 700 }}>Профиль</h1>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
boxShadow: 'var(--shadow)',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>Почта</span>
|
||||||
|
<p style={{ fontSize: 16, fontWeight: 500 }}>{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>Роль</span>
|
||||||
|
<p style={{ fontSize: 16, fontWeight: 500 }}>{user.role}</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 4,
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Имя
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
fontSize: 16,
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{message && (
|
||||||
|
<div style={{ fontSize: 14, color: message === 'Профиль обновлён' ? 'var(--color-positive)' : 'var(--color-negative)' }}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/frontend/src/pages/RegisterPage.tsx
Normal file
130
apps/frontend/src/pages/RegisterPage.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
export function RegisterPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { register } = useAuth();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const redirect = searchParams.get('redirect') || '/';
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Пароли не совпадают');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await register(email, password, name || undefined);
|
||||||
|
navigate(redirect);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ошибка регистрации');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 400, margin: '60px auto' }}>
|
||||||
|
<h1 style={{ marginBottom: 24, fontSize: 24, fontWeight: 700 }}>Регистрация</h1>
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{error && (
|
||||||
|
<div style={{ color: 'var(--color-negative)', fontSize: 14 }}>{error}</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||||
|
Имя (необязательно)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Иван Иванов"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="email@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Минимум 6 символов"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||||
|
Подтверждение пароля
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Повторите пароль"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={loading} style={buttonStyle}>
|
||||||
|
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
|
||||||
|
</button>
|
||||||
|
<p style={{ textAlign: 'center', fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||||
|
Уже есть аккаунт?{' '}
|
||||||
|
<Link to={`/login${redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} style={{ color: 'var(--color-primary)' }}>
|
||||||
|
Войти
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
fontSize: 16,
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonStyle: React.CSSProperties = {
|
||||||
|
padding: '12px 24px',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
@ -3,6 +3,10 @@ import { Layout } from './components/Layout';
|
|||||||
import { HomePage } from './pages/HomePage';
|
import { HomePage } from './pages/HomePage';
|
||||||
import { StockPage } from './pages/StockPage';
|
import { StockPage } from './pages/StockPage';
|
||||||
import { BondPage } from './pages/BondPage';
|
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';
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
@ -11,6 +15,16 @@ export function AppRoutes() {
|
|||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/stocks/:secid" element={<StockPage />} />
|
<Route path="/stocks/:secid" element={<StockPage />} />
|
||||||
<Route path="/bonds/:secid" element={<BondPage />} />
|
<Route path="/bonds/:secid" element={<BondPage />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route
|
||||||
|
path="/profile"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ProfilePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
1186
docs/superpowers/plans/2026-06-13-auth-system.md
Normal file
1186
docs/superpowers/plans/2026-06-13-auth-system.md
Normal file
File diff suppressed because it is too large
Load Diff
1423
package-lock.json
generated
1423
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user