Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions backend/src/auth/providers/verify-totp.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';

export class ExchangeRatesProviderError extends Error {
constructor(message: string) {
super(message);
this.name = 'ExchangeRatesProviderError';
}
}

@Injectable()
export class ExchangeRatesProviderClient {
private readonly baseUrl: string;
private readonly apiKey?: string;
private readonly timeoutMs: number;

constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService,
) {
this.baseUrl = this.getBaseUrl();
this.apiKey = this.configService.get<string>(
'EXCHANGE_RATES_PROVIDER_API_KEY',
);
this.timeoutMs = this.getTimeoutMs();
}

async fetchRate(
from: string,
to: string,
): Promise<{
rate: number;
fetchedAt: string;
source: string;
}> {
if (this.isFakeMode()) {
return {
rate: this.getFakeRate(from, to),
fetchedAt: new Date().toISOString(),
source: 'fake-provider',
};
}

const url = this.buildLatestUrl(from, to);
try {
const response = await firstValueFrom(
this.httpService.get(url, { timeout: this.timeoutMs }),
);

const data = response.data ?? null;
if (!data || data?.success === false) {
const message = data?.error?.info || 'Provider returned an error';
throw new ExchangeRatesProviderError(message);
}

const rate = this.extractRate(data, to);
if (!Number.isFinite(rate) || rate <= 0) {
throw new ExchangeRatesProviderError('Provider returned invalid rate');
}

return {
rate,
fetchedAt: new Date().toISOString(),
source: this.baseUrl,
};
} catch (error: any) {
if (error instanceof ExchangeRatesProviderError) {
throw error;
}
if (error?.code === 'ECONNABORTED') {
throw new ExchangeRatesProviderError('Provider request timed out');
}
const status = error?.response?.status;
if (status) {
throw new ExchangeRatesProviderError(
`Provider responded with status ${status}`,
);
}
throw new ExchangeRatesProviderError('Failed to fetch exchange rate');
}
}

private extractRate(data: any, to: string): number {
const symbol = to.toUpperCase();

if (data?.rates && typeof data.rates[symbol] !== 'undefined') {
return Number(data.rates[symbol]);
}

if (typeof data?.result !== 'undefined') {
return Number(data.result);
}

if (typeof data?.info?.rate !== 'undefined') {
return Number(data.info.rate);
}

return Number.NaN;
}

private buildLatestUrl(from: string, to: string): string {
const url = new URL(this.baseUrl);
const basePath = url.pathname.endsWith('/')
? url.pathname
: `${url.pathname}/`;
url.pathname = `${basePath}latest`;
url.searchParams.set('base', from);
url.searchParams.set('symbols', to);

if (this.apiKey) {
url.searchParams.set('access_key', this.apiKey);
}

return url.toString();
}

private getBaseUrl(): string {
const raw = this.configService.get<string>(
'EXCHANGE_RATES_PROVIDER_BASE_URL',
);
const base = raw && raw.trim().length > 0 ? raw.trim() : null;
return base || 'https://api.exchangerate.host';
}

private getTimeoutMs(): number {
const raw = this.configService.get<string>(
'EXCHANGE_RATES_PROVIDER_TIMEOUT_MS',
);
const parsed = raw ? Number(raw) : 5000;
if (!Number.isFinite(parsed) || parsed <= 0) return 5000;
return Math.floor(parsed);
}

private isFakeMode(): boolean {
return (
this.configService.get<string>('EXCHANGE_RATES_PROVIDER_FAKE_MODE') ===
'true'
);
}

