This commit is contained in:
Bastian Wagner
2026-07-15 14:09:28 +02:00
commit 3e5348b7ec
104 changed files with 30367 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
export type AccountDeleteRequestStatus = 'pending' | 'resolved';
@Entity({ name: 'account_delete_requests' })
export class AccountDeleteRequest {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
username!: string;
@Column({ default: 'pending' })
status!: AccountDeleteRequestStatus;
@Column({ nullable: true })
reason?: string;
@Column({ nullable: true })
resolvedAt?: Date;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -0,0 +1,125 @@
import { Body, Controller, Get, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequestUser } from '../common/request-user';
import { hashToken, randomToken } from '../common/token.util';
import { LldapService } from '../lldap/lldap.service';
import { PortalMailService } from '../mail/portal-mail.service';
import { AccountDeleteRequest } from './account-delete-request.entity';
import { ConfirmEmailChangeDto } from './dto/confirm-email-change.dto';
import { DeleteRequestDto } from './dto/delete-request.dto';
import { RequestEmailChangeDto } from './dto/request-email-change.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { EmailChangeRequest } from './email-change-request.entity';
@Controller('account')
@UseGuards(JwtAuthGuard)
export class AccountController {
constructor(
private readonly lldap: LldapService,
private readonly mail: PortalMailService,
private readonly config: ConfigService,
private readonly audit: AuditService,
@InjectRepository(EmailChangeRequest)
private readonly emailChanges: Repository<EmailChangeRequest>,
@InjectRepository(AccountDeleteRequest)
private readonly deleteRequests: Repository<AccountDeleteRequest>,
) {}
@Get('me')
me(@Req() request: Request & { user: RequestUser }) {
return this.lldap.getAccount(request.user.username);
}
@Patch('profile')
async updateProfile(
@Body() dto: UpdateProfileDto,
@Req() request: Request & { user: RequestUser },
) {
await this.lldap.updateUser(request.user.username, dto);
await this.audit.record({
type: 'account.profile_updated',
username: request.user.username,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
});
return this.lldap.getAccount(request.user.username);
}
@Post('email-change/request')
async requestEmailChange(
@Body() dto: RequestEmailChangeDto,
@Req() request: Request & { user: RequestUser },
) {
const token = randomToken();
await this.emailChanges.save(
this.emailChanges.create({
username: request.user.username,
newEmail: dto.newEmail.toLowerCase(),
tokenHash: hashToken(token, this.tokenSecret),
expiresAt: new Date(Date.now() + 24 * 60 * 60_000),
}),
);
await this.mail.sendEmailChangeMail(dto.newEmail, token);
await this.audit.record({
type: 'account.email_change_requested',
username: request.user.username,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
metadata: { newEmail: dto.newEmail.toLowerCase() },
});
return { message: 'Bitte bestaetige die neue E-Mail-Adresse.' };
}
@Post('email-change/confirm')
async confirmEmailChange(
@Body() dto: ConfirmEmailChangeDto,
@Req() request: Request & { user: RequestUser },
) {
const tokenHash = hashToken(dto.token, this.tokenSecret);
const record = await this.emailChanges.findOne({ where: { tokenHash, consumedAt: IsNull() } });
if (!record || record.expiresAt.getTime() < Date.now() || record.username !== request.user.username) {
return { message: 'Der Bestaetigungslink ist ungueltig oder abgelaufen.' };
}
await this.lldap.updateUser(request.user.username, { email: record.newEmail });
record.consumedAt = new Date();
await this.emailChanges.save(record);
await this.audit.record({
type: 'account.email_changed',
username: request.user.username,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
metadata: { newEmail: record.newEmail },
});
return { message: 'Die E-Mail-Adresse wurde aktualisiert.' };
}
@Post('delete-request')
async requestDeletion(
@Body() dto: DeleteRequestDto,
@Req() request: Request & { user: RequestUser },
) {
await this.deleteRequests.save(
this.deleteRequests.create({
username: request.user.username,
reason: dto.reason,
}),
);
await this.audit.record({
type: 'account.delete_requested',
username: request.user.username,
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
});
return { message: 'Die Anfrage wurde gespeichert.' };
}
private get tokenSecret(): string {
return this.config.getOrThrow<string>('TOKEN_SECRET');
}
}

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { LldapModule } from '../lldap/lldap.module';
import { MailModule } from '../mail/mail.module';
import { AccountDeleteRequest } from './account-delete-request.entity';
import { AccountController } from './account.controller';
import { EmailChangeRequest } from './email-change-request.entity';
@Module({
imports: [
TypeOrmModule.forFeature([EmailChangeRequest, AccountDeleteRequest]),
AuthModule,
LldapModule,
MailModule,
AuditModule,
],
controllers: [AccountController],
exports: [TypeOrmModule],
})
export class AccountModule {}

View File

@@ -0,0 +1,7 @@
import { IsString, Length } from 'class-validator';
export class ConfirmEmailChangeDto {
@IsString()
@Length(20, 256)
token!: string;
}

View File

@@ -0,0 +1,8 @@
import { IsOptional, IsString, Length } from 'class-validator';
export class DeleteRequestDto {
@IsString()
@Length(0, 1000)
@IsOptional()
reason?: string;
}

View File

@@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';
export class RequestEmailChangeDto {
@IsEmail()
newEmail!: string;
}

View File

@@ -0,0 +1,22 @@
import { IsOptional, IsString, Length } from 'class-validator';
export class UpdateProfileDto {
@IsString()
@Length(1, 128)
@IsOptional()
displayName?: string;
@IsString()
@Length(0, 128)
@IsOptional()
firstName?: string;
@IsString()
@Length(0, 128)
@IsOptional()
lastName?: string;
@IsString()
@IsOptional()
avatar?: string;
}

View File

@@ -0,0 +1,25 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity({ name: 'email_change_requests' })
export class EmailChangeRequest {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
username!: string;
@Column()
newEmail!: string;
@Column({ unique: true })
tokenHash!: string;
@Column()
expiresAt!: Date;
@Column({ nullable: true })
consumedAt?: Date;
@CreateDateColumn()
createdAt!: Date;
}