feat: Profilseite, Etikettendruck mit QR-Code, mehr Automatisierungen und Entra-ID-SSO
- Profilseite mit MFA-Einrichtung, Passwort/E-Mail/Name aendern, Konto deaktivieren - Bearbeiten-Buttons fuer Bienenstaende/Voelker/Koeniginnen/Aufgaben-Datensaetze - Restliche englische Enum-Werte durch deutsche Labels ersetzt (SelectValue-Fix) - Sidebar-Eintraege fuer Ernten/Schleuderungen ergaenzt - Automatisierungen von 3 auf 6 Regeln erweitert (Schwarmstimmung, kritischer Gesundheitsstatus, Lagerbestand) - Responsive Grid-Layout fuer Volk-/Koeniginnen-/Honigchargen-/Bienenstand-Detailseiten - Etikettenvorlagen mit Platzhaltern inkl. QR-Code (verweist auf Herkunftsseite) und PDF-Druck - Entra-ID-SSO (Authorization Code + PKCE, kein Auto-Provisioning, Secrets nur ueber Environment-Variablen)
This commit is contained in:
1 parent
0a52b29c57
commit
8432313fa9
83 files changed
+2684
-289
No files matched your search
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class DeactivateAccountDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
currentPassword!: string;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { SENSITIVE_ACTION_THROTTLE } from '../auth/util/throttle';
|
||||
import { isAllowedAvatarMimeType } from './avatar-mime-types';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { DeactivateAccountDto } from './dto/deactivate-account.dto';
|
||||
import { DisableMfaDto } from './dto/disable-mfa.dto';
|
||||
import { MfaConfirmDto } from './dto/mfa-confirm.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
@@ -64,6 +65,16 @@ export class ProfileController {
|
||||
);
|
||||
}
|
||||
|
||||
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||
@Delete()
|
||||
@HttpCode(204)
|
||||
deactivateAccount(
|
||||
@Body() dto: DeactivateAccountDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.profileService.deactivateAccount(actor.id, dto.currentPassword);
|
||||
}
|
||||
|
||||
@Post('avatar')
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MfaModule } from '../auth/mfa/mfa.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { ProfileController } from './profile.controller';
|
||||
import { ProfileService } from './profile.service';
|
||||
|
||||
@Module({
|
||||
imports: [MfaModule],
|
||||
imports: [MfaModule, UsersModule],
|
||||
controllers: [ProfileController],
|
||||
providers: [ProfileService],
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { MfaService } from '../auth/mfa/mfa.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
jest.mock('node:fs/promises');
|
||||
|
||||
@@ -29,6 +30,7 @@ describe('ProfileService', () => {
|
||||
confirmEnrollment: jest.Mock;
|
||||
};
|
||||
let permissionResolver: { invalidateUser: jest.Mock };
|
||||
let usersService: { deactivate: jest.Mock };
|
||||
|
||||
const baseUser = {
|
||||
id: 'user-1',
|
||||
@@ -52,11 +54,13 @@ describe('ProfileService', () => {
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
mfaService = { startEnrollment: jest.fn(), confirmEnrollment: jest.fn() };
|
||||
permissionResolver = { invalidateUser: jest.fn() };
|
||||
usersService = { deactivate: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new ProfileService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
mfaService as unknown as MfaService,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
usersService as unknown as UsersService,
|
||||
);
|
||||
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
@@ -155,6 +159,24 @@ describe('ProfileService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deactivateAccount', () => {
|
||||
it('throws UnauthorizedException when the current password is wrong', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||
await expect(
|
||||
service.deactivateAccount('user-1', 'wrong-password'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(usersService.deactivate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deactivates the user via UsersService when the password matches', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||
|
||||
await service.deactivateAccount('user-1', 'correct-password');
|
||||
|
||||
expect(usersService.deactivate).toHaveBeenCalledWith('user-1', 'user-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAvatar', () => {
|
||||
const file = {
|
||||
buffer: Buffer.from('binary'),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import {
|
||||
ALLOWED_AVATAR_MIME_TYPES,
|
||||
mimeTypeForExtension,
|
||||
@@ -41,6 +42,7 @@ export class ProfileService {
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly mfaService: MfaService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
private async requireUser(userId: string) {
|
||||
@@ -124,6 +126,21 @@ export class ProfileService {
|
||||
});
|
||||
}
|
||||
|
||||
async deactivateAccount(
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
): Promise<void> {
|
||||
const user = await this.requireUser(userId);
|
||||
const currentMatches = await bcrypt.compare(
|
||||
currentPassword,
|
||||
user.passwordHash,
|
||||
);
|
||||
if (!currentMatches) {
|
||||
throw new UnauthorizedException('Aktuelles Passwort ist falsch');
|
||||
}
|
||||
await this.usersService.deactivate(userId, userId);
|
||||
}
|
||||
|
||||
async uploadAvatar(userId: string, file: Express.Multer.File): Promise<void> {
|
||||
if (!(file.mimetype in ALLOWED_AVATAR_MIME_TYPES)) {
|
||||
throw new BadRequestException('Nicht unterstützter Dateityp');
|
||||
|
||||
Reference in new issue
Block a user