fixes
This commit is contained in:
18
.env.example
18
.env.example
@@ -20,13 +20,6 @@ DATABASE_CA=
|
||||
DATABASE_KEY=
|
||||
DATABASE_CERT=
|
||||
|
||||
# Support "local", "s3"
|
||||
FILE_DRIVER=local
|
||||
ACCESS_KEY_ID=
|
||||
SECRET_ACCESS_KEY=
|
||||
AWS_S3_REGION=
|
||||
AWS_DEFAULT_S3_BUCKET=
|
||||
|
||||
MAIL_HOST=change-me
|
||||
MAIL_PORT=465
|
||||
MAIL_USER=change-me
|
||||
@@ -41,15 +34,4 @@ MAIL_CLIENT_PORT=1080
|
||||
AUTH_JWT_SECRET=change-me
|
||||
AUTH_JWT_TOKEN_EXPIRES_IN=100d
|
||||
|
||||
FACEBOOK_APP_ID=
|
||||
FACEBOOK_APP_SECRET=
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
APPLE_APP_AUDIENCE=[]
|
||||
|
||||
TWITTER_CONSUMER_KEY=
|
||||
TWITTER_CONSUMER_SECRET=
|
||||
|
||||
WORKER_HOST=redis://redis:6379/1
|
||||
|
||||
@@ -19,13 +19,6 @@ DATABASE_CA=
|
||||
DATABASE_KEY=
|
||||
DATABASE_CERT=
|
||||
|
||||
# Support "local", "s3"
|
||||
FILE_DRIVER=local
|
||||
ACCESS_KEY_ID=
|
||||
SECRET_ACCESS_KEY=
|
||||
AWS_S3_REGION=
|
||||
AWS_DEFAULT_S3_BUCKET=
|
||||
|
||||
MAIL_HOST=maildev
|
||||
MAIL_PORT=1025
|
||||
MAIL_USER=
|
||||
|
||||
1720
myteamwallet_backend/package-lock.json
generated
1720
myteamwallet_backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -39,15 +39,10 @@
|
||||
"@nestjs/serve-static": "^3.0.0",
|
||||
"@nestjs/swagger": "6.1.3",
|
||||
"@nestjs/typeorm": "9.0.1",
|
||||
"apple-signin-auth": "1.7.4",
|
||||
"bcryptjs": "2.4.3",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.13.2",
|
||||
"fb": "2.0.0",
|
||||
"google-auth-library": "8.7.0",
|
||||
"handlebars": "4.7.7",
|
||||
"multer": "1.4.4",
|
||||
"multer-s3": "2.10.0",
|
||||
"mysql2": "^2.3.3",
|
||||
"nodemailer": "6.8.0",
|
||||
"passport": "0.6.0",
|
||||
@@ -59,7 +54,6 @@
|
||||
"rxjs": "7.5.7",
|
||||
"source-map-support": "0.5.21",
|
||||
"swagger-ui-express": "4.5.0",
|
||||
"twitter": "1.7.1",
|
||||
"typeorm": "0.3.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -69,17 +63,13 @@
|
||||
"@nestjs/testing": "9.1.6",
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/express": "4.17.14",
|
||||
"@types/facebook-js-sdk": "3.3.6",
|
||||
"@types/jest": "29.2.3",
|
||||
"@types/multer": "1.4.7",
|
||||
"@types/node": "16.18.3",
|
||||
"@types/passport-anonymous": "1.0.3",
|
||||
"@types/passport-jwt": "3.0.7",
|
||||
"@types/supertest": "2.0.12",
|
||||
"@types/twitter": "1.7.1",
|
||||
"@typescript-eslint/eslint-plugin": "5.43.0",
|
||||
"@typescript-eslint/parser": "5.43.0",
|
||||
"aws-sdk": "2.1243.0",
|
||||
"env-cmd": "10.1.0",
|
||||
"eslint": "8.27.0",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
|
||||
@@ -5,7 +5,6 @@ import databaseConfig from './config/database.config';
|
||||
import authConfig from './config/auth.config';
|
||||
import appConfig from './config/app.config';
|
||||
import mailConfig from './config/mail.config';
|
||||
import fileConfig from './config/file.config';
|
||||
import { MailerModule } from '@nestjs-modules/mailer';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
@@ -29,7 +28,7 @@ import { PenaltyModule } from './penalty/penalty.module';
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [databaseConfig, authConfig, appConfig, mailConfig, fileConfig],
|
||||
load: [databaseConfig, authConfig, appConfig, mailConfig],
|
||||
envFilePath: ['.env'],
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export enum AuthProvidersEnum {
|
||||
email = 'email',
|
||||
facebook = 'facebook',
|
||||
google = 'google',
|
||||
twitter = 'twitter',
|
||||
apple = 'apple',
|
||||
}
|
||||
|
||||
@@ -72,82 +72,6 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects social login when the existing account is inactive', async () => {
|
||||
const inactive = socialUser(
|
||||
AuthProvidersEnum.google,
|
||||
RoleEnum.user,
|
||||
StatusEnum.inactive,
|
||||
);
|
||||
configureSocialQueries([inactive], inactive);
|
||||
|
||||
await expect(
|
||||
service.validateSocialLogin(AuthProvidersEnum.google, {
|
||||
id: 'social-id',
|
||||
email: inactive.email,
|
||||
firstName: 'Inactive',
|
||||
lastName: 'User',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(usersService.update).not.toHaveBeenCalled();
|
||||
expect(jwtService.sign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reloads and rejects a concurrently deactivated social user instead of signing stale state', async () => {
|
||||
const stale = socialUser(
|
||||
AuthProvidersEnum.google,
|
||||
RoleEnum.admin,
|
||||
StatusEnum.active,
|
||||
);
|
||||
const current = socialUser(
|
||||
AuthProvidersEnum.google,
|
||||
RoleEnum.user,
|
||||
StatusEnum.inactive,
|
||||
);
|
||||
configureSocialQueries([stale], current);
|
||||
|
||||
await expect(
|
||||
service.validateSocialLogin(AuthProvidersEnum.google, {
|
||||
id: stale.socialId,
|
||||
email: 'updated@example.com',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(userRepository.save).not.toHaveBeenCalledWith(stale);
|
||||
expect(usersService.update).not.toHaveBeenCalled();
|
||||
expect(jwtService.sign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
AuthProvidersEnum.facebook,
|
||||
AuthProvidersEnum.google,
|
||||
AuthProvidersEnum.twitter,
|
||||
AuthProvidersEnum.apple,
|
||||
])(
|
||||
'locks and reloads an existing %s user, narrowly updates email, and signs the current role',
|
||||
async (provider) => {
|
||||
const stale = socialUser(provider, RoleEnum.admin, StatusEnum.active);
|
||||
const current = socialUser(provider, RoleEnum.user, StatusEnum.active);
|
||||
configureSocialQueries([stale], current);
|
||||
|
||||
const result = await service.validateSocialLogin(provider, {
|
||||
id: stale.socialId,
|
||||
email: 'updated@example.com',
|
||||
});
|
||||
|
||||
expect(userRepository.update).toHaveBeenCalledWith(stale.id, {
|
||||
email: 'updated@example.com',
|
||||
});
|
||||
expect(userRepository.save).not.toHaveBeenCalledWith(stale);
|
||||
expect(jwtService.sign).toHaveBeenCalledWith({
|
||||
id: current.id,
|
||||
role: current.role,
|
||||
});
|
||||
expect(result.user).toBe(current);
|
||||
},
|
||||
);
|
||||
|
||||
it('never includes an email in an unknown-user login audit event', async () => {
|
||||
usersService.findOne.mockResolvedValue(undefined);
|
||||
|
||||
@@ -204,11 +128,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
await service.confirmEmail('confirmation-hash');
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(lockedUserQuery.setLock).toHaveBeenCalledWith(
|
||||
'pessimistic_write',
|
||||
undefined,
|
||||
['user'],
|
||||
);
|
||||
expect(lockedUserQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||
expect(confirmationUser.status).toEqual({ id: StatusEnum.active });
|
||||
expect(confirmationUser.hash).toBeNull();
|
||||
expect(userRepository.save).toHaveBeenCalledWith(confirmationUser);
|
||||
@@ -247,45 +167,4 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function socialUser(
|
||||
provider: AuthProvidersEnum,
|
||||
roleId: RoleEnum,
|
||||
statusId: StatusEnum,
|
||||
) {
|
||||
return {
|
||||
...user(statusId),
|
||||
email: 'old@example.com',
|
||||
socialId: `${provider}-id`,
|
||||
provider,
|
||||
role: { id: roleId, name: roleId === RoleEnum.admin ? 'Admin' : 'User' },
|
||||
hash: 'stale-hash',
|
||||
};
|
||||
}
|
||||
|
||||
function configureSocialQueries(candidates: any[], current: any) {
|
||||
const candidateQuery = chain({ getMany: jest.fn(() => candidates) });
|
||||
const reloadQuery = chain({ getOne: jest.fn(() => current) });
|
||||
userRepository.createQueryBuilder = jest.fn((alias: string) =>
|
||||
alias === 'socialCandidate' ? candidateQuery : reloadQuery,
|
||||
);
|
||||
userRepository.update = jest.fn();
|
||||
}
|
||||
|
||||
function chain(overrides: Record<string, jest.Mock>) {
|
||||
const query: Record<string, jest.Mock> = {};
|
||||
[
|
||||
'leftJoinAndSelect',
|
||||
'where',
|
||||
'orWhere',
|
||||
'andWhere',
|
||||
'setParameter',
|
||||
'setParameters',
|
||||
'setLock',
|
||||
'orderBy',
|
||||
].forEach((method) => {
|
||||
query[method] = jest.fn(() => query);
|
||||
});
|
||||
return Object.assign(query, overrides);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ import { plainToClass } from 'class-transformer';
|
||||
import { Status } from 'src/statuses/entities/status.entity';
|
||||
import { Role } from 'src/roles/entities/role.entity';
|
||||
import { AuthProvidersEnum } from './auth-providers.enum';
|
||||
import { SocialInterface } from 'src/social/interfaces/social.interface';
|
||||
import { AuthRegisterLoginDto } from './dto/auth-register-login.dto';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
import { ForgotService } from 'src/forgot/forgot.service';
|
||||
@@ -113,74 +112,6 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async validateSocialLogin(
|
||||
authProvider: string,
|
||||
socialData: SocialInterface,
|
||||
): Promise<{ token: string; user: User }> {
|
||||
const socialEmail = socialData.email?.toLowerCase();
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const repository = manager.getRepository(User);
|
||||
const candidateQuery = repository
|
||||
.createQueryBuilder('socialCandidate')
|
||||
.where(
|
||||
'socialCandidate.socialId = :socialId AND socialCandidate.provider = :authProvider',
|
||||
{ socialId: socialData.id, authProvider },
|
||||
);
|
||||
if (socialEmail) {
|
||||
candidateQuery.orWhere('socialCandidate.email = :socialEmail', {
|
||||
socialEmail,
|
||||
});
|
||||
}
|
||||
const candidates = await candidateQuery
|
||||
.setLock('pessimistic_write', undefined, ['socialCandidate'])
|
||||
.orderBy('socialCandidate.id', 'ASC')
|
||||
.getMany();
|
||||
const socialUser = candidates.find(
|
||||
(candidate) =>
|
||||
candidate.socialId === socialData.id &&
|
||||
candidate.provider === authProvider,
|
||||
);
|
||||
const emailUser = socialEmail
|
||||
? candidates.find((candidate) => candidate.email === socialEmail)
|
||||
: undefined;
|
||||
let user = socialUser ?? emailUser;
|
||||
if (!user) {
|
||||
user = await repository.save(
|
||||
repository.create({
|
||||
email: socialEmail,
|
||||
firstName: socialData.firstName,
|
||||
lastName: socialData.lastName,
|
||||
socialId: socialData.id,
|
||||
provider: authProvider,
|
||||
role: { id: RoleEnum.user } as Role,
|
||||
status: { id: StatusEnum.active } as Status,
|
||||
}),
|
||||
);
|
||||
} else if (
|
||||
socialUser &&
|
||||
socialEmail &&
|
||||
!emailUser &&
|
||||
socialUser.email !== socialEmail
|
||||
) {
|
||||
await repository.update(socialUser.id, { email: socialEmail });
|
||||
}
|
||||
const currentUser = await repository
|
||||
.createQueryBuilder('currentSocialUser')
|
||||
.leftJoinAndSelect('currentSocialUser.role', 'role')
|
||||
.leftJoinAndSelect('currentSocialUser.status', 'status')
|
||||
.where('currentSocialUser.id = :userId', { userId: user.id })
|
||||
.setLock('pessimistic_write', undefined, ['currentSocialUser'])
|
||||
.getOne();
|
||||
if (!currentUser) throw new UnauthorizedException();
|
||||
await this.assertActiveUser(currentUser);
|
||||
const token = await this.jwtService.sign({
|
||||
id: currentUser.id,
|
||||
role: currentUser.role,
|
||||
});
|
||||
return { token, user: currentUser };
|
||||
});
|
||||
}
|
||||
|
||||
async register(dto: AuthRegisterLoginDto): Promise<void> {
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
@@ -222,7 +153,7 @@ export class AuthService {
|
||||
const user = await repository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.hash = :hash', { hash })
|
||||
.setLock('pessimistic_write', undefined, ['user'])
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Allow, IsNotEmpty } from 'class-validator';
|
||||
import { Tokens } from 'src/social/tokens';
|
||||
import { AuthProvidersEnum } from '../auth-providers.enum';
|
||||
|
||||
export class AuthSocialLoginDto {
|
||||
@Allow()
|
||||
@ApiProperty({ type: () => Tokens })
|
||||
tokens: Tokens;
|
||||
|
||||
@ApiProperty({ enum: AuthProvidersEnum })
|
||||
@IsNotEmpty()
|
||||
socialType: AuthProvidersEnum;
|
||||
|
||||
@Allow()
|
||||
@ApiProperty({ required: false })
|
||||
firstName?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiProperty({ required: false })
|
||||
lastName?: string;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('file', () => ({
|
||||
driver: process.env.FILE_DRIVER,
|
||||
accessKeyId: process.env.ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.SECRET_ACCESS_KEY,
|
||||
awsDefaultS3Bucket: process.env.AWS_DEFAULT_S3_BUCKET,
|
||||
awsDefaultS3Url: process.env.AWS_DEFAULT_S3_URL,
|
||||
awsS3Region: process.env.AWS_S3_REGION,
|
||||
maxFileSize: 5242880, // 5mb
|
||||
}));
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { HomeService } from './home.service';
|
||||
|
||||
@ApiTags('Home')
|
||||
@Controller()
|
||||
export class HomeController {
|
||||
constructor(private service: HomeService) {}
|
||||
|
||||
@Get()
|
||||
appInfo() {
|
||||
return this.service.appInfo();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HomeService } from './home.service';
|
||||
import { HomeController } from './home.controller';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [HomeController],
|
||||
providers: [HomeService],
|
||||
})
|
||||
export class HomeModule {}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class HomeService {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
appInfo() {
|
||||
return { name: this.configService.get('app.name') };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface SocialInterface {
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Allow, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class Tokens {
|
||||
@ApiProperty()
|
||||
@IsNotEmpty()
|
||||
token1: string;
|
||||
|
||||
@Allow()
|
||||
@ApiProperty()
|
||||
token2?: string;
|
||||
}
|
||||
@@ -94,11 +94,7 @@ describe('AdminUsersService', () => {
|
||||
},
|
||||
manager,
|
||||
);
|
||||
expect(lockedUserQuery.setLock).toHaveBeenCalledWith(
|
||||
'pessimistic_write',
|
||||
undefined,
|
||||
['lockedUser'],
|
||||
);
|
||||
expect(lockedUserQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||
expect(lockedUserQuery.leftJoinAndSelect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -122,11 +118,7 @@ describe('AdminUsersService', () => {
|
||||
service.updateRole(actorId, target.id, RoleEnum.user),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
|
||||
expect(lockQuery.setLock).toHaveBeenCalledWith(
|
||||
'pessimistic_write',
|
||||
undefined,
|
||||
['user'],
|
||||
);
|
||||
expect(lockQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(userRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -269,7 +269,7 @@ export class AdminUsersService {
|
||||
.andWhere('status.id = :activeStatus', {
|
||||
activeStatus: StatusEnum.active,
|
||||
})
|
||||
.setLock('pessimistic_write', undefined, ['user'])
|
||||
.setLock('pessimistic_write')
|
||||
.orderBy('user.id', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
@@ -283,7 +283,7 @@ export class AdminUsersService {
|
||||
.leftJoinAndSelect('lockedUser.role', 'role')
|
||||
.leftJoinAndSelect('lockedUser.status', 'status')
|
||||
.where('lockedUser.id = :userId', { userId })
|
||||
.setLock('pessimistic_write', undefined, ['lockedUser'])
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
return user;
|
||||
|
||||
@@ -28,6 +28,11 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword),
|
||||
},
|
||||
{
|
||||
path: 'confirm-email/:hash',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/confirm-email/confirm-email').then((m) => m.ConfirmEmail),
|
||||
},
|
||||
{
|
||||
path: 'password-change/:hash',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -69,6 +69,10 @@ export class AuthApi {
|
||||
return this.http.post<void>(`${this.baseUrl}/reset/password`, { hash, password });
|
||||
}
|
||||
|
||||
confirmEmail(hash: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/email/confirm`, { hash });
|
||||
}
|
||||
|
||||
createInvite(request: CreateInviteRequest): Observable<{ token: string }> {
|
||||
return this.http.post<{ token: string }>(`${this.baseUrl}/invite`, request);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<div class="auth-page">
|
||||
<mat-card class="auth-card">
|
||||
<mat-card-header><mat-card-title>E-Mail-Bestätigung</mat-card-title></mat-card-header>
|
||||
<mat-card-content>
|
||||
@if (confirming()) {
|
||||
<div class="state">
|
||||
<mat-spinner diameter="36" />
|
||||
<span>Deine E-Mail-Adresse wird bestätigt …</span>
|
||||
</div>
|
||||
} @else if (errorMessage()) {
|
||||
<p class="error">{{ errorMessage() }}</p>
|
||||
<a mat-flat-button routerLink="/auth/login">Zum Login</a>
|
||||
} @else {
|
||||
<p>Deine E-Mail-Adresse wurde erfolgreich bestätigt.</p>
|
||||
<a mat-flat-button routerLink="/auth/login">Zum Login</a>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
.auth-page {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.auth-card {
|
||||
width: min(100%, 420px);
|
||||
}
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.error {
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { ConfirmEmail } from './confirm-email';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
|
||||
describe('ConfirmEmail', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
function setup(hash: string | null) {
|
||||
return TestBed.configureTestingModule({
|
||||
imports: [ConfirmEmail],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: convertToParamMap(hash ? { hash } : {}) } },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
}
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('confirms the route hash and shows the success state', async () => {
|
||||
await setup('confirm-hash');
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
const fixture = TestBed.createComponent(ConfirmEmail);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance['confirming']()).toBe(true);
|
||||
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/confirm`);
|
||||
expect(request.request.body).toEqual({ hash: 'confirm-hash' });
|
||||
request.flush(null);
|
||||
|
||||
expect(fixture.componentInstance['confirming']()).toBe(false);
|
||||
expect(fixture.componentInstance['errorMessage']()).toBeNull();
|
||||
});
|
||||
|
||||
it('shows an error state when the hash is invalid or expired', async () => {
|
||||
await setup('bad-hash');
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
const fixture = TestBed.createComponent(ConfirmEmail);
|
||||
fixture.detectChanges();
|
||||
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/confirm`);
|
||||
request.flush(null, { status: 404, statusText: 'Not Found' });
|
||||
|
||||
expect(fixture.componentInstance['confirming']()).toBe(false);
|
||||
expect(fixture.componentInstance['errorMessage']()).toBe(
|
||||
'Der Bestätigungslink ist ungültig oder abgelaufen.',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows an error state without making a request when the hash is missing', async () => {
|
||||
await setup(null);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
const fixture = TestBed.createComponent(ConfirmEmail);
|
||||
fixture.detectChanges();
|
||||
|
||||
httpMock.expectNone(`${environment.apiUrl}auth/email/confirm`);
|
||||
expect(fixture.componentInstance['confirming']()).toBe(false);
|
||||
expect(fixture.componentInstance['errorMessage']()).toBe('Der Bestätigungslink ist unvollständig.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { AuthApi } from '../../../core/auth/auth-api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-email',
|
||||
imports: [RouterLink, MatButtonModule, MatCardModule, MatProgressSpinnerModule],
|
||||
templateUrl: './confirm-email.html',
|
||||
styleUrl: './confirm-email.scss',
|
||||
})
|
||||
export class ConfirmEmail {
|
||||
private readonly authApi = inject(AuthApi);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly hash = this.route.snapshot.paramMap.get('hash');
|
||||
|
||||
protected readonly confirming = signal(true);
|
||||
protected readonly errorMessage = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
if (!this.hash) {
|
||||
this.confirming.set(false);
|
||||
this.errorMessage.set('Der Bestätigungslink ist unvollständig.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.authApi.confirmEmail(this.hash).subscribe({
|
||||
next: () => this.confirming.set(false),
|
||||
error: () => {
|
||||
this.confirming.set(false);
|
||||
this.errorMessage.set('Der Bestätigungslink ist ungültig oder abgelaufen.');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user