Some checks failed
95 tests across 22 files covering all frontend modules: - API layer: client, auth - Components: BondDetails, Layout, PriceChart, ProtectedRoute, SearchBar, StockDetails - Context: AuthContext - Hooks: useAuth, useBond, useBondCandles, useSearch, useStock, useStockCandles, useStockDividends - Pages: BondPage, HomePage, LoginPage, ProfilePage, RegisterPage, StockPage Infrastructure: - vitest + @testing-library/react + MSW v2 with 13 API handlers - Co-located test files alongside source files - Test utilities: setup, server, factories, test-utils - BrowserRouter future flags for MemoryRouter test compatibility - Root test:frontend script for workspace-wide execution
144 lines
4.2 KiB
TypeScript
144 lines
4.2 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|