private getFakeRate(from: string, to: string): number {
if (from === to) {
return 1;
}

const rates: Record<string, number> = {
USD_USD: 1,
USD_NGN: 1500,
EUR_USD: 1.08,
EUR_NGN: 1620,
NGN_USD: 1 / 1500,
NGN_EUR: 1 / 1620,
XLM_USD: 0.12,
XLM_NGN: 180,
};

return rates[`${from}_${to}`] ?? 1.25;
}
}
111 changes: 111 additions & 0 deletions backend/src/users/members.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {
Controller,
Post,
Body,
UseGuards,
Get,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { CreateUserDto } from './dto/create-user.dto';
import { LoginUserDto } from './dto/login-user.dto';
import { JwtAuthGuard } from './guard/jwt.auth.guard';
import { RolesGuard } from './guard/roles.guard';
import { Roles } from './decorators/roles.decorators';
import { UserRole } from '../users/enums/userRoles.enum';
import { User } from '../users/entities/user.entity';
import { CurrentUser } from './decorators/current.user.decorators';
import { Public } from './decorators/public.decorator';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { ResendOtpDto } from './dto/resend-otp.dto';
import { SendPasswordResetOtpDto } from './dto/send-password-reset-otp.dto';

@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Public()
@Post('register')
@HttpCode(HttpStatus.CREATED)
create(@Body() createUserDto: CreateUserDto) {
return this.authService.createUser(createUserDto);
}

@Public()
@Post('verify-otp')
@HttpCode(HttpStatus.OK)
verifyOtp(@Body() verifyOtpDto: VerifyOtpDto) {
return this.authService.verifyOtp(verifyOtpDto);
}
@Public()
@Post('resend-verification-otp')
@HttpCode(HttpStatus.OK)
resendVerificationOtp(@Body() resendOtpDto: ResendOtpDto) {
return this.authService.resendVerificationOtp(resendOtpDto.email);
}

@Post('register-admin')
@HttpCode(HttpStatus.CREATED)
@Roles(UserRole.ADMIN)
@UseGuards(JwtAuthGuard, RolesGuard)
createAdmin(@Body() createUserDto: CreateUserDto) {
return this.authService.createAdminUser(createUserDto);
}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
login(@Body() loginUserDto: LoginUserDto) {
return this.authService.login(loginUserDto);
}
@Public()
@Post('refresh-token')
@HttpCode(HttpStatus.OK)
refreshToken(@Body('refreshToken') refreshToken: string) {
return this.authService.refreshToken(refreshToken);
}

@Get('current-user')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
retrieveCurrentUser(@CurrentUser() user: User) {
return user;
}

@Public()
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
forgotPassword(@Body() sendPasswordResetOtpDto: SendPasswordResetOtpDto) {
return this.authService.requestResetPasswordOtp(sendPasswordResetOtpDto);
}

@Public()
@Post('send-reset-password-otp')
@HttpCode(HttpStatus.OK)
requestResetPasswordOtp(
@Body() sendPasswordResetOtpDto: SendPasswordResetOtpDto,
) {
return this.authService.requestResetPasswordOtp(sendPasswordResetOtpDto);
}
@Public()
@Post('resend-reset-password-otp')
@HttpCode(HttpStatus.OK)
resendResetPasswordVerificationOtp(@Body() resendOtpDto: ResendOtpDto) {
return this.authService.resendResetPasswordVerificationOtp(resendOtpDto);
}

@Public()
@Post('verify-reset-password-otp')
@HttpCode(HttpStatus.OK)
verifyResetPasswordOtp(@Body() verifyOtpDto: VerifyOtpDto) {
return this.authService.verifyResetPasswordOtp(verifyOtpDto);
}

@Public()
@Post('reset-password')
@HttpCode(HttpStatus.OK)
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
return this.authService.resetPassword(resetPasswordDto);
}
}
54 changes: 54 additions & 0 deletions backend/src/users/providers/get-members.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RefreshToken } from '../entities/refreshToken.entity';
import { User } from '../../users/entities/user.entity';

@Injectable()
export class RefreshTokenRepositoryOperations {
constructor(
@InjectRepository(RefreshToken)
private readonly repo: Repository<RefreshToken>,
) {}

async saveRefreshToken(user: User, token: string): Promise<RefreshToken> {
const expiresAt = this.computeExpiryFromEnv();

const rt = this.repo.create({
userId: user.id,
token,
expiresAt,
revoked: false,
});

return this.repo.save(rt);
}

async revokeToken(token: string): Promise<void> {
await this.repo.update({ token }, { revoked: true });
}

async findValidToken(token: string): Promise<RefreshToken | null> {
const rt = await this.repo.findOne({ where: { token } });
if (!rt) return null;
if (rt.revoked) return null;
if (rt.expiresAt && rt.expiresAt < new Date()) return null;
return rt;
}

private computeExpiryFromEnv(): Date | undefined {
// supports ms number or '7d' etc? We'll keep ms for now.
const raw = process.env.JWT_REFRESH_EXPIRATION;
if (!raw) return undefined;

const ms = Number(raw);
if (Number.isFinite(ms) && ms > 0) {
return new Date(Date.now() + ms);
}
return undefined;
}

async revokeAllRefreshTokens(userId: string): Promise<void> {
await this.repo.update({ userId }, { revoked: true });
}
}
Loading
Loading