From d59d41e1ee2ba310133583dd8aa45bac4b3b90a4 Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Mon, 9 Sep 2024 14:40:55 +0200 Subject: [PATCH] work in progress --- .../src/entities/email-verification.entity.ts | 3 + .../auth-module/controller/auth.controller.ts | 16 +- .../modules/auth-module/models/dto/index.ts | 1 + .../models/dto/magic-link-signin.dto.ts | 12 + .../auth-module/services/auth.service.ts | 75 +- .../auth-module/strategies/local.strategy.ts | 39 +- .../password-confirmation.mail.service.ts | 4 - .../modules/session/guard/session.guard.ts | 14 +- .../controller/verify.controller.ts | 28 +- .../repositories/email-verify.repository.ts | 47 +- .../services/email-verification.service.ts | 97 +-- backend/src/shared/exceptions/index.ts | 2 + .../shared/exceptions/session.exception.ts | 12 + .../useragent-mismatch-exception.ts | 14 + .../shared/filters/http-exception.filter.ts | 2 +- .../event-empty-state.component.ts | 20 +- .../welcome-root/welcome-root.component.html | 717 +----------------- .../welcome-root/welcome-root.component.ts | 658 +++++----------- .../src/app/shared/service/auth.service.ts | 23 +- 19 files changed, 369 insertions(+), 1415 deletions(-) create mode 100644 backend/src/modules/auth-module/models/dto/magic-link-signin.dto.ts create mode 100644 backend/src/shared/exceptions/session.exception.ts create mode 100644 backend/src/shared/exceptions/useragent-mismatch-exception.ts diff --git a/backend/src/entities/email-verification.entity.ts b/backend/src/entities/email-verification.entity.ts index 36b6b0d..3fdc7cd 100644 --- a/backend/src/entities/email-verification.entity.ts +++ b/backend/src/entities/email-verification.entity.ts @@ -24,6 +24,9 @@ export class EmailVerification { @Column() public email: string; + @Column({ nullable: true }) + public userAgent: string; + @OneToOne(() => UserCredentials) @JoinColumn({ name: 'userCredentialsId' }) public user: UserCredentials; diff --git a/backend/src/modules/auth-module/controller/auth.controller.ts b/backend/src/modules/auth-module/controller/auth.controller.ts index b3829c6..182440e 100644 --- a/backend/src/modules/auth-module/controller/auth.controller.ts +++ b/backend/src/modules/auth-module/controller/auth.controller.ts @@ -17,6 +17,7 @@ import { Public } from 'src/shared/decorator'; import { LocalAuthGuard } from '../guard'; import { MagicLinkDto, + MagicLinkSigninDto, SigninResponseDto, UserCredentialsDto, } from '../models/dto'; @@ -36,9 +37,12 @@ export class AuthController { @HttpCode(HttpStatus.OK) @Public() public async sendMagicLink( - @Body() magicLinkDto: MagicLinkDto + @Body() magicLinkDto: MagicLinkDto, + @Req() request: Request ): Promise { - return this.authService.sendMagicLink(magicLinkDto); + const userAgent = request.headers['user-agent'] || 'Unknown'; + + return this.authService.sendMagicLink(magicLinkDto, userAgent); } @ApiCreatedResponse({ @@ -58,12 +62,14 @@ export class AuthController { description: 'User signin successfully', type: SigninResponseDto, }) - @ApiBody({ type: UserCredentialsDto }) + @ApiBody({ type: MagicLinkSigninDto }) @HttpCode(HttpStatus.OK) @UseGuards(LocalAuthGuard) @Public() - @Post('signin') - public async signin(@Req() request: Request): Promise { + @Post('magic-link-signin') + public async magicLinkSignin( + @Req() request: Request + ): Promise { return this.authService.getLoginResponse( request.user as SigninResponseDto & { userAgent: string } ); diff --git a/backend/src/modules/auth-module/models/dto/index.ts b/backend/src/modules/auth-module/models/dto/index.ts index e8eeec3..7393172 100644 --- a/backend/src/modules/auth-module/models/dto/index.ts +++ b/backend/src/modules/auth-module/models/dto/index.ts @@ -1,3 +1,4 @@ export * from './user-credentials.dto'; export * from './signin-response.dto'; export * from './magic-link.dto'; +export * from './magic-link-signin.dto'; diff --git a/backend/src/modules/auth-module/models/dto/magic-link-signin.dto.ts b/backend/src/modules/auth-module/models/dto/magic-link-signin.dto.ts new file mode 100644 index 0000000..25c71a9 --- /dev/null +++ b/backend/src/modules/auth-module/models/dto/magic-link-signin.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString } from 'class-validator'; + +export class MagicLinkSigninDto { + @ApiProperty() + @IsString() + public token: string; + + @ApiProperty() + @IsEmail() + public email: string; +} diff --git a/backend/src/modules/auth-module/services/auth.service.ts b/backend/src/modules/auth-module/services/auth.service.ts index e2d4a1a..a4b9f78 100644 --- a/backend/src/modules/auth-module/services/auth.service.ts +++ b/backend/src/modules/auth-module/services/auth.service.ts @@ -1,4 +1,8 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; import { UserCredentials } from 'src/entities'; import { SessionService } from 'src/modules/session/services/session.service'; import { EncryptionService, SuccessDto } from 'src/shared'; @@ -28,7 +32,10 @@ export class AuthService { private readonly sessionService: SessionService ) {} - public async sendMagicLink(magiclink: MagicLinkDto): Promise { + public async sendMagicLink( + magiclink: MagicLinkDto, + userAgent: string + ): Promise { try { const existingUser = await this.userCredentialsRepository.findUserByEmail( magiclink.email @@ -37,7 +44,9 @@ export class AuthService { if (existingUser) { const token = await this.emailVerificationService.generateEmailVerificationTokenForMagicLink( - magiclink.email + magiclink.email, + userAgent, + existingUser.id ); await this.passwordConfirmationMailService.sendLoginLinkEmail( @@ -45,24 +54,16 @@ export class AuthService { token ); } else { - const isEmailSubmitted: boolean = - await this.emailVerificationService.isEmailSubmitted(magiclink.email); - - if (!isEmailSubmitted) { - const token = - await this.emailVerificationService.generateEmailVerificationTokenForMagicLink( - magiclink.email - ); - - await this.passwordConfirmationMailService.sendRegistrationLinkEmail( + const token = + await this.emailVerificationService.generateEmailVerificationTokenForMagicLink( magiclink.email, - token + userAgent ); - } else { - throw new ConflictException('EMAIL_ALREADY_SUBMITTED', { - message: 'This email has already been submitted for registration.', - }); - } + + await this.passwordConfirmationMailService.sendRegistrationLinkEmail( + magiclink.email, + token + ); } return { success: true }; @@ -105,11 +106,6 @@ export class AuthService { // user.id // ); - // await this.passwordConfirmationMailService.sendPasswordConfirmationMail( - // user.email, - // token - // ); - return { success: true, }; @@ -125,28 +121,31 @@ export class AuthService { } public async validateUser( + token: string, email: string, - password: string + userAgent: string ): Promise { try { + const verificationResult = + await this.emailVerificationService.verifyEmail( + token, + email, + userAgent + ); + + if (!verificationResult.success) { + throw new UnauthorizedException('Invalid or expired token'); + } + const user = await this.userCredentialsRepository.findUserByEmail(email); if (!user) { - throw new ForbiddenException('INVALID_CREDENTIALS'); - } - - const passwordMatch = await EncryptionService.compareHash( - password, - user.hashedPassword - ); - - if (!passwordMatch) { - throw new ForbiddenException('INVALID_CREDENTIALS'); + throw new UnauthorizedException('User not found'); } return user; } catch (error) { - if (error instanceof ForbiddenException) { + if (error instanceof UnauthorizedException) { throw error; } else { throw new InternalServerErrorException('VALIDATION_ERROR', { @@ -156,6 +155,10 @@ export class AuthService { } } + public async getUserByEmail(email: string): Promise { + return this.userCredentialsRepository.findUserByEmail(email); + } + public async signout(sessionId: string): Promise { try { await this.sessionService.deleteSessionBySessionId(sessionId); diff --git a/backend/src/modules/auth-module/strategies/local.strategy.ts b/backend/src/modules/auth-module/strategies/local.strategy.ts index a77367a..fac77af 100644 --- a/backend/src/modules/auth-module/strategies/local.strategy.ts +++ b/backend/src/modules/auth-module/strategies/local.strategy.ts @@ -1,34 +1,51 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Request } from 'express'; import { Strategy } from 'passport-local'; +import { EmailVerificationService } from 'src/modules/verify-module/services/email-verification.service'; -import { SigninResponseDto } from '../models/dto'; +import { MagicLinkSigninDto } from '../models/dto'; import { AuthService } from '../services/auth.service'; @Injectable() export class LocalStrategy extends PassportStrategy(Strategy) { - public constructor(private readonly authService: AuthService) { + public constructor( + private authService: AuthService, + private emailVerificationService: EmailVerificationService + ) { super({ usernameField: 'email', - passwordField: 'password', + passwordField: 'token', passReqToCallback: true, }); } - public async validate( - request: Request, - email: string, - password: string - ): Promise { - const user = await this.authService.validateUser(email, password); + public async validate(request: Request): Promise { + const { token, email }: MagicLinkSigninDto = request.body; + + if (!token || !email) { + throw new UnauthorizedException('Missing token or email'); + } + + const verificationResult = await this.emailVerificationService.verifyEmail( + token as string, + email as string, + request.headers['user-agent'] + ); + + if (!verificationResult.success) { + throw new UnauthorizedException('Invalid or expired token'); + } + + const user = await this.authService.getUserByEmail(email as string); if (!user) { - throw new UnauthorizedException(); + throw new UnauthorizedException('User not found'); } const userAgent = request.headers['user-agent']; - return { id: user.id, email: user.email, userAgent: userAgent }; + return { id: user.id, email: user.email, userAgent }; } } diff --git a/backend/src/modules/sendgrid-module/services/password-confirmation.mail.service.ts b/backend/src/modules/sendgrid-module/services/password-confirmation.mail.service.ts index 819793e..79db592 100644 --- a/backend/src/modules/sendgrid-module/services/password-confirmation.mail.service.ts +++ b/backend/src/modules/sendgrid-module/services/password-confirmation.mail.service.ts @@ -67,10 +67,6 @@ export class PasswordConfirmationMailService extends BaseMailService { const token = `${registrationToken}|${UriEncoderService.encodeBase64(to)}`; const registrationLink = `${this.configService.get('APP_URL')}/?token=${token}&signup=true`; - console.log('##############'); - console.log(registrationLink); - console.log('##############'); - const mailoptions: SendGridMailApi.MailDataRequired = { to, from: { email: 'info@igor-propisnov.com', name: 'Ticket App' }, diff --git a/backend/src/modules/session/guard/session.guard.ts b/backend/src/modules/session/guard/session.guard.ts index 06e55e0..fe4712b 100644 --- a/backend/src/modules/session/guard/session.guard.ts +++ b/backend/src/modules/session/guard/session.guard.ts @@ -1,9 +1,5 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { SessionException } from 'src/shared/exceptions'; import { SessionService } from '../services/session.service'; @@ -19,20 +15,20 @@ export class SessionGuard implements CanActivate { const session = await this.sessionService.findSessionBySessionId(sessionId); if (!session) { - throw new UnauthorizedException('Session not found.'); + throw new SessionException('Session not found.'); } const isExpired = await this.sessionService.isSessioExpired(session); if (isExpired) { - throw new UnauthorizedException('Session expired.'); + throw new SessionException('Session expired.'); } const userAgentInSession = JSON.parse(session.json).passport.user .userAgent as string; if (userAgentInSession !== currentAgent) { - throw new UnauthorizedException('User agent mismatch.'); + throw new SessionException('User agent mismatch.'); } return true; diff --git a/backend/src/modules/verify-module/controller/verify.controller.ts b/backend/src/modules/verify-module/controller/verify.controller.ts index c4273e3..d1648d2 100644 --- a/backend/src/modules/verify-module/controller/verify.controller.ts +++ b/backend/src/modules/verify-module/controller/verify.controller.ts @@ -1,4 +1,11 @@ -import { Controller, HttpCode, HttpStatus, Query, Post } from '@nestjs/common'; +import { + Controller, + HttpCode, + HttpStatus, + Query, + Post, + Req, +} from '@nestjs/common'; import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger'; import { SuccessDto } from 'src/shared'; import { Public } from 'src/shared/decorator'; @@ -21,22 +28,15 @@ export class VerifyController { @HttpCode(HttpStatus.OK) public async verifyEmail( @Query('token') tokenToVerify: string, - @Query('email') emailToVerify: string + @Query('email') emailToVerify: string, + @Req() request: Request ): Promise { + const userAgent = request.headers['user-agent'] || 'Unknown'; + return this.emailVerificationService.verifyEmail( tokenToVerify, - emailToVerify + emailToVerify, + userAgent ); } - - // @ApiCreatedResponse({ - // description: 'Check if email is verified', - // type: Boolean, - // }) - // @Get('check') - // @HttpCode(HttpStatus.OK) - // @UseGuards(SessionGuard) - // public async isEmailVerified(@Req() request: Request): Promise { - // return this.emailVerificationService.isEmailVerified(request.sessionID); - // } } diff --git a/backend/src/modules/verify-module/repositories/email-verify.repository.ts b/backend/src/modules/verify-module/repositories/email-verify.repository.ts index f809161..13bf27f 100644 --- a/backend/src/modules/verify-module/repositories/email-verify.repository.ts +++ b/backend/src/modules/verify-module/repositories/email-verify.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { EmailVerification } from 'src/entities'; -import { LessThan, MoreThan, Repository } from 'typeorm'; +import { LessThan, Repository } from 'typeorm'; @Injectable() export class EmailVerifyRepository { @@ -14,30 +14,17 @@ export class EmailVerifyRepository { token: string, expiresAt: Date, email: string, - userId: string | null + userId: string | null, + userAgent: string ): Promise { + await this.repository.delete({ email }); + await this.repository.save({ token, expiresAt, email, user: userId ? { id: userId } : null, - }); - } - - public async findValidVerification( - token: string, - email: string - ): Promise { - const currentDate = new Date(); - const tenMinutesAgo = new Date(currentDate.getTime() - 10 * 60 * 1000); - - return await this.repository.findOne({ - where: { - token, - email, - createdAt: MoreThan(tenMinutesAgo), - expiresAt: MoreThan(currentDate), - }, + userAgent, }); } @@ -60,12 +47,6 @@ export class EmailVerifyRepository { await this.repository.delete({ token, email }); } - public async findItemByEmail( - email: string - ): Promise { - return this.repository.findOne({ where: { email } }); - } - public async deleteAllExpiredTokens(): Promise { const currentDate = new Date(); @@ -73,20 +54,4 @@ export class EmailVerifyRepository { expiresAt: LessThan(currentDate), }); } - - public async deleteEmailVerificationByToken( - tokenToDelete: string - ): Promise { - const emailVerification = await this.repository.findOne({ - where: { token: tokenToDelete }, - relations: ['user'], - }); - - if (emailVerification) { - await this.repository.delete({ token: tokenToDelete }); - return emailVerification; - } - - return null; - } } diff --git a/backend/src/modules/verify-module/services/email-verification.service.ts b/backend/src/modules/verify-module/services/email-verification.service.ts index 9d5ca88..cd15c73 100644 --- a/backend/src/modules/verify-module/services/email-verification.service.ts +++ b/backend/src/modules/verify-module/services/email-verification.service.ts @@ -1,64 +1,25 @@ import { randomBytes } from 'crypto'; import { Injectable } from '@nestjs/common'; -import { EmailVerification } from 'src/entities'; -import { SessionService } from 'src/modules/session/services/session.service'; import { SuccessDto, UriEncoderService } from 'src/shared'; import { InternalServerErrorException, TokenExpiredException, + UserAgentMismatchException, } from 'src/shared/exceptions'; -import { UserDataRepository } from '../../user-module/repositories/user-data.repository'; import { EmailVerifyRepository } from '../repositories'; @Injectable() export class EmailVerificationService { public constructor( - private readonly emailVerifyRepository: EmailVerifyRepository, - private readonly userDataRepository: UserDataRepository, - private readonly sessionService: SessionService + private readonly emailVerifyRepository: EmailVerifyRepository ) {} - public async generateEmailVerificationToken(userId: string): Promise { - try { - const verificationToken = await this.createVerificationToken(); - const expiration = new Date(Date.now() + 24 * 60 * 60 * 1000); - - await this.emailVerifyRepository.createEmailVerification( - verificationToken, - expiration, - userId, - null - ); - - return verificationToken; - } catch (error) { - throw new InternalServerErrorException( - 'EMAIL_VERIFICATION_TOKEN_GENERATION_ERROR', - { - message: - 'An error occurred while generating the email verification token.', - } - ); - } - } - - public async isEmailSubmitted(email: string): Promise { - try { - const emailVerification = - await this.emailVerifyRepository.findItemByEmail(email); - - return !!emailVerification; - } catch (error) { - throw new InternalServerErrorException('EMAIL_VERIFICATION_ERROR', { - message: 'An error occurred while verifying the email.', - }); - } - } - public async generateEmailVerificationTokenForMagicLink( - email: string + email: string, + userAgent: string, + userid?: string ): Promise { try { const verificationToken = await this.createVerificationToken(); @@ -68,7 +29,8 @@ export class EmailVerificationService { verificationToken, expiresAt, email, - null + userid || null, + userAgent ); return verificationToken; @@ -85,7 +47,8 @@ export class EmailVerificationService { public async verifyEmail( tokenToVerify: string, - emailToVerify: string + emailToVerify: string, + userAgent: string ): Promise { try { const token = await this.emailVerifyRepository.findByTokenAndEmail( @@ -97,6 +60,13 @@ export class EmailVerificationService { throw new TokenExpiredException(); } + if (token.userAgent !== userAgent) { + throw new UserAgentMismatchException({ + message: + 'The User Agent does not match the one used to generate the token.', + }); + } + const currentDate = new Date(); if (token.expiresAt.getTime() < currentDate.getTime()) { @@ -113,35 +83,16 @@ export class EmailVerificationService { if (error instanceof TokenExpiredException) { throw error; } + if (error instanceof UserAgentMismatchException) { + throw error; + } throw new InternalServerErrorException('EMAIL_VERIFICATION_ERROR', { message: 'An error occurred while verifying the email.', }); } } - public async isEmailVerified(sessionID: string): Promise { - try { - const userId = await this.sessionService.getUserIdBySessionId(sessionID); - - if (!userId) { - return false; - } - - const isVerified = - await this.userDataRepository.isEmailConfirmedByUserId(userId); - - return isVerified; - } catch (error) { - throw new InternalServerErrorException('EMAIL_VERIFICATION_CHECK_ERROR', { - message: - 'An error occurred while checking the email verification status.', - }); - } - } - - async deleteAllExpiredTokens(): Promise { - const currentDate = new Date(); - + public async deleteAllExpiredTokens(): Promise { await this.emailVerifyRepository.deleteAllExpiredTokens(); } @@ -150,12 +101,4 @@ export class EmailVerificationService { return UriEncoderService.encodeUri(verifyToken); } - - private async deleteEmailVerificationToken( - tokenToDelete: string - ): Promise { - return await this.emailVerifyRepository.deleteEmailVerificationByToken( - tokenToDelete - ); - } } diff --git a/backend/src/shared/exceptions/index.ts b/backend/src/shared/exceptions/index.ts index f1bb424..9c55f89 100644 --- a/backend/src/shared/exceptions/index.ts +++ b/backend/src/shared/exceptions/index.ts @@ -3,3 +3,5 @@ export * from './forbidden.exception'; export * from './internal-server-error.exception'; export * from './not-found.exception'; export * from './token-expired.exception'; +export * from './useragent-mismatch-exception'; +export * from './session.exception'; diff --git a/backend/src/shared/exceptions/session.exception.ts b/backend/src/shared/exceptions/session.exception.ts new file mode 100644 index 0000000..ea4872e --- /dev/null +++ b/backend/src/shared/exceptions/session.exception.ts @@ -0,0 +1,12 @@ +import { HttpStatus } from '@nestjs/common'; + +import { BaseException } from './base.exception'; + +export class SessionException extends BaseException { + public constructor(message: string, details?: unknown) { + super('Session Error', HttpStatus.UNAUTHORIZED, 'SESSION_ERROR', { + message, + details, + }); + } +} diff --git a/backend/src/shared/exceptions/useragent-mismatch-exception.ts b/backend/src/shared/exceptions/useragent-mismatch-exception.ts new file mode 100644 index 0000000..08b6e71 --- /dev/null +++ b/backend/src/shared/exceptions/useragent-mismatch-exception.ts @@ -0,0 +1,14 @@ +import { HttpStatus } from '@nestjs/common'; + +import { BaseException } from './base.exception'; + +export class UserAgentMismatchException extends BaseException { + public constructor(details?: unknown) { + super( + 'User Agent Mismatch', + HttpStatus.UNAUTHORIZED, + 'USER_AGENT_MISMATCH', + details + ); + } +} diff --git a/backend/src/shared/filters/http-exception.filter.ts b/backend/src/shared/filters/http-exception.filter.ts index 7a8915d..7fce13a 100644 --- a/backend/src/shared/filters/http-exception.filter.ts +++ b/backend/src/shared/filters/http-exception.filter.ts @@ -19,7 +19,7 @@ export class HttpExceptionFilter implements ExceptionFilter { const ctx = host.switchToHttp(); const response = ctx.getResponse(); - //console.error('Exception caught:', exception); + console.error('Exception caught:', exception); let status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR; let message: string = 'Internal server error'; diff --git a/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts b/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts index ab65b0b..09f49b6 100644 --- a/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts +++ b/frontend/src/app/pages/event-root/event-empty-state/event-empty-state.component.ts @@ -24,25 +24,11 @@ export class EventEmptyStateComponent { private readonly verifyApi: VerifyApiService ) {} - // public navigateToCreateEvent(): void { - // this.verifyApi - // .verifyControllerIsEmailVerified() - // .subscribe((isVerified: boolean) => { - // if (!isVerified) { - // this.openEmailVerificationModal(); - // } else { - // this.router.navigate(['/event/create']); - // } - // }); - // } + public navigateToCreateEvent(): void { + this.router.navigate(['/event/create']); + } public closeEmailVerificationModal(): void { (this.emailVerificationModal.nativeElement as HTMLDialogElement).close(); } - - private openEmailVerificationModal(): void { - ( - this.emailVerificationModal.nativeElement as HTMLDialogElement - ).showModal(); - } } diff --git a/frontend/src/app/pages/welcome-root/welcome-root.component.html b/frontend/src/app/pages/welcome-root/welcome-root.component.html index d001089..820dd04 100644 --- a/frontend/src/app/pages/welcome-root/welcome-root.component.html +++ b/frontend/src/app/pages/welcome-root/welcome-root.component.html @@ -1,646 +1,4 @@ - - - -
-
- @if (isTokenVerifing()) { + @if (displaySkeleton()) {
@@ -823,8 +181,7 @@ }
- @if (isTokenVerifing()) { - + @if (displaySkeleton()) {
@@ -888,8 +245,7 @@
- @if (isTokenVerifing()) { - + @if (displaySkeleton()) {
@@ -1012,7 +368,7 @@
} - @if (!isRegistrationMode() && !isTokenVerifing()) { + @if (!isRegistrationMode() && !displaySkeleton()) {

What happens next?

@@ -1025,7 +381,7 @@
- } @else if (isTokenVerifing()) { + } @else if (displaySkeleton()) {
@@ -1101,7 +457,7 @@
- diff --git a/frontend/src/app/pages/welcome-root/welcome-root.component.ts b/frontend/src/app/pages/welcome-root/welcome-root.component.ts index aa94260..9e8ba53 100644 --- a/frontend/src/app/pages/welcome-root/welcome-root.component.ts +++ b/frontend/src/app/pages/welcome-root/welcome-root.component.ts @@ -1,404 +1,3 @@ -/* import { CommonModule } from '@angular/common'; -import { HttpClientModule } from '@angular/common/http'; -import { - ChangeDetectionStrategy, - Component, - OnInit, - WritableSignal, - signal, - effect, - InputSignal, - input, - ElementRef, -} from '@angular/core'; -import { - FormBuilder, - FormControl, - FormGroup, - FormsModule, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; -import { Router } from '@angular/router'; - -import { ButtonModule } from 'primeng/button'; -import { CheckboxModule } from 'primeng/checkbox'; -import { InputTextModule } from 'primeng/inputtext'; -import { PasswordModule } from 'primeng/password'; -import { delay, finalize, takeWhile, tap } from 'rxjs'; - -import { - Configuration, - SigninResponseDtoApiModel, - SuccessDtoApiModel, - UserCredentialsDtoApiModel, -} from '../../api'; -import { ApiConfiguration } from '../../config/api-configuration'; -import { - AuthService, - BackgroundPatternService, - ThemeService, -} from '../../shared/service'; -import { LocalStorageService } from '../../shared/service/local-storage.service'; -import { - customEmailValidator, - customPasswordValidator, -} from '../../shared/validator'; - -type AuthAction = 'signin' | 'signup'; - -@Component({ - selector: 'app-register-root', - standalone: true, - imports: [ - CommonModule, - FormsModule, - InputTextModule, - ReactiveFormsModule, - ButtonModule, - CheckboxModule, - PasswordModule, - HttpClientModule, - ], - providers: [ - { - provide: Configuration, - useFactory: (): unknown => - new ApiConfiguration({ withCredentials: true }), - }, - ], - templateUrl: './welcome-root.component.html', - styleUrl: './welcome-root.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class WelcomeRootComponent implements OnInit { - public dialogBackgroundStyle: { 'background-image': string } | null = null; - public leftBackgroundStyle: { 'background-image': string } | null = null; - public rightBackgroundStyle: { 'background-image': string } | null = null; - public verified: InputSignal = input(false); - public login: InputSignal = input(false); - public email: InputSignal = input(''); - public signedOut: InputSignal = input(true); - public form!: FormGroup; - public rememberMe: FormControl = new FormControl(false); - public isSigninSignal: WritableSignal = signal(false); - public isSignupSignal: WritableSignal = signal(true); - public isSignUpSuccess: WritableSignal = signal(false); - public userSignupSuccess: WritableSignal = signal(false); - public isDialogOpen: WritableSignal = signal(false); - public isLoading: WritableSignal = signal(false); - public displaySkeleton: WritableSignal = signal(true); - private removeQueryParams: WritableSignal = signal(false); - - public get isDarkMode(): boolean { - return this.themeService.getTheme() === 'dark'; - } - - public constructor( - private readonly formBuilder: FormBuilder, - private readonly authService: AuthService, - private readonly router: Router, - private readonly themeService: ThemeService, - private readonly el: ElementRef, - private readonly backgroundPatternService: BackgroundPatternService, - private readonly localStorageService: LocalStorageService - ) { - effect(() => { - if (this.removeQueryParams()) { - this.clearRouteParams(); - } - }); - } - - public ngOnInit(): void { - this.autologin(); - this.setBackground(); - this.initializeForm(); - this.setupValueChanges(); - - if ((this.email() && this.verified()) || this.login()) { - this.handleRedirect(); - this.removeQueryParams.set(true); - } - } - - public autologin(): void { - const rememberMe = this.localStorageService.getItem('remember-me'); - - if (rememberMe && !this.signedOut()) { - this.authService - .status() - .pipe( - delay(1500), - takeWhile((response: SuccessDtoApiModel) => response.success, true), - tap({ - next: (response: SuccessDtoApiModel) => { - if (response.success) { - this.router.navigate(['/dashboard']); - } - }, - finalize: () => this.displaySkeleton.set(false), - }) - ) - .subscribe(); - } else { - this.displaySkeleton.set(false); - } - } - - public setBackground(): void { - const theme = this.themeService.getTheme(); - let opacity: number; - - if (theme === 'dark') { - opacity = 0.05; - } else { - opacity = 0.1; - } - - const colorPrimary = getComputedStyle( - this.el.nativeElement - ).getPropertyValue('--p'); - - const colorPrimaryC = getComputedStyle( - this.el.nativeElement - ).getPropertyValue('--pc'); - - const svgUrlforDialog = this.backgroundPatternService.getWigglePattern( - colorPrimary, - opacity - ); - const svgUrlForLeft = this.backgroundPatternService.getBankNotePattern( - colorPrimaryC, - opacity - ); - const svgUrlForRight = this.backgroundPatternService.getHideoutPattern( - colorPrimary, - opacity - ); - - this.dialogBackgroundStyle = { - 'background-image': `url("${svgUrlforDialog}")`, - }; - this.leftBackgroundStyle = { - 'background-image': `url("${svgUrlForLeft}")`, - }; - this.rightBackgroundStyle = { - 'background-image': `url("${svgUrlForRight}")`, - }; - } - - public openModal(): void { - this.isDialogOpen.set(true); - } - - public closeModal(): void { - this.isDialogOpen.set(false); - } - - public toggleTheme(): void { - this.themeService.toggleTheme(); - this.setBackground(); - } - - public toggleAction(action: AuthAction): void { - this.resetFormValidation(); - - if (action === 'signin') { - this.handlePreselect(); - this.isSigninSignal.set(true); - this.isSignupSignal.set(false); - } else { - this.isSigninSignal.set(false); - this.isSignupSignal.set(true); - } - } - - public onSubmit(): void { - this.markControlsAsTouchedAndDirty(['email', 'password']); - - if (this.form?.valid) { - if (this.isSigninSignal()) { - this.signin(this.form.value); - } else { - this.signup(this.form.value); - } - } - } - - private handlePreselect(): void { - const rememberMe = this.localStorageService.getItem('remember-me'); - const email = this.localStorageService.getItem('email'); - - if (rememberMe) { - this.isSigninSignal.set(true); - this.isSignupSignal.set(false); - } - - if (email) { - this.form?.get('email')?.setValue(email); - } - - this.rememberMe.setValue(rememberMe); - } - - private initializeForm(): void { - const rememberMeValue = - this.localStorageService.getItem('remember-me'); - const email = this.localStorageService.getItem('email'); - - if (rememberMeValue) { - this.isSigninSignal.set(true); - this.isSignupSignal.set(false); - } - - const emailValue = rememberMeValue && email ? email : ''; - - this.form = this.formBuilder.group({ - email: new FormControl(emailValue, { - validators: [Validators.required, customEmailValidator()], - updateOn: 'change', - }), - password: new FormControl('', { - validators: [Validators.required, customPasswordValidator()], - updateOn: 'change', - }), - }); - - this.rememberMe.setValue(rememberMeValue); - } - - private handleRedirect(): void { - if (this.verified()) { - this.isSigninSignal.set(true); - this.isSignupSignal.set(false); - } - if (this.email()) { - this.form?.get('email')?.setValue(decodeURIComponent(atob(this.email()))); - } - - if (this.login()) { - this.isSignupSignal.set(true); - this.isSigninSignal.set(false); - } - } - - private clearRouteParams(): void { - this.router.navigate([], { queryParams: {} }); - } - - private setupValueChanges(): void { - this.setupEmailValueChanges(); - this.setupPasswordValueChanges(); - } - - private setupEmailValueChanges(): void { - const emailControl = this.form?.get('email'); - - emailControl?.valueChanges.subscribe((value: string) => { - if (value?.length >= 4) { - emailControl.setValidators([ - Validators.required, - customEmailValidator(), - ]); - } else { - emailControl.setValidators([ - Validators.required, - Validators.minLength(4), - ]); - } - emailControl.updateValueAndValidity({ emitEvent: false }); - }); - } - - private setupPasswordValueChanges(): void { - const passwordControl = this.form?.get('password'); - - passwordControl?.valueChanges.subscribe((value: string) => { - if (value?.length >= 8) { - passwordControl.setValidators([ - Validators.required, - customPasswordValidator(), - ]); - } else { - passwordControl.setValidators([ - Validators.required, - Validators.minLength(8), - ]); - } - passwordControl.updateValueAndValidity({ emitEvent: false }); - }); - } - - private markControlsAsTouchedAndDirty(controlNames: string[]): void { - controlNames.forEach((controlName: string) => { - const control = this.form?.get(controlName); - - if (control) { - control.markAsTouched(); - control.markAsDirty(); - control.updateValueAndValidity(); - } - }); - } - - private resetFormValidation(): void { - ['email', 'password'].forEach((controlName: string) => { - this.resetControlValidation(controlName); - }); - } - - private resetControlValidation(controlName: string): void { - const control = this.form?.get(controlName); - - if (control) { - control.reset(); - control.markAsPristine(); - control.markAsUntouched(); - control.updateValueAndValidity(); - } - } - - private signin(logiCredentials: UserCredentialsDtoApiModel): void { - const rememberMe: boolean = this.rememberMe.value; - - if (rememberMe) { - this.localStorageService.setItem('email', logiCredentials.email); - this.localStorageService.setItem('remember-me', rememberMe); - } - - this.authService - .signin(logiCredentials) - .pipe( - tap(() => this.isLoading.set(true)), - delay(1000), - finalize(() => this.isLoading.set(false)) - ) - .subscribe((response: SigninResponseDtoApiModel) => { - if (response) { - this.router.navigate(['/dashboard']); - } - }); - } - - private signup(logiCredentials: UserCredentialsDtoApiModel): void { - this.isLoading.set(true); - this.authService - .signup(logiCredentials) - .pipe( - delay(1000), - tap(() => this.isLoading.set(true)), - finalize(() => this.isLoading.set(false)) - ) - .subscribe((response: SuccessDtoApiModel) => { - if (response.success) { - this.openModal(); - this.userSignupSuccess.set(true); - } - }); - } -} - */ import { CommonModule } from '@angular/common'; import { HttpClientModule } from '@angular/common/http'; import { @@ -425,11 +24,12 @@ import { Router } from '@angular/router'; import { ButtonModule } from 'primeng/button'; import { InputTextModule } from 'primeng/inputtext'; -import { delay, finalize, tap, timer } from 'rxjs'; +import { delay, finalize, switchMap, takeWhile, tap, timer } from 'rxjs'; import { Configuration, MagicLinkDtoApiModel, + SigninResponseDtoApiModel, SuccessDtoApiModel, UserCredentialsDtoApiModel, VerifyApiService, @@ -467,20 +67,24 @@ import { customEmailValidator } from '../../shared/validator'; export class WelcomeRootComponent implements OnInit { @ViewChild('passwordInput') public passwordInput!: ElementRef; public token: InputSignal = input(''); + public signedOut: InputSignal = input(false); public signup: InputSignal = input(false); + public signin: InputSignal = input(false); public dialogBackgroundStyle: { 'background-image': string } | null = null; public leftBackgroundStyle: { 'background-image': string } | null = null; public rightBackgroundStyle: { 'background-image': string } | null = null; public form!: FormGroup; public isLoading: WritableSignal = signal(false); public isEmailSent: WritableSignal = signal(false); - public isTokenVerifing: WritableSignal = signal(false); + public displaySkeleton: WritableSignal = signal(false); + public isVerifying: WritableSignal = signal(false); public isTokenVerified: WritableSignal = signal(false); public errorReasons: WritableSignal = signal([]); public verificationError: WritableSignal = signal< string | null >(null); public isRegistrationMode: WritableSignal = signal(false); + public isAutoLoginInProgress: WritableSignal = signal(false); private removeQueryParams: WritableSignal = signal(false); public get isDarkMode(): boolean { @@ -498,89 +102,54 @@ export class WelcomeRootComponent implements OnInit { ) { effect(() => { if (this.removeQueryParams()) { - this.clearRouteParams(); + //this.clearRouteParams(); } }); } public ngOnInit(): void { + this.autologin(); this.setBackground(); this.initializeForm(); this.verifySignupMagicLink(); + this.verifySigninMagicLink(); + } + + public autologin(): void { + if ( + !this.token() && + (!this.signin() || !this.signup()) && + !this.signedOut() + ) { + this.isAutoLoginInProgress.set(true); + this.displaySkeleton.set(true); + + timer(2000) + .pipe( + switchMap(() => this.authService.status()), + takeWhile((response: SuccessDtoApiModel) => response.success, true), + tap({ + next: (response: SuccessDtoApiModel) => { + if (response.success) { + this.router.navigate(['/dashboard']); + } + }, + }), + finalize(() => { + this.isAutoLoginInProgress.set(false); + this.displaySkeleton.set(false); + }) + ) + .subscribe(); + } + } + + public verifySigninMagicLink(): void { + this.verifyMagicLink(false); } public verifySignupMagicLink(): void { - if (this.token() && this.signup()) { - const token: string = this.extractVerifyToken(); - const email: string = this.extractEmail(); - - this.removeQueryParams.set(true); - - if (token && email) { - this.isTokenVerifing.set(true); - this.verificationError.set(null); - this.errorReasons.set([]); - this.addPasswordFieldToForm(); - this.isRegistrationMode.set(true); - const decodedEmail = decodeURIComponent(atob(email)); - - this.verifyApiService - .verifyControllerVerifyEmail(token, decodedEmail) - .pipe( - delay(2000), - finalize(() => { - if (!this.verificationError()) { - this.isTokenVerifing.set(false); - this.isTokenVerified.set(true); - // Warte 3 Sekunden, dann schließe das Modal und fokussiere das Passwort-Feld - timer(3000).subscribe(() => { - this.isTokenVerified.set(false); - this.focusPasswordField(); - }); - } - }) - ) - .subscribe({ - next: (response: SuccessDtoApiModel) => { - if (response.success) { - this.isTokenVerifing.set(false); - console.log('Verification successful'); - } else { - console.error('Verification failed'); - this.verificationError.set( - 'Verification failed. Please check the reasons below:' - ); - this.errorReasons.set([ - 'The verification token may have expired.', - 'The email address may not match our records.', - 'The verification link may have been used already.', - ]); - } - }, - error: (error) => { - console.error('Verification failed', error); - this.verificationError.set( - 'An error occurred during verification. Please check the reasons below:' - ); - this.errorReasons.set([ - 'There might be a problem with your internet connection.', - 'Our servers might be experiencing issues.', - 'The verification service might be temporarily unavailable.', - ]); - }, - }); - - this.form.patchValue({ email: decodedEmail }); - const emailControl = this.form.get('email'); - - if (emailControl) { - emailControl.setValue(decodedEmail); - emailControl.disable(); - emailControl.markAsTouched(); - emailControl.setErrors(null); - } - } - } + this.verifyMagicLink(true); } public getInputClass(controlName: string): string { @@ -669,8 +238,8 @@ export class WelcomeRootComponent implements OnInit { if (this.isRegistrationMode()) { const signupCredentials: UserCredentialsDtoApiModel = { - email: this.form.value.email, - password: this.form.value.password, + email: this.form.getRawValue().email.trim(), + password: this.form.getRawValue().password.trim(), }; this.signupNewUser(signupCredentials); @@ -685,6 +254,127 @@ export class WelcomeRootComponent implements OnInit { } } + private verifyMagicLink(isSignup: boolean): void { + if (this.token() && (isSignup ? this.signup() : this.signin())) { + const token: string = this.extractVerifyToken(); + const email: string = this.extractEmail(); + const decodedEmail: string = decodeURIComponent(atob(email)); + + if (token && email) { + if (isSignup) { + this.setupEmailField(decodedEmail); + this.removeQueryParams.set(true); + this.addPasswordFieldToForm(); + this.isRegistrationMode.set(true); + } + + this.isVerifying.set(true); + this.verificationError.set(null); + this.errorReasons.set([]); + + timer(2500) + .pipe( + tap(() => { + this.isVerifying.set(false); + }), + switchMap(() => + this.verifyApiService.verifyControllerVerifyEmail( + token, + decodedEmail + ) + ), + tap((response: SuccessDtoApiModel) => { + if (response.success) { + this.isTokenVerified.set(true); + } + }), + delay(1000), + finalize(() => this.handleVerificationFinalize()) + ) + .subscribe({ + next: (response: SuccessDtoApiModel) => + this.handleVerificationResponse( + response, + isSignup, + decodedEmail, + token + ), + error: () => this.handleVerificationError(), + }); + } + } + } + + private handleVerificationFinalize(): void { + if (!this.verificationError()) { + this.displaySkeleton.set(false); + this.isTokenVerified.set(true); + timer(2000).subscribe(() => { + this.isTokenVerified.set(false); + this.focusPasswordField(); + }); + } + } + + private handleVerificationResponse( + response: SuccessDtoApiModel, + isSignup: boolean, + email: string, + token: string + ): void { + if (response.success) { + this.displaySkeleton.set(false); + this.isTokenVerified.set(true); + if (!isSignup) { + timer(2000).subscribe(() => { + this.authService + .signinMagicLink({ email, token }) + .subscribe((response: SigninResponseDtoApiModel) => { + if (response) { + this.router.navigate(['/dashboard']); + } + }); + }); + } + } else { + this.handleVerificationFailure( + 'Verification failed. Please check the reasons below:' + ); + } + } + + private handleVerificationError(): void { + this.isVerifying.set(false); + this.handleVerificationFailure( + 'An error occurred during verification. Please check the reasons below:' + ); + } + + private handleVerificationFailure(message: string): void { + this.verificationError.set(message); + this.errorReasons.set([ + 'The verification token may have expired.', + 'The device you are using may not match the one used to generate the token.', + 'The email address may not match our records.', + 'The verification link may have been used already.', + 'There might be a problem with your internet connection.', + 'Our servers might be experiencing issues.', + 'The verification service might be temporarily unavailable.', + ]); + } + + private setupEmailField(email: string): void { + this.form.patchValue({ email }); + this.form.get('email')?.setValue(email); + const emailControl = this.form.get('email'); + + if (emailControl) { + emailControl.disable({ onlySelf: true, emitEvent: false }); + emailControl.markAsTouched(); + emailControl.setErrors(null); + } + } + private clearRouteParams(): void { this.router.navigate([], { queryParams: {} }); } @@ -703,13 +393,10 @@ export class WelcomeRootComponent implements OnInit { private initializeForm(): void { this.form = this.formBuilder.group({ - email: new FormControl( - { value: '', disabled: false }, - { - validators: [Validators.required, customEmailValidator()], - updateOn: 'change', - } - ), + email: new FormControl('', { + validators: [Validators.required, customEmailValidator()], + updateOn: 'change', + }), }); } @@ -734,8 +421,7 @@ export class WelcomeRootComponent implements OnInit { ) .subscribe((response: SuccessDtoApiModel) => { if (response.success) { - console.log('User signed up successfully'); - // TODO: Redirect to Dashbord + // Display Modal // You have successfully signed up. Please check your email for the magic link. } }); } diff --git a/frontend/src/app/shared/service/auth.service.ts b/frontend/src/app/shared/service/auth.service.ts index c0ba5f4..5519be5 100644 --- a/frontend/src/app/shared/service/auth.service.ts +++ b/frontend/src/app/shared/service/auth.service.ts @@ -7,6 +7,7 @@ import { catchError, shareReplay, tap } from 'rxjs/operators'; import { AuthenticationApiService, MagicLinkDtoApiModel, + MagicLinkSigninDtoApiModel, SigninResponseDtoApiModel, SuccessDtoApiModel, UserCredentialsDtoApiModel, @@ -28,6 +29,14 @@ export class AuthService { this.statusCheck$ = this.initializeStatusCheck(); } + public signinMagicLink( + credentials: MagicLinkSigninDtoApiModel + ): Observable { + return this.authenticationApiService + .authControllerMagicLinkSignin(credentials) + .pipe(tap(() => this.isAuthenticatedSignal.set(true))); + } + public sendMagicLink( email: MagicLinkDtoApiModel ): Observable { @@ -42,13 +51,13 @@ export class AuthService { .pipe(tap(() => this.isAuthenticatedSignal.set(true))); } - public signin( - credentials: UserCredentialsDtoApiModel - ): Observable { - return this.authenticationApiService - .authControllerSignin(credentials) - .pipe(tap(() => this.isAuthenticatedSignal.set(true))); - } + // public signin( + // credentials: UserCredentialsDtoApiModel + // ): Observable { + // return this.authenticationApiService + // .authControllerSignin(credentials) + // .pipe(tap(() => this.isAuthenticatedSignal.set(true))); + // } public signout(): Observable { return this.authenticationApiService