Compare commits
30 Commits
feature/er
...
main
|
@ -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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,9 @@ 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,
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
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(private readonly sessionService: SessionService) {}
|
public constructor(
|
||||||
|
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',
|
||||||
|
@ -17,4 +21,14 @@ 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('-------------------------------------------');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,12 @@ 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;
|
||||||
|
|
|
@ -8,6 +8,8 @@ import * as cookieParser from 'cookie-parser';
|
||||||
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { SessionInitService } from './modules/session/services';
|
import { SessionInitService } from './modules/session/services';
|
||||||
|
import { HttpExceptionFilter } from './shared/filters';
|
||||||
|
import { ErrorHandlingInterceptor } from './shared/interceptors';
|
||||||
|
|
||||||
async function setupSwagger(app: INestApplication): Promise<void> {
|
async function setupSwagger(app: INestApplication): Promise<void> {
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
|
@ -50,12 +52,22 @@ async function setupClassValidator(app: INestApplication): Promise<void> {
|
||||||
app.useGlobalPipes(new ValidationPipe());
|
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> {
|
async function bootstrap(): Promise<void> {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
await setupCookieParser(app);
|
await setupCookieParser(app);
|
||||||
await setupSwagger(app);
|
await setupSwagger(app);
|
||||||
await setupPrefix(app);
|
await setupPrefix(app);
|
||||||
|
await setupGlobalFilters(app);
|
||||||
|
await setupGlobalInterceptors(app);
|
||||||
await setupClassValidator(app);
|
await setupClassValidator(app);
|
||||||
await setupSessions(app);
|
await setupSessions(app);
|
||||||
await app.listen(3000);
|
await app.listen(3000);
|
||||||
|
|
|
@ -13,6 +13,13 @@ 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();
|
||||||
|
|
|
@ -15,7 +15,12 @@ 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 { SigninResponseDto, UserCredentialsDto } from '../models/dto';
|
import {
|
||||||
|
MagicLinkDto,
|
||||||
|
MagicLinkSigninDto,
|
||||||
|
SigninResponseDto,
|
||||||
|
UserCredentialsDto,
|
||||||
|
} from '../models/dto';
|
||||||
import { AuthService } from '../services/auth.service';
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
@ApiTags('Authentication')
|
@ApiTags('Authentication')
|
||||||
|
@ -23,6 +28,23 @@ 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,
|
||||||
|
@ -31,21 +53,26 @@ 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> {
|
||||||
return this.authService.signup(userCredentials);
|
const userAgent = request.headers['user-agent'] || 'Unknown';
|
||||||
|
|
||||||
|
return this.authService.signup(userCredentials, userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiCreatedResponse({
|
@ApiCreatedResponse({
|
||||||
description: 'User signin successfully',
|
description: 'User signin successfully',
|
||||||
type: SigninResponseDto,
|
type: SigninResponseDto,
|
||||||
})
|
})
|
||||||
@ApiBody({ type: UserCredentialsDto })
|
@ApiBody({ type: MagicLinkSigninDto })
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@UseGuards(LocalAuthGuard)
|
@UseGuards(LocalAuthGuard)
|
||||||
@Public()
|
@Public()
|
||||||
@Post('signin')
|
@Post('magic-link-signin')
|
||||||
public async signin(@Req() request: Request): Promise<SigninResponseDto> {
|
public async magicLinkSignin(
|
||||||
|
@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 }
|
||||||
);
|
);
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
export * from './local.auth.guard';
|
export * from './local.auth.guard';
|
||||||
|
export * from './is-authenticated.guard';
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,2 +1,4 @@
|
||||||
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';
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -12,12 +12,13 @@ export class SigninResponseDto {
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
public email: string;
|
public email: string;
|
||||||
|
|
||||||
@ApiProperty({
|
// TODO: ID is saved in the session, so it is not needed here
|
||||||
title: 'User ID',
|
// @ApiProperty({
|
||||||
description: 'User ID',
|
// title: 'User ID',
|
||||||
})
|
// description: 'User ID',
|
||||||
@IsNotEmpty()
|
// })
|
||||||
@IsString()
|
// @IsNotEmpty()
|
||||||
@IsEmail()
|
// @IsString()
|
||||||
public id: string;
|
// @IsEmail()
|
||||||
|
// public id: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpException,
|
InternalServerErrorException,
|
||||||
HttpStatus,
|
} from 'src/shared/exceptions';
|
||||||
Injectable,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { UserCredentials } from 'src/entities';
|
|
||||||
import { SessionService } from 'src/modules/session/services/session.service';
|
|
||||||
import { EncryptionService, SuccessDto } from 'src/shared';
|
|
||||||
|
|
||||||
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 { SigninResponseDto, UserCredentialsDto } from '../models/dto';
|
import {
|
||||||
|
MagicLinkDto,
|
||||||
|
SigninResponseDto,
|
||||||
|
UserCredentialsDto,
|
||||||
|
} from '../models/dto';
|
||||||
import { UserCredentialsRepository } from '../repositories/user-credentials.repository';
|
import { UserCredentialsRepository } from '../repositories/user-credentials.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -20,13 +27,57 @@ export class AuthService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly userCredentialsRepository: UserCredentialsRepository,
|
private readonly userCredentialsRepository: UserCredentialsRepository,
|
||||||
private readonly userDataRepository: UserDataRepository,
|
private readonly userDataRepository: UserDataRepository,
|
||||||
private readonly passwordConfirmationMailService: PasswordConfirmationMailService,
|
private readonly authEmailService: AuthEmailService,
|
||||||
private readonly emailVerificationService: EmailVerificationService,
|
private readonly emailVerificationService: EmailVerificationService,
|
||||||
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.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(
|
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(
|
||||||
|
@ -34,7 +85,7 @@ export class AuthService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
throw new ConflictException('User already exists');
|
throw new ConflictException('USER_ALREADY_EXISTS');
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHashed = await EncryptionService.hashData(
|
const passwordHashed = await EncryptionService.hashData(
|
||||||
|
@ -46,114 +97,111 @@ 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,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ConflictException) {
|
if (error instanceof ConflictException) {
|
||||||
throw new ConflictException(
|
throw error;
|
||||||
'User already exists. Please try to login instead.'
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
throw new HttpException(
|
throw new InternalServerErrorException('SIGNUP_ERROR', {
|
||||||
'Error while signing up',
|
cause: error,
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async validateUser(
|
public async validateUser(
|
||||||
|
token: string,
|
||||||
email: string,
|
email: string,
|
||||||
password: string
|
userAgent: 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 ForbiddenException('Access Denied');
|
throw new UnauthorizedException('User not found');
|
||||||
}
|
|
||||||
|
|
||||||
const passwordMatch = await EncryptionService.compareHash(
|
|
||||||
password,
|
|
||||||
user.hashedPassword
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!passwordMatch) {
|
|
||||||
throw new ForbiddenException('Access Denied');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ForbiddenException) {
|
if (error instanceof UnauthorizedException) {
|
||||||
throw new ForbiddenException(
|
throw error;
|
||||||
'E-Mail address or password is incorrect. Please try again.'
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
throw new HttpException(
|
throw new InternalServerErrorException('VALIDATION_ERROR', {
|
||||||
'Error while validating user credentials',
|
cause: error,
|
||||||
HttpStatus.INTERNAL_SERVER_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 {
|
try {
|
||||||
this.sessionService.deleteSessionBySessionId(sessionId);
|
await this.sessionService.deleteSessionBySessionId(sessionId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new HttpException(
|
throw new InternalServerErrorException('SIGNOUT_ERROR', {
|
||||||
'Fehler beim Logout',
|
message:
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR
|
'An error occurred during the sign out process. Please try again later.',
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async checkAuthStatus(
|
public async checkAuthStatus(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
userAgend: string
|
userAgent: string
|
||||||
): Promise<SuccessDto> {
|
): Promise<SuccessDto> {
|
||||||
try {
|
try {
|
||||||
const session =
|
const session =
|
||||||
await this.sessionService.findSessionBySessionId(sessionId);
|
await this.sessionService.findSessionBySessionId(sessionId);
|
||||||
|
|
||||||
if (!session) {
|
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;
|
.userAgent;
|
||||||
|
|
||||||
if (userAgendFromSession !== userAgend) {
|
if (userAgentFromSession !== userAgent) {
|
||||||
throw new ForbiddenException('User-Agent does not match');
|
throw new ForbiddenException('USER_AGENT_MISMATCH');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new HttpException(
|
if (error instanceof ForbiddenException) {
|
||||||
'Error while checking auth status',
|
throw error;
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR
|
} else {
|
||||||
);
|
throw new InternalServerErrorException('AUTH_STATUS_CHECK_ERROR', {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getLoginResponse(
|
public getLoginResponse(
|
||||||
user: SigninResponseDto & { userAgent: string }
|
user: SigninResponseDto & { userAgent: string }
|
||||||
): SigninResponseDto {
|
): SigninResponseDto {
|
||||||
const { id, email }: SigninResponseDto = user;
|
const { email }: SigninResponseDto = user;
|
||||||
const responseData: SigninResponseDto = { id, email };
|
const responseData: SigninResponseDto = { email };
|
||||||
|
|
||||||
return responseData;
|
return responseData;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,34 +1,56 @@
|
||||||
|
/* 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 { SigninResponseDto } from '../models/dto';
|
import { MagicLinkSigninDto } 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(private readonly authService: AuthService) {
|
public constructor(
|
||||||
|
private authService: AuthService,
|
||||||
|
private emailVerificationService: EmailVerificationService
|
||||||
|
) {
|
||||||
super({
|
super({
|
||||||
usernameField: 'email',
|
usernameField: 'email',
|
||||||
passwordField: 'password',
|
passwordField: 'token',
|
||||||
passReqToCallback: true,
|
passReqToCallback: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async validate(
|
public async validate(request: Request): Promise<any> {
|
||||||
request: Request,
|
const { token, email }: MagicLinkSigninDto = request.body;
|
||||||
email: string,
|
|
||||||
password: string
|
if (!token || !email) {
|
||||||
): Promise<SigninResponseDto & { userAgent: string }> {
|
throw new UnauthorizedException('Missing token or email');
|
||||||
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();
|
throw new UnauthorizedException('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const userAgent = request.headers['user-agent'];
|
const userAgent = request.headers['user-agent'];
|
||||||
|
|
||||||
return { id: user.id, email: user.email, userAgent: userAgent };
|
return { id: user.id, email: user.email, userAgent };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
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';
|
import { TemplateConfigService } from './services/template-config.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
@ -13,10 +13,10 @@ import { TemplateConfigService } from './services/template-config.service';
|
||||||
configService.get<string>('SEND_GRID_API_KEY'),
|
configService.get<string>('SEND_GRID_API_KEY'),
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
},
|
},
|
||||||
PasswordConfirmationMailService,
|
AuthEmailService,
|
||||||
TemplateConfigService,
|
TemplateConfigService,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
exports: [PasswordConfirmationMailService],
|
exports: [AuthEmailService],
|
||||||
})
|
})
|
||||||
export class SendgridModule {}
|
export class SendgridModule {}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +1,5 @@
|
||||||
import {
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
CanActivate,
|
import { SessionException } from 'src/shared/exceptions';
|
||||||
ExecutionContext,
|
|
||||||
Injectable,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
|
|
||||||
import { SessionService } from '../services/session.service';
|
import { SessionService } from '../services/session.service';
|
||||||
|
|
||||||
|
@ -19,20 +15,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 UnauthorizedException('Session not found.');
|
throw new SessionException('Session not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isExpired = await this.sessionService.isSessioExpired(session);
|
const isExpired = await this.sessionService.isSessioExpired(session);
|
||||||
|
|
||||||
if (isExpired) {
|
if (isExpired) {
|
||||||
throw new UnauthorizedException('Session expired.');
|
throw new SessionException('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 UnauthorizedException('User agent mismatch.');
|
throw new SessionException('User agent mismatch.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
|
@ -24,6 +24,7 @@ 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:
|
||||||
|
@ -34,7 +35,7 @@ export class SessionInitService {
|
||||||
sameSite:
|
sameSite:
|
||||||
this.configService.get<string>('NODE_ENV') === 'development'
|
this.configService.get<string>('NODE_ENV') === 'development'
|
||||||
? 'strict'
|
? 'strict'
|
||||||
: 'none',
|
: 'strict',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,13 @@
|
||||||
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 { Request } from 'express';
|
import { SuccessDto } from 'src/shared';
|
||||||
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';
|
||||||
|
@ -24,25 +21,22 @@ export class VerifyController {
|
||||||
|
|
||||||
@ApiCreatedResponse({
|
@ApiCreatedResponse({
|
||||||
description: 'Verify email',
|
description: 'Verify email',
|
||||||
type: Boolean,
|
type: SuccessDto,
|
||||||
})
|
})
|
||||||
@Public()
|
@Public()
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
public async verifyEmail(
|
public async verifyEmail(
|
||||||
@Query('token') tokenToVerify: string
|
@Query('token') tokenToVerify: string,
|
||||||
): Promise<boolean> {
|
@Query('email') emailToVerify: string,
|
||||||
return this.emailVerificationService.verifyEmail(tokenToVerify);
|
@Req() request: Request
|
||||||
}
|
): Promise<SuccessDto> {
|
||||||
|
const userAgent = request.headers['user-agent'] || 'Unknown';
|
||||||
|
|
||||||
@ApiCreatedResponse({
|
return this.emailVerificationService.verifyEmail(
|
||||||
description: 'Check if email is verified',
|
tokenToVerify,
|
||||||
type: Boolean,
|
emailToVerify,
|
||||||
})
|
userAgent
|
||||||
@Get('check')
|
);
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@UseGuards(SessionGuard)
|
|
||||||
public async isEmailVerified(@Req() request: Request): Promise<boolean> {
|
|
||||||
return this.emailVerificationService.isEmailVerified(request.sessionID);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { MoreThan, Repository } from 'typeorm';
|
import { LessThan, Repository } from 'typeorm';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailVerifyRepository {
|
export class EmailVerifyRepository {
|
||||||
|
@ -13,36 +13,45 @@ export class EmailVerifyRepository {
|
||||||
public async createEmailVerification(
|
public async createEmailVerification(
|
||||||
token: string,
|
token: string,
|
||||||
expiresAt: Date,
|
expiresAt: Date,
|
||||||
userId: string
|
email: 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,
|
||||||
user: { id: userId },
|
email,
|
||||||
|
user: userId ? { id: userId } : null,
|
||||||
|
userAgent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async findEmailVerificationByToken(token: string): Promise<boolean> {
|
public async findByTokenAndEmail(
|
||||||
const result = await this.repository.findOne({
|
token: string,
|
||||||
where: { token, expiresAt: MoreThan(new Date()) },
|
email: string
|
||||||
|
): Promise<EmailVerification | undefined> {
|
||||||
|
return await this.repository.findOne({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
email,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return result !== null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteEmailVerificationByToken(
|
public async removeEmailVerificationByTokenAndEmail(
|
||||||
tokenToDelete: string
|
token: string,
|
||||||
): Promise<EmailVerification | null> {
|
email: string
|
||||||
const emailVerification = await this.repository.findOne({
|
): Promise<void> {
|
||||||
where: { token: tokenToDelete },
|
await this.repository.delete({ token, email });
|
||||||
relations: ['user'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (emailVerification) {
|
|
||||||
await this.repository.delete({ token: tokenToDelete });
|
|
||||||
return emailVerification;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
public async deleteAllExpiredTokens(): Promise<void> {
|
||||||
|
const currentDate = new Date();
|
||||||
|
|
||||||
|
await this.repository.delete({
|
||||||
|
expiresAt: LessThan(currentDate),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,76 +1,104 @@
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
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,
|
||||||
|
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,
|
|
||||||
private readonly configService: ConfigService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async generateEmailVerificationToken(userId: string): Promise<string> {
|
public async generateEmailVerificationTokenForMagicLink(
|
||||||
|
email: string,
|
||||||
|
userAgent: string,
|
||||||
|
userid?: string
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
const verificationToken = await this.createVerificationToken();
|
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
|
await this.emailVerifyRepository.createEmailVerification(
|
||||||
const expiration = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
this.emailVerifyRepository.createEmailVerification(
|
|
||||||
verificationToken,
|
verificationToken,
|
||||||
expiration,
|
expiresAt,
|
||||||
userId
|
email,
|
||||||
|
userid || null,
|
||||||
|
userAgent
|
||||||
);
|
);
|
||||||
|
|
||||||
return verificationToken;
|
return verificationToken;
|
||||||
|
} catch (error) {
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
'EMAIL_VERIFICATION_TOKEN_GENERATION_ERROR',
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'An error occurred while generating the email verification token.',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async verifyEmail(tokenToVerify: string): Promise<boolean> {
|
public async verifyEmail(
|
||||||
const isTokenVerified =
|
tokenToVerify: string,
|
||||||
await this.emailVerifyRepository.findEmailVerificationByToken(
|
emailToVerify: string,
|
||||||
tokenToVerify
|
userAgent: string
|
||||||
|
): Promise<SuccessDto> {
|
||||||
|
try {
|
||||||
|
const token = await this.emailVerifyRepository.findByTokenAndEmail(
|
||||||
|
tokenToVerify,
|
||||||
|
emailToVerify
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isTokenVerified) {
|
if (!token) {
|
||||||
const emailVerification =
|
throw new TokenExpiredException();
|
||||||
await this.deleteEmailVerificationToken(tokenToVerify);
|
}
|
||||||
|
|
||||||
if (emailVerification && emailVerification.user) {
|
if (token.userAgent !== userAgent) {
|
||||||
const isStatusUpdated =
|
throw new UserAgentMismatchException({
|
||||||
await this.userDataRepository.updateEmailVerificationStatus(
|
message:
|
||||||
emailVerification.user.id
|
'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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeEmailVerificationByTokenAndEmail(
|
||||||
|
token: string,
|
||||||
|
email: string
|
||||||
|
): Promise<void> {
|
||||||
|
await this.emailVerifyRepository.removeEmailVerificationByTokenAndEmail(
|
||||||
|
token,
|
||||||
|
email
|
||||||
);
|
);
|
||||||
|
|
||||||
return isStatusUpdated;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
public async deleteAllExpiredTokens(): Promise<void> {
|
||||||
}
|
await this.emailVerifyRepository.deleteAllExpiredTokens();
|
||||||
|
|
||||||
public async isEmailVerified(sessionID: string): Promise<boolean> {
|
|
||||||
const userId = await this.sessionService.getUserIdBySessionId(sessionID);
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isVerfiied =
|
|
||||||
await this.userDataRepository.isEmailConfirmedByUserId(userId);
|
|
||||||
|
|
||||||
if (isVerfiied) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createVerificationToken(): Promise<string> {
|
private async createVerificationToken(): Promise<string> {
|
||||||
|
@ -78,12 +106,4 @@ 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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';
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './http-exception.filter';
|
|
@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './error-handling.interceptor';
|
|
@ -10,13 +10,6 @@ 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 = [
|
||||||
|
@ -46,6 +39,11 @@ const protectedRoutes: Routes = [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'foo',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/foo-root/foo.component').then((m) => m.FooComponent),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
|
|
|
@ -1,156 +1,399 @@
|
||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen w-screen bg-base-200">
|
||||||
<div
|
<div class="flex flex-col w-full lg:w-full bg-base-100 h-screen">
|
||||||
[ngStyle]="navigation"
|
<!-- Header mit dem Burger-Menü -->
|
||||||
[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="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)]">
|
class="w-full navbar bg-primary text-primary-content z-40">
|
||||||
<div class="w-10 flex items-center justify-center md:hidden">
|
<div class="flex-1">
|
||||||
<label class="btn btn-ghost swap swap-rotate">
|
<a class="btn btn-ghost normal-case text-xl text-primary-content">
|
||||||
<input
|
[APP-NAME]
|
||||||
type="checkbox"
|
</a>
|
||||||
(change)="toggleSidebar()"
|
</div>
|
||||||
[checked]="!isCollapsed" />
|
<!-- 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
|
<svg
|
||||||
class="swap-off fill-current"
|
*ngIf="!isDrawerOpen"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 512 512">
|
viewBox="0 0 24 24"
|
||||||
<path
|
fill="none"
|
||||||
d="M64,384H448V341.33H64Zm0-106.67H448V234.67H64ZM64,128v42.67H448V128Z" />
|
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>
|
||||||
<svg
|
<svg
|
||||||
class="swap-on fill-current"
|
*ngIf="isDrawerOpen"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 512 512">
|
viewBox="0 0 24 24"
|
||||||
<polygon
|
fill="none"
|
||||||
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="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>
|
</svg>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div [ngStyle]="mainContent" class="overflow-y-auto h-screen">
|
<!-- Hauptcontainer -->
|
||||||
<router-outlet></router-outlet>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Original Submenu für normalen Modus -->
|
||||||
|
<div *ngIf="!isCompact" class="overflow-hidden">
|
||||||
<div
|
<div
|
||||||
*ngIf="!isCollapsed"
|
(click)="toggleSubmenu(item, $event)"
|
||||||
class="fixed inset-0 bg-black bg-opacity-50 z-10 md:hidden"
|
(keydown.enter)="toggleSubmenu(item, $event)"
|
||||||
(click)="toggleSidebar()"></div>
|
(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>
|
||||||
|
|
|
@ -1,13 +1,29 @@
|
||||||
|
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 { 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 { SuccessDtoApiModel } from '../../api';
|
||||||
import { BackgroundPatternService, ThemeService } from '../../shared/service';
|
import { BackgroundPatternService, ThemeService } from '../../shared/service';
|
||||||
|
@ -16,8 +32,17 @@ 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 {
|
||||||
|
@ -30,14 +55,99 @@ interface BottomMenuItem {
|
||||||
selector: 'app-layout',
|
selector: 'app-layout',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
providers: [],
|
providers: [],
|
||||||
imports: [RouterOutlet, CommonModule],
|
imports: [RouterOutlet, CommonModule, RouterModule],
|
||||||
templateUrl: './layout.component.html',
|
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 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',
|
||||||
|
@ -49,11 +159,41 @@ export class LayoutComponent implements OnInit {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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[] = [
|
||||||
|
@ -68,35 +208,91 @@ export class LayoutComponent implements OnInit {
|
||||||
];
|
];
|
||||||
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
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public ngOnInit(): void {
|
@HostListener('window:resize', ['$event'])
|
||||||
this.setActiveItemBasedOnRoute();
|
public onResize(event: Event): void {
|
||||||
this.router.events.subscribe(() => {
|
this.isMobile = (event.target as Window).innerWidth < 1024;
|
||||||
this.setActiveItemBasedOnRoute();
|
this.adjustDrawerState((event.target as Window).innerWidth);
|
||||||
});
|
|
||||||
this.setBackground();
|
|
||||||
this.onResize();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:resize', ['$event'])
|
public ngOnInit(): void {
|
||||||
public onResize(): void {
|
this.setBackground();
|
||||||
if (window.innerWidth >= 768) {
|
this.adjustDrawerState(window.innerWidth);
|
||||||
this.showMobileMenu = false;
|
|
||||||
this.isCollapsed = false;
|
window.addEventListener('resize', this.onResize.bind(this));
|
||||||
} else {
|
|
||||||
this.isDesktopCollapsed = false;
|
this.updateMenuState(this.router.url);
|
||||||
this.isCollapsed = true;
|
|
||||||
this.showMobileMenu = false;
|
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 {
|
public setBackground(): void {
|
||||||
|
@ -134,34 +330,29 @@ export class LayoutComponent implements OnInit {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleSidebar(): void {
|
private updateMenuState(currentRoute: string): 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) => {
|
||||||
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
|
@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +1,6 @@
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<div
|
<div class="w-full max-w-full sticky top-0 z-10 pt-4 px-4 sm:px-8">
|
||||||
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 mx-auto">
|
||||||
<div class="w-full max-w-4xl mx-auto">
|
|
||||||
<app-stepper-indicator
|
<app-stepper-indicator
|
||||||
[steps]="steps"
|
[steps]="steps"
|
||||||
[currentStep]="currentStep()"
|
[currentStep]="currentStep()"
|
||||||
|
@ -14,7 +13,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 max-w-4xl mx-auto">
|
<div class="w-full mx-auto">
|
||||||
@if (currentStep() === 0) {
|
@if (currentStep() === 0) {
|
||||||
<app-basic-step [form]="form"></app-basic-step>
|
<app-basic-step [form]="form"></app-basic-step>
|
||||||
}
|
}
|
||||||
|
@ -24,9 +23,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="w-full bg-base-100 sticky bottom-0 z-10 px-4 sm:px-8 py-4">
|
||||||
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 mx-auto">
|
||||||
<div class="flex justify-between max-w-4xl mx-auto">
|
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
@ -25,24 +25,10 @@ export class EventEmptyStateComponent {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public navigateToCreateEvent(): void {
|
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 {
|
public closeEmailVerificationModal(): void {
|
||||||
(this.emailVerificationModal.nativeElement as HTMLDialogElement).close();
|
(this.emailVerificationModal.nativeElement as HTMLDialogElement).close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private openEmailVerificationModal(): void {
|
|
||||||
(
|
|
||||||
this.emailVerificationModal.nativeElement as HTMLDialogElement
|
|
||||||
).showModal();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {}
|
|
@ -1,35 +1,231 @@
|
||||||
@if (!userSignupSuccess()) {
|
<div class="flex h-screen w-screen bg-base-200">
|
||||||
<div class="flex h-screen w-screen">
|
|
||||||
<div
|
<div
|
||||||
[ngStyle]="leftBackgroundStyle"
|
[ngStyle]="leftBackgroundStyle"
|
||||||
class="hidden md:flex md:flex-col md:w-1/2 bg-primary">
|
class="hidden lg:flex lg:flex-col lg:w-1/2 bg-primary text-primary-content">
|
||||||
<div class="flex-1 flex items-start pt-16 px-12">
|
<div class="flex-1 flex items-start pt-16 px-12">
|
||||||
<h1 class="text-3xl text-base-100">[LOGO] APP-NAME</h1>
|
<h1 class="text-4xl font-bold">APP-NAME</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex flex-col justify-center px-12">
|
||||||
|
@if (displaySkeleton()) {
|
||||||
|
<!-- Skeleton loader for the main content -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="skeleton h-8 w-3/4"></div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="skeleton h-8 w-8 mr-4"></div>
|
||||||
|
<div class="skeleton h-6 w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="skeleton h-8 w-8 mr-4"></div>
|
||||||
|
<div class="skeleton h-6 w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="skeleton h-8 w-8 mr-4"></div>
|
||||||
|
<div class="skeleton h-6 w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="skeleton h-8 w-8 mr-4"></div>
|
||||||
|
<div class="skeleton h-6 w-3/5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
@if (isRegistrationMode()) {
|
||||||
|
<h2 class="text-3xl font-semibold mb-8">
|
||||||
|
Complete Your Registration
|
||||||
|
</h2>
|
||||||
|
<ul class="space-y-6">
|
||||||
|
<li class="flex items-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 mr-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-xl">Secure Account Creation</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 mr-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-xl">Quick and Easy Setup</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 mr-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-xl">Enhanced Security Measures</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 mr-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-xl">Personalized Experience</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<h2 class="text-3xl font-semibold mb-8">
|
||||||
|
Elevate Your Event Management
|
||||||
|
</h2>
|
||||||
|
<ul class="space-y-6">
|
||||||
|
<li class="flex items-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 mr-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-xl">Streamlined Event Creation</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 mr-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-xl">Integrated Ticketing System</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 mr-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-xl">Advanced Analytics Dashboard</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 mr-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-xl">Virtual Event Integration</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 mr-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-xl">Automated Reminder System</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 flex flex-col justify-end pb-16 px-12">
|
<div class="flex-1 flex flex-col justify-end pb-16 px-12">
|
||||||
<blockquote>
|
@if (displaySkeleton()) {
|
||||||
<p class="text-xl text-base-100 font-semibold">
|
<div class="p-6">
|
||||||
“This library has saved me countless hours of work and helped me
|
<div class="skeleton h-4 w-full mb-2"></div>
|
||||||
deliver stunning designs to my clients faster than ever before.”
|
<div class="skeleton h-4 w-5/6 mb-2"></div>
|
||||||
|
<div class="skeleton h-4 w-4/5 mb-4"></div>
|
||||||
|
<div class="skeleton h-3 w-1/4 mb-1"></div>
|
||||||
|
<div class="skeleton h-3 w-1/3"></div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<blockquote
|
||||||
|
class="backdrop-blur-sm p-6 rounded-lg border-base-100 border-2 shadow-lg">
|
||||||
|
@if (isRegistrationMode()) {
|
||||||
|
<p class="text-xl font-semibold italic">
|
||||||
|
"Registering for APP-NAME was a breeze. In just a few minutes, I
|
||||||
|
had access to powerful event management tools that transformed our
|
||||||
|
business."
|
||||||
</p>
|
</p>
|
||||||
<small class="block text-sm font-light text-base-100 mt-4">
|
<footer class="mt-4">
|
||||||
— Sofia Davis
|
<p class="font-semibold">Alex Johnson</p>
|
||||||
</small>
|
<p class="text-sm">Event Coordinator, InnovateConferences</p>
|
||||||
|
</footer>
|
||||||
|
} @else {
|
||||||
|
<p class="text-xl font-semibold italic">
|
||||||
|
"APP-NAME has revolutionized our event management process. The
|
||||||
|
efficiency and insights it provides are unparalleled."
|
||||||
|
</p>
|
||||||
|
<footer class="mt-4">
|
||||||
|
<p class="font-semibold">Emily Chen</p>
|
||||||
|
<p class="text-sm">Director of Events, TechCorp International</p>
|
||||||
|
</footer>
|
||||||
|
}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rechter Bereich, immer sichtbar -->
|
<div
|
||||||
<div [ngStyle]="rightBackgroundStyle" class="flex flex-col w-full md:w-1/2">
|
[ngStyle]="rightBackgroundStyle"
|
||||||
<div class="flex px-12 gap-3">
|
class="flex flex-col w-full lg:w-1/2 bg-base-100 h-screen">
|
||||||
<div class="flex items-start justify-end pt-16">
|
<div class="flex justify-end items-center pt-16 px-12">
|
||||||
<label class="swap swap-rotate">
|
<label class="swap swap-rotate">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
(change)="toggleTheme()"
|
(change)="toggleTheme()"
|
||||||
[checked]="isDarkMode" />
|
[checked]="isDarkMode" />
|
||||||
|
|
||||||
<!-- sun icon -->
|
|
||||||
<svg
|
<svg
|
||||||
class="swap-on h-10 w-10 fill-current"
|
class="swap-on h-10 w-10 fill-current"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
@ -37,8 +233,6 @@
|
||||||
<path
|
<path
|
||||||
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
|
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<!-- moon icon -->
|
|
||||||
<svg
|
<svg
|
||||||
class="swap-off h-10 w-10 fill-current"
|
class="swap-off h-10 w-10 fill-current"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
@ -48,254 +242,531 @@
|
||||||
</svg>
|
</svg>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 items-start flex justify-end pt-16">
|
|
||||||
@if (isSignupSignal()) {
|
|
||||||
<button
|
|
||||||
(click)="toggleAction('signin')"
|
|
||||||
class="btn btn-primary btn-outline no-animation">
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (isSigninSignal()) {
|
|
||||||
@if (displaySkeleton()) {
|
|
||||||
<div class="skeleton w-36 h-12"></div>
|
|
||||||
} @else {
|
|
||||||
<button
|
|
||||||
(click)="toggleAction('signup')"
|
|
||||||
class="btn btn-primary btn-outline no-animation">
|
|
||||||
New here - Register now!
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (isSignupSignal()) {
|
|
||||||
<div
|
<div
|
||||||
class="animate-fade-down animate-once animate-duration-1000 animate-ease-in-out flex-1 flex flex-col justify-center items-center px-12">
|
class="flex-1 flex flex-col justify-center items-center px-6 sm:px-12 overflow-y-auto">
|
||||||
<h1 class="text-3xl font-semibold text-center">Create an Account</h1>
|
@if (displaySkeleton()) {
|
||||||
<p class="text-center">
|
<div class="w-full max-w-md space-y-6">
|
||||||
Enter your email below to create your Account
|
<div class="skeleton h-10 w-3/4 mx-auto"></div>
|
||||||
|
<div class="skeleton h-4 w-1/2 mx-auto"></div>
|
||||||
|
<div class="skeleton h-12 w-full"></div>
|
||||||
|
@if (isRegistrationMode()) {
|
||||||
|
<div class="skeleton h-12 w-full"></div>
|
||||||
|
}
|
||||||
|
<div class="skeleton h-12 w-full"></div>
|
||||||
|
<div class="skeleton h-4 w-1/4 mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<h1 class="text-4xl font-bold text-center mb-2">
|
||||||
|
@if (isRegistrationMode()) {
|
||||||
|
Complete Your Registration
|
||||||
|
} @else {
|
||||||
|
Welcome to APP-NAME
|
||||||
|
}
|
||||||
|
</h1>
|
||||||
|
<p class="text-center text-base-content/60 mb-8">
|
||||||
|
@if (isRegistrationMode()) {
|
||||||
|
You're one step away from unlocking powerful event management
|
||||||
|
tools
|
||||||
|
} @else {
|
||||||
|
Enter your email to access your account or get started
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
<form
|
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="space-y-3">
|
||||||
[formGroup]="form"
|
<label class="form-control w-full">
|
||||||
(ngSubmit)="onSubmit()"
|
|
||||||
class="flex gap-4 flex-col items-center py-6 w-full max-w-md">
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label
|
<label
|
||||||
|
class="input input-bordered flex items-center gap-2 p-3"
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'w-full': true,
|
'bg-base-200/50': !isRegistrationMode(),
|
||||||
'border-error focus:border-error':
|
'bg-base-300/50': isRegistrationMode(),
|
||||||
form.get('email')?.invalid &&
|
'input-success !border-success':
|
||||||
(form.get('email')?.dirty || form.get('email')?.touched)
|
isRegistrationMode() ||
|
||||||
|
getInputClass('email') === 'input-success',
|
||||||
|
'input-error':
|
||||||
|
!isRegistrationMode() &&
|
||||||
|
getInputClass('email') === 'input-error'
|
||||||
}"
|
}"
|
||||||
class="input input-bordered flex items-center gap-2">
|
[style.border-color]="
|
||||||
|
isRegistrationMode() ||
|
||||||
|
getInputClass('email') === 'input-success'
|
||||||
|
? 'var(--success)'
|
||||||
|
: null
|
||||||
|
">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
class="h-4 w-4 opacity-70">
|
class="h-5 w-5 opacity-70">
|
||||||
<path
|
<path
|
||||||
d="M2.5 3A1.5 1.5 0 0 0 1 4.5v.793c.026.009.051.02.076.032L7.674 8.51c.206.1.446.1.652 0l6.598-3.185A.755.755 0 0 1 15 5.293V4.5A1.5 1.5 0 0 0 13.5 3h-11Z" />
|
d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
||||||
<path
|
<path
|
||||||
d="M15 6.954 8.978 9.86a2.25 2.25 0 0 1-1.956 0L1 6.954V11.5A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5V6.954Z" />
|
d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
formControlName="email"
|
formControlName="email"
|
||||||
type="text"
|
type="text"
|
||||||
class="grow"
|
class="grow bg-transparent focus:outline-none"
|
||||||
|
[attr.readonly]="isRegistrationMode() ? '' : null"
|
||||||
|
[ngClass]="{ 'cursor-not-allowed': isRegistrationMode() }"
|
||||||
placeholder="name@example.com" />
|
placeholder="name@example.com" />
|
||||||
</label>
|
</label>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt text-error">
|
||||||
|
{{ getErrorMessage('email') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control w-full">
|
</label>
|
||||||
|
|
||||||
|
@if (isRegistrationMode()) {
|
||||||
|
<label class="form-control w-full">
|
||||||
<label
|
<label
|
||||||
[ngClass]="{
|
class="input input-bordered flex items-center gap-2 p-3 bg-base-200/50"
|
||||||
'w-full': true,
|
[ngClass]="getInputClass('password')">
|
||||||
'border-error focus:border-error':
|
|
||||||
form.get('password')?.invalid &&
|
|
||||||
(form.get('password')?.dirty ||
|
|
||||||
form.get('password')?.touched)
|
|
||||||
}"
|
|
||||||
class="input input-bordered flex items-center gap-2">
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
class="h-4 w-4 opacity-70">
|
class="h-5 w-5 opacity-70">
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
|
d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z"
|
||||||
clip-rule="evenodd" />
|
clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
|
#passwordInput
|
||||||
formControlName="password"
|
formControlName="password"
|
||||||
type="password"
|
type="password"
|
||||||
class="grow"
|
class="grow bg-transparent focus:outline-none"
|
||||||
value="" />
|
placeholder="Create a strong password" />
|
||||||
</label>
|
</label>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt text-error">
|
||||||
|
{{ getErrorMessage('password') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn w-full btn-primary font-semibold">
|
</label>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary text-lg w-full h-auto"
|
||||||
|
[disabled]="isLoading()">
|
||||||
@if (isLoading()) {
|
@if (isLoading()) {
|
||||||
<span class="loading loading-spinner"></span>
|
<span class="loading loading-spinner"></span>
|
||||||
}
|
}
|
||||||
Sign Up with Email
|
@if (isRegistrationMode()) {
|
||||||
|
Complete Registration
|
||||||
|
} @else {
|
||||||
|
Send Magic Link
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
<p class="text-xs w-full text-center">
|
</form>
|
||||||
By clicking continue, you agree to our
|
<div class="mt-8 text-center">
|
||||||
<u class="cursor-pointer">Terms of Service</u>
|
<a href="#" class="text-primary hover:underline">Need help?</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!isRegistrationMode() && !displaySkeleton()) {
|
||||||
|
<div class="w-full max-w-md mt-12">
|
||||||
|
<div class="bg-base-200 p-6 rounded-lg">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">What happens next?</h3>
|
||||||
|
<ul class="list-disc list-inside space-y-2 text-sm">
|
||||||
|
<li>We'll send a magic link to your email</li>
|
||||||
|
<li>Click the link in the email to securely log in</li>
|
||||||
|
<li>If you're new, you'll be prompted to create a password</li>
|
||||||
|
<li>Existing users will be logged in instantly</li>
|
||||||
|
<li>The magic link expires in 10 minutes for security</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else if (displaySkeleton()) {
|
||||||
|
<div class="w-full max-w-md mt-12">
|
||||||
|
<div class="bg-base-200 p-6 rounded-lg">
|
||||||
|
<div class="skeleton h-6 w-3/4 mb-4"></div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="skeleton h-4 w-full"></div>
|
||||||
|
<div class="skeleton h-4 w-5/6"></div>
|
||||||
|
<div class="skeleton h-4 w-4/5"></div>
|
||||||
|
<div class="skeleton h-4 w-full"></div>
|
||||||
|
<div class="skeleton h-4 w-5/6"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center py-6">
|
||||||
|
<p class="text-sm text-base-content/60 text-center max-w-md">
|
||||||
|
By continuing, you agree to APP-NAME's
|
||||||
|
<a href="#" class="link link-primary">Terms of Service</a>
|
||||||
and
|
and
|
||||||
<u class="cursor-pointer">Privacy Policy</u>
|
<a href="#" class="link link-primary">Privacy Policy</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (isSigninSignal()) {
|
|
||||||
<div
|
|
||||||
class="animate-fade-down animate-once animate-duration-1000 animate-ease-in-out flex-1 flex flex-col justify-center items-center px-12">
|
|
||||||
@if (displaySkeleton()) {
|
|
||||||
<div class="flex items-center w-full flex-col max-w-md gap-4">
|
|
||||||
<div class="skeleton w-36 h-10"></div>
|
|
||||||
<div class="skeleton w-full h-10 max-w-md"></div>
|
|
||||||
<div class="skeleton w-full h-10 max-w-md"></div>
|
|
||||||
<div class="skeleton w-full h-10 max-w-md"></div>
|
|
||||||
<div class="skeleton w-full h-10 max-w-md"></div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<h1 class="text-3xl font-semibold text-center">Login</h1>
|
|
||||||
<form
|
|
||||||
[formGroup]="form"
|
|
||||||
(ngSubmit)="onSubmit()"
|
|
||||||
class="flex gap-4 flex-col items-center py-6 w-full max-w-md">
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label
|
|
||||||
[ngClass]="{
|
|
||||||
'w-full': true,
|
|
||||||
'border-error focus:border-error':
|
|
||||||
form.get('email')?.invalid &&
|
|
||||||
(form.get('email')?.dirty || form.get('email')?.touched)
|
|
||||||
}"
|
|
||||||
class="input input-bordered flex items-center gap-2">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="h-4 w-4 opacity-70">
|
|
||||||
<path
|
|
||||||
d="M2.5 3A1.5 1.5 0 0 0 1 4.5v.793c.026.009.051.02.076.032L7.674 8.51c.206.1.446.1.652 0l6.598-3.185A.755.755 0 0 1 15 5.293V4.5A1.5 1.5 0 0 0 13.5 3h-11Z" />
|
|
||||||
<path
|
|
||||||
d="M15 6.954 8.978 9.86a2.25 2.25 0 0 1-1.956 0L1 6.954V11.5A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5V6.954Z" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
formControlName="email"
|
|
||||||
type="text"
|
|
||||||
class="grow"
|
|
||||||
placeholder="name@example.com" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label
|
|
||||||
[ngClass]="{
|
|
||||||
'w-full': true,
|
|
||||||
'border-error focus:border-error':
|
|
||||||
form.get('password')?.invalid &&
|
|
||||||
(form.get('password')?.dirty ||
|
|
||||||
form.get('password')?.touched)
|
|
||||||
}"
|
|
||||||
class="input input-bordered flex items-center gap-2">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="h-4 w-4 opacity-70">
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
|
|
||||||
clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
formControlName="password"
|
|
||||||
type="password"
|
|
||||||
class="grow"
|
|
||||||
value="" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<label class="label cursor-pointer">
|
|
||||||
<input
|
|
||||||
[formControl]="rememberMe"
|
|
||||||
type="checkbox"
|
|
||||||
checked="checked"
|
|
||||||
class="checkbox checkbox-md checkbox-primary" />
|
|
||||||
<span class="label-text ml-1.5">Remember me</span>
|
|
||||||
</label>
|
|
||||||
<a class="text-primary label-text cursor-pointer">
|
|
||||||
Forgot password?
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn w-full btn-primary font-semibold">
|
</div>
|
||||||
@if (isLoading()) {
|
|
||||||
<span class="loading loading-spinner"></span>
|
|
||||||
}
|
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<span class="text-xs">Not registered yet?</span>
|
|
||||||
<a
|
|
||||||
(click)="toggleAction('signup')"
|
|
||||||
(keypress)="toggleAction('signup')"
|
|
||||||
tabindex="0"
|
|
||||||
class="text-primary cursor-pointer text-xs">
|
|
||||||
Create An Account
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="flex flex-col items-center justify-center py-12">
|
|
||||||
<footer>
|
|
||||||
<p class="text-xs">Made with ♥️ in Germany</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="flex h-screen w-screen bg-primary">
|
|
||||||
<div class="hidden md:flex md:flex-col md:w-1/1"></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="modal modal-open" *ngIf="isDialogOpen()">
|
<div
|
||||||
|
class="modal modal-open"
|
||||||
|
*ngIf="
|
||||||
|
!isTokenVerified() &&
|
||||||
|
!verificationError() &&
|
||||||
|
!isAutoLoginInProgress() &&
|
||||||
|
(isVerifying() || isValidLoginAttempt())
|
||||||
|
"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="verify-modal-title"
|
||||||
|
aria-describedby="verify-modal-description"
|
||||||
|
aria-modal="true"
|
||||||
|
role="dialog">
|
||||||
<div
|
<div
|
||||||
[ngStyle]="dialogBackgroundStyle"
|
class="modal-box w-11/12 max-w-2xl mx-auto bg-base-100 shadow-xl rounded-lg transition-all transform duration-300 ease-out">
|
||||||
class="modal-box w-11/12 h-2/6 max-w-5xl flex">
|
<div class="flex flex-col items-center text-center p-6 space-y-4">
|
||||||
<div class="w-full flex flex-col justify-center items-center">
|
<div class="relative w-10 h-10">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 transition-opacity duration-300 ease-in-out opacity-100">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="verify-modal-title" class="text-3xl font-semibold mb-2">
|
||||||
|
Verifying Your Account
|
||||||
|
</h2>
|
||||||
|
<p id="verify-modal-description">
|
||||||
|
Please wait while we verify your email and token.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="modal modal-open"
|
||||||
|
*ngIf="displayAutologinDialog()"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="verify-modal-title"
|
||||||
|
aria-describedby="verify-modal-description"
|
||||||
|
aria-modal="true"
|
||||||
|
role="dialog">
|
||||||
|
<div
|
||||||
|
class="modal-box w-11/12 max-w-2xl mx-auto bg-base-100 shadow-xl rounded-lg transition-all transform duration-300 ease-out">
|
||||||
|
<div class="flex flex-col items-center text-center p-6 space-y-4">
|
||||||
|
<div class="relative w-10 h-10">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 transition-opacity duration-300 ease-in-out opacity-100">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="verify-modal-title" class="text-3xl font-semibold mb-2">
|
||||||
|
Logging You In
|
||||||
|
</h2>
|
||||||
|
<p id="verify-modal-description">
|
||||||
|
Please wait while we automatically log you in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="modal modal-open"
|
||||||
|
*ngIf="isTokenVerified() && !verificationError() && !isAutoLoginInProgress()"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="verify-modal-title"
|
||||||
|
aria-describedby="verify-modal-description"
|
||||||
|
aria-modal="true"
|
||||||
|
role="dialog">
|
||||||
|
<div
|
||||||
|
class="modal-box w-11/12 max-w-2xl mx-auto bg-base-100 shadow-xl rounded-lg transition-all transform duration-300 ease-out">
|
||||||
|
<div class="flex flex-col items-center text-center p-6 space-y-4">
|
||||||
|
<div class="relative w-10 h-10">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 transition-opacity duration-300 ease-in-out opacity-100">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-10 w-10 text-success"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke-width="1"
|
stroke="currentColor">
|
||||||
stroke="currentColor"
|
|
||||||
class="size-28 animate-jump animate-once animate-duration-[2000ms] animate-delay-500 animate-ease-in-out animate-normal">
|
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
d="M21.75 9v.906a2.25 2.25 0 0 1-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 0 0 1.183 1.981l6.478 3.488m8.839 2.51-4.66-2.51m0 0-1.023-.55a2.25 2.25 0 0 0-2.134 0l-1.022.55m0 0-4.661 2.51m16.5 1.615a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V8.844a2.25 2.25 0 0 1 1.183-1.981l7.5-4.039a2.25 2.25 0 0 1 2.134 0l7.5 4.039a2.25 2.25 0 0 1 1.183 1.98V19.5Z" />
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="verify-modal-title" class="text-3xl font-semibold mb-2">
|
||||||
|
Verification Complete
|
||||||
|
</h2>
|
||||||
|
<p id="verify-modal-description">
|
||||||
|
Your email has been successfully verified.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h1 class="font-bold text-3xl pt-5">Check your inbox, please!</h1>
|
<div
|
||||||
<div class="flex flex-col items-center text-center">
|
class="modal modal-open"
|
||||||
<p class="pt-3">
|
*ngIf="verificationError()"
|
||||||
Hey, to start using [APP-NAME], we need to verify your email.
|
tabindex="-1"
|
||||||
|
aria-labelledby="verify-modal-title"
|
||||||
|
aria-describedby="verify-modal-description"
|
||||||
|
aria-modal="true"
|
||||||
|
role="dialog">
|
||||||
|
<div
|
||||||
|
class="modal-box w-11/12 max-w-2xl mx-auto bg-base-100 shadow-xl rounded-lg transition-all transform duration-300 ease-out">
|
||||||
|
<div class="flex flex-col items-center text-center p-6 space-y-4">
|
||||||
|
<div class="relative w-10 h-10">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 transition-opacity duration-300 ease-in-out opacity-100">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-10 w-10 text-error"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="verify-modal-title" class="text-3xl font-semibold mb-2">
|
||||||
|
Verification Failed
|
||||||
|
</h2>
|
||||||
|
<p id="verify-modal-description">
|
||||||
|
{{ verificationError() }}
|
||||||
</p>
|
</p>
|
||||||
<p class="pt-1">
|
<div class="mt-4 text-left">
|
||||||
We´ve already sent out the verification link. Please check it and
|
<p class="font-semibold">Possible reasons:</p>
|
||||||
<br />
|
<ul class="list-disc list-inside">
|
||||||
confirm it´s really you.
|
<li *ngFor="let reason of errorReasons()">{{ reason }}</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mt-4 font-semibold">What to do next:</p>
|
||||||
|
<p>
|
||||||
|
Please try to start the registration process again. If the problem
|
||||||
|
persists, contact our support team.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="mt-6 flex justify-center">
|
||||||
|
<a href="/" class="btn btn-primary">Back to Login</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- <div
|
||||||
|
class="modal modal-open"
|
||||||
|
*ngIf="
|
||||||
|
(isTokenVerified() ||
|
||||||
|
isVerifying() ||
|
||||||
|
verificationError() ||
|
||||||
|
isAutoLoginInProgress()) &&
|
||||||
|
(isValidLoginAttempt() || (signup() && token()))
|
||||||
|
"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="verify-modal-title"
|
||||||
|
aria-describedby="verify-modal-description"
|
||||||
|
aria-modal="true"
|
||||||
|
role="dialog">
|
||||||
|
<div
|
||||||
|
class="modal-box w-11/12 max-w-2xl mx-auto bg-base-100 shadow-xl rounded-lg transition-all transform duration-300 ease-out">
|
||||||
|
<div class="flex flex-col items-center text-center p-6 space-y-4">
|
||||||
|
<div class="relative w-10 h-10">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 transition-opacity duration-300 ease-in-out"
|
||||||
|
[class.opacity-100]="isVerifying() || isAutoLoginInProgress()"
|
||||||
|
[class.opacity-0]="!isVerifying() && !isAutoLoginInProgress()">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 transition-opacity duration-300 ease-in-out"
|
||||||
|
[class.opacity-0]="
|
||||||
|
!isTokenVerified() || verificationError() || isAutoLoginInProgress()
|
||||||
|
"
|
||||||
|
[class.opacity-100]="
|
||||||
|
isTokenVerified() &&
|
||||||
|
!verificationError() &&
|
||||||
|
!isAutoLoginInProgress()
|
||||||
|
">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-10 w-10 text-success"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 transition-opacity duration-300 ease-in-out"
|
||||||
|
[class.opacity-0]="!verificationError()"
|
||||||
|
[class.opacity-100]="verificationError()">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-10 w-10 text-error"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="verify-modal-title" class="text-3xl font-semibold mb-2">
|
||||||
|
{{
|
||||||
|
isTokenVerified() && !verificationError() && !isAutoLoginInProgress()
|
||||||
|
? 'Verification Complete'
|
||||||
|
: isAutoLoginInProgress()
|
||||||
|
? 'Logging You In'
|
||||||
|
: verificationError()
|
||||||
|
? 'Verification Failed'
|
||||||
|
: 'Verifying Your Account'
|
||||||
|
}}
|
||||||
|
</h2>
|
||||||
|
<p id="verify-modal-description">
|
||||||
|
{{
|
||||||
|
isTokenVerified() && !verificationError() && !isAutoLoginInProgress()
|
||||||
|
? 'Your email has been successfully verified.'
|
||||||
|
: isAutoLoginInProgress()
|
||||||
|
? 'Please wait while we automatically log you in.'
|
||||||
|
: verificationError()
|
||||||
|
? verificationError()
|
||||||
|
: 'Please wait while we verify your email and token.'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
@if (errorReasons().length > 0) {
|
||||||
|
<div class="mt-4 text-left">
|
||||||
|
<p class="font-semibold">Possible reasons:</p>
|
||||||
|
<ul class="list-disc list-inside">
|
||||||
|
@for (reason of errorReasons(); track reason) {
|
||||||
|
<li>{{ reason }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<p class="mt-4 font-semibold">What to do next:</p>
|
||||||
|
<p>
|
||||||
|
Please try to start the registration process again. If the problem
|
||||||
|
persists, contact our support team.
|
||||||
|
</p>
|
||||||
|
@if (isTokenVerified() || verificationError()) {
|
||||||
|
<div class="mt-6 flex justify-center">
|
||||||
|
<a href="/" class="btn btn-primary">Back to Login</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="modal modal-open"
|
||||||
|
*ngIf="isUserSignupSuccessfully()"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
aria-describedby="modal-description"
|
||||||
|
aria-modal="true"
|
||||||
|
role="dialog">
|
||||||
|
<div
|
||||||
|
class="modal-box w-11/12 max-w-2xl mx-auto bg-base-100 shadow-xl rounded-lg transition-all transform duration-300 ease-out">
|
||||||
|
<div class="flex flex-col items-center text-center p-6 space-y-4">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-24 w-24 text-primary mb-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 19v-8.93a2 2 0 01.89-1.664l7-4.666a2 2 0 012.22 0l7 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5m0 0l-1.14.76a2 2 0 01-2.22 0l-1.14-.76" />
|
||||||
|
</svg>
|
||||||
|
<h2 id="modal-title" class="text-3xl font-semibold mb-2">
|
||||||
|
Registration Successful!
|
||||||
|
</h2>
|
||||||
|
<p id="modal-description">
|
||||||
|
Your registration has been completed. A login link has been sent to your
|
||||||
|
email address.
|
||||||
|
</p>
|
||||||
|
<ul class="text-left p-4 rounded-lg w-full max-w-lg list-disc">
|
||||||
|
<li>
|
||||||
|
Open your email inbox and look for the email containing the login
|
||||||
|
link.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
If you can't find the email, please check your spam or junk folder.
|
||||||
|
</li>
|
||||||
|
<li>Click on the login link in the email to sign in.</li>
|
||||||
|
<li>Ensure your email client does not block emails from our domain.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="mt-6 flex items-center justify-center">
|
||||||
|
You can now close this tab.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="modal modal-open"
|
||||||
|
*ngIf="isEmailSent()"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
aria-describedby="modal-description"
|
||||||
|
aria-modal="true"
|
||||||
|
role="dialog">
|
||||||
|
<div
|
||||||
|
class="modal-box w-11/12 max-w-2xl mx-auto bg-base-100 shadow-xl rounded-lg transition-all transform duration-300 ease-out">
|
||||||
|
<div class="flex flex-col items-center text-center p-6 space-y-4">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-24 w-24 text-primary mb-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 19v-8.93a2 2 0 01.89-1.664l7-4.666a2 2 0 012.22 0l7 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5m0 0l-1.14.76a2 2 0 01-2.22 0l-1.14-.76" />
|
||||||
|
</svg>
|
||||||
|
<h2 id="modal-title" class="text-3xl font-semibold mb-2">
|
||||||
|
Please Check Your Email
|
||||||
|
</h2>
|
||||||
|
<p id="modal-description">
|
||||||
|
A confirmation email has been sent. Follow the instructions in the email
|
||||||
|
to activate your account.
|
||||||
|
</p>
|
||||||
|
<ul class="text-left p-4 rounded-lg w-full max-w-lg list-disc">
|
||||||
|
<li>Open your email inbox and look for the confirmation email.</li>
|
||||||
|
<li>If you don't see it, check your spam or junk folder.</li>
|
||||||
|
<li>
|
||||||
|
Click the confirmation link in the email to complete your
|
||||||
|
registration.
|
||||||
|
</li>
|
||||||
|
<li>Ensure your email client does not block emails from our domain.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="mt-6 flex items-center justify-center">
|
||||||
|
You can now close this tab.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
*ngIf="isAutoLoginInProgress()"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-20 z-50 flex items-center justify-center">
|
||||||
|
<div class="rounded-lg p-8">
|
||||||
|
<span class="loading loading-spinner w-20 h-20 text-primary"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
|
@ -6,10 +6,11 @@ import {
|
||||||
OnInit,
|
OnInit,
|
||||||
WritableSignal,
|
WritableSignal,
|
||||||
signal,
|
signal,
|
||||||
effect,
|
ElementRef,
|
||||||
InputSignal,
|
InputSignal,
|
||||||
input,
|
input,
|
||||||
ElementRef,
|
effect,
|
||||||
|
ViewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
FormBuilder,
|
FormBuilder,
|
||||||
|
@ -22,16 +23,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 { PasswordModule } from 'primeng/password';
|
import { delay, finalize, of, switchMap, takeWhile, tap, timer } from 'rxjs';
|
||||||
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 {
|
||||||
|
@ -39,16 +40,12 @@ import {
|
||||||
BackgroundPatternService,
|
BackgroundPatternService,
|
||||||
ThemeService,
|
ThemeService,
|
||||||
} from '../../shared/service';
|
} from '../../shared/service';
|
||||||
import { LocalStorageService } from '../../shared/service/local-storage.service';
|
import { customEmailValidator } from '../../shared/validator';
|
||||||
import {
|
|
||||||
customEmailValidator,
|
|
||||||
customPasswordValidator,
|
|
||||||
} from '../../shared/validator';
|
|
||||||
|
|
||||||
type AuthAction = 'signin' | 'signup';
|
import { LocalStorageService } from './../../shared/service/local-storage.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-register-root',
|
selector: 'app-unified-login',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
@ -56,8 +53,6 @@ type AuthAction = 'signin' | 'signup';
|
||||||
InputTextModule,
|
InputTextModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
ButtonModule,
|
ButtonModule,
|
||||||
CheckboxModule,
|
|
||||||
PasswordModule,
|
|
||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
@ -72,22 +67,28 @@ type AuthAction = 'signin' | 'signup';
|
||||||
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 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);
|
private removeQueryParams: WritableSignal<boolean> = signal(false);
|
||||||
|
|
||||||
public get isDarkMode(): boolean {
|
public get isDarkMode(): boolean {
|
||||||
|
@ -97,6 +98,7 @@ 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,
|
||||||
|
@ -114,38 +116,76 @@ export class WelcomeRootComponent implements OnInit {
|
||||||
this.autologin();
|
this.autologin();
|
||||||
this.setBackground();
|
this.setBackground();
|
||||||
this.initializeForm();
|
this.initializeForm();
|
||||||
this.setupValueChanges();
|
this.prefillEmail();
|
||||||
|
this.verifySignupMagicLink();
|
||||||
if ((this.email() && this.verified()) || this.login()) {
|
this.verifySigninMagicLink();
|
||||||
this.handleRedirect();
|
|
||||||
this.removeQueryParams.set(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public autologin(): void {
|
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()) {
|
timer(2000)
|
||||||
this.authService
|
|
||||||
.status()
|
|
||||||
.pipe(
|
.pipe(
|
||||||
delay(1500),
|
switchMap(() => this.authService.status()),
|
||||||
takeWhile((response: SuccessDtoApiModel) => response.success, true),
|
takeWhile((response: SuccessDtoApiModel) => response.success, true),
|
||||||
tap({
|
switchMap((response: SuccessDtoApiModel) => {
|
||||||
next: (response: SuccessDtoApiModel) => {
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.router.navigate(['/dashboard']);
|
return this.router.navigate(['/dashboard']).then(() => response);
|
||||||
}
|
}
|
||||||
},
|
return of(response);
|
||||||
finalize: () => this.displaySkeleton.set(false),
|
}),
|
||||||
|
finalize(() => {
|
||||||
|
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;
|
||||||
|
@ -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 {
|
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 {
|
||||||
this.markControlsAsTouchedAndDirty(['email', 'password']);
|
if (this.form.invalid) {
|
||||||
|
Object.keys(this.form.controls).forEach((key) => {
|
||||||
|
const control = this.form.get(key);
|
||||||
|
|
||||||
if (this.form?.valid) {
|
control?.markAsTouched();
|
||||||
if (this.isSigninSignal()) {
|
|
||||||
this.signin(this.form.value);
|
|
||||||
} else {
|
|
||||||
this.signup(this.form.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handlePreselect(): void {
|
|
||||||
const rememberMe = this.localStorageService.getItem<boolean>('remember-me');
|
|
||||||
const email = this.localStorageService.getItem<string>('email');
|
|
||||||
|
|
||||||
if (rememberMe) {
|
|
||||||
this.isSigninSignal.set(true);
|
|
||||||
this.isSignupSignal.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (email) {
|
|
||||||
this.form?.get('email')?.setValue(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.rememberMe.setValue(rememberMe);
|
|
||||||
}
|
|
||||||
|
|
||||||
private initializeForm(): void {
|
|
||||||
const rememberMeValue =
|
|
||||||
this.localStorageService.getItem<boolean>('remember-me');
|
|
||||||
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',
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
this.rememberMe.setValue(rememberMeValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleRedirect(): void {
|
if (this.isRegistrationMode()) {
|
||||||
if (this.verified()) {
|
const signupCredentials: UserCredentialsDtoApiModel = {
|
||||||
this.isSigninSignal.set(true);
|
email: this.form.getRawValue().email.trim(),
|
||||||
this.isSignupSignal.set(false);
|
password: this.form.getRawValue().password.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.signupNewUser(signupCredentials);
|
||||||
|
} else {
|
||||||
|
this.sendLoginEmail(this.form.value.email);
|
||||||
}
|
}
|
||||||
if (this.email()) {
|
|
||||||
this.form?.get('email')?.setValue(decodeURIComponent(atob(this.email())));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.login()) {
|
public isValidLoginAttempt(): boolean {
|
||||||
this.isSignupSignal.set(true);
|
return this.signin() && this.token() !== '';
|
||||||
this.isSigninSignal.set(false);
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if (!this.verificationError()) {
|
||||||
|
this.displaySkeleton.set(false);
|
||||||
|
this.isTokenVerified.set(true);
|
||||||
|
|
||||||
|
if (this.isValidLoginAttempt() && !this.signup()) {
|
||||||
|
this.isAutoLoginInProgress.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
timer(2000).subscribe(() => {
|
||||||
|
this.isTokenVerified.set(false);
|
||||||
|
this.focusPasswordField();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleVerificationResponse(
|
||||||
|
response: SuccessDtoApiModel,
|
||||||
|
isSignup: boolean,
|
||||||
|
email: string,
|
||||||
|
token: string
|
||||||
|
): void {
|
||||||
|
if (response.success) {
|
||||||
|
this.displaySkeleton.set(false);
|
||||||
|
this.isTokenVerified.set(true);
|
||||||
|
if (!isSignup) {
|
||||||
|
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:'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleVerificationError(): void {
|
||||||
|
this.isVerifying.set(false);
|
||||||
|
this.handleVerificationFailure(
|
||||||
|
'An error occurred during verification. Please check the reasons below:'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleVerificationFailure(message: string): void {
|
||||||
|
this.verificationError.set(message);
|
||||||
|
this.errorReasons.set([
|
||||||
|
'The verification token may have expired.',
|
||||||
|
'The device you are using may not match the one used to generate the token.',
|
||||||
|
'The email address may not match our records.',
|
||||||
|
'The verification link may have been used already.',
|
||||||
|
'There might be a problem with your internet connection.',
|
||||||
|
'Our servers might be experiencing issues.',
|
||||||
|
'The verification service might be temporarily unavailable.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEmailField(email: string): void {
|
||||||
|
this.form.patchValue({ email });
|
||||||
|
this.form.get('email')?.setValue(email);
|
||||||
|
const emailControl = this.form.get('email');
|
||||||
|
|
||||||
|
if (emailControl) {
|
||||||
|
emailControl.disable({ onlySelf: true, emitEvent: false });
|
||||||
|
emailControl.markAsTouched();
|
||||||
|
emailControl.setErrors(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -287,104 +401,41 @@ export class WelcomeRootComponent implements OnInit {
|
||||||
this.router.navigate([], { queryParams: {} });
|
this.router.navigate([], { queryParams: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupValueChanges(): void {
|
private extractVerifyToken(): string {
|
||||||
this.setupEmailValueChanges();
|
const [verifyToken]: string[] = this.token().split('|');
|
||||||
this.setupPasswordValueChanges();
|
|
||||||
|
return verifyToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupEmailValueChanges(): void {
|
private extractEmail(): string {
|
||||||
const emailControl = this.form?.get('email');
|
const [, email]: string[] = this.token().split('|');
|
||||||
|
|
||||||
emailControl?.valueChanges.subscribe((value: string) => {
|
return email;
|
||||||
if (value?.length >= 4) {
|
|
||||||
emailControl.setValidators([
|
|
||||||
Validators.required,
|
|
||||||
customEmailValidator(),
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
emailControl.setValidators([
|
|
||||||
Validators.required,
|
|
||||||
Validators.minLength(4),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
emailControl.updateValueAndValidity({ emitEvent: false });
|
|
||||||
|
private initializeForm(): void {
|
||||||
|
this.form = this.formBuilder.group({
|
||||||
|
email: new FormControl('', {
|
||||||
|
validators: [Validators.required, customEmailValidator()],
|
||||||
|
updateOn: 'change',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupPasswordValueChanges(): void {
|
private addPasswordFieldToForm(): void {
|
||||||
const passwordControl = this.form?.get('password');
|
this.form.addControl(
|
||||||
|
'password',
|
||||||
passwordControl?.valueChanges.subscribe((value: string) => {
|
new FormControl('', {
|
||||||
if (value?.length >= 8) {
|
validators: [Validators.required, Validators.minLength(8)],
|
||||||
passwordControl.setValidators([
|
updateOn: 'change',
|
||||||
Validators.required,
|
})
|
||||||
customPasswordValidator(),
|
);
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
passwordControl.setValidators([
|
|
||||||
Validators.required,
|
|
||||||
Validators.minLength(8),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
passwordControl.updateValueAndValidity({ emitEvent: false });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private markControlsAsTouchedAndDirty(controlNames: string[]): void {
|
private signupNewUser(signupCredentials: UserCredentialsDtoApiModel): 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 {
|
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
this.authService
|
this.authService
|
||||||
.signup(logiCredentials)
|
.signup(signupCredentials)
|
||||||
.pipe(
|
.pipe(
|
||||||
delay(1000),
|
delay(1000),
|
||||||
tap(() => this.isLoading.set(true)),
|
tap(() => this.isLoading.set(true)),
|
||||||
|
@ -392,8 +443,41 @@ export class WelcomeRootComponent implements OnInit {
|
||||||
)
|
)
|
||||||
.subscribe((response: SuccessDtoApiModel) => {
|
.subscribe((response: SuccessDtoApiModel) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.openModal();
|
this.remeberUserMail(signupCredentials.email);
|
||||||
this.userSignupSuccess.set(true);
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,8 @@ import { catchError, shareReplay, tap } from 'rxjs/operators';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AuthenticationApiService,
|
AuthenticationApiService,
|
||||||
|
MagicLinkDtoApiModel,
|
||||||
|
MagicLinkSigninDtoApiModel,
|
||||||
SigninResponseDtoApiModel,
|
SigninResponseDtoApiModel,
|
||||||
SuccessDtoApiModel,
|
SuccessDtoApiModel,
|
||||||
UserCredentialsDtoApiModel,
|
UserCredentialsDtoApiModel,
|
||||||
|
@ -27,6 +29,20 @@ 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> {
|
||||||
|
@ -35,13 +51,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
|
||||||
|
@ -49,6 +65,7 @@ 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 });
|
||||||
|
@ -56,6 +73,7 @@ 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)),
|
||||||
|
|
|
@ -23,7 +23,7 @@ export class ThemeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleTheme(): void {
|
public toggleTheme(): void {
|
||||||
this.currentTheme = this.currentTheme === 'light' ? 'dark' : 'light';
|
this.currentTheme = this.currentTheme === 'light' ? 'sunset' : 'light';
|
||||||
this.setTheme(this.currentTheme);
|
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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');
|
||||||
|
}
|
|
@ -5,7 +5,6 @@
|
||||||
<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>
|
||||||
|
|
|
@ -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 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
|
||||||
|
;}
|
||||||
|
|
||||||
|
|
|
@ -4,12 +4,27 @@ module.exports = {
|
||||||
"./src/**/*.{html,ts}",
|
"./src/**/*.{html,ts}",
|
||||||
],
|
],
|
||||||
theme: {
|
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')],
|
plugins: [require('daisyui'), require('tailwindcss-animated')],
|
||||||
daisyui: {
|
daisyui: {
|
||||||
themes: ["light", "dark"],
|
themes: ["light", "sunset"],
|
||||||
darkMode: ['class', '[data-theme="dark"]']
|
darkMode: ['class', '[data-theme="sunset"]'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue