From d4a3c6e61fb7fd4f49e8310caa03f6adbaccc540 Mon Sep 17 00:00:00 2001 From: Fahmed2024 <100038212+Fahmedo@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:40:01 +0100 Subject: [PATCH 1/4] commit Verify TOTP Provider --- .../users/providers/get-members.provider.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 backend/src/users/providers/get-members.provider.ts diff --git a/backend/src/users/providers/get-members.provider.ts b/backend/src/users/providers/get-members.provider.ts new file mode 100644 index 00000000..70461997 --- /dev/null +++ b/backend/src/users/providers/get-members.provider.ts @@ -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, + ) {} + + async saveRefreshToken(user: User, token: string): Promise { + 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 { + await this.repo.update({ token }, { revoked: true }); + } + + async findValidToken(token: string): Promise { + 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 { + await this.repo.update({ userId }, { revoked: true }); + } +} From de16ce8a5f4bab85feb7f187ede713442d5f95d2 Mon Sep 17 00:00:00 2001 From: Fahmed2024 <100038212+Fahmedo@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:40:32 +0100 Subject: [PATCH 2/4] commit Get Members Provider & Endpoint --- .../auth/providers/verify-totp.provider.ts | 161 ++++++++++++++++++ backend/src/users/members.controller.ts | 111 ++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 backend/src/auth/providers/verify-totp.provider.ts create mode 100644 backend/src/users/members.controller.ts diff --git a/backend/src/auth/providers/verify-totp.provider.ts b/backend/src/auth/providers/verify-totp.provider.ts new file mode 100644 index 00000000..e46ca79d --- /dev/null +++ b/backend/src/auth/providers/verify-totp.provider.ts @@ -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( + '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( + '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( + '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('EXCHANGE_RATES_PROVIDER_FAKE_MODE') === + 'true' + ); + } + + private getFakeRate(from: string, to: string): number { + if (from === to) { + return 1; + } + + const rates: Record = { + 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; + } +} diff --git a/backend/src/users/members.controller.ts b/backend/src/users/members.controller.ts new file mode 100644 index 00000000..7c0f724c --- /dev/null +++ b/backend/src/users/members.controller.ts @@ -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); + } +} From 803ac26a1edee443d8df66e3458eaafb4def6fe1 Mon Sep 17 00:00:00 2001 From: Fahmed2024 <100038212+Fahmedo@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:43:16 +0100 Subject: [PATCH 3/4] commit Verify 2FA Page --- frontend/app/(auth)/verify-2fa/page.tsx | 244 ++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 frontend/app/(auth)/verify-2fa/page.tsx diff --git a/frontend/app/(auth)/verify-2fa/page.tsx b/frontend/app/(auth)/verify-2fa/page.tsx new file mode 100644 index 00000000..c11e4430 --- /dev/null +++ b/frontend/app/(auth)/verify-2fa/page.tsx @@ -0,0 +1,244 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { toast } from 'sonner'; +import { apiClient } from '@/lib/apiClient'; +import { useAuthStore } from '@/lib/store/authStore'; +import { storage } from '@/lib/storage'; +import { Mail, ArrowLeft, Loader2, Send, Clock } from 'lucide-react'; +import Link from 'next/link'; + +export default function VerifyOtpPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const email = searchParams.get('email') || ''; + + const [otp, setOtp] = useState(['', '', '', '']); + const [isVerifying, setIsVerifying] = useState(false); + const [isResending, setIsResending] = useState(false); + const [countdown, setCountdown] = useState(0); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + useEffect(() => { + if (countdown > 0) { + const timer = setTimeout(() => setCountdown(countdown - 1), 1000); + return () => clearTimeout(timer); + } + }, [countdown]); + + useEffect(() => { + inputRefs.current[0]?.focus(); + }, []); + + const handleChange = (index: number, value: string) => { + if (!/^\d*$/.test(value)) return; + const newOtp = [...otp]; + newOtp[index] = value.slice(-1); + setOtp(newOtp); + + if (value && index < 3) { + inputRefs.current[index + 1]?.focus(); + } + }; + + const handleKeyDown = (index: number, e: React.KeyboardEvent) => { + if (e.key === 'Backspace' && !otp[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pasted = e.clipboardData + .getData('text') + .replace(/\D/g, '') + .slice(0, 4); + const newOtp = [...otp]; + for (let i = 0; i < pasted.length; i++) { + newOtp[i] = pasted[i]; + } + setOtp(newOtp); + const focusIndex = Math.min(pasted.length, 3); + inputRefs.current[focusIndex]?.focus(); + }; + + const handleVerify = async () => { + const code = otp.join(''); + if (code.length !== 4) { + toast.error('Please enter the full 4-digit code'); + return; + } + + setIsVerifying(true); + try { + const response = await apiClient.post<{ + message: string; + user: any; + tokens: { accessToken: string; refreshToken: string }; + }>('/auth/verify-otp', { email, otp: code }); + + apiClient.setToken(response.tokens.accessToken); + useAuthStore.getState().setUser(response.user); + useAuthStore.getState().setToken(response.tokens.accessToken); + storage.setToken(response.tokens.accessToken); + storage.setUser(response.user); + + toast.success('Email verified successfully!'); + router.push('/dashboard'); + } catch (error: any) { + toast.error(error.message || 'Invalid or expired OTP'); + } finally { + setIsVerifying(false); + } + }; + + const handleResend = async () => { + if (countdown > 0) return; + setIsResending(true); + try { + await apiClient.post('/auth/resend-verification-otp', { email }); + toast.success('A new verification code has been sent'); + setCountdown(60); + setOtp(['', '', '', '']); + inputRefs.current[0]?.focus(); + } catch (error: any) { + toast.error(error.message || 'Failed to resend code'); + } finally { + setIsResending(false); + } + }; + + return ( +
+
+ {/* Header */} +
+

+ Verify Your Email +

+

+ We sent a 4-digit code to{' '} + + {email || 'your email'} + +

+
+ + {/* Main Content Card */} +
+
+
+ +
+
+ +
+ {otp.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + className="w-14 h-14 text-center text-2xl font-bold border-2 border-gray-300 rounded-lg focus:border-gray-900 focus:ring-2 focus:ring-gray-300 focus:outline-none bg-white text-gray-900 transition-all" + /> + ))} +
+ + + +
+
+
+ +
+

+ Didn't receive the code? +

+

+ Check your spam folder or click the resend button below +

+
+
+
+
+ + + +
+
+
+
+
+ OR +
+
+ + + + Back to Sign In + +
+ + {/* Footer */} +
+

© 2026 ManageHub. All rights reserved.

+ +
+
+
+ ); +} From c08ca9df97e78b0c97046a29360d38a2ee577030 Mon Sep 17 00:00:00 2001 From: Fahmed2024 <100038212+Fahmedo@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:43:38 +0100 Subject: [PATCH 4/4] commit TwoFactorModal Component --- .../components/settings/TwoFactorModal.tsx | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 frontend/components/settings/TwoFactorModal.tsx diff --git a/frontend/components/settings/TwoFactorModal.tsx b/frontend/components/settings/TwoFactorModal.tsx new file mode 100644 index 00000000..ce2f5dd5 --- /dev/null +++ b/frontend/components/settings/TwoFactorModal.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { Fingerprint } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { cn } from '@/utils/cn'; + +interface BiometricLoginViewProps { + onStartScan: () => void; + onSwitchToEmail: () => void; + className?: string; + isScanning?: boolean; +} + +export function BiometricLoginView({ + onStartScan, + onSwitchToEmail, + className, + isScanning = false, +}: BiometricLoginViewProps) { + return ( +
+ {/* Scanner Icon */} +
+
+ +
+ {/* Scanning animation corners */} +
+
+
+
+
+
+
+ + {/* Content */} +
+

+ Biometric Authentication +

+

+ Place your finger on the scanner or look at the camera to sign in +

+
+ + {/* Start Scan Button */} + + + {/* Fallback Link */} + +
+ ); +}