Compare commits

...

30 Commits

Author SHA1 Message Date
Igor Hrenowitsch Propisnov fc245314a6 renaming 2024-09-19 14:28:01 +02:00
Igor Hrenowitsch Propisnov aac55478bd added env templates 2024-09-19 14:19:50 +02:00
Igor Hrenowitsch Propisnov a25462474f Merge pull request 'feature/refactor-login' (#19) from feature/refactor-login into main
Reviewed-on: #19
2024-09-19 13:58:11 +02:00
Igor Hrenowitsch Propisnov 43d27368fc added some security features 2024-09-16 23:36:10 +02:00
Igor Hrenowitsch Propisnov 1add1e573f added new guard for protected routes, fix tooltip for sidemenu 2024-09-16 23:04:46 +02:00
Igor Hrenowitsch Propisnov cf29c1c9bb remove unused components 2024-09-11 22:38:47 +02:00
Igor Hrenowitsch Propisnov 8a0dda4a83 fix dialog 2024-09-11 22:32:49 +02:00
Igor Hrenowitsch Propisnov f03df3386c Added fonts 2024-09-11 21:49:19 +02:00
Igor Hrenowitsch Propisnov dff76010d9 Added E-Mail 2024-09-10 22:15:06 +02:00
Igor Hrenowitsch Propisnov 9e543a00eb fix dialog, and wierd scroll bar 2024-09-10 20:39:25 +02:00
Igor Hrenowitsch Propisnov 28a7006d0d fix overflow 2024-09-10 20:17:35 +02:00
Igor Hrenowitsch Propisnov 3a45fa1de6 added submenu comapct items back 2024-09-10 19:02:39 +02:00
Igor Hrenowitsch Propisnov 17ba5c9640 set backgrounds 2024-09-10 16:56:40 +02:00
Igor Hrenowitsch Propisnov da92256699 w.i.p 2024-09-10 16:46:20 +02:00
Igor Hrenowitsch Propisnov 98ce43781b remove unsed code 2024-09-10 15:13:20 +02:00
Igor Hrenowitsch Propisnov 217afa9465 added tool tip 2024-09-10 15:10:32 +02:00
Igor Hrenowitsch Propisnov 46caa71549 added animations 2024-09-10 14:49:52 +02:00
Igor Hrenowitsch Propisnov b0065819f5 added svg for comact mode 2024-09-10 13:51:33 +02:00
Igor Hrenowitsch Propisnov 8862ff5cc4 added animation 2024-09-10 13:47:05 +02:00
Igor Hrenowitsch Propisnov 609a08f479 added comapct mode 2024-09-10 13:30:23 +02:00
Igor Hrenowitsch Propisnov e17e4191b7 update side menu 2024-09-10 13:06:52 +02:00
Igor Hrenowitsch Propisnov eb33c9bbdf work in progress 2024-09-09 21:38:09 +02:00
Igor Hrenowitsch Propisnov 53311438ae improve login 2024-09-09 16:56:12 +02:00
Igor Hrenowitsch Propisnov 9b87258d2d w.i.p 2024-09-09 15:58:53 +02:00
Igor Hrenowitsch Propisnov 1532daa061 prefil email 2024-09-09 14:51:50 +02:00
Igor Hrenowitsch Propisnov d59d41e1ee work in progress 2024-09-09 14:40:55 +02:00
Igor Hrenowitsch Propisnov c9a8e9967a fix token issue 2024-09-09 09:32:43 +02:00
Igor Hrenowitsch Propisnov 8a1089ce9d delete tokens after 10 minutes 2024-09-09 09:18:15 +02:00
Igor Hrenowitsch Propisnov 786e4a59b8 W.I.P 2024-09-09 02:27:42 +02:00
Igor Hrenowitsch Propisnov 8e82733dd0 error-handling (#18)
Reviewed-on: #18
Co-authored-by: Igor Propisnov <info@igor-propisnov.com>
Co-committed-by: Igor Propisnov <info@igor-propisnov.com>
2024-09-08 18:01:17 +02:00
68 changed files with 2527 additions and 1106 deletions

6
.env.template Normal file
View File

@ -0,0 +1,6 @@
# Local Postgres
POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_DB=ticket_mvp
PGADMIN_DEFAULT_EMAIL=admin@admin.com
PGADMIN_DEFAULT_PASSWORD=root

32
backend/.env.template Normal file
View File

@ -0,0 +1,32 @@
# Environment
NODE_ENV = development
# App Environment
APP_URL = http://localhost:4200
# DB
DB_HOST = localhost
DB_PORT = 5432
DB_USERNAME = root
DB_PASSWORD = root
DB_NAME = ticket_mvp
# CORS
CORS_ALLOW_ORIGIN=http://localhost:4200,http://localhost:5000,null
CORS_ALLOW_METHODS = GET,POST,PUT,DELETE,OPTIONS
CORS_ALLOW_HEADERS = Origin,X-Requested-With,Content-Type,Accept,Authorization
# CSP
CSP_DIRECTIVES="default-src 'self' http://localhost:4200; script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:4200; style-src 'self' 'unsafe-inline' http://localhost:4200; img-src 'self' data:; connect-src 'self' http://localhost:4200; font-src 'self' http://localhost:4200; frame-src 'self'; object-src 'none'"
# SESSION Secret
SESSION_SECRET = secret
SESSION_LIMIT = 1
# API KEY
SEND_GRID_API_KEY = API_KEY
# SENDGRID
SENDGRID_TEMPLATE_REGISTER_EMAIL = TEMPLATE_ID

View File

@ -36,6 +36,9 @@ export class AppModule {
public configure(consumer: MiddlewareConsumer): void {
consumer
// 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(
CspMiddleware,
SecurityHeadersMiddleware,

View File

@ -1,12 +1,16 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SessionService } from 'src/modules/session/services/session.service';
import { EmailVerificationService } from 'src/modules/verify-module/services/email-verification.service';
@Injectable()
export class ClearExpiredSessionsCron {
private readonly logger: Logger = new Logger(ClearExpiredSessionsCron.name);
public constructor(private readonly sessionService: SessionService) {}
public constructor(
private readonly sessionService: SessionService,
private readonly emailVerificationService: EmailVerificationService
) {}
@Cron(CronExpression.EVERY_12_HOURS, {
name: 'Clear-Expired-Sessions',
@ -17,4 +21,14 @@ export class ClearExpiredSessionsCron {
this.sessionService.deleteAllExpiredSessions();
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,6 +21,12 @@ export class EmailVerification {
@Column()
public expiresAt: Date;
@Column()
public email: string;
@Column({ nullable: true })
public userAgent: string;
@OneToOne(() => UserCredentials)
@JoinColumn({ name: 'userCredentialsId' })
public user: UserCredentials;

View File

@ -8,6 +8,8 @@ import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
import { SessionInitService } from './modules/session/services';
import { HttpExceptionFilter } from './shared/filters';
import { ErrorHandlingInterceptor } from './shared/interceptors';
async function setupSwagger(app: INestApplication): Promise<void> {
const config = new DocumentBuilder()
@ -50,12 +52,22 @@ async function setupClassValidator(app: INestApplication): Promise<void> {
app.useGlobalPipes(new ValidationPipe());
}
async function setupGlobalFilters(app: INestApplication): Promise<void> {
app.useGlobalFilters(new HttpExceptionFilter());
}
async function setupGlobalInterceptors(app: INestApplication): Promise<void> {
app.useGlobalInterceptors(new ErrorHandlingInterceptor());
}
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
await setupCookieParser(app);
await setupSwagger(app);
await setupPrefix(app);
await setupGlobalFilters(app);
await setupGlobalInterceptors(app);
await setupClassValidator(app);
await setupSessions(app);
await app.listen(3000);

View File

@ -13,6 +13,13 @@ export class SecurityHeadersMiddleware implements NestMiddleware {
'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-Frame-Options', 'SAMEORIGIN');
next();

View File

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

View File

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

View File

@ -0,0 +1,9 @@
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,2 +1,4 @@
export * from './user-credentials.dto';
export * from './signin-response.dto';
export * from './magic-link.dto';
export * from './magic-link-signin.dto';

View File

@ -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;
}

View File

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

View File

@ -1,18 +1,25 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { UserCredentials } from 'src/entities';
import { AuthEmailService } from 'src/modules/sendgrid-module/services/auth.mail.service';
import { SessionService } from 'src/modules/session/services/session.service';
import { EncryptionService, SuccessDto } from 'src/shared';
import {
ConflictException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { UserCredentials } from 'src/entities';
import { SessionService } from 'src/modules/session/services/session.service';
import { EncryptionService, SuccessDto } from 'src/shared';
InternalServerErrorException,
} from 'src/shared/exceptions';
import { PasswordConfirmationMailService } from '../../sendgrid-module/services/password-confirmation.mail.service';
import { UserDataRepository } from '../../user-module/repositories/user-data.repository';
import { EmailVerificationService } from '../../verify-module/services/email-verification.service';
import { SigninResponseDto, UserCredentialsDto } from '../models/dto';
import {
MagicLinkDto,
SigninResponseDto,
UserCredentialsDto,
} from '../models/dto';
import { UserCredentialsRepository } from '../repositories/user-credentials.repository';
@Injectable()
@ -20,13 +27,57 @@ export class AuthService {
public constructor(
private readonly userCredentialsRepository: UserCredentialsRepository,
private readonly userDataRepository: UserDataRepository,
private readonly passwordConfirmationMailService: PasswordConfirmationMailService,
private readonly authEmailService: AuthEmailService,
private readonly emailVerificationService: EmailVerificationService,
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.authEmailService.sendLoginLinkEmail(magiclink.email, token);
} else {
const token =
await this.emailVerificationService.generateEmailVerificationTokenForMagicLink(
magiclink.email,
userAgent
);
await this.authEmailService.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(
userCredentials: UserCredentialsDto
userCredentials: UserCredentialsDto,
userAgent: string
): Promise<SuccessDto> {
try {
const existingUser = await this.userCredentialsRepository.findUserByEmail(
@ -34,7 +85,7 @@ export class AuthService {
);
if (existingUser) {
throw new ConflictException('User already exists');
throw new ConflictException('USER_ALREADY_EXISTS');
}
const passwordHashed = await EncryptionService.hashData(
@ -46,114 +97,111 @@ export class AuthService {
passwordHashed
);
await this.sendMagicLink({ email: user.email }, userAgent);
await this.userDataRepository.createInitialUserData(user);
const token =
await this.emailVerificationService.generateEmailVerificationToken(
user.id
);
await this.passwordConfirmationMailService.sendPasswordConfirmationMail(
user.email,
token
);
return {
success: true,
};
} catch (error) {
if (error instanceof ConflictException) {
throw new ConflictException(
'User already exists. Please try to login instead.'
);
throw error;
} else {
throw new HttpException(
'Error while signing up',
HttpStatus.INTERNAL_SERVER_ERROR
);
throw new InternalServerErrorException('SIGNUP_ERROR', {
cause: error,
});
}
}
}
public async validateUser(
token: string,
email: string,
password: string
userAgent: string
): Promise<UserCredentials> {
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('Access Denied');
}
const passwordMatch = await EncryptionService.compareHash(
password,
user.hashedPassword
);
if (!passwordMatch) {
throw new ForbiddenException('Access Denied');
throw new UnauthorizedException('User not found');
}
return user;
} catch (error) {
if (error instanceof ForbiddenException) {
throw new ForbiddenException(
'E-Mail address or password is incorrect. Please try again.'
);
if (error instanceof UnauthorizedException) {
throw error;
} else {
throw new HttpException(
'Error while validating user credentials',
HttpStatus.INTERNAL_SERVER_ERROR
);
throw new InternalServerErrorException('VALIDATION_ERROR', {
cause: error,
});
}
}
}
public async signout(sessionId: string): Promise<{ success: boolean }> {
public async getUserByEmail(email: string): Promise<UserCredentials> {
return this.userCredentialsRepository.findUserByEmail(email);
}
public async signout(sessionId: string): Promise<SuccessDto> {
try {
this.sessionService.deleteSessionBySessionId(sessionId);
await this.sessionService.deleteSessionBySessionId(sessionId);
return { success: true };
} catch (error) {
throw new HttpException(
'Fehler beim Logout',
HttpStatus.INTERNAL_SERVER_ERROR
);
throw new InternalServerErrorException('SIGNOUT_ERROR', {
message:
'An error occurred during the sign out process. Please try again later.',
});
}
}
public async checkAuthStatus(
sessionId: string,
userAgend: string
userAgent: string
): Promise<SuccessDto> {
try {
const session =
await this.sessionService.findSessionBySessionId(sessionId);
if (!session) {
throw new ForbiddenException('Session not found');
throw new ForbiddenException('SESSION_NOT_FOUND');
}
const userAgendFromSession = JSON.parse(session.json).passport.user
const userAgentFromSession = JSON.parse(session.json).passport.user
.userAgent;
if (userAgendFromSession !== userAgend) {
throw new ForbiddenException('User-Agent does not match');
if (userAgentFromSession !== userAgent) {
throw new ForbiddenException('USER_AGENT_MISMATCH');
}
return { success: true };
} catch (error) {
throw new HttpException(
'Error while checking auth status',
HttpStatus.INTERNAL_SERVER_ERROR
);
if (error instanceof ForbiddenException) {
throw error;
} else {
throw new InternalServerErrorException('AUTH_STATUS_CHECK_ERROR', {
cause: error,
});
}
}
}
public getLoginResponse(
user: SigninResponseDto & { userAgent: string }
): SigninResponseDto {
const { id, email }: SigninResponseDto = user;
const responseData: SigninResponseDto = { id, email };
const { email }: SigninResponseDto = user;
const responseData: SigninResponseDto = { email };
return responseData;
}

View File

@ -1,34 +1,56 @@
/* 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<SigninResponseDto & { userAgent: string }> {
const user = await this.authService.validateUser(email, password);
public async validate(request: Request): Promise<any> {
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']
);
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) {
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 };
}
}

View File

@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PasswordConfirmationMailService } from './services/password-confirmation.mail.service';
import { AuthEmailService } from './services/auth.mail.service';
import { TemplateConfigService } from './services/template-config.service';
@Module({
@ -13,10 +13,10 @@ import { TemplateConfigService } from './services/template-config.service';
configService.get<string>('SEND_GRID_API_KEY'),
inject: [ConfigService],
},
PasswordConfirmationMailService,
AuthEmailService,
TemplateConfigService,
],
controllers: [],
exports: [PasswordConfirmationMailService],
exports: [AuthEmailService],
})
export class SendgridModule {}

View File

@ -0,0 +1,60 @@
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as SendGridMailApi from '@sendgrid/mail';
import { UriEncoderService } from 'src/shared';
import { BaseMailService } from './base.mail.service';
import { TemplateConfigService } from './template-config.service';
@Injectable()
export class AuthEmailService extends BaseMailService {
private readonly REGISTER_EMAIL: string = 'REGISTER_EMAIL';
public constructor(
@Inject('SEND_GRID_API_KEY') protected readonly sendGridApiKey: string,
private readonly templateConfigService: TemplateConfigService,
private readonly configService: ConfigService
) {
super(sendGridApiKey);
}
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,44 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as SendGridMailApi from '@sendgrid/mail';
import { UriEncoderService } from 'src/shared';
import { BaseMailService } from './base.mail.service';
import { TemplateConfigService } from './template-config.service';
@Injectable()
export class PasswordConfirmationMailService extends BaseMailService {
private readonly PASSWORD_CONFIRMATION_EMAIL: string =
'PASSWORD_CONFIRMATION_EMAIL';
public constructor(
@Inject('SEND_GRID_API_KEY') protected readonly sendGridApiKey: string,
private readonly templateConfigService: TemplateConfigService,
private readonly configService: ConfigService
) {
super(sendGridApiKey);
}
public async sendPasswordConfirmationMail(
to: string,
verificationToken: string
): Promise<void> {
const templateId: string = this.templateConfigService.getTemplateId(
this.PASSWORD_CONFIRMATION_EMAIL
);
const token = `${verificationToken}|${UriEncoderService.encodeBase64(to)}`;
const mailoptions: SendGridMailApi.MailDataRequired = {
to,
from: { email: 'info@igor-propisnov.com', name: 'Ticket App' },
templateId: templateId,
dynamicTemplateData: {
name: 'Mara',
buttonUrl: `${this.configService.get<string>('APP_URL')}/verify/?token=${token}`,
},
};
await this.sendMail(mailoptions);
}
}

View File

@ -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;

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EmailVerification } from 'src/entities';
import { MoreThan, Repository } from 'typeorm';
import { LessThan, Repository } from 'typeorm';
@Injectable()
export class EmailVerifyRepository {
@ -13,36 +13,45 @@ export class EmailVerifyRepository {
public async createEmailVerification(
token: string,
expiresAt: Date,
userId: string
email: string,
userId: string | null,
userAgent: string
): Promise<void> {
await this.repository.delete({ email });
await this.repository.save({
token,
expiresAt,
user: { id: userId },
email,
user: userId ? { id: userId } : null,
userAgent,
});
}
public async findEmailVerificationByToken(token: string): Promise<boolean> {
const result = await this.repository.findOne({
where: { token, expiresAt: MoreThan(new Date()) },
public async findByTokenAndEmail(
token: string,
email: string
): Promise<EmailVerification | undefined> {
return await this.repository.findOne({
where: {
token,
email,
},
});
return result !== null;
}
public async deleteEmailVerificationByToken(
tokenToDelete: string
): Promise<EmailVerification | null> {
const emailVerification = await this.repository.findOne({
where: { token: tokenToDelete },
relations: ['user'],
public async removeEmailVerificationByTokenAndEmail(
token: string,
email: string
): Promise<void> {
await this.repository.delete({ token, email });
}
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,76 +1,104 @@
import { randomBytes } from 'crypto';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EmailVerification } from 'src/entities';
import { SessionService } from 'src/modules/session/services/session.service';
import { UriEncoderService } from 'src/shared';
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 configService: ConfigService
private readonly emailVerifyRepository: EmailVerifyRepository
) {}
public async generateEmailVerificationToken(userId: string): Promise<string> {
const verificationToken = await this.createVerificationToken();
public async generateEmailVerificationTokenForMagicLink(
email: string,
userAgent: string,
userid?: string
): Promise<string> {
try {
const verificationToken = await this.createVerificationToken();
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
// TODO Check users local time zone and set expiration time accordingly
const expiration = new Date(Date.now() + 24 * 60 * 60 * 1000);
this.emailVerifyRepository.createEmailVerification(
verificationToken,
expiration,
userId
);
return verificationToken;
}
public async verifyEmail(tokenToVerify: string): Promise<boolean> {
const isTokenVerified =
await this.emailVerifyRepository.findEmailVerificationByToken(
tokenToVerify
await this.emailVerifyRepository.createEmailVerification(
verificationToken,
expiresAt,
email,
userid || null,
userAgent
);
if (isTokenVerified) {
const emailVerification =
await this.deleteEmailVerificationToken(tokenToVerify);
if (emailVerification && emailVerification.user) {
const isStatusUpdated =
await this.userDataRepository.updateEmailVerificationStatus(
emailVerification.user.id
);
return isStatusUpdated;
}
return verificationToken;
} catch (error) {
throw new InternalServerErrorException(
'EMAIL_VERIFICATION_TOKEN_GENERATION_ERROR',
{
message:
'An error occurred while generating the email verification token.',
}
);
}
return false;
}
public async isEmailVerified(sessionID: string): Promise<boolean> {
const userId = await this.sessionService.getUserIdBySessionId(sessionID);
public async verifyEmail(
tokenToVerify: string,
emailToVerify: string,
userAgent: string
): Promise<SuccessDto> {
try {
const token = await this.emailVerifyRepository.findByTokenAndEmail(
tokenToVerify,
emailToVerify
);
if (!userId) {
return false;
if (!token) {
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()) {
throw new TokenExpiredException();
}
return { success: true };
} catch (error) {
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.',
});
}
}
const isVerfiied =
await this.userDataRepository.isEmailConfirmedByUserId(userId);
public async removeEmailVerificationByTokenAndEmail(
token: string,
email: string
): Promise<void> {
await this.emailVerifyRepository.removeEmailVerificationByTokenAndEmail(
token,
email
);
}
if (isVerfiied) {
return true;
}
return false;
public async deleteAllExpiredTokens(): Promise<void> {
await this.emailVerifyRepository.deleteAllExpiredTokens();
}
private async createVerificationToken(): Promise<string> {
@ -78,12 +106,4 @@ export class EmailVerificationService {
return UriEncoderService.encodeUri(verifyToken);
}
private async deleteEmailVerificationToken(
tokenToDelete: string
): Promise<EmailVerification | null> {
return await this.emailVerifyRepository.deleteEmailVerificationByToken(
tokenToDelete
);
}
}

View File

@ -0,0 +1,11 @@
export class BaseException extends Error {
public constructor(
public readonly message: string,
public readonly status: number,
public readonly error: string,
public readonly details?: unknown
) {
super(message);
this.name = this.constructor.name;
}
}

View File

@ -0,0 +1,9 @@
import { HttpStatus } from '@nestjs/common';
import { BaseException } from './base.exception';
export class ConflictException extends BaseException {
public constructor(errorCode: string, details?: unknown) {
super('Conflict Error', HttpStatus.CONFLICT, errorCode, details);
}
}

View File

@ -0,0 +1,9 @@
import { HttpStatus } from '@nestjs/common';
import { BaseException } from './base.exception';
export class ForbiddenException extends BaseException {
public constructor(errorCode: string, details?: unknown) {
super('Forbidden Error', HttpStatus.FORBIDDEN, errorCode, details);
}
}

View File

@ -0,0 +1,7 @@
export * from './conflict.exception';
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';

View File

@ -0,0 +1,14 @@
import { HttpStatus } from '@nestjs/common';
import { BaseException } from './base.exception';
export class InternalServerErrorException extends BaseException {
public constructor(errorCode: string, details?: unknown) {
super(
'Internal Server Error',
HttpStatus.INTERNAL_SERVER_ERROR,
errorCode,
details
);
}
}

View File

@ -0,0 +1,9 @@
import { HttpStatus } from '@nestjs/common';
import { BaseException } from './base.exception';
export class NotFoundException extends BaseException {
public constructor(errorCode: string, details?: unknown) {
super('Not Found Error', HttpStatus.NOT_FOUND, errorCode, details);
}
}

View File

@ -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,
});
}
}

View File

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

@ -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
);
}
}

View File

@ -0,0 +1,72 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
import { BaseException } from '../exceptions/base.exception';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger: Logger = new Logger(HttpExceptionFilter.name);
public catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
console.error('Exception caught:', exception);
let status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
let message: string = 'Internal server error';
let error: string = 'INTERNAL_SERVER_ERROR';
let details: unknown = undefined;
if (exception instanceof BaseException) {
status = exception.status;
message = exception.message;
error = exception.error;
details = exception.details;
} else if (exception instanceof HttpException) {
status = exception.getStatus();
message = exception.message;
}
// Logging
this.logger.error(`${error}: ${message}`, exception);
// TODO: Error reporting (mock implementation)
this.reportError(error, message, details);
response.status(status).json({
status: status,
message,
error,
details: this.sanitizeErrorDetails(details),
timestamp: new Date().toISOString(),
});
}
private sanitizeErrorDetails(details: any): any {
if (details && typeof details === 'object') {
const sanitized = { ...details };
// TODO: Remove sensitive data
// delete sanitized.password;
// delete sanitized.creditCard;
return sanitized;
}
return details;
}
private reportError(errorCode: string, message: string, details: any): void {
console.log(`Error reported: ${errorCode} - ${message}`);
// TODO: Implement error reporting (i. e. Sentry)
// Example Sentry.captureException(new Error(`${errorCode}: ${message}`), { extra: details });
}
}

View File

@ -0,0 +1 @@
export * from './http-exception.filter';

View File

@ -0,0 +1,37 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
HttpException,
InternalServerErrorException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { BaseException } from '../exceptions/base.exception';
@Injectable()
export class ErrorHandlingInterceptor implements NestInterceptor {
public intercept(
context: ExecutionContext,
next: CallHandler
): Observable<any> {
return next.handle().pipe(
catchError((error) => {
if (error instanceof BaseException) {
throw error;
} else if (error instanceof HttpException) {
throw error;
} else {
throw new InternalServerErrorException({
message: 'An unexpected error occurred',
errorCode: 'UNEXPECTED_ERROR',
details: { originalError: error.message },
});
}
})
);
}
}

View File

@ -0,0 +1 @@
export * from './error-handling.interceptor';

View File

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

View File

@ -1,156 +1,399 @@
<div class="flex h-screen overflow-hidden">
<div
[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">
<div class="flex h-screen w-screen bg-base-200">
<div class="flex flex-col w-full lg:w-full bg-base-100 h-screen">
<!-- Header mit dem Burger-Menü -->
<header
[ngStyle]="navigation"
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="w-10 flex items-center justify-center md:hidden">
<label class="btn btn-ghost swap swap-rotate">
<input
type="checkbox"
(change)="toggleSidebar()"
[checked]="!isCollapsed" />
<svg
class="swap-off fill-current"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 512 512">
<path
d="M64,384H448V341.33H64Zm0-106.67H448V234.67H64ZM64,128v42.67H448V128Z" />
</svg>
<svg
class="swap-on fill-current"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 512 512">
<polygon
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" />
</svg>
</label>
class="w-full navbar bg-primary text-primary-content z-40">
<div class="flex-1">
<a class="btn btn-ghost normal-case text-xl text-primary-content">
[APP-NAME]
</a>
</div>
<!-- Der Button wird nur auf mobilen Geräten angezeigt -->
<div class="flex-none lg:hidden">
<button
(click)="toggleDrawer()"
class="btn btn-square btn-ghost drawer-button">
<div
[@burgerAnimation]="isDrawerOpen ? 'open' : 'closed'"
class="w-6 h-6">
<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="3" y1="12" x2="21" y2="12"></line>
<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>
</header>
<div [ngStyle]="mainContent" class="overflow-y-auto h-screen">
<router-outlet></router-outlet>
<!-- Hauptcontainer -->
<div class="flex-1 flex overflow-hidden">
<!-- 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
*ngIf="!isCollapsed"
class="fixed inset-0 bg-black bg-opacity-50 z-10 md:hidden"
(click)="toggleSidebar()"></div>
</div>

View File

@ -1,13 +1,29 @@
import {
animate,
state,
style,
transition,
trigger,
} from '@angular/animations';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
HostListener,
OnDestroy,
OnInit,
} from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Router, RouterOutlet } from '@angular/router';
import {
ActivatedRoute,
NavigationEnd,
Router,
RouterModule,
RouterOutlet,
} from '@angular/router';
import { filter, Subject, takeUntil } from 'rxjs';
import { SuccessDtoApiModel } from '../../api';
import { BackgroundPatternService, ThemeService } from '../../shared/service';
@ -16,8 +32,17 @@ import { AuthService } from '../../shared/service/auth.service';
interface TopMenuItem {
name: string;
icon: SafeHtml;
route?: string;
active?: boolean;
subitems?: SubMenuItem[];
isOpen?: boolean;
}
interface SubMenuItem {
name: string;
route: string;
active?: boolean;
icon?: SafeHtml;
}
interface BottomMenuItem {
@ -30,14 +55,99 @@ interface BottomMenuItem {
selector: 'app-layout',
standalone: true,
providers: [],
imports: [RouterOutlet, CommonModule],
imports: [RouterOutlet, CommonModule, RouterModule],
templateUrl: './layout.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
changeDetection: ChangeDetectionStrategy.Default,
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 {
export class LayoutComponent implements OnInit, OnDestroy {
public isCollapsed: boolean = false;
public isDesktopCollapsed: boolean = false;
public showMobileMenu: boolean = false;
public isDrawerOpen: boolean = true;
public isCompact: boolean = false;
public isMobile: boolean = window.innerWidth < 1024;
public menuItems: TopMenuItem[] = [
{
name: 'Dashboard',
@ -49,11 +159,41 @@ export class LayoutComponent implements OnInit {
},
{
name: 'Event',
route: '/event',
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="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>`),
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[] = [
@ -68,37 +208,93 @@ export class LayoutComponent implements OnInit {
];
public mainContent: { 'background-image': string } | null = null;
public navigation: { 'background-image': string } | null = null;
private destroy$: Subject<void> = new Subject<void>();
public constructor(
private readonly sanitizer: DomSanitizer,
private readonly router: Router,
private readonly backgroundPatternService: BackgroundPatternService,
private readonly route: ActivatedRoute,
private readonly themeService: ThemeService,
private readonly el: ElementRef,
private readonly authService: AuthService
) {}
public ngOnInit(): void {
this.setActiveItemBasedOnRoute();
this.router.events.subscribe(() => {
this.setActiveItemBasedOnRoute();
});
this.setBackground();
this.onResize();
@HostListener('window:resize', ['$event'])
public onResize(event: Event): void {
this.isMobile = (event.target as Window).innerWidth < 1024;
this.adjustDrawerState((event.target as Window).innerWidth);
}
@HostListener('window:resize', ['$event'])
public onResize(): void {
if (window.innerWidth >= 768) {
this.showMobileMenu = false;
this.isCollapsed = false;
} else {
this.isDesktopCollapsed = false;
this.isCollapsed = true;
this.showMobileMenu = false;
public ngOnInit(): void {
this.setBackground();
this.adjustDrawerState(window.innerWidth);
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 {
window.removeEventListener('resize', this.onResize.bind(this));
this.destroy$.next();
this.destroy$.complete();
}
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 {
this.isCompact = false;
this.isDrawerOpen = true;
}
}
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 {
const theme = this.themeService.getTheme();
let opacity: number;
@ -134,34 +330,29 @@ export class LayoutComponent implements OnInit {
};
}
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;
private updateMenuState(currentRoute: string): void {
this.menuItems.forEach((item: TopMenuItem) => {
item.active = url.startsWith(item.route);
// Set top-level items active state
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

@ -1,39 +0,0 @@
<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

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

View File

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

View File

@ -0,0 +1,13 @@
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,10 +6,11 @@ import {
OnInit,
WritableSignal,
signal,
effect,
ElementRef,
InputSignal,
input,
ElementRef,
effect,
ViewChild,
} from '@angular/core';
import {
FormBuilder,
@ -22,16 +23,16 @@ import {
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 { delay, finalize, of, switchMap, takeWhile, tap, timer } from 'rxjs';
import {
Configuration,
MagicLinkDtoApiModel,
SigninResponseDtoApiModel,
SuccessDtoApiModel,
UserCredentialsDtoApiModel,
VerifyApiService,
} from '../../api';
import { ApiConfiguration } from '../../config/api-configuration';
import {
@ -39,16 +40,12 @@ import {
BackgroundPatternService,
ThemeService,
} from '../../shared/service';
import { LocalStorageService } from '../../shared/service/local-storage.service';
import {
customEmailValidator,
customPasswordValidator,
} from '../../shared/validator';
import { customEmailValidator } from '../../shared/validator';
type AuthAction = 'signin' | 'signup';
import { LocalStorageService } from './../../shared/service/local-storage.service';
@Component({
selector: 'app-register-root',
selector: 'app-unified-login',
standalone: true,
imports: [
CommonModule,
@ -56,8 +53,6 @@ type AuthAction = 'signin' | 'signup';
InputTextModule,
ReactiveFormsModule,
ButtonModule,
CheckboxModule,
PasswordModule,
HttpClientModule,
],
providers: [
@ -72,22 +67,28 @@ type AuthAction = 'signin' | 'signup';
changeDetection: ChangeDetectionStrategy.OnPush,
})
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 leftBackgroundStyle: { '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 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 displaySkeleton: WritableSignal<boolean> = signal(true);
public isEmailSent: WritableSignal<boolean> = signal(false);
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);
public get isDarkMode(): boolean {
@ -97,6 +98,7 @@ export class WelcomeRootComponent implements OnInit {
public constructor(
private readonly formBuilder: FormBuilder,
private readonly authService: AuthService,
private readonly verifyApiService: VerifyApiService,
private readonly router: Router,
private readonly themeService: ThemeService,
private readonly el: ElementRef,
@ -114,38 +116,76 @@ export class WelcomeRootComponent implements OnInit {
this.autologin();
this.setBackground();
this.initializeForm();
this.setupValueChanges();
if ((this.email() && this.verified()) || this.login()) {
this.handleRedirect();
this.removeQueryParams.set(true);
}
this.prefillEmail();
this.verifySignupMagicLink();
this.verifySigninMagicLink();
}
public autologin(): void {
const rememberMe = this.localStorageService.getItem<boolean>('remember-me');
if (
!this.token() &&
(!this.signin() || !this.signup()) &&
!this.signedOut()
) {
this.isAutoLoginInProgress.set(true);
this.displaySkeleton.set(true);
if (rememberMe && !this.signedOut()) {
this.authService
.status()
timer(2000)
.pipe(
delay(1500),
switchMap(() => this.authService.status()),
takeWhile((response: SuccessDtoApiModel) => response.success, true),
tap({
next: (response: SuccessDtoApiModel) => {
if (response.success) {
this.router.navigate(['/dashboard']);
}
},
finalize: () => this.displaySkeleton.set(false),
switchMap((response: SuccessDtoApiModel) => {
if (response.success) {
return this.router.navigate(['/dashboard']).then(() => response);
}
return of(response);
}),
finalize(() => {
this.isAutoLoginInProgress.set(false);
setTimeout(() => {
this.displaySkeleton.set(false);
}, 100);
})
)
.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 {
const theme = this.themeService.getTheme();
let opacity: number;
@ -188,98 +228,172 @@ export class WelcomeRootComponent implements OnInit {
};
}
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();
public onSubmit(): void {
if (this.form.invalid) {
Object.keys(this.form.controls).forEach((key) => {
const control = this.form.get(key);
if (action === 'signin') {
this.handlePreselect();
this.isSigninSignal.set(true);
this.isSignupSignal.set(false);
control?.markAsTouched();
});
return;
}
if (this.isRegistrationMode()) {
const signupCredentials: UserCredentialsDtoApiModel = {
email: this.form.getRawValue().email.trim(),
password: this.form.getRawValue().password.trim(),
};
this.signupNewUser(signupCredentials);
} else {
this.isSigninSignal.set(false);
this.isSignupSignal.set(true);
this.sendLoginEmail(this.form.value.email);
}
}
public onSubmit(): void {
this.markControlsAsTouchedAndDirty(['email', 'password']);
public isValidLoginAttempt(): boolean {
return this.signin() && this.token() !== '';
}
if (this.form?.valid) {
if (this.isSigninSignal()) {
this.signin(this.form.value);
} else {
this.signup(this.form.value);
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 handlePreselect(): void {
const rememberMe = this.localStorageService.getItem<boolean>('remember-me');
const email = this.localStorageService.getItem<string>('email');
private handleVerificationFinalize(): void {
if (!this.verificationError()) {
this.displaySkeleton.set(false);
this.isTokenVerified.set(true);
if (rememberMe) {
this.isSigninSignal.set(true);
this.isSignupSignal.set(false);
if (this.isValidLoginAttempt() && !this.signup()) {
this.isAutoLoginInProgress.set(true);
}
timer(2000).subscribe(() => {
this.isTokenVerified.set(false);
this.focusPasswordField();
});
}
if (email) {
this.form?.get('email')?.setValue(email);
}
this.rememberMe.setValue(rememberMe);
}
private initializeForm(): void {
const rememberMeValue =
this.localStorageService.getItem<boolean>('remember-me');
const email = this.localStorageService.getItem<string>('email');
if (rememberMeValue) {
this.isSigninSignal.set(true);
this.isSignupSignal.set(false);
private handleVerificationResponse(
response: SuccessDtoApiModel,
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:'
);
}
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())));
}
private handleVerificationError(): void {
this.isVerifying.set(false);
this.handleVerificationFailure(
'An error occurred during verification. Please check the reasons below:'
);
}
if (this.login()) {
this.isSignupSignal.set(true);
this.isSigninSignal.set(false);
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);
}
}
@ -287,104 +401,41 @@ export class WelcomeRootComponent implements OnInit {
this.router.navigate([], { queryParams: {} });
}
private setupValueChanges(): void {
this.setupEmailValueChanges();
this.setupPasswordValueChanges();
private extractVerifyToken(): string {
const [verifyToken]: string[] = this.token().split('|');
return verifyToken;
}
private setupEmailValueChanges(): void {
const emailControl = this.form?.get('email');
private extractEmail(): string {
const [, email]: string[] = this.token().split('|');
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 });
return email;
}
private initializeForm(): void {
this.form = this.formBuilder.group({
email: new FormControl('', {
validators: [Validators.required, customEmailValidator()],
updateOn: 'change',
}),
});
}
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 addPasswordFieldToForm(): void {
this.form.addControl(
'password',
new FormControl('', {
validators: [Validators.required, Validators.minLength(8)],
updateOn: 'change',
})
);
}
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<string>('email', logiCredentials.email);
this.localStorageService.setItem<boolean>('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 {
private signupNewUser(signupCredentials: UserCredentialsDtoApiModel): void {
this.isLoading.set(true);
this.authService
.signup(logiCredentials)
.signup(signupCredentials)
.pipe(
delay(1000),
tap(() => this.isLoading.set(true)),
@ -392,8 +443,41 @@ export class WelcomeRootComponent implements OnInit {
)
.subscribe((response: SuccessDtoApiModel) => {
if (response.success) {
this.openModal();
this.userSignupSuccess.set(true);
this.remeberUserMail(signupCredentials.email);
this.isUserSignupSuccessfully.set(true);
}
});
}
private prefillEmail(): 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);
const magiclink: MagicLinkDtoApiModel = {
email: email,
};
this.authService
.sendMagicLink(magiclink)
.pipe(
delay(1000),
tap(() => this.isLoading.set(true)),
finalize(() => this.isLoading.set(false))
)
.subscribe((response: SuccessDtoApiModel) => {
if (response.success) {
this.isEmailSent.set(true);
this.remeberUserMail(email);
}
});
}

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@ -1,10 +1,12 @@
// 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 components;
@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,12 +4,27 @@ module.exports = {
"./src/**/*.{html,ts}",
],
theme: {
extend: {},
fontFamily: {
'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')],
daisyui: {
themes: ["light", "dark"],
darkMode: ['class', '[data-theme="dark"]']
themes: ["light", "sunset"],
darkMode: ['class', '[data-theme="sunset"]'],
}
}