Compare commits

..

No commits in common. "a25462474fb2b6115a94e11f22141b3768af14cf" and "8e82733dd015d8c55ee9e7c8282d4e6a9b20ffff" have entirely different histories.

55 changed files with 1010 additions and 2229 deletions

View File

@ -36,9 +36,6 @@ export class AppModule {
public configure(consumer: MiddlewareConsumer): void { public configure(consumer: MiddlewareConsumer): void {
consumer consumer
// TODO Redirect via Reverse Proxy all HTTP requests to HTTPS // TODO Redirect via Reverse Proxy all HTTP requests to HTTPS
// TODO use env.dev and end.prod
// TODO Implement helmet module
// TODO Implement CSRF protection -> csurf module
.apply( .apply(
CspMiddleware, CspMiddleware,
SecurityHeadersMiddleware, SecurityHeadersMiddleware,

View File

@ -1,16 +1,12 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { SessionService } from 'src/modules/session/services/session.service'; import { SessionService } from 'src/modules/session/services/session.service';
import { EmailVerificationService } from 'src/modules/verify-module/services/email-verification.service';
@Injectable() @Injectable()
export class ClearExpiredSessionsCron { export class ClearExpiredSessionsCron {
private readonly logger: Logger = new Logger(ClearExpiredSessionsCron.name); private readonly logger: Logger = new Logger(ClearExpiredSessionsCron.name);
public constructor( public constructor(private readonly sessionService: SessionService) {}
private readonly sessionService: SessionService,
private readonly emailVerificationService: EmailVerificationService
) {}
@Cron(CronExpression.EVERY_12_HOURS, { @Cron(CronExpression.EVERY_12_HOURS, {
name: 'Clear-Expired-Sessions', name: 'Clear-Expired-Sessions',
@ -21,14 +17,4 @@ export class ClearExpiredSessionsCron {
this.sessionService.deleteAllExpiredSessions(); this.sessionService.deleteAllExpiredSessions();
this.logger.log('-------------------------------------------'); this.logger.log('-------------------------------------------');
} }
@Cron(CronExpression.EVERY_5_MINUTES, {
name: 'Clear-Expired-Tokens',
timeZone: 'Europe/Berlin',
})
public handleClearExpiredTokens(): void {
this.logger.log('-Cronjob Executed: Delete-Expired-Tokens-');
this.emailVerificationService.deleteAllExpiredTokens();
this.logger.log('-------------------------------------------');
}
} }

View File

@ -21,12 +21,6 @@ export class EmailVerification {
@Column() @Column()
public expiresAt: Date; public expiresAt: Date;
@Column()
public email: string;
@Column({ nullable: true })
public userAgent: string;
@OneToOne(() => UserCredentials) @OneToOne(() => UserCredentials)
@JoinColumn({ name: 'userCredentialsId' }) @JoinColumn({ name: 'userCredentialsId' })
public user: UserCredentials; public user: UserCredentials;

View File

@ -13,13 +13,6 @@ export class SecurityHeadersMiddleware implements NestMiddleware {
'max-age=63072000; includeSubDomains; preload' 'max-age=63072000; includeSubDomains; preload'
); );
} }
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader(
'Permissions-Policy',
'geolocation=(), microphone=(), camera=()'
);
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN'); res.setHeader('X-Frame-Options', 'SAMEORIGIN');
next(); next();

View File

@ -15,12 +15,7 @@ import { SuccessDto } from 'src/shared';
import { Public } from 'src/shared/decorator'; import { Public } from 'src/shared/decorator';
import { LocalAuthGuard } from '../guard'; import { LocalAuthGuard } from '../guard';
import { import { SigninResponseDto, UserCredentialsDto } from '../models/dto';
MagicLinkDto,
MagicLinkSigninDto,
SigninResponseDto,
UserCredentialsDto,
} from '../models/dto';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
@ApiTags('Authentication') @ApiTags('Authentication')
@ -28,23 +23,6 @@ import { AuthService } from '../services/auth.service';
export class AuthController { export class AuthController {
public constructor(private readonly authService: AuthService) {} public constructor(private readonly authService: AuthService) {}
@ApiCreatedResponse({
description: 'Magic link sent successfully',
type: SuccessDto,
})
@ApiBody({ type: MagicLinkDto })
@Post('send-magic-link')
@HttpCode(HttpStatus.OK)
@Public()
public async sendMagicLink(
@Body() magicLinkDto: MagicLinkDto,
@Req() request: Request
): Promise<SuccessDto> {
const userAgent = request.headers['user-agent'] || 'Unknown';
return this.authService.sendMagicLink(magicLinkDto, userAgent);
}
@ApiCreatedResponse({ @ApiCreatedResponse({
description: 'User signed up successfully', description: 'User signed up successfully',
type: SuccessDto, type: SuccessDto,
@ -53,26 +31,21 @@ export class AuthController {
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@Public() @Public()
public async signup( public async signup(
@Body() userCredentials: UserCredentialsDto, @Body() userCredentials: UserCredentialsDto
@Req() request: Request
): Promise<SuccessDto> { ): Promise<SuccessDto> {
const userAgent = request.headers['user-agent'] || 'Unknown'; return this.authService.signup(userCredentials);
return this.authService.signup(userCredentials, userAgent);
} }
@ApiCreatedResponse({ @ApiCreatedResponse({
description: 'User signin successfully', description: 'User signin successfully',
type: SigninResponseDto, type: SigninResponseDto,
}) })
@ApiBody({ type: MagicLinkSigninDto }) @ApiBody({ type: UserCredentialsDto })
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@UseGuards(LocalAuthGuard) @UseGuards(LocalAuthGuard)
@Public() @Public()
@Post('magic-link-signin') @Post('signin')
public async magicLinkSignin( public async signin(@Req() request: Request): Promise<SigninResponseDto> {
@Req() request: Request
): Promise<SigninResponseDto> {
return this.authService.getLoginResponse( return this.authService.getLoginResponse(
request.user as SigninResponseDto & { userAgent: string } request.user as SigninResponseDto & { userAgent: string }
); );

View File

@ -1,2 +1 @@
export * from './local.auth.guard'; export * from './local.auth.guard';
export * from './is-authenticated.guard';

View File

@ -1,9 +0,0 @@
import { CanActivate, ExecutionContext } from '@nestjs/common';
export class IsAuthenticatedGuard implements CanActivate {
public canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return request.isAuthenticated();
}
}

View File

@ -1,4 +1,2 @@
export * from './user-credentials.dto'; export * from './user-credentials.dto';
export * from './signin-response.dto'; export * from './signin-response.dto';
export * from './magic-link.dto';
export * from './magic-link-signin.dto';

View File

@ -1,12 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString } from 'class-validator';
export class MagicLinkSigninDto {
@ApiProperty()
@IsString()
public token: string;
@ApiProperty()
@IsEmail()
public email: string;
}

View File

@ -1,12 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsEmail } from 'class-validator';
export class MagicLinkDto {
@ApiProperty({
description: 'User email',
example: 'foo@bar.com',
})
@IsNotEmpty()
@IsEmail()
public email: string;
}

View File

@ -12,13 +12,12 @@ export class SigninResponseDto {
@IsEmail() @IsEmail()
public email: string; public email: string;
// TODO: ID is saved in the session, so it is not needed here @ApiProperty({
// @ApiProperty({ title: 'User ID',
// title: 'User ID', description: 'User ID',
// description: 'User ID', })
// }) @IsNotEmpty()
// @IsNotEmpty() @IsString()
// @IsString() @IsEmail()
// @IsEmail() public id: string;
// public id: string;
} }

View File

@ -1,8 +1,4 @@
import { import { Injectable } from '@nestjs/common';
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { UserCredentials } from 'src/entities'; import { UserCredentials } from 'src/entities';
import { SessionService } from 'src/modules/session/services/session.service'; import { SessionService } from 'src/modules/session/services/session.service';
import { EncryptionService, SuccessDto } from 'src/shared'; import { EncryptionService, SuccessDto } from 'src/shared';
@ -15,11 +11,7 @@ import {
import { PasswordConfirmationMailService } from '../../sendgrid-module/services/password-confirmation.mail.service'; import { PasswordConfirmationMailService } from '../../sendgrid-module/services/password-confirmation.mail.service';
import { UserDataRepository } from '../../user-module/repositories/user-data.repository'; import { UserDataRepository } from '../../user-module/repositories/user-data.repository';
import { EmailVerificationService } from '../../verify-module/services/email-verification.service'; import { EmailVerificationService } from '../../verify-module/services/email-verification.service';
import { import { SigninResponseDto, UserCredentialsDto } from '../models/dto';
MagicLinkDto,
SigninResponseDto,
UserCredentialsDto,
} from '../models/dto';
import { UserCredentialsRepository } from '../repositories/user-credentials.repository'; import { UserCredentialsRepository } from '../repositories/user-credentials.repository';
@Injectable() @Injectable()
@ -32,55 +24,8 @@ export class AuthService {
private readonly sessionService: SessionService private readonly sessionService: SessionService
) {} ) {}
public async sendMagicLink(
magiclink: MagicLinkDto,
userAgent: string
): Promise<SuccessDto> {
try {
const existingUser = await this.userCredentialsRepository.findUserByEmail(
magiclink.email
);
if (existingUser) {
const token =
await this.emailVerificationService.generateEmailVerificationTokenForMagicLink(
magiclink.email,
userAgent,
existingUser.id
);
// TODO: Add OTP or 2FA here as an additional security measure
await this.passwordConfirmationMailService.sendLoginLinkEmail(
magiclink.email,
token
);
} else {
const token =
await this.emailVerificationService.generateEmailVerificationTokenForMagicLink(
magiclink.email,
userAgent
);
await this.passwordConfirmationMailService.sendRegistrationLinkEmail(
magiclink.email,
token
);
}
return { success: true };
} catch (error) {
if (error instanceof BadRequestException) {
throw error;
}
throw new InternalServerErrorException('MAGIC_LINK_ERROR', {
cause: error,
});
}
}
public async signup( public async signup(
userCredentials: UserCredentialsDto, userCredentials: UserCredentialsDto
userAgent: string
): Promise<SuccessDto> { ): Promise<SuccessDto> {
try { try {
const existingUser = await this.userCredentialsRepository.findUserByEmail( const existingUser = await this.userCredentialsRepository.findUserByEmail(
@ -100,10 +45,18 @@ export class AuthService {
passwordHashed passwordHashed
); );
await this.sendMagicLink({ email: user.email }, userAgent);
await this.userDataRepository.createInitialUserData(user); await this.userDataRepository.createInitialUserData(user);
const token =
await this.emailVerificationService.generateEmailVerificationToken(
user.id
);
await this.passwordConfirmationMailService.sendPasswordConfirmationMail(
user.email,
token
);
return { return {
success: true, success: true,
}; };
@ -119,31 +72,28 @@ export class AuthService {
} }
public async validateUser( public async validateUser(
token: string,
email: string, email: string,
userAgent: string password: string
): Promise<UserCredentials> { ): Promise<UserCredentials> {
try { 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); const user = await this.userCredentialsRepository.findUserByEmail(email);
if (!user) { if (!user) {
throw new UnauthorizedException('User not found'); throw new ForbiddenException('INVALID_CREDENTIALS');
}
const passwordMatch = await EncryptionService.compareHash(
password,
user.hashedPassword
);
if (!passwordMatch) {
throw new ForbiddenException('INVALID_CREDENTIALS');
} }
return user; return user;
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedException) { if (error instanceof ForbiddenException) {
throw error; throw error;
} else { } else {
throw new InternalServerErrorException('VALIDATION_ERROR', { throw new InternalServerErrorException('VALIDATION_ERROR', {
@ -153,10 +103,6 @@ export class AuthService {
} }
} }
public async getUserByEmail(email: string): Promise<UserCredentials> {
return this.userCredentialsRepository.findUserByEmail(email);
}
public async signout(sessionId: string): Promise<SuccessDto> { public async signout(sessionId: string): Promise<SuccessDto> {
try { try {
await this.sessionService.deleteSessionBySessionId(sessionId); await this.sessionService.deleteSessionBySessionId(sessionId);
@ -203,8 +149,8 @@ export class AuthService {
public getLoginResponse( public getLoginResponse(
user: SigninResponseDto & { userAgent: string } user: SigninResponseDto & { userAgent: string }
): SigninResponseDto { ): SigninResponseDto {
const { email }: SigninResponseDto = user; const { id, email }: SigninResponseDto = user;
const responseData: SigninResponseDto = { email }; const responseData: SigninResponseDto = { id, email };
return responseData; return responseData;
} }

View File

@ -1,56 +1,34 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express'; import { Request } from 'express';
import { Strategy } from 'passport-local'; import { Strategy } from 'passport-local';
import { EmailVerificationService } from 'src/modules/verify-module/services/email-verification.service';
import { MagicLinkSigninDto } from '../models/dto'; import { SigninResponseDto } from '../models/dto';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
@Injectable() @Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) { export class LocalStrategy extends PassportStrategy(Strategy) {
public constructor( public constructor(private readonly authService: AuthService) {
private authService: AuthService,
private emailVerificationService: EmailVerificationService
) {
super({ super({
usernameField: 'email', usernameField: 'email',
passwordField: 'token', passwordField: 'password',
passReqToCallback: true, passReqToCallback: true,
}); });
} }
public async validate(request: Request): Promise<any> { public async validate(
const { token, email }: MagicLinkSigninDto = request.body; request: Request,
email: string,
if (!token || !email) { password: string
throw new UnauthorizedException('Missing token or email'); ): Promise<SigninResponseDto & { userAgent: string }> {
} const user = await this.authService.validateUser(email, password);
const verificationResult = await this.emailVerificationService.verifyEmail(
token as string,
email as string,
request.headers['user-agent']
);
this.emailVerificationService.removeEmailVerificationByTokenAndEmail(
token as string,
email as string
);
if (!verificationResult.success) {
throw new UnauthorizedException('Invalid or expired token');
}
const user = await this.authService.getUserByEmail(email as string);
if (!user) { if (!user) {
throw new UnauthorizedException('User not found'); throw new UnauthorizedException();
} }
const userAgent = request.headers['user-agent']; const userAgent = request.headers['user-agent'];
return { id: user.id, email: user.email, userAgent }; return { id: user.id, email: user.email, userAgent: userAgent };
} }
} }

View File

@ -10,7 +10,6 @@ import { TemplateConfigService } from './template-config.service';
export class PasswordConfirmationMailService extends BaseMailService { export class PasswordConfirmationMailService extends BaseMailService {
private readonly PASSWORD_CONFIRMATION_EMAIL: string = private readonly PASSWORD_CONFIRMATION_EMAIL: string =
'PASSWORD_CONFIRMATION_EMAIL'; 'PASSWORD_CONFIRMATION_EMAIL';
private readonly REGISTER_EMAIL: string = 'REGISTER_EMAIL';
public constructor( public constructor(
@Inject('SEND_GRID_API_KEY') protected readonly sendGridApiKey: string, @Inject('SEND_GRID_API_KEY') protected readonly sendGridApiKey: string,
@ -42,44 +41,4 @@ export class PasswordConfirmationMailService extends BaseMailService {
await this.sendMail(mailoptions); await this.sendMail(mailoptions);
} }
public async sendLoginLinkEmail(
to: string,
loginToken: string
): Promise<void> {
const token = `${loginToken}|${UriEncoderService.encodeBase64(to)}`;
const loginLink = `${this.configService.get<string>('APP_URL')}/?token=${token}&signin=true`;
const mailoptions: SendGridMailApi.MailDataRequired = {
to,
from: { email: 'info@igor-propisnov.com', name: 'Ticket App' },
subject: 'Login to Your Account',
text: `Hi ${to}, Click this link to log in to your account: ${loginLink}`,
html: `<p>Click <a href="${loginLink}">here</a> to log in to your account.</p>`,
};
await this.sendMail(mailoptions);
}
public async sendRegistrationLinkEmail(
to: string,
registrationToken: string
): Promise<void> {
const token = `${registrationToken}|${UriEncoderService.encodeBase64(to)}`;
const registrationLink = `${this.configService.get<string>('APP_URL')}/?token=${token}&signup=true`;
const templateId: string = this.templateConfigService.getTemplateId(
this.REGISTER_EMAIL
);
const mailoptions: SendGridMailApi.MailDataRequired = {
to,
from: { email: 'info@igor-propisnov.com', name: 'Ticket App' },
templateId: templateId,
dynamicTemplateData: {
buttonUrl: registrationLink,
},
};
await this.sendMail(mailoptions);
}
} }

View File

@ -1,5 +1,9 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import {
import { SessionException } from 'src/shared/exceptions'; CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { SessionService } from '../services/session.service'; import { SessionService } from '../services/session.service';
@ -15,20 +19,20 @@ export class SessionGuard implements CanActivate {
const session = await this.sessionService.findSessionBySessionId(sessionId); const session = await this.sessionService.findSessionBySessionId(sessionId);
if (!session) { if (!session) {
throw new SessionException('Session not found.'); throw new UnauthorizedException('Session not found.');
} }
const isExpired = await this.sessionService.isSessioExpired(session); const isExpired = await this.sessionService.isSessioExpired(session);
if (isExpired) { if (isExpired) {
throw new SessionException('Session expired.'); throw new UnauthorizedException('Session expired.');
} }
const userAgentInSession = JSON.parse(session.json).passport.user const userAgentInSession = JSON.parse(session.json).passport.user
.userAgent as string; .userAgent as string;
if (userAgentInSession !== currentAgent) { if (userAgentInSession !== currentAgent) {
throw new SessionException('User agent mismatch.'); throw new UnauthorizedException('User agent mismatch.');
} }
return true; return true;

View File

@ -24,7 +24,6 @@ export class SessionInitService {
ttl: 86400, ttl: 86400,
}).connect(this.dataSource.getRepository(Session)), }).connect(this.dataSource.getRepository(Session)),
cookie: { cookie: {
// TODO: Check sameSite strict configuration on production
maxAge: 86400000, maxAge: 86400000,
httpOnly: true, httpOnly: true,
secure: secure:
@ -35,7 +34,7 @@ export class SessionInitService {
sameSite: sameSite:
this.configService.get<string>('NODE_ENV') === 'development' this.configService.get<string>('NODE_ENV') === 'development'
? 'strict' ? 'strict'
: 'strict', : 'none',
}, },
}); });
} }

View File

@ -1,13 +1,16 @@
import { import {
Controller, Controller,
Get,
Req,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Query, Query,
UseGuards,
Post, Post,
Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger'; import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger';
import { SuccessDto } from 'src/shared'; import { Request } from 'express';
import { SessionGuard } from 'src/modules/session/guard';
import { Public } from 'src/shared/decorator'; import { Public } from 'src/shared/decorator';
import { EmailVerificationService } from '../services/email-verification.service'; import { EmailVerificationService } from '../services/email-verification.service';
@ -21,22 +24,25 @@ export class VerifyController {
@ApiCreatedResponse({ @ApiCreatedResponse({
description: 'Verify email', description: 'Verify email',
type: SuccessDto, type: Boolean,
}) })
@Public() @Public()
@Post() @Post()
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
public async verifyEmail( public async verifyEmail(
@Query('token') tokenToVerify: string, @Query('token') tokenToVerify: string
@Query('email') emailToVerify: string, ): Promise<boolean> {
@Req() request: Request return this.emailVerificationService.verifyEmail(tokenToVerify);
): Promise<SuccessDto> { }
const userAgent = request.headers['user-agent'] || 'Unknown';
return this.emailVerificationService.verifyEmail( @ApiCreatedResponse({
tokenToVerify, description: 'Check if email is verified',
emailToVerify, type: Boolean,
userAgent })
); @Get('check')
@HttpCode(HttpStatus.OK)
@UseGuards(SessionGuard)
public async isEmailVerified(@Req() request: Request): Promise<boolean> {
return this.emailVerificationService.isEmailVerified(request.sessionID);
} }
} }

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { EmailVerification } from 'src/entities'; import { EmailVerification } from 'src/entities';
import { LessThan, Repository } from 'typeorm'; import { MoreThan, Repository } from 'typeorm';
@Injectable() @Injectable()
export class EmailVerifyRepository { export class EmailVerifyRepository {
@ -13,45 +13,36 @@ export class EmailVerifyRepository {
public async createEmailVerification( public async createEmailVerification(
token: string, token: string,
expiresAt: Date, expiresAt: Date,
email: string, userId: string
userId: string | null,
userAgent: string
): Promise<void> { ): Promise<void> {
await this.repository.delete({ email });
await this.repository.save({ await this.repository.save({
token, token,
expiresAt, expiresAt,
email, user: { id: userId },
user: userId ? { id: userId } : null,
userAgent,
}); });
} }
public async findByTokenAndEmail( public async findEmailVerificationByToken(token: string): Promise<boolean> {
token: string, const result = await this.repository.findOne({
email: string where: { token, expiresAt: MoreThan(new Date()) },
): Promise<EmailVerification | undefined> {
return await this.repository.findOne({
where: {
token,
email,
},
}); });
return result !== null;
} }
public async removeEmailVerificationByTokenAndEmail( public async deleteEmailVerificationByToken(
token: string, tokenToDelete: string
email: string ): Promise<EmailVerification | null> {
): Promise<void> { const emailVerification = await this.repository.findOne({
await this.repository.delete({ token, email }); where: { token: tokenToDelete },
} relations: ['user'],
public async deleteAllExpiredTokens(): Promise<void> {
const currentDate = new Date();
await this.repository.delete({
expiresAt: LessThan(currentDate),
}); });
if (emailVerification) {
await this.repository.delete({ token: tokenToDelete });
return emailVerification;
}
return null;
} }
} }

View File

@ -1,36 +1,31 @@
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SuccessDto, UriEncoderService } from 'src/shared'; import { EmailVerification } from 'src/entities';
import { import { SessionService } from 'src/modules/session/services/session.service';
InternalServerErrorException, import { UriEncoderService } from 'src/shared';
TokenExpiredException, import { InternalServerErrorException } from 'src/shared/exceptions';
UserAgentMismatchException,
} from 'src/shared/exceptions';
import { UserDataRepository } from '../../user-module/repositories/user-data.repository';
import { EmailVerifyRepository } from '../repositories'; import { EmailVerifyRepository } from '../repositories';
@Injectable() @Injectable()
export class EmailVerificationService { export class EmailVerificationService {
public constructor( public constructor(
private readonly emailVerifyRepository: EmailVerifyRepository private readonly emailVerifyRepository: EmailVerifyRepository,
private readonly userDataRepository: UserDataRepository,
private readonly sessionService: SessionService
) {} ) {}
public async generateEmailVerificationTokenForMagicLink( public async generateEmailVerificationToken(userId: string): Promise<string> {
email: string,
userAgent: string,
userid?: string
): Promise<string> {
try { try {
const verificationToken = await this.createVerificationToken(); const verificationToken = await this.createVerificationToken();
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); const expiration = new Date(Date.now() + 24 * 60 * 60 * 1000);
await this.emailVerifyRepository.createEmailVerification( await this.emailVerifyRepository.createEmailVerification(
verificationToken, verificationToken,
expiresAt, expiration,
email, userId
userid || null,
userAgent
); );
return verificationToken; return verificationToken;
@ -45,60 +40,55 @@ export class EmailVerificationService {
} }
} }
public async verifyEmail( public async verifyEmail(tokenToVerify: string): Promise<boolean> {
tokenToVerify: string,
emailToVerify: string,
userAgent: string
): Promise<SuccessDto> {
try { try {
const token = await this.emailVerifyRepository.findByTokenAndEmail( const emailVerification =
tokenToVerify, await this.emailVerifyRepository.findEmailVerificationByToken(
emailToVerify tokenToVerify
); );
if (!token) { if (!emailVerification) {
throw new TokenExpiredException(); return false;
} }
if (token.userAgent !== userAgent) { const deletedVerification =
throw new UserAgentMismatchException({ await this.deleteEmailVerificationToken(tokenToVerify);
message:
'The User Agent does not match the one used to generate the token.', if (deletedVerification && deletedVerification.user) {
}); const isStatusUpdated =
await this.userDataRepository.updateEmailVerificationStatus(
deletedVerification.user.id
);
return isStatusUpdated;
} }
const currentDate = new Date(); return false;
if (token.expiresAt.getTime() < currentDate.getTime()) {
throw new TokenExpiredException();
}
return { success: true };
} catch (error) { } catch (error) {
if (error instanceof TokenExpiredException) {
throw error;
}
if (error instanceof UserAgentMismatchException) {
throw error;
}
throw new InternalServerErrorException('EMAIL_VERIFICATION_ERROR', { throw new InternalServerErrorException('EMAIL_VERIFICATION_ERROR', {
message: 'An error occurred while verifying the email.', message: 'An error occurred while verifying the email.',
}); });
} }
} }
public async removeEmailVerificationByTokenAndEmail( public async isEmailVerified(sessionID: string): Promise<boolean> {
token: string, try {
email: string const userId = await this.sessionService.getUserIdBySessionId(sessionID);
): Promise<void> {
await this.emailVerifyRepository.removeEmailVerificationByTokenAndEmail(
token,
email
);
}
public async deleteAllExpiredTokens(): Promise<void> { if (!userId) {
await this.emailVerifyRepository.deleteAllExpiredTokens(); 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.',
});
}
} }
private async createVerificationToken(): Promise<string> { private async createVerificationToken(): Promise<string> {
@ -106,4 +96,12 @@ export class EmailVerificationService {
return UriEncoderService.encodeUri(verifyToken); return UriEncoderService.encodeUri(verifyToken);
} }
private async deleteEmailVerificationToken(
tokenToDelete: string
): Promise<EmailVerification | null> {
return await this.emailVerifyRepository.deleteEmailVerificationByToken(
tokenToDelete
);
}
} }

View File

@ -2,6 +2,3 @@ export * from './conflict.exception';
export * from './forbidden.exception'; export * from './forbidden.exception';
export * from './internal-server-error.exception'; export * from './internal-server-error.exception';
export * from './not-found.exception'; export * from './not-found.exception';
export * from './token-expired.exception';
export * from './useragent-mismatch-exception';
export * from './session.exception';

View File

@ -1,12 +0,0 @@
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,
});
}
}

View File

@ -1,14 +0,0 @@
import { HttpStatus } from '@nestjs/common';
import { BaseException } from './base.exception';
export class TokenExpiredException extends BaseException {
public constructor(details?: unknown) {
super(
'The verification token has expired. Please request a new one.',
HttpStatus.BAD_REQUEST,
'TOKEN_EXPIRED',
details
);
}
}

View File

@ -1,14 +0,0 @@
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
);
}
}

View File

@ -19,8 +19,6 @@ export class HttpExceptionFilter implements ExceptionFilter {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
console.error('Exception caught:', exception);
let status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR; let status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
let message: string = 'Internal server error'; let message: string = 'Internal server error';
let error: string = 'INTERNAL_SERVER_ERROR'; let error: string = 'INTERNAL_SERVER_ERROR';

View File

@ -10,6 +10,13 @@ const simpleLayoutRoutes: Routes = [
(m) => m.WelcomeRootComponent (m) => m.WelcomeRootComponent
), ),
}, },
{
path: 'verify',
loadComponent: () =>
import('./pages/email-verify-root/email-verify-root.component').then(
(m) => m.EmailVerifyRootComponent
),
},
]; ];
const protectedRoutes: Routes = [ const protectedRoutes: Routes = [
@ -39,11 +46,6 @@ const protectedRoutes: Routes = [
}, },
], ],
}, },
{
path: 'foo',
loadComponent: () =>
import('./pages/foo-root/foo.component').then((m) => m.FooComponent),
},
]; ];
export const routes: Routes = [ export const routes: Routes = [

View File

@ -1,399 +1,156 @@
<div class="flex h-screen w-screen bg-base-200"> <div class="flex h-screen overflow-hidden">
<div class="flex flex-col w-full lg:w-full bg-base-100 h-screen"> <div
<!-- Header mit dem Burger-Menü --> [ngStyle]="navigation"
[class]="
isCollapsed
? 'bg-primary w-0 md:w-20 transition-all duration-300 ease-in-out shadow-[5px_0_20px_rgba(0,0,0,0.5)]'
: showMobileMenu
? 'bg-primary w-64 transition-all duration-300 ease-in-out shadow-[5px_0_20px_rgba(0,0,0,0.5)]'
: isDesktopCollapsed
? 'bg-primary w-48 md:w-14 transition-all duration-300 ease-in-out shadow-[5px_0_20px_rgba(0,0,0,0.5)]'
: 'bg-primary w-48 md:w-48 transition-all duration-300 ease-in-out shadow-[5px_0_20px_rgba(0,0,0,0.5)]'
"
class="transform h-full z-20 overflow-y-auto fixed md:relative flex flex-col">
<div
[ngClass]="showMobileMenu ? 'justify-center' : 'justify-between'"
[ngStyle]="navigation"
class="p-1 w-full h-16 z-50 bg-base-100 flex items-center relative">
<div class="flex items-center justify-center h-full w-full">
<div class="flex items-center space-x-4">
@if (!isCollapsed && !isDesktopCollapsed) {
<div class="text-primary">LOGO</div>
}
@if (!isCollapsed && !showMobileMenu) {
<button
(click)="toggleDesktopSidebar()"
class="flex items-center justify-center w-10 h-10 rounded-full">
@if (isDesktopCollapsed) {
<svg
class="stroke-current text-primary w-6 h-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="3">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
} @else {
<svg
class="stroke-current text-primary w-6 h-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="3">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
}
</button>
}
</div>
</div>
</div>
<ul class="m-1">
<li
class="cursor-pointer rounded-btn mt-2"
[ngClass]="{
'bg-base-100 text-primary': item.active,
'text-primary-content hover:text-accent-content hover:bg-accent':
!item.active
}"
(click)="setActive(item)"
(keydown.enter)="setActive(item)"
(keydown.space)="setActive(item)"
tabindex="0"
role="button"
*ngFor="let item of menuItems">
<div
class="flex justify-center p-1"
*ngIf="isDesktopCollapsed && !showMobileMenu">
<span class="p-1" [innerHTML]="item.icon"></span>
</div>
<div
class="flex items-center rounded-btn justify-between cursor-pointer px-1 py-2"
*ngIf="!isDesktopCollapsed || showMobileMenu">
<div class="flex items-center">
<span [innerHTML]="item.icon" class="mx-2"></span>
<span>{{ item.name }}</span>
</div>
</div>
</li>
</ul>
<ul class="m-1 mt-auto">
<li
class="cursor-pointer bg-base-100 text-base-content rounded-btn mb-2"
*ngFor="let item of bottomMenuItems"
(click)="item.action ? item.action() : null"
(keydown.enter)="item.action ? item.action() : null"
(keydown.space)="item.action ? item.action() : null"
tabindex="0"
role="button">
<div
class="flex justify-center p-1"
*ngIf="isDesktopCollapsed && !showMobileMenu">
<span class="p-1" [innerHTML]="item.icon"></span>
</div>
<div
class="flex items-center rounded-btn justify-between cursor-pointer px-1 py-2"
*ngIf="!isDesktopCollapsed || showMobileMenu">
<div class="flex items-center">
<span [innerHTML]="item.icon" class="mx-2"></span>
<span>{{ item.name }}</span>
</div>
</div>
</li>
</ul>
</div>
<div class="flex flex-col flex-grow">
<header <header
[ngStyle]="navigation" [ngStyle]="navigation"
class="w-full navbar bg-primary text-primary-content z-40"> class="p-4 z-0 md:z-20 relative bg-primary text-primary-content flex items-center h-16 shadow-[0_5px_20px_rgba(0,0,0,0.5)]">
<div class="flex-1"> <div class="w-10 flex items-center justify-center md:hidden">
<a class="btn btn-ghost normal-case text-xl text-primary-content"> <label class="btn btn-ghost swap swap-rotate">
[APP-NAME] <input
</a> type="checkbox"
</div> (change)="toggleSidebar()"
<!-- Der Button wird nur auf mobilen Geräten angezeigt --> [checked]="!isCollapsed" />
<div class="flex-none lg:hidden"> <svg
<button class="swap-off fill-current"
(click)="toggleDrawer()" xmlns="http://www.w3.org/2000/svg"
class="btn btn-square btn-ghost drawer-button"> width="24"
<div height="24"
[@burgerAnimation]="isDrawerOpen ? 'open' : 'closed'" viewBox="0 0 512 512">
class="w-6 h-6"> <path
<svg d="M64,384H448V341.33H64Zm0-106.67H448V234.67H64ZM64,128v42.67H448V128Z" />
*ngIf="!isDrawerOpen" </svg>
xmlns="http://www.w3.org/2000/svg" <svg
width="24" class="swap-on fill-current"
height="24" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" width="24"
fill="none" height="24"
stroke="currentColor" viewBox="0 0 512 512">
stroke-width="2" <polygon
stroke-linecap="round" points="400 145.49 366.51 112 256 222.51 145.49 112 112 145.49 222.51 256 112 366.51 145.49 400 256 289.49 366.51 400 400 366.51 289.49 256 400 145.49" />
stroke-linejoin="round"> </svg>
<line x1="3" y1="12" x2="21" y2="12"></line> </label>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
<svg
*ngIf="isDrawerOpen"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</div>
</button>
</div>
<!-- Compact Mode Toggle Button für Desktop -->
<div class="hidden lg:flex items-center space-x-2 mr-4">
<button (click)="toggleCompactMode()" class="btn btn-square btn-ghost">
<div
[@compactAnimation]="isCompact ? 'compact' : 'normal'"
class="relative w-6 h-6">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="absolute inset-0 w-6 h-6">
<!-- Sidebar outline -->
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<!-- Icon representation -->
<circle cx="7" cy="8" r="1" />
<circle cx="7" cy="12" r="1" />
<circle cx="7" cy="16" r="1" />
<!-- Text representation -->
<line
x1="11"
y1="8"
x2="19"
y2="8"
class="transition-opacity duration-300"
[class.opacity-0]="isCompact" />
<line
x1="11"
y1="12"
x2="19"
y2="12"
class="transition-opacity duration-300"
[class.opacity-0]="isCompact" />
<line
x1="11"
y1="16"
x2="19"
y2="16"
class="transition-opacity duration-300"
[class.opacity-0]="isCompact" />
</svg>
</div>
</button>
</div> </div>
</header> </header>
<!-- Hauptcontainer --> <div [ngStyle]="mainContent" class="overflow-y-auto h-screen">
<div class="flex-1 flex overflow-hidden"> <router-outlet></router-outlet>
<!-- Drawer -->
<div
[@drawerAnimation]="
isDrawerOpen
? isCompact
? 'compact'
: 'open'
: isCompact
? 'compact'
: 'closed'
"
class="h-full transition-transform duration-300 ease-in-out bg-primary text-primary-content flex flex-col lg:translate-x-0"
[ngClass]="{
'w-16': isCompact && !isDrawerOpen,
'w-64': isDrawerOpen,
'fixed lg:relative': true,
'top-0 left-0 z-30': true,
'translate-x-0': isDrawerOpen,
'-translate-x-full': !isDrawerOpen && !isCompact
}">
<aside class="h-full flex flex-col">
<!-- Drawer-Inhalt -->
<div class="relative flex-1 pt-16 lg:pt-0">
<ul class="w-full p-0 m-0 [&_li>*]:rounded-none">
<ng-container *ngFor="let item of menuItems">
<li class="w-full">
<ng-container *ngIf="!item.subitems; else submenu">
<div
[ngClass]="{
'tooltip tooltip-right w-full': isCompact
}"
[attr.data-tip]="item.name">
<a
[routerLink]="item.route"
[class.active]="item.active"
class="flex items-center w-full px-4 py-3 transition-colors duration-200 ease-in-out focus:outline-none hover:bg-base-300 hover:text-primary"
[ngClass]="{
'bg-base-100': item.active,
'text-base-content': item.active,
'text-primary': item.active,
'font-semibold': item.active,
'justify-center': isCompact,
'px-4': !isCompact
}"
role="menuitem"
tabindex="0"
(click)="onLinkClick()">
<!-- Icon für Compact Mode -->
<span
*ngIf="isCompact"
class="flex-shrink-0 w-6 h-6"
[innerHTML]="item.icon"></span>
<!-- Icon und Text für normalen Modus -->
<span
*ngIf="!isCompact"
class="flex items-center space-x-2">
<span
class="flex-shrink-0 w-6 h-6 mr-3"
[innerHTML]="item.icon"></span>
<span>{{ item.name }}</span>
</span>
</a>
</div>
</ng-container>
<ng-template #submenu>
<!-- Wrapper für Submenu im Compact Mode -->
<div
*ngIf="isCompact"
[ngClass]="{
'tooltip tooltip-right w-full': isCompact
}"
[attr.data-tip]="item.name"
class="relative">
<!-- Hauptcontainer für den Menüpunkt -->
<div
(click)="toggleSubmenu(item, $event)"
(keydown.enter)="toggleSubmenu(item, $event)"
(keydown.space)="toggleSubmenu(item, $event)"
class="flex items-center w-full px-4 py-3 transition-colors duration-200 ease-in-out focus:outline-none hover:bg-base-300 hover:text-primary cursor-pointer"
[ngClass]="{
'bg-base-300': item.active,
'text-primary': item.active,
'font-semibold': item.active,
'bg-base-100': item.isOpen,
'text-base-content': item.isOpen,
'justify-center': isCompact,
'px-4': !isCompact
}"
role="button"
tabindex="0"
[attr.aria-expanded]="item.isOpen">
<span
class="flex-shrink-0 w-6 h-6"
[innerHTML]="item.icon"
aria-hidden="true"></span>
<!-- Normale Darstellung für nicht Compact Mode -->
<span
*ngIf="!isCompact"
class="flex items-center space-x-2">
<span
class="flex-shrink-0 w-6 h-6 mr-3"
[innerHTML]="item.icon"
aria-hidden="true"></span>
<span>{{ item.name }}</span>
</span>
<!-- Pfeil-Icon für normalen Modus -->
<svg
*ngIf="!isCompact"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4 transition-transform"
[class.rotate-180]="item.isOpen"
aria-hidden="true">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</div>
<!-- Submenu Box nur im Compact Mode -->
<div
*ngIf="isCompact && item.isOpen"
class="absolute left-16 border-2 border-base-300 mx-0.5 bg-base-100 text-base-content"
style="top: 0; z-index: 50; width: 200px">
<div
[ngClass]="{
'bg-base-100': item.active,
'text-base-content': item.active,
'text-primary': item.active,
'font-semibold': item.active
}"
class="font-semibold bg-base-300 px-2 py-[11px]">
{{ item.name }}
</div>
<ul>
<li *ngFor="let subItem of item.subitems">
<a
[routerLink]="subItem.route"
[class.active]="subItem.active"
class="block py-2 px-2 rounded transition-colors duration-200 ease-in-out hover:bg-base-200 hover:text-primary flex items-center space-x-2"
[ngClass]="{
'bg-base-300': subItem.active,
'text-primary': subItem.active,
'font-semibold': subItem.active
}"
(click)="onLinkClick()">
<!-- Icon für das Subitem -->
<span
*ngIf="subItem.icon"
class="flex-shrink-0 w-5 h-5 mr-2"
[innerHTML]="subItem.icon"></span>
<!-- Text für das Subitem -->
<span>{{ subItem.name }}</span>
</a>
</li>
</ul>
</div>
</div>
<!-- Original Submenu für normalen Modus -->
<div *ngIf="!isCompact" class="overflow-hidden">
<div
(click)="toggleSubmenu(item, $event)"
(keydown.enter)="toggleSubmenu(item, $event)"
(keydown.space)="toggleSubmenu(item, $event)"
class="flex items-center w-full px-4 py-3 transition-colors duration-200 ease-in-out focus:outline-none hover:bg-base-300 hover:text-primary cursor-pointer"
[ngClass]="{
'bg-base-300': item.active,
'text-primary': item.active,
'font-semibold': item.active,
'bg-base-100': item.isOpen,
'text-base-content': item.isOpen
}"
role="button"
tabindex="0"
[attr.aria-expanded]="item.isOpen"
[attr.aria-controls]="'submenu-' + item.name">
<span
class="flex-shrink-0 w-6 h-6"
[ngClass]="{ 'mr-3': !isCompact }"
[innerHTML]="item.icon"
aria-hidden="true"></span>
<span class="flex-grow">{{ item.name }}</span>
<svg
*ngIf="!isCompact"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4 transition-transform"
[class.rotate-180]="item.isOpen"
aria-hidden="true">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</div>
<div
[@submenuAnimation]="item.isOpen ? 'open' : 'closed'"
class="overflow-hidden">
<ul class="bg-base-100">
<li *ngFor="let subItem of item.subitems">
<a
[routerLink]="subItem.route"
[class.active]="subItem.active"
class="flex items-center w-full px-4 pl-8 py-2 transition-colors duration-200 ease-in-out text-base-content focus:outline-none hover:bg-base-300 hover:text-primary"
[ngClass]="{
'bg-base-300': subItem.active,
'text-primary': subItem.active,
'font-semibold': subItem.active
}"
(click)="onLinkClick()">
<span
class="flex-shrink-0 w-5 h-5 mr-2"
[innerHTML]="subItem.icon"></span>
{{ subItem.name }}
</a>
</li>
</ul>
</div>
</div>
</ng-template>
</li>
</ng-container>
</ul>
</div>
<div>
<ul class="w-full px-2 py-2">
<ng-container *ngFor="let item of bottomMenuItems">
<li>
<button
(click)="executeAction(item)"
(keydown.enter)="executeAction(item)"
class="py-2 px-4 rounded flex items-center space-x-2 bg-base-300 text-base-content hover:text-primary hover:font-semibold w-full text-left"
role="menuitem"
tabindex="0">
<div
*ngIf="isCompact"
class="tooltip tooltip-right pr-4"
[attr.data-tip]="item.name">
<span [innerHTML]="item.icon"></span>
</div>
<span [innerHTML]="item.icon" *ngIf="!isCompact"></span>
<span *ngIf="!isCompact && isDrawerOpen">
{{ item.name }}
</span>
</button>
</li>
</ng-container>
</ul>
</div>
</aside>
</div>
<!-- Backdrop -->
<div
[@backdropAnimation]="isDrawerOpen && !isCompact ? 'visible' : 'hidden'"
*ngIf="isDrawerOpen && !isCompact && isMobile"
class="fixed inset-0 bg-black bg-opacity-50 z-20"
(click)="toggleDrawer()"
(keydown.escape)="toggleDrawer()"
tabindex="0"
role="button"
aria-label="Close drawer"
(keydown.enter)="toggleDrawer()"
(keydown.space)="toggleDrawer()"></div>
<!-- Hauptinhalt -->
<div
class="flex-1 overflow-y-auto bg-base-100 transition-all duration-200 ease-in-out"
[ngClass]="{
'ml-16 lg:ml-0': isCompact && isMobile,
'ml-64 lg:ml-0': !isCompact && isDrawerOpen && isMobile,
'ml-0': (!isDrawerOpen && !isCompact) || !isMobile
}">
<main [ngStyle]="mainContent" class="w-full h-full p-4">
<router-outlet></router-outlet>
</main>
</div>
</div> </div>
</div> </div>
<div
*ngIf="!isCollapsed"
class="fixed inset-0 bg-black bg-opacity-50 z-10 md:hidden"
(click)="toggleSidebar()"></div>
</div> </div>

