work in progress
This commit is contained in:
parent
c9a8e9967a
commit
d59d41e1ee
|
@ -24,6 +24,9 @@ export class EmailVerification {
|
|||
@Column()
|
||||
public email: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public userAgent: string;
|
||||
|
||||
@OneToOne(() => UserCredentials)
|
||||
@JoinColumn({ name: 'userCredentialsId' })
|
||||
public user: UserCredentials;
|
||||
|
|
|
@ -17,6 +17,7 @@ import { Public } from 'src/shared/decorator';
|
|||
import { LocalAuthGuard } from '../guard';
|
||||
import {
|
||||
MagicLinkDto,
|
||||
MagicLinkSigninDto,
|
||||
SigninResponseDto,
|
||||
UserCredentialsDto,
|
||||
} from '../models/dto';
|
||||
|
@ -36,9 +37,12 @@ export class AuthController {
|
|||
@HttpCode(HttpStatus.OK)
|
||||
@Public()
|
||||
public async sendMagicLink(
|
||||
@Body() magicLinkDto: MagicLinkDto
|
||||
@Body() magicLinkDto: MagicLinkDto,
|
||||
@Req() request: Request
|
||||
): Promise<SuccessDto> {
|
||||
return this.authService.sendMagicLink(magicLinkDto);
|
||||
const userAgent = request.headers['user-agent'] || 'Unknown';
|
||||
|
||||
return this.authService.sendMagicLink(magicLinkDto, userAgent);
|
||||
}
|
||||
|
||||
@ApiCreatedResponse({
|
||||
|
@ -58,12 +62,14 @@ export class AuthController {
|
|||
description: 'User signin successfully',
|
||||
type: SigninResponseDto,
|
||||
})
|
||||
@ApiBody({ type: UserCredentialsDto })
|
||||
@ApiBody({ type: MagicLinkSigninDto })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(LocalAuthGuard)
|
||||
@Public()
|
||||
@Post('signin')
|
||||
public async signin(@Req() request: Request): Promise<SigninResponseDto> {
|
||||
@Post('magic-link-signin')
|
||||
public async magicLinkSignin(
|
||||
@Req() request: Request
|
||||
): Promise<SigninResponseDto> {
|
||||
return this.authService.getLoginResponse(
|
||||
request.user as SigninResponseDto & { userAgent: string }
|
||||
);
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from './user-credentials.dto';
|
||||
export * from './signin-response.dto';
|
||||
export * from './magic-link.dto';
|
||||
export * from './magic-link-signin.dto';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -1,4 +1,8 @@
|
|||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { UserCredentials } from 'src/entities';
|
||||
import { SessionService } from 'src/modules/session/services/session.service';
|
||||
import { EncryptionService, SuccessDto } from 'src/shared';
|
||||
|
@ -28,7 +32,10 @@ export class AuthService {
|
|||
private readonly sessionService: SessionService
|
||||
) {}
|
||||
|
||||
public async sendMagicLink(magiclink: MagicLinkDto): Promise<SuccessDto> {
|
||||
public async sendMagicLink(
|
||||
magiclink: MagicLinkDto,
|
||||
userAgent: string
|
||||
): Promise<SuccessDto> {
|
||||
try {
|
||||
const existingUser = await this.userCredentialsRepository.findUserByEmail(
|
||||
magiclink.email
|
||||
|
@ -37,7 +44,9 @@ export class AuthService {
|
|||
if (existingUser) {
|
||||
const token =
|
||||
await this.emailVerificationService.generateEmailVerificationTokenForMagicLink(
|
||||
magiclink.email
|
||||
magiclink.email,
|
||||
userAgent,
|
||||
existingUser.id
|
||||
);
|
||||
|
||||
await this.passwordConfirmationMailService.sendLoginLinkEmail(
|
||||
|
@ -45,24 +54,16 @@ export class AuthService {
|
|||
token
|
||||
);
|
||||
} else {
|
||||
const isEmailSubmitted: boolean =
|
||||
await this.emailVerificationService.isEmailSubmitted(magiclink.email);
|
||||
|
||||
if (!isEmailSubmitted) {
|
||||
const token =
|
||||
await this.emailVerificationService.generateEmailVerificationTokenForMagicLink(
|
||||
magiclink.email
|
||||
magiclink.email,
|
||||
userAgent
|
||||
);
|
||||
|
||||
await this.passwordConfirmationMailService.sendRegistrationLinkEmail(
|
||||
magiclink.email,
|
||||
token
|
||||
);
|
||||
} else {
|
||||
throw new ConflictException('EMAIL_ALREADY_SUBMITTED', {
|
||||
message: 'This email has already been submitted for registration.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
@ -105,11 +106,6 @@ export class AuthService {
|
|||
// user.id
|
||||
// );
|
||||
|
||||
// await this.passwordConfirmationMailService.sendPasswordConfirmationMail(
|
||||
// user.email,
|
||||
// token
|
||||
// );
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
|
@ -125,28 +121,31 @@ export class AuthService {
|
|||
}
|
||||
|
||||
public async validateUser(
|
||||
token: string,
|
||||
email: string,
|
||||
password: string
|
||||
userAgent: string
|
||||
): Promise<UserCredentials> {
|
||||
try {
|
||||
const verificationResult =
|
||||
await this.emailVerificationService.verifyEmail(
|
||||
token,
|
||||
email,
|
||||
userAgent
|
||||
);
|
||||
|
||||
if (!verificationResult.success) {
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
|
||||
const user = await this.userCredentialsRepository.findUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException('INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
const passwordMatch = await EncryptionService.compareHash(
|
||||
password,
|
||||
user.hashedPassword
|
||||
);
|
||||
|
||||
if (!passwordMatch) {
|
||||
throw new ForbiddenException('INVALID_CREDENTIALS');
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenException) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new InternalServerErrorException('VALIDATION_ERROR', {
|
||||
|
@ -156,6 +155,10 @@ export class AuthService {
|
|||
}
|
||||
}
|
||||
|
||||
public async getUserByEmail(email: string): Promise<UserCredentials> {
|
||||
return this.userCredentialsRepository.findUserByEmail(email);
|
||||
}
|
||||
|
||||
public async signout(sessionId: string): Promise<SuccessDto> {
|
||||
try {
|
||||
await this.sessionService.deleteSessionBySessionId(sessionId);
|
||||
|
|
|
@ -1,34 +1,51 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Request } from 'express';
|
||||
import { Strategy } from 'passport-local';
|
||||
import { EmailVerificationService } from 'src/modules/verify-module/services/email-verification.service';
|
||||
|
||||
import { SigninResponseDto } from '../models/dto';
|
||||
import { MagicLinkSigninDto } from '../models/dto';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
public constructor(private readonly authService: AuthService) {
|
||||
public constructor(
|
||||
private authService: AuthService,
|
||||
private emailVerificationService: EmailVerificationService
|
||||
) {
|
||||
super({
|
||||
usernameField: 'email',
|
||||
passwordField: 'password',
|
||||
passwordField: 'token',
|
||||
passReqToCallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
public async validate(
|
||||
request: Request,
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<SigninResponseDto & { userAgent: string }> {
|
||||
const user = await this.authService.validateUser(email, password);
|
||||
public async validate(request: Request): Promise<any> {
|
||||
const { token, email }: MagicLinkSigninDto = request.body;
|
||||
|
||||
if (!token || !email) {
|
||||
throw new UnauthorizedException('Missing token or email');
|
||||
}
|
||||
|
||||
const verificationResult = await this.emailVerificationService.verifyEmail(
|
||||
token as string,
|
||||
email as string,
|
||||
request.headers['user-agent']
|
||||
);
|
||||
|
||||
if (!verificationResult.success) {
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
|
||||
const user = await this.authService.getUserByEmail(email as string);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
const userAgent = request.headers['user-agent'];
|
||||
|
||||
return { id: user.id, email: user.email, userAgent: userAgent };
|
||||
return { id: user.id, email: user.email, userAgent };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -67,10 +67,6 @@ export class PasswordConfirmationMailService extends BaseMailService {
|
|||
const token = `${registrationToken}|${UriEncoderService.encodeBase64(to)}`;
|
||||
const registrationLink = `${this.configService.get<string>('APP_URL')}/?token=${token}&signup=true`;
|
||||
|
||||
console.log('##############');
|
||||
console.log(registrationLink);
|
||||
console.log('##############');
|
||||
|
||||
const mailoptions: SendGridMailApi.MailDataRequired = {
|
||||
to,
|
||||
from: { email: 'info@igor-propisnov.com', name: 'Ticket App' },
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { SessionException } from 'src/shared/exceptions';
|
||||
|
||||
import { SessionService } from '../services/session.service';
|
||||
|
||||
|
@ -19,20 +15,20 @@ export class SessionGuard implements CanActivate {
|
|||
const session = await this.sessionService.findSessionBySessionId(sessionId);
|
||||
|
||||
if (!session) {
|
||||
throw new UnauthorizedException('Session not found.');
|
||||
throw new SessionException('Session not found.');
|
||||
}
|
||||
|
||||
const isExpired = await this.sessionService.isSessioExpired(session);
|
||||
|
||||
if (isExpired) {
|
||||
throw new UnauthorizedException('Session expired.');
|
||||
throw new SessionException('Session expired.');
|
||||
}
|
||||
|
||||
const userAgentInSession = JSON.parse(session.json).passport.user
|
||||
.userAgent as string;
|
||||
|
||||
if (userAgentInSession !== currentAgent) {
|
||||
throw new UnauthorizedException('User agent mismatch.');
|
||||
throw new SessionException('User agent mismatch.');
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
|
@ -1,4 +1,11 @@
|
|||
import { Controller, HttpCode, HttpStatus, Query, Post } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Query,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { SuccessDto } from 'src/shared';
|
||||
import { Public } from 'src/shared/decorator';
|
||||
|
@ -21,22 +28,15 @@ export class VerifyController {
|
|||
@HttpCode(HttpStatus.OK)
|
||||
public async verifyEmail(
|
||||
@Query('token') tokenToVerify: string,
|
||||
@Query('email') emailToVerify: string
|
||||
@Query('email') emailToVerify: string,
|
||||
@Req() request: Request
|
||||
): Promise<SuccessDto> {
|
||||
const userAgent = request.headers['user-agent'] || 'Unknown';
|
||||
|
||||
return this.emailVerificationService.verifyEmail(
|
||||
tokenToVerify,
|
||||
emailToVerify
|
||||
emailToVerify,
|
||||
userAgent
|
||||
);
|
||||
}
|
||||
|
||||
// @ApiCreatedResponse({
|
||||
// description: 'Check if email is verified',
|
||||
// type: Boolean,
|
||||
// })
|
||||
// @Get('check')
|
||||
// @HttpCode(HttpStatus.OK)
|
||||
// @UseGuards(SessionGuard)
|
||||
// public async isEmailVerified(@Req() request: Request): Promise<boolean> {
|
||||
// return this.emailVerificationService.isEmailVerified(request.sessionID);
|
||||
// }
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { EmailVerification } from 'src/entities';
|
||||
import { LessThan, MoreThan, Repository } from 'typeorm';
|
||||
import { LessThan, Repository } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class EmailVerifyRepository {
|
||||
|
@ -14,30 +14,17 @@ export class EmailVerifyRepository {
|
|||
token: string,
|
||||
expiresAt: Date,
|
||||
email: string,
|
||||
userId: string | null
|
||||
userId: string | null,
|
||||
userAgent: string
|
||||
): Promise<void> {
|
||||
await this.repository.delete({ email });
|
||||
|
||||
await this.repository.save({
|
||||
token,
|
||||
expiresAt,
|
||||
email,
|
||||
user: userId ? { id: userId } : null,
|
||||
});
|
||||
}
|
||||
|
||||
public async findValidVerification(
|
||||
token: string,
|
||||
email: string
|
||||
): Promise<EmailVerification | undefined> {
|
||||
const currentDate = new Date();
|
||||
const tenMinutesAgo = new Date(currentDate.getTime() - 10 * 60 * 1000);
|
||||
|
||||
return await this.repository.findOne({
|
||||
where: {
|
||||
token,
|
||||
email,
|
||||
createdAt: MoreThan(tenMinutesAgo),
|
||||
expiresAt: MoreThan(currentDate),
|
||||
},
|
||||
userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -60,12 +47,6 @@ export class EmailVerifyRepository {
|
|||
await this.repository.delete({ token, email });
|
||||
}
|
||||
|
||||
public async findItemByEmail(
|
||||
email: string
|
||||
): Promise<EmailVerification | null> {
|
||||
return this.repository.findOne({ where: { email } });
|
||||
}
|
||||
|
||||
public async deleteAllExpiredTokens(): Promise<void> {
|
||||
const currentDate = new Date();
|
||||
|
||||
|
@ -73,20 +54,4 @@ export class EmailVerifyRepository {
|
|||
expiresAt: LessThan(currentDate),
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteEmailVerificationByToken(
|
||||
tokenToDelete: string
|
||||
): Promise<EmailVerification | null> {
|
||||
const emailVerification = await this.repository.findOne({
|
||||
where: { token: tokenToDelete },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (emailVerification) {
|
||||
await this.repository.delete({ token: tokenToDelete });
|
||||
return emailVerification;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,64 +1,25 @@
|
|||
import { randomBytes } from 'crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EmailVerification } from 'src/entities';
|
||||
import { SessionService } from 'src/modules/session/services/session.service';
|
||||
import { SuccessDto, UriEncoderService } from 'src/shared';
|
||||
import {
|
||||
InternalServerErrorException,
|
||||
TokenExpiredException,
|
||||
UserAgentMismatchException,
|
||||
} from 'src/shared/exceptions';
|
||||
|
||||
import { UserDataRepository } from '../../user-module/repositories/user-data.repository';
|
||||
import { EmailVerifyRepository } from '../repositories';
|
||||
|
||||
@Injectable()
|
||||
export class EmailVerificationService {
|
||||
public constructor(
|
||||
private readonly emailVerifyRepository: EmailVerifyRepository,
|
||||
private readonly userDataRepository: UserDataRepository,
|
||||
private readonly sessionService: SessionService
|
||||
private readonly emailVerifyRepository: EmailVerifyRepository
|
||||
) {}
|
||||
|
||||
public async generateEmailVerificationToken(userId: string): Promise<string> {
|
||||
try {
|
||||
const verificationToken = await this.createVerificationToken();
|
||||
const expiration = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
await this.emailVerifyRepository.createEmailVerification(
|
||||
verificationToken,
|
||||
expiration,
|
||||
userId,
|
||||
null
|
||||
);
|
||||
|
||||
return verificationToken;
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(
|
||||
'EMAIL_VERIFICATION_TOKEN_GENERATION_ERROR',
|
||||
{
|
||||
message:
|
||||
'An error occurred while generating the email verification token.',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async isEmailSubmitted(email: string): Promise<boolean> {
|
||||
try {
|
||||
const emailVerification =
|
||||
await this.emailVerifyRepository.findItemByEmail(email);
|
||||
|
||||
return !!emailVerification;
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException('EMAIL_VERIFICATION_ERROR', {
|
||||
message: 'An error occurred while verifying the email.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async generateEmailVerificationTokenForMagicLink(
|
||||
email: string
|
||||
email: string,
|
||||
userAgent: string,
|
||||
userid?: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const verificationToken = await this.createVerificationToken();
|
||||
|
@ -68,7 +29,8 @@ export class EmailVerificationService {
|
|||
verificationToken,
|
||||
expiresAt,
|
||||
email,
|
||||
null
|
||||
userid || null,
|
||||
userAgent
|
||||
);
|
||||
|
||||
return verificationToken;
|
||||
|
@ -85,7 +47,8 @@ export class EmailVerificationService {
|
|||
|
||||
public async verifyEmail(
|
||||
tokenToVerify: string,
|
||||
emailToVerify: string
|
||||
emailToVerify: string,
|
||||
userAgent: string
|
||||
): Promise<SuccessDto> {
|
||||
try {
|
||||
const token = await this.emailVerifyRepository.findByTokenAndEmail(
|
||||
|
@ -97,6 +60,13 @@ export class EmailVerificationService {
|
|||
throw new TokenExpiredException();
|
||||
}
|
||||
|
||||
if (token.userAgent !== userAgent) {
|
||||
throw new UserAgentMismatchException({
|
||||
message:
|
||||
'The User Agent does not match the one used to generate the token.',
|
||||
});
|
||||
}
|
||||
|
||||
const currentDate = new Date();
|
||||
|
||||
if (token.expiresAt.getTime() < currentDate.getTime()) {
|
||||
|
@ -113,35 +83,16 @@ export class EmailVerificationService {
|
|||
if (error instanceof TokenExpiredException) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof UserAgentMismatchException) {
|
||||
throw error;
|
||||
}
|
||||
throw new InternalServerErrorException('EMAIL_VERIFICATION_ERROR', {
|
||||
message: 'An error occurred while verifying the email.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async isEmailVerified(sessionID: string): Promise<boolean> {
|
||||
try {
|
||||
const userId = await this.sessionService.getUserIdBySessionId(sessionID);
|
||||
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isVerified =
|
||||
await this.userDataRepository.isEmailConfirmedByUserId(userId);
|
||||
|
||||
return isVerified;
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException('EMAIL_VERIFICATION_CHECK_ERROR', {
|
||||
message:
|
||||
'An error occurred while checking the email verification status.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAllExpiredTokens(): Promise<void> {
|
||||
const currentDate = new Date();
|
||||
|
||||
public async deleteAllExpiredTokens(): Promise<void> {
|
||||
await this.emailVerifyRepository.deleteAllExpiredTokens();
|
||||
}
|
||||
|
||||
|
@ -150,12 +101,4 @@ export class EmailVerificationService {
|
|||
|
||||
return UriEncoderService.encodeUri(verifyToken);
|
||||
}
|
||||
|
||||
private async deleteEmailVerificationToken(
|
||||
tokenToDelete: string
|
||||
): Promise<EmailVerification | null> {
|
||||
return await this.emailVerifyRepository.deleteEmailVerificationByToken(
|
||||
tokenToDelete
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,3 +3,5 @@ export * from './forbidden.exception';
|
|||
export * from './internal-server-error.exception';
|
||||
export * from './not-found.exception';
|
||||
export * from './token-expired.exception';
|
||||
export * from './useragent-mismatch-exception';
|
||||
export * from './session.exception';
|
||||
|
|
|
@ -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 UserAgentMismatchException extends BaseException {
|
||||
public constructor(details?: unknown) {
|
||||
super(
|
||||
'User Agent Mismatch',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
'USER_AGENT_MISMATCH',
|
||||
details
|
||||
);
|
||||
}
|
||||
}
|
|
@ -19,7 +19,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
//console.error('Exception caught:', exception);
|
||||
console.error('Exception caught:', exception);
|
||||
|
||||
let status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message: string = 'Internal server error';
|
||||
|
|
|
@ -24,25 +24,11 @@ export class EventEmptyStateComponent {
|
|||
private readonly verifyApi: VerifyApiService
|
||||
) {}
|
||||
|
||||
// public navigateToCreateEvent(): void {
|
||||
// this.verifyApi
|
||||
// .verifyControllerIsEmailVerified()
|
||||
// .subscribe((isVerified: boolean) => {
|
||||
// if (!isVerified) {
|
||||
// this.openEmailVerificationModal();
|
||||
// } else {
|
||||
// this.router.navigate(['/event/create']);
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
public navigateToCreateEvent(): void {
|
||||
this.router.navigate(['/event/create']);
|
||||
}
|
||||
|
||||
public closeEmailVerificationModal(): void {
|
||||
(this.emailVerificationModal.nativeElement as HTMLDialogElement).close();
|
||||
}
|
||||
|
||||
private openEmailVerificationModal(): void {
|
||||
(
|
||||
this.emailVerificationModal.nativeElement as HTMLDialogElement
|
||||
).showModal();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,646 +1,4 @@
|
|||
<!-- @if (!userSignupSuccess()) {
|
||||
<div class="flex h-screen w-screen">
|
||||
<div
|
||||
[ngStyle]="leftBackgroundStyle"
|
||||
class="hidden md:flex md:flex-col md:w-1/2 bg-primary">
|
||||
<div class="flex-1 flex items-start pt-16 px-12">
|
||||
<h1 class="text-3xl text-base-100">[LOGO] APP-NAME</h1>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col justify-end pb-16 px-12">
|
||||
<blockquote>
|
||||
<p class="text-xl text-base-100 font-semibold">
|
||||
“This library has saved me countless hours of work and helped me
|
||||
deliver stunning designs to my clients faster than ever before.”
|
||||
</p>
|
||||
<small class="block text-sm font-light text-base-100 mt-4">
|
||||
— Sofia Davis
|
||||
</small>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div [ngStyle]="rightBackgroundStyle" class="flex flex-col w-full md:w-1/2">
|
||||
<div class="flex px-12 gap-3">
|
||||
<div class="flex items-start justify-end pt-16">
|
||||
<label class="swap swap-rotate">
|
||||
<input
|
||||
type="checkbox"
|
||||
(change)="toggleTheme()"
|
||||
[checked]="isDarkMode" />
|
||||
|
||||
|
||||
<svg
|
||||
class="swap-on h-10 w-10 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
|
||||
|
||||
<svg
|
||||
class="swap-off h-10 w-10 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
|
||||
</svg>
|
||||
</label>
|
||||
</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
|
||||
class="animate-fade-down animate-once animate-duration-1000 animate-ease-in-out flex-1 flex flex-col justify-center items-center px-12">
|
||||
<h1 class="text-3xl font-semibold text-center">Create an Account</h1>
|
||||
<p class="text-center">
|
||||
Enter your email below to create your Account
|
||||
</p>
|
||||
<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>
|
||||
<button class="btn w-full btn-primary font-semibold">
|
||||
@if (isLoading()) {
|
||||
<span class="loading loading-spinner"></span>
|
||||
}
|
||||
Sign Up with Email
|
||||
</button>
|
||||
<p class="text-xs w-full text-center">
|
||||
By clicking continue, you agree to our
|
||||
<u class="cursor-pointer">Terms of Service</u>
|
||||
and
|
||||
<u class="cursor-pointer">Privacy Policy</u>
|
||||
.
|
||||
</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>
|
||||
<button class="btn w-full btn-primary font-semibold">
|
||||
@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
|
||||
[ngStyle]="dialogBackgroundStyle"
|
||||
class="modal-box w-11/12 h-2/6 max-w-5xl flex">
|
||||
<div class="w-full flex flex-col justify-center items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1"
|
||||
stroke="currentColor"
|
||||
class="size-28 animate-jump animate-once animate-duration-[2000ms] animate-delay-500 animate-ease-in-out animate-normal">
|
||||
<path
|
||||
stroke-linecap="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" />
|
||||
</svg>
|
||||
|
||||
<h1 class="font-bold text-3xl pt-5">Check your inbox, please!</h1>
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<p class="pt-3">
|
||||
Hey, to start using [APP-NAME], we need to verify your email.
|
||||
</p>
|
||||
<p class="pt-1">
|
||||
We´ve already sent out the verification link. Please check it and
|
||||
<br />
|
||||
confirm it´s really you.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<!-- <div class="flex h-screen w-screen">
|
||||
<div
|
||||
[ngStyle]="leftBackgroundStyle"
|
||||
class="hidden md:flex md:flex-col md:w-1/2 bg-primary">
|
||||
<div class="flex-1 flex items-start pt-16 px-12">
|
||||
<h1 class="text-3xl text-base-100">[LOGO] APP-NAME</h1>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col justify-end pb-16 px-12">
|
||||
<blockquote>
|
||||
<p class="text-xl text-base-100 font-semibold">
|
||||
"This library has saved me countless hours of work and helped me
|
||||
deliver stunning designs to my clients faster than ever before."
|
||||
</p>
|
||||
<small class="block text-sm font-light text-base-100 mt-4">
|
||||
— Sofia Davis
|
||||
</small>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div [ngStyle]="rightBackgroundStyle" class="flex flex-col w-full md:w-1/2">
|
||||
<div class="flex px-12 gap-3">
|
||||
<div class="flex items-start justify-end pt-16">
|
||||
<label class="swap swap-rotate">
|
||||
<input
|
||||
type="checkbox"
|
||||
(change)="toggleTheme()"
|
||||
[checked]="isDarkMode" />
|
||||
<svg
|
||||
class="swap-on h-10 w-10 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
class="swap-off h-10 w-10 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col justify-center items-center px-12">
|
||||
<h1 class="text-3xl font-semibold text-center">Login or Register</h1>
|
||||
<p class="text-center">Enter your email to login or create an account</p>
|
||||
<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>
|
||||
<button class="btn w-full btn-primary font-semibold">
|
||||
@if (isLoading()) {
|
||||
<span class="loading loading-spinner"></span>
|
||||
}
|
||||
Send Login Link
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<div class="modal modal-open" *ngIf="isEmailSent()">
|
||||
<div
|
||||
[ngStyle]="dialogBackgroundStyle"
|
||||
class="modal-box w-11/12 h-2/6 max-w-5xl flex">
|
||||
<div class="w-full flex flex-col justify-center items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1"
|
||||
stroke="currentColor"
|
||||
class="size-28 animate-jump animate-once animate-duration-[2000ms] animate-delay-500 animate-ease-in-out animate-normal">
|
||||
<path
|
||||
stroke-linecap="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" />
|
||||
</svg>
|
||||
|
||||
<h1 class="font-bold text-3xl pt-5">Check your inbox, please!</h1>
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<p class="pt-3">We've sent you an email with a login link.</p>
|
||||
<p class="pt-1">
|
||||
Please check your inbox and click the link to continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- <div class="flex h-screen w-screen bg-base-200">
|
||||
<div
|
||||
[ngStyle]="leftBackgroundStyle"
|
||||
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">
|
||||
<h1 class="text-4xl font-bold">APP-NAME</h1>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col justify-center px-12">
|
||||
<h2 class="text-3xl font-semibold mb-6">Create Unforgettable Events</h2>
|
||||
<ul class="space-y-4">
|
||||
<li class="flex items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 mr-2"
|
||||
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>
|
||||
Easy event creation and management
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 mr-2"
|
||||
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>
|
||||
Seamless ticket sales and distribution
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 mr-2"
|
||||
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>
|
||||
Powerful analytics for event success
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col justify-end pb-16 px-12">
|
||||
<blockquote>
|
||||
<p class="text-xl font-semibold">
|
||||
"Eventbreriber has transformed how we manage our events. From creation
|
||||
to ticket sales, it's all seamless and intuitive."
|
||||
</p>
|
||||
<footer class="mt-4">
|
||||
<p class="font-semibold">Sarah Johnson</p>
|
||||
<p class="text-sm">Event Manager, Stellar Conferences</p>
|
||||
</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
[ngStyle]="rightBackgroundStyle"
|
||||
class="flex flex-col w-full lg:w-1/2 bg-base-100">
|
||||
<div class="flex px-12 gap-3">
|
||||
<div class="flex items-start justify-end pt-16">
|
||||
<label class="swap swap-rotate">
|
||||
<input
|
||||
type="checkbox"
|
||||
(change)="toggleTheme()"
|
||||
[checked]="isDarkMode" />
|
||||
<svg
|
||||
class="swap-on h-10 w-10 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
class="swap-off h-10 w-10 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col justify-center items-center px-6 sm:px-12">
|
||||
<div class="w-full max-w-md">
|
||||
<h1 class="text-3xl font-bold text-center mb-2">Welcome to APP-NAME</h1>
|
||||
<p class="text-center text-base-content/60 mb-8">
|
||||
Enter your email to login or create an account
|
||||
</p>
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="space-y-6">
|
||||
<label class="form-control w-full mb-4">
|
||||
<div class="label">
|
||||
<span class="label-text"></span>
|
||||
<span class="label-text-alt"></span>
|
||||
</div>
|
||||
<label
|
||||
class="input input-bordered flex items-center gap-2"
|
||||
[ngClass]="getInputClass('email')">
|
||||
<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 class="label">
|
||||
<span class="label-text-alt"></span>
|
||||
<span class="label-text-alt text-error">
|
||||
{{ getErrorMessage('email') }}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-full"
|
||||
[disabled]="isLoading()">
|
||||
@if (isLoading()) {
|
||||
<span class="loading loading-spinner"></span>
|
||||
}
|
||||
Send Magic Link
|
||||
</button>
|
||||
</form>
|
||||
<div class="divider my-8">OR</div>
|
||||
<div class="flex flex-col space-y-4">
|
||||
<button class="btn btn-outline gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor">
|
||||
<path
|
||||
d="M6.29 18.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0020 3.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.073 4.073 0 01.8 7.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 010 16.407a11.616 11.616 0 006.29 1.84" />
|
||||
</svg>
|
||||
Continue with X
|
||||
</button>
|
||||
<button class="btn btn-outline gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 0C4.477 0 0 4.477 0 10c0 4.991 3.657 9.128 8.438 9.879V12.89h-2.54V10h2.54V7.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V10h2.773l-.443 2.89h-2.33v6.989C16.343 19.129 20 14.99 20 10c0-5.523-4.477-10-10-10z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Continue with Facebook
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center py-6">
|
||||
<p class="text-sm text-base-content/60">
|
||||
By continuing, you agree to Eventbreriber's
|
||||
<a href="#" class="link link-primary">Terms of Service</a>
|
||||
and
|
||||
<a href="#" class="link link-primary">Privacy Policy</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal modal-open" *ngIf="isEmailSent()">
|
||||
<div class="modal-box w-11/12 max-w-lg">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<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 class="text-2xl font-bold mb-2">Check your inbox</h2>
|
||||
<p class="mb-4">
|
||||
We've sent you an email with a magic link. Click the link to access your
|
||||
Eventbreriber account.
|
||||
</p>
|
||||
<button class="btn btn-primary">Got it</button>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="flex h-screen w-screen bg-base-200">
|
||||
<!-- Left side (hidden on mobile) -->
|
||||
<div
|
||||
[ngStyle]="leftBackgroundStyle"
|
||||
class="hidden lg:flex lg:flex-col lg:w-1/2 bg-primary text-primary-content">
|
||||
|
@ -648,7 +6,7 @@
|
|||
<h1 class="text-4xl font-bold">APP-NAME</h1>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col justify-center px-12">
|
||||
@if (isTokenVerifing()) {
|
||||
@if (displaySkeleton()) {
|
||||
<!-- Skeleton loader for the main content -->
|
||||
<div class="space-y-6">
|
||||
<div class="skeleton h-8 w-3/4"></div>
|
||||
|
@ -823,8 +181,7 @@
|
|||
}
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col justify-end pb-16 px-12">
|
||||
@if (isTokenVerifing()) {
|
||||
<!-- Skeleton loader for the quote -->
|
||||
@if (displaySkeleton()) {
|
||||
<div class="p-6">
|
||||
<div class="skeleton h-4 w-full mb-2"></div>
|
||||
<div class="skeleton h-4 w-5/6 mb-2"></div>
|
||||
|
@ -888,8 +245,7 @@
|
|||
|
||||
<div
|
||||
class="flex-1 flex flex-col justify-center items-center px-6 sm:px-12 overflow-y-auto">
|
||||
@if (isTokenVerifing()) {
|
||||
<!-- Skeleton loader for the form -->
|
||||
@if (displaySkeleton()) {
|
||||
<div class="w-full max-w-md space-y-6">
|
||||
<div class="skeleton h-10 w-3/4 mx-auto"></div>
|
||||
<div class="skeleton h-4 w-1/2 mx-auto"></div>
|
||||
|
@ -1012,7 +368,7 @@
|
|||
</div>
|
||||
}
|
||||
|
||||
@if (!isRegistrationMode() && !isTokenVerifing()) {
|
||||
@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>
|
||||
|
@ -1025,7 +381,7 @@
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (isTokenVerifing()) {
|
||||
} @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>
|
||||
|
@ -1101,7 +457,7 @@
|
|||
|
||||
<div
|
||||
class="modal modal-open"
|
||||
*ngIf="isTokenVerifing() || isTokenVerified() || verificationError()"
|
||||
*ngIf="isTokenVerified() || isVerifying() || verificationError()"
|
||||
tabindex="-1"
|
||||
aria-labelledby="verify-modal-title"
|
||||
aria-describedby="verify-modal-description"
|
||||
|
@ -1113,8 +469,8 @@
|
|||
<div class="relative w-10 h-10">
|
||||
<div
|
||||
class="absolute inset-0 transition-opacity duration-300 ease-in-out"
|
||||
[class.opacity-100]="isTokenVerifing() && !verificationError()"
|
||||
[class.opacity-0]="!isTokenVerifing() || verificationError()">
|
||||
[class.opacity-100]="isVerifying() && !verificationError()"
|
||||
[class.opacity-0]="!isVerifying() || verificationError()">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -1184,8 +540,8 @@
|
|||
persists, contact our support team.
|
||||
</p>
|
||||
@if (isTokenVerified() || verificationError()) {
|
||||
<div class="mt-6">
|
||||
<a href="/" class="btn btn-primary">Back to Welcome Page</a>
|
||||
<div class="mt-6 flex justify-center">
|
||||
<a href="/" class="btn btn-primary">Back to Login</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
@ -1193,56 +549,3 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
<div
|
||||
class="modal modal-open"
|
||||
*ngIf="isTokenVerifing() || isTokenVerified() || verificationError()"
|
||||
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-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()
|
||||
? 'Verification Complete'
|
||||
: verificationError()
|
||||
? 'Verification Failed'
|
||||
: 'Verifying Your Account'
|
||||
}}
|
||||
</h2>
|
||||
<p id="verify-modal-description">
|
||||
{{
|
||||
isTokenVerified()
|
||||
? 'Your email has been successfully verified.'
|
||||
: verificationError()
|
||||
? verificationError()
|
||||
: 'Please wait while we verify your email and token.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
|
|
@ -1,404 +1,3 @@
|
|||
/* import { CommonModule } from '@angular/common';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
WritableSignal,
|
||||
signal,
|
||||
effect,
|
||||
InputSignal,
|
||||
input,
|
||||
ElementRef,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { CheckboxModule } from 'primeng/checkbox';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { PasswordModule } from 'primeng/password';
|
||||
import { delay, finalize, takeWhile, tap } from 'rxjs';
|
||||
|
||||
import {
|
||||
Configuration,
|
||||
SigninResponseDtoApiModel,
|
||||
SuccessDtoApiModel,
|
||||
UserCredentialsDtoApiModel,
|
||||
} from '../../api';
|
||||
import { ApiConfiguration } from '../../config/api-configuration';
|
||||
import {
|
||||
AuthService,
|
||||
BackgroundPatternService,
|
||||
ThemeService,
|
||||
} from '../../shared/service';
|
||||
import { LocalStorageService } from '../../shared/service/local-storage.service';
|
||||
import {
|
||||
customEmailValidator,
|
||||
customPasswordValidator,
|
||||
} from '../../shared/validator';
|
||||
|
||||
type AuthAction = 'signin' | 'signup';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register-root',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
InputTextModule,
|
||||
ReactiveFormsModule,
|
||||
ButtonModule,
|
||||
CheckboxModule,
|
||||
PasswordModule,
|
||||
HttpClientModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: Configuration,
|
||||
useFactory: (): unknown =>
|
||||
new ApiConfiguration({ withCredentials: true }),
|
||||
},
|
||||
],
|
||||
templateUrl: './welcome-root.component.html',
|
||||
styleUrl: './welcome-root.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WelcomeRootComponent implements OnInit {
|
||||
public dialogBackgroundStyle: { 'background-image': string } | null = null;
|
||||
public leftBackgroundStyle: { 'background-image': string } | null = null;
|
||||
public rightBackgroundStyle: { 'background-image': string } | null = null;
|
||||
public verified: InputSignal<boolean> = input<boolean>(false);
|
||||
public login: InputSignal<boolean> = input<boolean>(false);
|
||||
public email: InputSignal<string> = input<string>('');
|
||||
public signedOut: InputSignal<boolean> = input<boolean>(true);
|
||||
public form!: FormGroup;
|
||||
public rememberMe: FormControl = new FormControl(false);
|
||||
public isSigninSignal: WritableSignal<boolean> = signal(false);
|
||||
public isSignupSignal: WritableSignal<boolean> = signal(true);
|
||||
public isSignUpSuccess: WritableSignal<boolean> = signal(false);
|
||||
public userSignupSuccess: WritableSignal<boolean> = signal(false);
|
||||
public isDialogOpen: WritableSignal<boolean> = signal(false);
|
||||
public isLoading: WritableSignal<boolean> = signal(false);
|
||||
public displaySkeleton: WritableSignal<boolean> = signal(true);
|
||||
private removeQueryParams: WritableSignal<boolean> = signal(false);
|
||||
|
||||
public get isDarkMode(): boolean {
|
||||
return this.themeService.getTheme() === 'dark';
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly formBuilder: FormBuilder,
|
||||
private readonly authService: AuthService,
|
||||
private readonly router: Router,
|
||||
private readonly themeService: ThemeService,
|
||||
private readonly el: ElementRef,
|
||||
private readonly backgroundPatternService: BackgroundPatternService,
|
||||
private readonly localStorageService: LocalStorageService
|
||||
) {
|
||||
effect(() => {
|
||||
if (this.removeQueryParams()) {
|
||||
this.clearRouteParams();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.autologin();
|
||||
this.setBackground();
|
||||
this.initializeForm();
|
||||
this.setupValueChanges();
|
||||
|
||||
if ((this.email() && this.verified()) || this.login()) {
|
||||
this.handleRedirect();
|
||||
this.removeQueryParams.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
public autologin(): void {
|
||||
const rememberMe = this.localStorageService.getItem<boolean>('remember-me');
|
||||
|
||||
if (rememberMe && !this.signedOut()) {
|
||||
this.authService
|
||||
.status()
|
||||
.pipe(
|
||||
delay(1500),
|
||||
takeWhile((response: SuccessDtoApiModel) => response.success, true),
|
||||
tap({
|
||||
next: (response: SuccessDtoApiModel) => {
|
||||
if (response.success) {
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
},
|
||||
finalize: () => this.displaySkeleton.set(false),
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
} else {
|
||||
this.displaySkeleton.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
public setBackground(): void {
|
||||
const theme = this.themeService.getTheme();
|
||||
let opacity: number;
|
||||
|
||||
if (theme === 'dark') {
|
||||
opacity = 0.05;
|
||||
} else {
|
||||
opacity = 0.1;
|
||||
}
|
||||
|
||||
const colorPrimary = getComputedStyle(
|
||||
this.el.nativeElement
|
||||
).getPropertyValue('--p');
|
||||
|
||||
const colorPrimaryC = getComputedStyle(
|
||||
this.el.nativeElement
|
||||
).getPropertyValue('--pc');
|
||||
|
||||
const svgUrlforDialog = this.backgroundPatternService.getWigglePattern(
|
||||
colorPrimary,
|
||||
opacity
|
||||
);
|
||||
const svgUrlForLeft = this.backgroundPatternService.getBankNotePattern(
|
||||
colorPrimaryC,
|
||||
opacity
|
||||
);
|
||||
const svgUrlForRight = this.backgroundPatternService.getHideoutPattern(
|
||||
colorPrimary,
|
||||
opacity
|
||||
);
|
||||
|
||||
this.dialogBackgroundStyle = {
|
||||
'background-image': `url("${svgUrlforDialog}")`,
|
||||
};
|
||||
this.leftBackgroundStyle = {
|
||||
'background-image': `url("${svgUrlForLeft}")`,
|
||||
};
|
||||
this.rightBackgroundStyle = {
|
||||
'background-image': `url("${svgUrlForRight}")`,
|
||||
};
|
||||
}
|
||||
|
||||
public openModal(): void {
|
||||
this.isDialogOpen.set(true);
|
||||
}
|
||||
|
||||
public closeModal(): void {
|
||||
this.isDialogOpen.set(false);
|
||||
}
|
||||
|
||||
public toggleTheme(): void {
|
||||
this.themeService.toggleTheme();
|
||||
this.setBackground();
|
||||
}
|
||||
|
||||
public toggleAction(action: AuthAction): void {
|
||||
this.resetFormValidation();
|
||||
|
||||
if (action === 'signin') {
|
||||
this.handlePreselect();
|
||||
this.isSigninSignal.set(true);
|
||||
this.isSignupSignal.set(false);
|
||||
} else {
|
||||
this.isSigninSignal.set(false);
|
||||
this.isSignupSignal.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
public onSubmit(): void {
|
||||
this.markControlsAsTouchedAndDirty(['email', 'password']);
|
||||
|
||||
if (this.form?.valid) {
|
||||
if (this.isSigninSignal()) {
|
||||
this.signin(this.form.value);
|
||||
} else {
|
||||
this.signup(this.form.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handlePreselect(): void {
|
||||
const rememberMe = this.localStorageService.getItem<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',
|
||||
}),
|
||||
});
|
||||
|
||||
this.rememberMe.setValue(rememberMeValue);
|
||||
}
|
||||
|
||||
private handleRedirect(): void {
|
||||
if (this.verified()) {
|
||||
this.isSigninSignal.set(true);
|
||||
this.isSignupSignal.set(false);
|
||||
}
|
||||
if (this.email()) {
|
||||
this.form?.get('email')?.setValue(decodeURIComponent(atob(this.email())));
|
||||
}
|
||||
|
||||
if (this.login()) {
|
||||
this.isSignupSignal.set(true);
|
||||
this.isSigninSignal.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private clearRouteParams(): void {
|
||||
this.router.navigate([], { queryParams: {} });
|
||||
}
|
||||
|
||||
private setupValueChanges(): void {
|
||||
this.setupEmailValueChanges();
|
||||
this.setupPasswordValueChanges();
|
||||
}
|
||||
|
||||
private setupEmailValueChanges(): void {
|
||||
const emailControl = this.form?.get('email');
|
||||
|
||||
emailControl?.valueChanges.subscribe((value: string) => {
|
||||
if (value?.length >= 4) {
|
||||
emailControl.setValidators([
|
||||
Validators.required,
|
||||
customEmailValidator(),
|
||||
]);
|
||||
} else {
|
||||
emailControl.setValidators([
|
||||
Validators.required,
|
||||
Validators.minLength(4),
|
||||
]);
|
||||
}
|
||||
emailControl.updateValueAndValidity({ emitEvent: false });
|
||||
});
|
||||
}
|
||||
|
||||
private setupPasswordValueChanges(): void {
|
||||
const passwordControl = this.form?.get('password');
|
||||
|
||||
passwordControl?.valueChanges.subscribe((value: string) => {
|
||||
if (value?.length >= 8) {
|
||||
passwordControl.setValidators([
|
||||
Validators.required,
|
||||
customPasswordValidator(),
|
||||
]);
|
||||
} else {
|
||||
passwordControl.setValidators([
|
||||
Validators.required,
|
||||
Validators.minLength(8),
|
||||
]);
|
||||
}
|
||||
passwordControl.updateValueAndValidity({ emitEvent: false });
|
||||
});
|
||||
}
|
||||
|
||||
private markControlsAsTouchedAndDirty(controlNames: string[]): void {
|
||||
controlNames.forEach((controlName: string) => {
|
||||
const control = this.form?.get(controlName);
|
||||
|
||||
if (control) {
|
||||
control.markAsTouched();
|
||||
control.markAsDirty();
|
||||
control.updateValueAndValidity();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resetFormValidation(): void {
|
||||
['email', 'password'].forEach((controlName: string) => {
|
||||
this.resetControlValidation(controlName);
|
||||
});
|
||||
}
|
||||
|
||||
private resetControlValidation(controlName: string): void {
|
||||
const control = this.form?.get(controlName);
|
||||
|
||||
if (control) {
|
||||
control.reset();
|
||||
control.markAsPristine();
|
||||
control.markAsUntouched();
|
||||
control.updateValueAndValidity();
|
||||
}
|
||||
}
|
||||
|
||||
private signin(logiCredentials: UserCredentialsDtoApiModel): void {
|
||||
const rememberMe: boolean = this.rememberMe.value;
|
||||
|
||||
if (rememberMe) {
|
||||
this.localStorageService.setItem<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.authService
|
||||
.signup(logiCredentials)
|
||||
.pipe(
|
||||
delay(1000),
|
||||
tap(() => this.isLoading.set(true)),
|
||||
finalize(() => this.isLoading.set(false))
|
||||
)
|
||||
.subscribe((response: SuccessDtoApiModel) => {
|
||||
if (response.success) {
|
||||
this.openModal();
|
||||
this.userSignupSuccess.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
*/
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import {
|
||||
|
@ -425,11 +24,12 @@ import { Router } from '@angular/router';
|
|||
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { delay, finalize, tap, timer } from 'rxjs';
|
||||
import { delay, finalize, switchMap, takeWhile, tap, timer } from 'rxjs';
|
||||
|
||||
import {
|
||||
Configuration,
|
||||
MagicLinkDtoApiModel,
|
||||
SigninResponseDtoApiModel,
|
||||
SuccessDtoApiModel,
|
||||
UserCredentialsDtoApiModel,
|
||||
VerifyApiService,
|
||||
|
@ -467,20 +67,24 @@ import { customEmailValidator } from '../../shared/validator';
|
|||
export class WelcomeRootComponent implements OnInit {
|
||||
@ViewChild('passwordInput') public passwordInput!: ElementRef;
|
||||
public token: InputSignal<string> = input<string>('');
|
||||
public signedOut: InputSignal<boolean> = input<boolean>(false);
|
||||
public signup: InputSignal<boolean> = input<boolean>(false);
|
||||
public signin: InputSignal<boolean> = input<boolean>(false);
|
||||
public dialogBackgroundStyle: { 'background-image': string } | null = null;
|
||||
public leftBackgroundStyle: { 'background-image': string } | null = null;
|
||||
public rightBackgroundStyle: { 'background-image': string } | null = null;
|
||||
public form!: FormGroup;
|
||||
public isLoading: WritableSignal<boolean> = signal(false);
|
||||
public isEmailSent: WritableSignal<boolean> = signal(false);
|
||||
public isTokenVerifing: WritableSignal<boolean> = signal(false);
|
||||
public displaySkeleton: WritableSignal<boolean> = signal(false);
|
||||
public isVerifying: 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);
|
||||
private removeQueryParams: WritableSignal<boolean> = signal(false);
|
||||
|
||||
public get isDarkMode(): boolean {
|
||||
|
@ -498,89 +102,54 @@ export class WelcomeRootComponent implements OnInit {
|
|||
) {
|
||||
effect(() => {
|
||||
if (this.removeQueryParams()) {
|
||||
this.clearRouteParams();
|
||||
//this.clearRouteParams();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.autologin();
|
||||
this.setBackground();
|
||||
this.initializeForm();
|
||||
this.verifySignupMagicLink();
|
||||
this.verifySigninMagicLink();
|
||||
}
|
||||
|
||||
public autologin(): void {
|
||||
if (
|
||||
!this.token() &&
|
||||
(!this.signin() || !this.signup()) &&
|
||||
!this.signedOut()
|
||||
) {
|
||||
this.isAutoLoginInProgress.set(true);
|
||||
this.displaySkeleton.set(true);
|
||||
|
||||
timer(2000)
|
||||
.pipe(
|
||||
switchMap(() => this.authService.status()),
|
||||
takeWhile((response: SuccessDtoApiModel) => response.success, true),
|
||||
tap({
|
||||
next: (response: SuccessDtoApiModel) => {
|
||||
if (response.success) {
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
},
|
||||
}),
|
||||
finalize(() => {
|
||||
this.isAutoLoginInProgress.set(false);
|
||||
this.displaySkeleton.set(false);
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
public verifySigninMagicLink(): void {
|
||||
this.verifyMagicLink(false);
|
||||
}
|
||||
|
||||
public verifySignupMagicLink(): void {
|
||||
if (this.token() && this.signup()) {
|
||||
const token: string = this.extractVerifyToken();
|
||||
const email: string = this.extractEmail();
|
||||
|
||||
this.removeQueryParams.set(true);
|
||||
|
||||
if (token && email) {
|
||||
this.isTokenVerifing.set(true);
|
||||
this.verificationError.set(null);
|
||||
this.errorReasons.set([]);
|
||||
this.addPasswordFieldToForm();
|
||||
this.isRegistrationMode.set(true);
|
||||
const decodedEmail = decodeURIComponent(atob(email));
|
||||
|
||||
this.verifyApiService
|
||||
.verifyControllerVerifyEmail(token, decodedEmail)
|
||||
.pipe(
|
||||
delay(2000),
|
||||
finalize(() => {
|
||||
if (!this.verificationError()) {
|
||||
this.isTokenVerifing.set(false);
|
||||
this.isTokenVerified.set(true);
|
||||
// Warte 3 Sekunden, dann schließe das Modal und fokussiere das Passwort-Feld
|
||||
timer(3000).subscribe(() => {
|
||||
this.isTokenVerified.set(false);
|
||||
this.focusPasswordField();
|
||||
});
|
||||
}
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
next: (response: SuccessDtoApiModel) => {
|
||||
if (response.success) {
|
||||
this.isTokenVerifing.set(false);
|
||||
console.log('Verification successful');
|
||||
} else {
|
||||
console.error('Verification failed');
|
||||
this.verificationError.set(
|
||||
'Verification failed. Please check the reasons below:'
|
||||
);
|
||||
this.errorReasons.set([
|
||||
'The verification token may have expired.',
|
||||
'The email address may not match our records.',
|
||||
'The verification link may have been used already.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Verification failed', error);
|
||||
this.verificationError.set(
|
||||
'An error occurred during verification. Please check the reasons below:'
|
||||
);
|
||||
this.errorReasons.set([
|
||||
'There might be a problem with your internet connection.',
|
||||
'Our servers might be experiencing issues.',
|
||||
'The verification service might be temporarily unavailable.',
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
this.form.patchValue({ email: decodedEmail });
|
||||
const emailControl = this.form.get('email');
|
||||
|
||||
if (emailControl) {
|
||||
emailControl.setValue(decodedEmail);
|
||||
emailControl.disable();
|
||||
emailControl.markAsTouched();
|
||||
emailControl.setErrors(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.verifyMagicLink(true);
|
||||
}
|
||||
|
||||
public getInputClass(controlName: string): string {
|
||||
|
@ -669,8 +238,8 @@ export class WelcomeRootComponent implements OnInit {
|
|||
|
||||
if (this.isRegistrationMode()) {
|
||||
const signupCredentials: UserCredentialsDtoApiModel = {
|
||||
email: this.form.value.email,
|
||||
password: this.form.value.password,
|
||||
email: this.form.getRawValue().email.trim(),
|
||||
password: this.form.getRawValue().password.trim(),
|
||||
};
|
||||
|
||||
this.signupNewUser(signupCredentials);
|
||||
|
@ -685,6 +254,127 @@ export class WelcomeRootComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
private verifyMagicLink(isSignup: boolean): void {
|
||||
if (this.token() && (isSignup ? this.signup() : this.signin())) {
|
||||
const token: string = this.extractVerifyToken();
|
||||
const email: string = this.extractEmail();
|
||||
const decodedEmail: string = decodeURIComponent(atob(email));
|
||||
|
||||
if (token && email) {
|
||||
if (isSignup) {
|
||||
this.setupEmailField(decodedEmail);
|
||||
this.removeQueryParams.set(true);
|
||||
this.addPasswordFieldToForm();
|
||||
this.isRegistrationMode.set(true);
|
||||
}
|
||||
|
||||
this.isVerifying.set(true);
|
||||
this.verificationError.set(null);
|
||||
this.errorReasons.set([]);
|
||||
|
||||
timer(2500)
|
||||
.pipe(
|
||||
tap(() => {
|
||||
this.isVerifying.set(false);
|
||||
}),
|
||||
switchMap(() =>
|
||||
this.verifyApiService.verifyControllerVerifyEmail(
|
||||
token,
|
||||
decodedEmail
|
||||
)
|
||||
),
|
||||
tap((response: SuccessDtoApiModel) => {
|
||||
if (response.success) {
|
||||
this.isTokenVerified.set(true);
|
||||
}
|
||||
}),
|
||||
delay(1000),
|
||||
finalize(() => this.handleVerificationFinalize())
|
||||
)
|
||||
.subscribe({
|
||||
next: (response: SuccessDtoApiModel) =>
|
||||
this.handleVerificationResponse(
|
||||
response,
|
||||
isSignup,
|
||||
decodedEmail,
|
||||
token
|
||||
),
|
||||
error: () => this.handleVerificationError(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleVerificationFinalize(): void {
|
||||
if (!this.verificationError()) {
|
||||
this.displaySkeleton.set(false);
|
||||
this.isTokenVerified.set(true);
|
||||
timer(2000).subscribe(() => {
|
||||
this.isTokenVerified.set(false);
|
||||
this.focusPasswordField();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleVerificationResponse(
|
||||
response: SuccessDtoApiModel,
|
||||
isSignup: boolean,
|
||||
email: string,
|
||||
token: string
|
||||
): void {
|
||||
if (response.success) {
|
||||
this.displaySkeleton.set(false);
|
||||
this.isTokenVerified.set(true);
|
||||
if (!isSignup) {
|
||||
timer(2000).subscribe(() => {
|
||||
this.authService
|
||||
.signinMagicLink({ email, token })
|
||||
.subscribe((response: SigninResponseDtoApiModel) => {
|
||||
if (response) {
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.handleVerificationFailure(
|
||||
'Verification failed. Please check the reasons below:'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleVerificationError(): void {
|
||||
this.isVerifying.set(false);
|
||||
this.handleVerificationFailure(
|
||||
'An error occurred during verification. Please check the reasons below:'
|
||||
);
|
||||
}
|
||||
|
||||
private handleVerificationFailure(message: string): void {
|
||||
this.verificationError.set(message);
|
||||
this.errorReasons.set([
|
||||
'The verification token may have expired.',
|
||||
'The device you are using may not match the one used to generate the token.',
|
||||
'The email address may not match our records.',
|
||||
'The verification link may have been used already.',
|
||||
'There might be a problem with your internet connection.',
|
||||
'Our servers might be experiencing issues.',
|
||||
'The verification service might be temporarily unavailable.',
|
||||
]);
|
||||
}
|
||||
|
||||
private setupEmailField(email: string): void {
|
||||
this.form.patchValue({ email });
|
||||
this.form.get('email')?.setValue(email);
|
||||
const emailControl = this.form.get('email');
|
||||
|
||||
if (emailControl) {
|
||||
emailControl.disable({ onlySelf: true, emitEvent: false });
|
||||
emailControl.markAsTouched();
|
||||
emailControl.setErrors(null);
|
||||
}
|
||||
}
|
||||
|
||||
private clearRouteParams(): void {
|
||||
this.router.navigate([], { queryParams: {} });
|
||||
}
|
||||
|
@ -703,13 +393,10 @@ export class WelcomeRootComponent implements OnInit {
|
|||
|
||||
private initializeForm(): void {
|
||||
this.form = this.formBuilder.group({
|
||||
email: new FormControl(
|
||||
{ value: '', disabled: false },
|
||||
{
|
||||
email: new FormControl('', {
|
||||
validators: [Validators.required, customEmailValidator()],
|
||||
updateOn: 'change',
|
||||
}
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -734,8 +421,7 @@ export class WelcomeRootComponent implements OnInit {
|
|||
)
|
||||
.subscribe((response: SuccessDtoApiModel) => {
|
||||
if (response.success) {
|
||||
console.log('User signed up successfully');
|
||||
// TODO: Redirect to Dashbord
|
||||
// Display Modal // You have successfully signed up. Please check your email for the magic link.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { catchError, shareReplay, tap } from 'rxjs/operators';
|
|||
import {
|
||||
AuthenticationApiService,
|
||||
MagicLinkDtoApiModel,
|
||||
MagicLinkSigninDtoApiModel,
|
||||
SigninResponseDtoApiModel,
|
||||
SuccessDtoApiModel,
|
||||
UserCredentialsDtoApiModel,
|
||||
|
@ -28,6 +29,14 @@ export class AuthService {
|
|||
this.statusCheck$ = this.initializeStatusCheck();
|
||||
}
|
||||
|
||||
public signinMagicLink(
|
||||
credentials: MagicLinkSigninDtoApiModel
|
||||
): Observable<SigninResponseDtoApiModel> {
|
||||
return this.authenticationApiService
|
||||
.authControllerMagicLinkSignin(credentials)
|
||||
.pipe(tap(() => this.isAuthenticatedSignal.set(true)));
|
||||
}
|
||||
|
||||
public sendMagicLink(
|
||||
email: MagicLinkDtoApiModel
|
||||
): Observable<SuccessDtoApiModel> {
|
||||
|
@ -42,13 +51,13 @@ export class AuthService {
|
|||
.pipe(tap(() => this.isAuthenticatedSignal.set(true)));
|
||||
}
|
||||
|
||||
public signin(
|
||||
credentials: UserCredentialsDtoApiModel
|
||||
): Observable<SigninResponseDtoApiModel> {
|
||||
return this.authenticationApiService
|
||||
.authControllerSignin(credentials)
|
||||
.pipe(tap(() => this.isAuthenticatedSignal.set(true)));
|
||||
}
|
||||
// public signin(
|
||||
// credentials: UserCredentialsDtoApiModel
|
||||
// ): Observable<SigninResponseDtoApiModel> {
|
||||
// return this.authenticationApiService
|
||||
// .authControllerSignin(credentials)
|
||||
// .pipe(tap(() => this.isAuthenticatedSignal.set(true)));
|
||||
// }
|
||||
|
||||
public signout(): Observable<SuccessDtoApiModel> {
|
||||
return this.authenticationApiService
|
||||
|
|
Loading…
Reference in New Issue