View File

@ -1,29 +1,13 @@
import {
animate,
state,
style,
transition,
trigger,
} from '@angular/animations';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
ElementRef, ElementRef,
HostListener, HostListener,
OnDestroy,
OnInit, OnInit,
} from '@angular/core'; } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { import { Router, RouterOutlet } from '@angular/router';
ActivatedRoute,
NavigationEnd,
Router,
RouterModule,
RouterOutlet,
} from '@angular/router';
import { filter, Subject, takeUntil } from 'rxjs';
import { SuccessDtoApiModel } from '../../api'; import { SuccessDtoApiModel } from '../../api';
import { BackgroundPatternService, ThemeService } from '../../shared/service'; import { BackgroundPatternService, ThemeService } from '../../shared/service';
@ -32,17 +16,8 @@ import { AuthService } from '../../shared/service/auth.service';
interface TopMenuItem { interface TopMenuItem {
name: string; name: string;
icon: SafeHtml; icon: SafeHtml;
route?: string;
active?: boolean;
subitems?: SubMenuItem[];
isOpen?: boolean;
}
interface SubMenuItem {
name: string;
route: string; route: string;
active?: boolean; active?: boolean;
icon?: SafeHtml;
} }
interface BottomMenuItem { interface BottomMenuItem {
@ -55,99 +30,14 @@ interface BottomMenuItem {
selector: 'app-layout', selector: 'app-layout',
standalone: true, standalone: true,
providers: [], providers: [],
imports: [RouterOutlet, CommonModule, RouterModule], imports: [RouterOutlet, CommonModule],
templateUrl: './layout.component.html', templateUrl: './layout.component.html',
changeDetection: ChangeDetectionStrategy.Default, changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('compactAnimation', [
state(
'normal',
style({
transform: 'scaleX(1)',
})
),
state(
'compact',
style({
transform: 'scaleX(1)',
})
),
transition('normal <=> compact', [animate('300ms ease-in-out')]),
]),
trigger('burgerAnimation', [
state('closed', style({ transform: 'rotate(0deg)' })),
state('open', style({ transform: 'rotate(180deg)' })),
transition('closed <=> open', [animate('0.3s ease-in-out')]),
]),
trigger('submenuAnimation', [
state(
'closed',
style({
height: '0',
opacity: '0',
})
),
state(
'open',
style({
height: '*',
opacity: '1',
})
),
transition('closed <=> open', [animate('200ms ease-in-out')]),
]),
trigger('drawerAnimation', [
state(
'open',
style({
width: '16rem',
})
),
state(
'compact',
style({
width: '4rem',
})
),
state(
'closed',
style({
width: '0',
})
),
transition('closed <=> open', [animate('200ms ease-in-out')]),
transition('open <=> compact', [animate('200ms ease-in-out')]),
transition('compact <=> closed', [animate('200ms ease-in-out')]),
]),
trigger('backdropAnimation', [
state('visible', style({ opacity: 1 })),
state('hidden', style({ opacity: 0 })),
transition('hidden <=> visible', [animate('200ms ease-in-out')]),
]),
trigger('mainContentAnimation', [
state(
'shifted',
style({
marginLeft: '4rem',
})
),
state(
'normal',
style({
marginLeft: '0',
})
),
transition('shifted <=> normal', [animate('200ms ease-in-out')]),
]),
],
}) })
export class LayoutComponent implements OnInit, OnDestroy { export class LayoutComponent implements OnInit {
public isCollapsed: boolean = false; public isCollapsed: boolean = false;
public isDesktopCollapsed: boolean = false; public isDesktopCollapsed: boolean = false;
public showMobileMenu: boolean = false; public showMobileMenu: boolean = false;
public isDrawerOpen: boolean = true;
public isCompact: boolean = false;
public isMobile: boolean = window.innerWidth < 1024;
public menuItems: TopMenuItem[] = [ public menuItems: TopMenuItem[] = [
{ {
name: 'Dashboard', name: 'Dashboard',
@ -159,41 +49,11 @@ export class LayoutComponent implements OnInit, OnDestroy {
}, },
{ {
name: 'Event', name: 'Event',
route: '/event',
icon: this.sanitizer icon: this.sanitizer
.bypassSecurityTrustHtml(`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> .bypassSecurityTrustHtml(`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 0 1 0 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 0 1 0-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 0 1 0 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 0 1 0-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375Z" />
</svg>`), </svg>`),
route: '/event',
},
{
name: 'Profile',
route: '/profile',
icon: this.sanitizer
.bypassSecurityTrustHtml(`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>`),
subitems: [
{
name: 'Edit Profile',
route: '/foo',
icon: this.sanitizer.bypassSecurityTrustHtml(`
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536-8.678 8.678H6.554v-3.536l8.678-8.678z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M16.368 4.096a1.5 1.5 0 0 1 2.121 0l1.415 1.415a1.5 1.5 0 0 1 0 2.121l-.707.707-3.536-3.536.707-.707z" />
</svg>
`),
},
{
name: 'Delete Profile',
route: '/foo-1',
icon: this.sanitizer.bypassSecurityTrustHtml(`
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7H5m3-4h8m1 4v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7h12z" />
</svg>
`),
},
],
isOpen: false,
}, },
]; ];
public bottomMenuItems: BottomMenuItem[] = [ public bottomMenuItems: BottomMenuItem[] = [
@ -208,93 +68,37 @@ export class LayoutComponent implements OnInit, OnDestroy {
]; ];
public mainContent: { 'background-image': string } | null = null; public mainContent: { 'background-image': string } | null = null;
public navigation: { 'background-image': string } | null = null; public navigation: { 'background-image': string } | null = null;
private destroy$: Subject<void> = new Subject<void>();
public constructor( public constructor(
private readonly sanitizer: DomSanitizer, private readonly sanitizer: DomSanitizer,
private readonly router: Router, private readonly router: Router,
private readonly backgroundPatternService: BackgroundPatternService, private readonly backgroundPatternService: BackgroundPatternService,
private readonly route: ActivatedRoute,
private readonly themeService: ThemeService, private readonly themeService: ThemeService,
private readonly el: ElementRef, private readonly el: ElementRef,
private readonly authService: AuthService private readonly authService: AuthService
) {} ) {}
@HostListener('window:resize', ['$event'])
public onResize(event: Event): void {
this.isMobile = (event.target as Window).innerWidth < 1024;
this.adjustDrawerState((event.target as Window).innerWidth);
}
public ngOnInit(): void { public ngOnInit(): void {
this.setActiveItemBasedOnRoute();
this.router.events.subscribe(() => {
this.setActiveItemBasedOnRoute();
});
this.setBackground(); this.setBackground();
this.adjustDrawerState(window.innerWidth); this.onResize();
window.addEventListener('resize', this.onResize.bind(this));
this.updateMenuState(this.router.url);
this.router.events
.pipe(
filter(
(event): event is NavigationEnd => event instanceof NavigationEnd
),
takeUntil(this.destroy$)
)
.subscribe((event: NavigationEnd) => {
this.updateMenuState(event.urlAfterRedirects);
});
} }
public ngOnDestroy(): void { @HostListener('window:resize', ['$event'])
window.removeEventListener('resize', this.onResize.bind(this)); public onResize(): void {
this.destroy$.next(); if (window.innerWidth >= 768) {
this.destroy$.complete(); this.showMobileMenu = false;
} this.isCollapsed = false;
public onLinkClick(): void {
if (window.innerWidth < 1024 && this.isDrawerOpen) {
this.isDrawerOpen = false;
this.isCompact = true;
}
}
public toggleDrawer(): void {
this.isDrawerOpen = !this.isDrawerOpen;
this.isCompact = !this.isDrawerOpen;
}
public toggleCompactMode(): void {
this.isCompact = !this.isCompact;
this.isDrawerOpen = !this.isCompact;
}
public adjustDrawerState(width: number): void {
if (width < 1024) {
this.isCompact = true;
this.isDrawerOpen = false;
} else { } else {
this.isCompact = false; this.isDesktopCollapsed = false;
this.isDrawerOpen = true; this.isCollapsed = true;
this.showMobileMenu = false;
} }
} }
public navigateTo(route: string): void {
this.router.navigate([route]);
}
public executeAction(item: BottomMenuItem): void {
if (item.action) {
item.action();
}
}
public toggleSubmenu(item: TopMenuItem, event: Event): void {
event.preventDefault();
event.stopPropagation();
item.isOpen = !item.isOpen;
}
public setBackground(): void { public setBackground(): void {
const theme = this.themeService.getTheme(); const theme = this.themeService.getTheme();
let opacity: number; let opacity: number;
@ -330,29 +134,34 @@ export class LayoutComponent implements OnInit, OnDestroy {
}; };
} }
private updateMenuState(currentRoute: string): void { public toggleSidebar(): void {
if (window.innerWidth < 768) {
this.showMobileMenu = !this.showMobileMenu;
this.isCollapsed = !this.showMobileMenu;
} else {
this.isDesktopCollapsed = !this.isDesktopCollapsed;
}
}
public toggleDesktopSidebar(): void {
this.isDesktopCollapsed = !this.isDesktopCollapsed;
}
public setActive(item: TopMenuItem): void {
this.menuItems.forEach((menu: TopMenuItem) => {
menu.active = false;
});
this.router.navigate([item.route]);
if (!this.isCollapsed && this.showMobileMenu) {
this.toggleSidebar();
}
}
private setActiveItemBasedOnRoute(): void {
const url = this.router.url;
this.menuItems.forEach((item: TopMenuItem) => { this.menuItems.forEach((item: TopMenuItem) => {
// Set top-level items active state item.active = url.startsWith(item.route);
if (item.route) {
item.active = currentRoute.startsWith(item.route);
}
// Handle subitems
if (item.subitems) {
// Check if any subitem matches the current route
const activeSubItem = item.subitems.some((subItem) =>
currentRoute.startsWith(subItem.route)
);
// Set the parent item and subitem active state
item.active = activeSubItem;
item.isOpen = activeSubItem;
// Set active states for all subitems
item.subitems.forEach((subItem) => {
subItem.active = currentRoute.startsWith(subItem.route);
});
}
}); });
} }

View File

@ -0,0 +1,39 @@
<div class="bg-primary w-screen h-screen">
<div class="modal modal-open">
<div
[ngStyle]="backgroundStyle"
class="modal-box w-11/12 h-2/6 max-w-5xl flex">
<div class="w-full flex flex-col justify-center items-center">
@if (verifyStatus() === true) {
@if (showRedirectMessage()) {
<div class="text-center">
<h1 class="font-bold text-3xl pt-5">Your email is verified!</h1>
<p class="pt-3 pb-6">
Your email {{ email() }} has been successfully verified. will
<br />
You will be automatically redirected in to the login page to
access the application shortly.
</p>
<button
(click)="navigateToWelcomeScreen()"
class="btn btn-primary no-animation">
Go to the App
</button>
</div>
}
} @else if (verifyStatus() === false) {
<div class="text-center">
<h1 class="font-bold text-3xl pt-5">
Oops, something went wrong! :(
</h1>
<p class="pt-3">We couldn't verify your email.</p>
</div>
} @else {
<div class="text-center">
<span class="loading loading-dots loading-lg"></span>
</div>
}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,117 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
InputSignal,
OnInit,
WritableSignal,
input,
signal,
} from '@angular/core';
import { Router } from '@angular/router';
import { delay, filter, tap } from 'rxjs';
import { VerifyApiService } from '../../api';
import { BackgroundPatternService, ThemeService } from '../../shared/service';
@Component({
selector: 'app-email-verify-root',
standalone: true,
imports: [CommonModule],
providers: [],
templateUrl: './email-verify-root.component.html',
styleUrl: './email-verify-root.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EmailVerifyRootComponent implements OnInit {
public token: InputSignal<string> = input<string>('');
public email: WritableSignal<string> = signal<string>('');
public backgroundStyle: { 'background-image': string } | null = null;
public verifyStatus: WritableSignal<boolean | null> = signal<boolean | null>(
null
);
public showRedirectMessage: WritableSignal<boolean> = signal<boolean>(false);
public constructor(
private readonly api: VerifyApiService,
private readonly router: Router,
private readonly el: ElementRef,
private readonly backgroundPatternService: BackgroundPatternService,
private readonly themeService: ThemeService
) {}
public ngOnInit(): void {
this.verifyEmail();
this.setBackground();
}
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 svgUrl = this.backgroundPatternService.getWigglePattern(
colorPrimary,
opacity
);
this.backgroundStyle = { 'background-image': `url("${svgUrl}")` };
}
public navigateToWelcomeScreen(): void {
const email: string = this.extractEmail();
this.router.navigate(['/welcome'], {
queryParams: { verified: true, email: email },
});
}
private extractVerifyToken(): string {
const [verifyToken]: string[] = this.token().split('|');
return verifyToken;
}
private extractEmail(): string {
const [, email]: string[] = this.token().split('|');
return email;
}
private verifyEmail(): void {
const verifyToken: string = this.extractVerifyToken();
const email: string = this.extractEmail();
if (verifyToken && email) {
this.email.set(decodeURIComponent(atob(email)));
}
this.api
.verifyControllerVerifyEmail(verifyToken)
.pipe(
delay(1500),
tap((isVerified: boolean) => {
this.verifyStatus.set(isVerified);
}),
filter((isVerified) => isVerified),
tap(() => {
this.showRedirectMessage.set(true);
}),
delay(10000)
)
.subscribe(() => {
this.navigateToWelcomeScreen();
});
}
}

View File

@ -1,6 +1,7 @@
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<div class="w-full max-w-full sticky top-0 z-10 pt-4 px-4 sm:px-8"> <div
<div class="w-full mx-auto"> class="w-full bg-base-100 max-w-full sticky top-0 z-10 pt-4 px-4 sm:px-8">
<div class="w-full max-w-4xl mx-auto">
<app-stepper-indicator <app-stepper-indicator
[steps]="steps" [steps]="steps"
[currentStep]="currentStep()" [currentStep]="currentStep()"
@ -13,7 +14,7 @@
<!-- Rest of the component remains the same --> <!-- Rest of the component remains the same -->
<div class="flex-grow overflow-y-auto px-4 sm:px-8 py-8"> <div class="flex-grow overflow-y-auto px-4 sm:px-8 py-8">
<div class="w-full mx-auto"> <div class="w-full max-w-4xl mx-auto">
@if (currentStep() === 0) { @if (currentStep() === 0) {
<app-basic-step [form]="form"></app-basic-step> <app-basic-step [form]="form"></app-basic-step>
} }
@ -23,8 +24,9 @@
</div> </div>
</div> </div>
<div class="w-full bg-base-100 sticky bottom-0 z-10 px-4 sm:px-8 py-4"> <div
<div class="flex justify-between mx-auto"> class="w-full bg-base-100 max-w-full sticky bottom-0 z-10 px-4 sm:px-8 py-4">
<div class="flex justify-between max-w-4xl mx-auto">
<div> <div>
<button <button
type="button" type="button"

View File

@ -25,10 +25,24 @@ export class EventEmptyStateComponent {
) {} ) {}
public navigateToCreateEvent(): void { public navigateToCreateEvent(): void {
this.router.navigate(['/event/create']); this.verifyApi
.verifyControllerIsEmailVerified()
.subscribe((isVerified: boolean) => {
if (!isVerified) {
this.openEmailVerificationModal();
} else {
this.router.navigate(['/event/create']);
}
});
} }
public closeEmailVerificationModal(): void { public closeEmailVerificationModal(): void {
(this.emailVerificationModal.nativeElement as HTMLDialogElement).close(); (this.emailVerificationModal.nativeElement as HTMLDialogElement).close();
} }
private openEmailVerificationModal(): void {
(
this.emailVerificationModal.nativeElement as HTMLDialogElement
).showModal();
}
} }

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterOutlet, RouterModule } from '@angular/router';
@Component({
selector: 'app-foo',
standalone: true,
template: '<h1>Foo</h1>',
providers: [],
imports: [RouterOutlet, CommonModule, RouterModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FooComponent {}

View File

@ -6,11 +6,10 @@ import {
OnInit, OnInit,
WritableSignal, WritableSignal,
signal, signal,
ElementRef, effect,
InputSignal, InputSignal,
input, input,
effect, ElementRef,
ViewChild,
} from '@angular/core'; } from '@angular/core';
import { import {
FormBuilder, FormBuilder,
@ -23,16 +22,16 @@ import {
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ButtonModule } from 'primeng/button'; import { ButtonModule } from 'primeng/button';
import { CheckboxModule } from 'primeng/checkbox';
import { InputTextModule } from 'primeng/inputtext'; import { InputTextModule } from 'primeng/inputtext';
import { delay, finalize, of, switchMap, takeWhile, tap, timer } from 'rxjs'; import { PasswordModule } from 'primeng/password';
import { delay, finalize, takeWhile, tap } from 'rxjs';
import { import {
Configuration, Configuration,
MagicLinkDtoApiModel,
SigninResponseDtoApiModel, SigninResponseDtoApiModel,
SuccessDtoApiModel, SuccessDtoApiModel,
UserCredentialsDtoApiModel, UserCredentialsDtoApiModel,
VerifyApiService,
} from '../../api'; } from '../../api';
import { ApiConfiguration } from '../../config/api-configuration'; import { ApiConfiguration } from '../../config/api-configuration';
import { import {
@ -40,12 +39,16 @@ import {
BackgroundPatternService, BackgroundPatternService,
ThemeService, ThemeService,
} from '../../shared/service'; } from '../../shared/service';
import { customEmailValidator } from '../../shared/validator'; import { LocalStorageService } from '../../shared/service/local-storage.service';
import {
customEmailValidator,
customPasswordValidator,
} from '../../shared/validator';
import { LocalStorageService } from './../../shared/service/local-storage.service'; type AuthAction = 'signin' | 'signup';
@Component({ @Component({
selector: 'app-unified-login', selector: 'app-register-root',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
@ -53,6 +56,8 @@ import { LocalStorageService } from './../../shared/service/local-storage.servic
InputTextModule, InputTextModule,
ReactiveFormsModule, ReactiveFormsModule,
ButtonModule, ButtonModule,
CheckboxModule,
PasswordModule,
HttpClientModule, HttpClientModule,
], ],
providers: [ providers: [
@ -67,28 +72,22 @@ import { LocalStorageService } from './../../shared/service/local-storage.servic
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class WelcomeRootComponent implements OnInit { export class WelcomeRootComponent implements OnInit {
@ViewChild('passwordInput') public passwordInput!: ElementRef;
public token: InputSignal<string> = input<string>('');
public signedOut: InputSignal<boolean> = input<boolean>(false);
public signup: InputSignal<boolean> = input<boolean>(false);
public signin: InputSignal<boolean> = input<boolean>(false);
public dialogBackgroundStyle: { 'background-image': string } | null = null; public dialogBackgroundStyle: { 'background-image': string } | null = null;
public leftBackgroundStyle: { 'background-image': string } | null = null; public leftBackgroundStyle: { 'background-image': string } | null = null;
public rightBackgroundStyle: { 'background-image': string } | null = null; public rightBackgroundStyle: { 'background-image': string } | null = null;
public verified: InputSignal<boolean> = input<boolean>(false);
public login: InputSignal<boolean> = input<boolean>(false);
public email: InputSignal<string> = input<string>('');
public signedOut: InputSignal<boolean> = input<boolean>(true);
public form!: FormGroup; public form!: FormGroup;
public rememberMe: FormControl = new FormControl(false);
public isSigninSignal: WritableSignal<boolean> = signal(false);
public isSignupSignal: WritableSignal<boolean> = signal(true);
public isSignUpSuccess: WritableSignal<boolean> = signal(false);
public userSignupSuccess: WritableSignal<boolean> = signal(false);
public isDialogOpen: WritableSignal<boolean> = signal(false);
public isLoading: WritableSignal<boolean> = signal(false); public isLoading: WritableSignal<boolean> = signal(false);
public isEmailSent: WritableSignal<boolean> = signal(false); public displaySkeleton: WritableSignal<boolean> = signal(true);
public displaySkeleton: WritableSignal<boolean> = signal(false);
public isVerifying: WritableSignal<boolean> = signal(false);
public isUserSignupSuccessfully: WritableSignal<boolean> = signal(false);
public isTokenVerified: WritableSignal<boolean> = signal(false);
public errorReasons: WritableSignal<string[]> = signal<string[]>([]);
public verificationError: WritableSignal<string | null> = signal<
string | null
>(null);
public isRegistrationMode: WritableSignal<boolean> = signal(false);
public isAutoLoginInProgress: WritableSignal<boolean> = signal(false);
public displayAutologinDialog: WritableSignal<boolean> = signal(false);
private removeQueryParams: WritableSignal<boolean> = signal(false); private removeQueryParams: WritableSignal<boolean> = signal(false);
public get isDarkMode(): boolean { public get isDarkMode(): boolean {
@ -98,7 +97,6 @@ export class WelcomeRootComponent implements OnInit {
public constructor( public constructor(
private readonly formBuilder: FormBuilder, private readonly formBuilder: FormBuilder,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly verifyApiService: VerifyApiService,
private readonly router: Router, private readonly router: Router,
private readonly themeService: ThemeService, private readonly themeService: ThemeService,
private readonly el: ElementRef, private readonly el: ElementRef,
@ -116,76 +114,38 @@ export class WelcomeRootComponent implements OnInit {
this.autologin(); this.autologin();
this.setBackground(); this.setBackground();
this.initializeForm(); this.initializeForm();
this.prefillEmail(); this.setupValueChanges();
this.verifySignupMagicLink();
this.verifySigninMagicLink(); if ((this.email() && this.verified()) || this.login()) {
this.handleRedirect();
this.removeQueryParams.set(true);
}
} }
public autologin(): void { public autologin(): void {
if ( const rememberMe = this.localStorageService.getItem<boolean>('remember-me');
!this.token() &&
(!this.signin() || !this.signup()) &&
!this.signedOut()
) {
this.isAutoLoginInProgress.set(true);
this.displaySkeleton.set(true);
timer(2000) if (rememberMe && !this.signedOut()) {
this.authService
.status()
.pipe( .pipe(
switchMap(() => this.authService.status()), delay(1500),
takeWhile((response: SuccessDtoApiModel) => response.success, true), takeWhile((response: SuccessDtoApiModel) => response.success, true),
switchMap((response: SuccessDtoApiModel) => { tap({
if (response.success) { next: (response: SuccessDtoApiModel) => {
return this.router.navigate(['/dashboard']).then(() => response); if (response.success) {
} this.router.navigate(['/dashboard']);
return of(response); }
}), },
finalize(() => { finalize: () => this.displaySkeleton.set(false),
this.isAutoLoginInProgress.set(false);
setTimeout(() => {
this.displaySkeleton.set(false);
}, 100);
}) })
) )
.subscribe(); .subscribe();
} else {
this.displaySkeleton.set(false);
} }
} }
public verifySigninMagicLink(): void {
this.verifyMagicLink(false);
}
public verifySignupMagicLink(): void {
this.verifyMagicLink(true);
}
public getInputClass(controlName: string): string {
const control = this.form.get(controlName);
if (controlName === 'email' && this.isRegistrationMode()) {
return 'input-success';
}
if (control?.touched) {
return control.valid ? 'input-success' : 'input-error';
}
return '';
}
public getErrorMessage(controlName: string): string {
const control = this.form.get(controlName);
if (control?.touched && control.errors) {
if (control.errors['required']) {
return 'This field is required.';
}
if (control.errors['email']) {
return 'Please enter a valid email address.';
}
}
return '';
}
public setBackground(): void { public setBackground(): void {
const theme = this.themeService.getTheme(); const theme = this.themeService.getTheme();
let opacity: number; let opacity: number;
@ -228,172 +188,98 @@ export class WelcomeRootComponent implements OnInit {
}; };
} }
public openModal(): void {
this.isDialogOpen.set(true);
}
public closeModal(): void {
this.isDialogOpen.set(false);
}
public toggleTheme(): void { public toggleTheme(): void {
this.themeService.toggleTheme(); this.themeService.toggleTheme();
this.setBackground(); 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 { public onSubmit(): void {
if (this.form.invalid) { this.markControlsAsTouchedAndDirty(['email', 'password']);
Object.keys(this.form.controls).forEach((key) => {
const control = this.form.get(key);
control?.markAsTouched(); if (this.form?.valid) {
}); if (this.isSigninSignal()) {
return; this.signin(this.form.value);
} } else {
this.signup(this.form.value);
if (this.isRegistrationMode()) {
const signupCredentials: UserCredentialsDtoApiModel = {
email: this.form.getRawValue().email.trim(),
password: this.form.getRawValue().password.trim(),
};
this.signupNewUser(signupCredentials);
} else {
this.sendLoginEmail(this.form.value.email);
}
}
public isValidLoginAttempt(): boolean {
return this.signin() && this.token() !== '';
}
private focusPasswordField(): void {
if (this.passwordInput) {
this.passwordInput.nativeElement.focus();
}
}
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));
this.removeQueryParams.set(true);
if (token && email) {
if (isSignup) {
this.setupEmailField(decodedEmail);
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);
this.displayAutologinDialog.set(true);
}
}),
delay(1000),
finalize(() => this.handleVerificationFinalize())
)
.subscribe({
next: (response: SuccessDtoApiModel) =>
this.handleVerificationResponse(
response,
isSignup,
decodedEmail,
token
),
error: () => this.handleVerificationError(),
});
} }
} }
} }
private handleVerificationFinalize(): void { private handlePreselect(): void {
if (!this.verificationError()) { const rememberMe = this.localStorageService.getItem<boolean>('remember-me');
this.displaySkeleton.set(false); const email = this.localStorageService.getItem<string>('email');
this.isTokenVerified.set(true);
if (this.isValidLoginAttempt() && !this.signup()) { if (rememberMe) {
this.isAutoLoginInProgress.set(true); this.isSigninSignal.set(true);
} this.isSignupSignal.set(false);
timer(2000).subscribe(() => {
this.isTokenVerified.set(false);
this.focusPasswordField();
});
} }
}
private handleVerificationResponse( if (email) {
response: SuccessDtoApiModel, this.form?.get('email')?.setValue(email);
isSignup: boolean,
email: string,
token: string
): void {
if (response.success) {
this.displaySkeleton.set(false);
this.isTokenVerified.set(true);
if (!isSignup) {
this.isAutoLoginInProgress.set(true);
timer(2000).subscribe(() => {
this.authService
.signinMagicLink({ email, token })
.subscribe((response: SigninResponseDtoApiModel) => {
if (response) {
this.router.navigate(['/dashboard']);
} else {
this.isAutoLoginInProgress.set(false);
//TODO: Handle login failure
}
});
});
}
} else {
this.handleVerificationFailure(
'Verification failed. Please check the reasons below:'
);
} }
this.rememberMe.setValue(rememberMe);
} }
private handleVerificationError(): void { private initializeForm(): void {
this.isVerifying.set(false); const rememberMeValue =
this.handleVerificationFailure( this.localStorageService.getItem<boolean>('remember-me');
'An error occurred during verification. Please check the reasons below:' const email = this.localStorageService.getItem<string>('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 handleVerificationFailure(message: string): void { private handleRedirect(): void {
this.verificationError.set(message); if (this.verified()) {
this.errorReasons.set([ this.isSigninSignal.set(true);
'The verification token may have expired.', this.isSignupSignal.set(false);
'The device you are using may not match the one used to generate the token.', }
'The email address may not match our records.', if (this.email()) {
'The verification link may have been used already.', this.form?.get('email')?.setValue(decodeURIComponent(atob(this.email())));
'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 { if (this.login()) {
this.form.patchValue({ email }); this.isSignupSignal.set(true);
this.form.get('email')?.setValue(email); this.isSigninSignal.set(false);
const emailControl = this.form.get('email');
if (emailControl) {
emailControl.disable({ onlySelf: true, emitEvent: false });
emailControl.markAsTouched();
emailControl.setErrors(null);
} }
} }
@ -401,74 +287,104 @@ export class WelcomeRootComponent implements OnInit {
this.router.navigate([], { queryParams: {} }); this.router.navigate([], { queryParams: {} });
} }
private extractVerifyToken(): string { private setupValueChanges(): void {
const [verifyToken]: string[] = this.token().split('|'); this.setupEmailValueChanges();
this.setupPasswordValueChanges();
return verifyToken;
} }
private extractEmail(): string { private setupEmailValueChanges(): void {
const [, email]: string[] = this.token().split('|'); const emailControl = this.form?.get('email');
return email; emailControl?.valueChanges.subscribe((value: string) => {
} if (value?.length >= 4) {
emailControl.setValidators([
private initializeForm(): void { Validators.required,
this.form = this.formBuilder.group({ customEmailValidator(),
email: new FormControl('', { ]);
validators: [Validators.required, customEmailValidator()], } else {
updateOn: 'change', emailControl.setValidators([
}), Validators.required,
Validators.minLength(4),
]);
}
emailControl.updateValueAndValidity({ emitEvent: false });
}); });
} }
private addPasswordFieldToForm(): void { private setupPasswordValueChanges(): void {
this.form.addControl( const passwordControl = this.form?.get('password');
'password',
new FormControl('', { passwordControl?.valueChanges.subscribe((value: string) => {
validators: [Validators.required, Validators.minLength(8)], if (value?.length >= 8) {
updateOn: 'change', passwordControl.setValidators([
}) Validators.required,
); customPasswordValidator(),
]);
} else {
passwordControl.setValidators([
Validators.required,
Validators.minLength(8),
]);
}
passwordControl.updateValueAndValidity({ emitEvent: false });
});
} }
private signupNewUser(signupCredentials: UserCredentialsDtoApiModel): void { private markControlsAsTouchedAndDirty(controlNames: string[]): void {
this.isLoading.set(true); 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<string>('email', logiCredentials.email);
this.localStorageService.setItem<boolean>('remember-me', rememberMe);
}
this.authService this.authService
.signup(signupCredentials) .signin(logiCredentials)
.pipe( .pipe(
delay(1000),
tap(() => this.isLoading.set(true)), tap(() => this.isLoading.set(true)),
delay(1000),
finalize(() => this.isLoading.set(false)) finalize(() => this.isLoading.set(false))
) )
.subscribe((response: SuccessDtoApiModel) => { .subscribe((response: SigninResponseDtoApiModel) => {
if (response.success) { if (response) {
this.remeberUserMail(signupCredentials.email); this.router.navigate(['/dashboard']);
this.isUserSignupSuccessfully.set(true);
} }
}); });
} }
private prefillEmail(): void { private signup(logiCredentials: UserCredentialsDtoApiModel): void {
const email = this.localStorageService.getItem('email');
if (email) {
this.form.get('email')?.setValue(email);
}
}
private remeberUserMail(email: string): void {
this.localStorageService.setItem('email', email);
}
private sendLoginEmail(email: string): void {
this.isLoading.set(true); this.isLoading.set(true);
const magiclink: MagicLinkDtoApiModel = {
email: email,
};
this.authService this.authService
.sendMagicLink(magiclink) .signup(logiCredentials)
.pipe( .pipe(
delay(1000), delay(1000),
tap(() => this.isLoading.set(true)), tap(() => this.isLoading.set(true)),
@ -476,8 +392,8 @@ export class WelcomeRootComponent implements OnInit {
) )
.subscribe((response: SuccessDtoApiModel) => { .subscribe((response: SuccessDtoApiModel) => {
if (response.success) { if (response.success) {
this.isEmailSent.set(true); this.openModal();
this.remeberUserMail(email); this.userSignupSuccess.set(true);
} }
}); });
} }

View File

@ -6,8 +6,6 @@ import { catchError, shareReplay, tap } from 'rxjs/operators';
import { import {
AuthenticationApiService, AuthenticationApiService,
MagicLinkDtoApiModel,
MagicLinkSigninDtoApiModel,
SigninResponseDtoApiModel, SigninResponseDtoApiModel,
SuccessDtoApiModel, SuccessDtoApiModel,
UserCredentialsDtoApiModel, UserCredentialsDtoApiModel,
@ -29,20 +27,6 @@ export class AuthService {
this.statusCheck$ = this.initializeStatusCheck(); this.statusCheck$ = this.initializeStatusCheck();
} }
public signinMagicLink(
credentials: MagicLinkSigninDtoApiModel
): Observable<SigninResponseDtoApiModel> {
return this.authenticationApiService
.authControllerMagicLinkSignin(credentials)
.pipe(tap(() => this.isAuthenticatedSignal.set(true)));
}
public sendMagicLink(
email: MagicLinkDtoApiModel
): Observable<SuccessDtoApiModel> {
return this.authenticationApiService.authControllerSendMagicLink(email);
}
public signup( public signup(
credentials: UserCredentialsDtoApiModel credentials: UserCredentialsDtoApiModel
): Observable<SuccessDtoApiModel> { ): Observable<SuccessDtoApiModel> {
@ -51,13 +35,13 @@ export class AuthService {
.pipe(tap(() => this.isAuthenticatedSignal.set(true))); .pipe(tap(() => this.isAuthenticatedSignal.set(true)));
} }
// public signin( public signin(
// credentials: UserCredentialsDtoApiModel credentials: UserCredentialsDtoApiModel
// ): Observable<SigninResponseDtoApiModel> { ): Observable<SigninResponseDtoApiModel> {
// return this.authenticationApiService return this.authenticationApiService
// .authControllerSignin(credentials) .authControllerSignin(credentials)
// .pipe(tap(() => this.isAuthenticatedSignal.set(true))); .pipe(tap(() => this.isAuthenticatedSignal.set(true)));
// } }
public signout(): Observable<SuccessDtoApiModel> { public signout(): Observable<SuccessDtoApiModel> {
return this.authenticationApiService return this.authenticationApiService
@ -65,7 +49,6 @@ export class AuthService {
.pipe(tap(() => this.isAuthenticatedSignal.set(false))); .pipe(tap(() => this.isAuthenticatedSignal.set(false)));
} }
// TODO: Later for Autologin
public status(): Observable<SuccessDtoApiModel> { public status(): Observable<SuccessDtoApiModel> {
if (this.isAuthenticatedSignal()) { if (this.isAuthenticatedSignal()) {
return of({ success: true }); return of({ success: true });
@ -73,7 +56,6 @@ export class AuthService {
return this.statusCheck$; return this.statusCheck$;
} }
// TODO Later for AutoLogin
private initializeStatusCheck(): Observable<SuccessDtoApiModel> { private initializeStatusCheck(): Observable<SuccessDtoApiModel> {
return this.authenticationApiService.authControllerStatus().pipe( return this.authenticationApiService.authControllerStatus().pipe(
tap((response) => this.isAuthenticatedSignal.set(response.success)), tap((response) => this.isAuthenticatedSignal.set(response.success)),

View File

@ -23,7 +23,7 @@ export class ThemeService {
} }
public toggleTheme(): void { public toggleTheme(): void {
this.currentTheme = this.currentTheme === 'light' ? 'sunset' : 'light'; this.currentTheme = this.currentTheme === 'light' ? 'dark' : 'light';
this.setTheme(this.currentTheme); this.setTheme(this.currentTheme);
} }

View File

@ -1,99 +0,0 @@
// src/assets/scss/_fonts.scss
@font-face {
font-family: 'Nunito Sans Regular';
font-style: normal;
font-weight: normal;
src: local('Nunito Sans Regular'), url('../fonts/NunitoSans-Regular.woff') format('woff');
}
@font-face {
font-family: 'Nunito Sans Italic';
font-style: italic;
font-weight: normal;
src: local('Nunito Sans Italic'), url('../fonts/NunitoSans-Italic.woff') format('woff');
}
@font-face {
font-family: 'Nunito Sans ExtraLight';
font-style: normal;
font-weight: 200;
src: local('Nunito Sans ExtraLight'), url('../fonts/NunitoSans-ExtraLight.woff') format('woff');
}
@font-face {
font-family: 'Nunito Sans ExtraLight Italic';
font-style: italic;
font-weight: 200;
src: local('Nunito Sans ExtraLight Italic'), url('../fonts/NunitoSans-ExtraLightItalic.woff') format('woff');
}
@font-face {
font-family: 'Nunito Sans Light';
font-style: normal;
font-weight: 300;
src: local('Nunito Sans Light'), url('../fonts/NunitoSans-Light.woff') format('woff');
}
@font-face {
font-family: 'Nunito Sans Light Italic';
font-style: italic;
font-weight: 300;
src: local('Nunito Sans Light Italic'), url('../fonts/NunitoSans-LightItalic.woff') format('woff');
}
@font-face {
font-family: 'Nunito Sans SemiBold';
font-style: normal;
font-weight: 600;
src: local('Nunito Sans SemiBold'), url('../fonts/NunitoSans-SemiBold.woff') format('woff');
}
@font-face {
font-family: 'Nunito Sans SemiBold Italic';
font-style: italic;
font-weight: 600;
src: local('Nunito Sans SemiBold Italic'), url('../fonts/NunitoSans-SemiBoldItalic.woff') format('woff');
}
@font-face {
font-family: 'Nunito Sans Bold';
font-style: normal;
font-weight: bold;
src: local('Nunito Sans Bold'), url('../fonts/NunitoSans-Bold.woff') format('woff');
}
@font-face {
font-family: 'Nunito Sans Bold Italic';
font-style: italic;
font-weight: bold;
src: local('Nunito Sans Bold Italic'), url('../fonts/NunitoSans-BoldItalic.woff') format('woff');
}
@font-face {
font-family: 'Nunito Sans ExtraBold';
font-style: normal;
font-weight: 800;
src: local('Nunito Sans ExtraBold'), url('../fonts/NunitoSans-ExtraBold.woff') format('woff');
}
@font-face {
font-family: 'Nunito Sans ExtraBold Italic';
font-style: italic;
font-weight: 800;
src: local('Nunito Sans ExtraBold Italic'), url('../fonts/NunitoSans-ExtraBoldItalic.woff') format('woff');
}
@font-face {
font-family: 'Nunito Sans Black';
font-style: normal;
font-weight: 900;
src: local('Nunito Sans Black'), url('../fonts/NunitoSans-Black.woff') format('woff');
}
@font-face {
font-family: 'Nunito Sans Black Italic';
font-style: italic;
font-weight: 900;
src: local('Nunito Sans Black Italic'), url('../fonts/NunitoSans-BlackItalic.woff') format('woff');
}

View File

@ -5,6 +5,7 @@
<title>Frontend</title> <title>Frontend</title>
<base href="/" /> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>

View File

@ -1,12 +1,10 @@
// Import PrimeNG styles
// @import 'primeng/resources/themes/lara-light-blue/theme.css';
// @import 'primeng/resources/primeng.css';
// // PrimeNG icons
// @import 'primeicons/primeicons.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
// Nunito Sans
@import './assets/scss/font.scss';
// TODO: Dirty Hack, Remove later
:root:has(:is(.modal-open, .modal:target, .modal-toggle:checked + .modal, .modal[open])) {
scrollbar-gutter: auto
;}

View File

@ -4,27 +4,12 @@ module.exports = {
"./src/**/*.{html,ts}", "./src/**/*.{html,ts}",
], ],
theme: { theme: {
fontFamily: { extend: {},
'sans': ['"Nunito Sans Regular"', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'Noto Sans', 'sans-serif'],
'serif': ['"Nunito Sans Regular"', 'ui-serif', 'Georgia', 'Cambria', 'Times New Roman', 'Times', 'serif'],
'mono': ['"Nunito Sans Regular"', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', 'monospace'],
},
extend: {
fontFamily: {
'nunito-sans-italic': ['"Nunito Sans Italic"', 'sans-serif'],
'nunito-sans-extralight': ['"Nunito Sans ExtraLight"', 'sans-serif'],
'nunito-sans-light': ['"Nunito Sans Light"', 'sans-serif'],
'nunito-sans-semibold': ['"Nunito Sans SemiBold"', 'sans-serif'],
'nunito-sans-bold': ['"Nunito Sans Bold"', 'sans-serif'],
'nunito-sans-extrabold': ['"Nunito Sans ExtraBold"', 'sans-serif'],
'nunito-sans-black': ['"Nunito Sans Black"', 'sans-serif'],
}
}
}, },
plugins: [require('daisyui'), require('tailwindcss-animated')], plugins: [require('daisyui'), require('tailwindcss-animated')],
daisyui: { daisyui: {
themes: ["light", "sunset"], themes: ["light", "dark"],
darkMode: ['class', '[data-theme="sunset"]'], darkMode: ['class', '[data-theme="dark"]']
} }
} }