name beim kopieren

This commit is contained in:
Bastian Wagner
2026-06-09 16:03:20 +02:00
parent 06c5b1768e
commit 16605c961f
18 changed files with 693 additions and 44 deletions

View File

@@ -4,6 +4,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module';
import { ListTemplatesModule } from './list-templates/list-templates.module';
import { ListsModule } from './lists/lists.module';
@@ -50,6 +51,7 @@ import { DatabaseLogger } from './database/database.logger';
},
}),
EventEmitterModule.forRoot(),
AuditModule,
AuthModule,
MailModule,
ListsModule,

View File

@@ -0,0 +1,42 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity('audit_logs')
export class AuditLogEntity {
@PrimaryColumn({ type: 'varchar', length: 36 })
id!: string;
@Index('IDX_audit_logs_actor_user_id')
@Column({ type: 'varchar', length: 36, nullable: true })
actorUserId?: string | null;
@Column({ type: 'varchar', length: 320, nullable: true })
actorEmail?: string | null;
@Index('IDX_audit_logs_action')
@Column({ type: 'varchar', length: 100 })
action!: string;
@Index('IDX_audit_logs_entity')
@Column({ type: 'varchar', length: 80, nullable: true })
entityType?: string | null;
@Column({ type: 'varchar', length: 36, nullable: true })
entityId?: string | null;
@Column({ type: 'json', nullable: true })
metadata?: Record<string, unknown> | null;
@Column({ type: 'varchar', length: 64, nullable: true })
ipAddress?: string | null;
@Column({ type: 'varchar', length: 512, nullable: true })
userAgent?: string | null;
@Index('IDX_audit_logs_created_at')
@CreateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
})
createdAt!: Date;
}

View File

@@ -0,0 +1,70 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm';
import { AuditLogEntity } from './audit-log.entity';
import type { AuditLogInput } from './audit-log.types';
const SENSITIVE_KEY_PATTERN = /password|token|secret|authorization|cookie|hash/i;
@Injectable()
export class AuditLogService {
private readonly logger = new Logger(AuditLogService.name);
constructor(
@InjectRepository(AuditLogEntity)
private readonly auditLogsRepository: Repository<AuditLogEntity>,
) {}
async record(input: AuditLogInput): Promise<void> {
try {
await this.auditLogsRepository.save(
this.auditLogsRepository.create({
id: randomUUID(),
actorUserId: input.actorUserId ?? null,
actorEmail: input.actorEmail ?? null,
action: input.action,
entityType: input.entityType ?? null,
entityId: input.entityId ?? null,
metadata: input.metadata ? this.sanitizeValue(input.metadata) : null,
ipAddress: input.ipAddress ?? null,
userAgent: input.userAgent ?? null,
}),
);
} catch (error) {
this.logger.error(
`Audit log could not be stored for action ${input.action}.`,
error instanceof Error ? error.stack : undefined,
);
}
}
private sanitizeValue(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { value: this.sanitizeNestedValue(value) };
}
return this.sanitizeNestedValue(value) as Record<string, unknown>;
}
private sanitizeNestedValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((entry) => this.sanitizeNestedValue(entry));
}
if (value instanceof Date) {
return value.toISOString();
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
key,
SENSITIVE_KEY_PATTERN.test(key) ? '[redacted]' : this.sanitizeNestedValue(entry),
]),
);
}
return value;
}
}

View File

@@ -0,0 +1,35 @@
export type AuditAction =
| 'user.registered'
| 'user.email_verified'
| 'user.verification_resent'
| 'user.login_succeeded'
| 'user.login_failed'
| 'user.token_refreshed'
| 'user.onboarding_updated'
| 'template.created'
| 'template.updated'
| 'template.deleted'
| 'template.item_created'
| 'template.item_updated'
| 'template.item_deleted'
| 'template.items_reordered'
| 'list.created'
| 'list.created_from_template'
| 'list.updated'
| 'list.deleted'
| 'list.item_created'
| 'list.item_updated'
| 'list.item_checked'
| 'list.item_unchecked'
| 'list.item_deleted';
export interface AuditLogInput {
actorUserId?: string | null;
actorEmail?: string | null;
action: AuditAction;
entityType?: string | null;
entityId?: string | null;
metadata?: Record<string, unknown> | null;
ipAddress?: string | null;
userAgent?: string | null;
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditLogEntity } from './audit-log.entity';
import { AuditLogService } from './audit-log.service';
@Module({
imports: [TypeOrmModule.forFeature([AuditLogEntity])],
providers: [AuditLogService],
exports: [AuditLogService],
})
export class AuditModule {}

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { AuthController } from './auth.controller';
import { RefreshTokenEntity } from './refresh-token.entity';
import { AuthService } from './auth.service';
@@ -8,7 +9,11 @@ import { JwtAuthGuard } from './jwt-auth.guard';
import { UserEntity } from './user.entity';
@Module({
imports: [JwtModule.register({}), TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity])],
imports: [
AuditModule,
JwtModule.register({}),
TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]),
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard],
exports: [AuthService, JwtAuthGuard],

View File

@@ -2,6 +2,7 @@ import {
BadRequestException,
ConflictException,
Injectable,
Optional,
UnauthorizedException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
@@ -9,6 +10,7 @@ import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
import { Repository } from 'typeorm';
import { AuditLogService } from '../audit/audit-log.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@@ -39,6 +41,8 @@ export class AuthService {
private readonly usersRepository: Repository<UserEntity>,
@InjectRepository(RefreshTokenEntity)
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
@Optional()
private readonly auditLogService?: AuditLogService,
) {}
async register(
@@ -67,6 +71,15 @@ export class AuthService {
});
const savedUser = await this.usersRepository.save(user);
await this.auditLogService?.record({
actorUserId: savedUser.id,
actorEmail: savedUser.email,
action: 'user.registered',
entityType: 'user',
entityId: savedUser.id,
metadata: { verified: savedUser.verified },
});
this.eventEmitter.emit(AppEvents.UserRegistered, {
email,
verificationUrl: this.createVerificationUrl(verificationToken),
@@ -98,10 +111,18 @@ export class AuthService {
try {
const savedUser = await this.usersRepository.save(user);
return {
message: 'Email verified successfully.',
user: this.toPublicUser(savedUser),
};
await this.auditLogService?.record({
actorUserId: savedUser.id,
actorEmail: savedUser.email,
action: 'user.email_verified',
entityType: 'user',
entityId: savedUser.id,
});
return {
message: 'Email verified successfully.',
user: this.toPublicUser(savedUser),
};
} catch {
throw new BadRequestException('user not saved.')
}
@@ -122,6 +143,14 @@ export class AuthService {
user.verificationToken = this.createToken();
const savedUser = await this.usersRepository.save(user);
await this.auditLogService?.record({
actorUserId: savedUser.id,
actorEmail: savedUser.email,
action: 'user.verification_resent',
entityType: 'user',
entityId: savedUser.id,
});
this.eventEmitter.emit(AppEvents.UserRegistered, {
email: savedUser.email,
verificationUrl: this.createVerificationUrl(savedUser.verificationToken!),
@@ -136,17 +165,42 @@ export class AuthService {
const user = await this.usersRepository.findOne({ where: { email } });
if (!user || !this.passwordMatches(password, user.passwordHash)) {
await this.auditLogService?.record({
actorEmail: email,
action: 'user.login_failed',
entityType: 'user',
entityId: user?.id,
metadata: { reason: 'invalid_credentials' },
});
throw new UnauthorizedException('Invalid email or password.');
}
if (!user.verified) {
await this.auditLogService?.record({
actorUserId: user.id,
actorEmail: user.email,
action: 'user.login_failed',
entityType: 'user',
entityId: user.id,
metadata: { reason: 'email_not_verified' },
});
throw new UnauthorizedException('Please verify your email before login.');
}
return {
const response = {
...(await this.createAuthTokens(user)),
user: this.toPublicUser(user),
};
await this.auditLogService?.record({
actorUserId: user.id,
actorEmail: user.email,
action: 'user.login_succeeded',
entityType: 'user',
entityId: user.id,
});
return response;
}
async refresh(refreshTokenDto: RefreshTokenDto = {}): Promise<AuthTokenResponse> {
@@ -175,10 +229,20 @@ export class AuthService {
await this.refreshTokensRepository.delete({ jti: payload.jti });
return {
const response = {
...(await this.createAuthTokens(user)),
user: this.toPublicUser(user),
};
await this.auditLogService?.record({
actorUserId: user.id,
actorEmail: user.email,
action: 'user.token_refreshed',
entityType: 'user',
entityId: user.id,
});
return response;
}
async verifyAccessToken(accessToken: string): Promise<JwtTokenPayload> {
@@ -243,7 +307,18 @@ export class AuthService {
user.onboardingCompleted = completed;
return this.toPublicUser(await this.usersRepository.save(user));
const savedUser = await this.usersRepository.save(user);
await this.auditLogService?.record({
actorUserId: savedUser.id,
actorEmail: savedUser.email,
action: 'user.onboarding_updated',
entityType: 'user',
entityId: savedUser.id,
metadata: { completed },
});
return this.toPublicUser(savedUser);
}
private normalizeEmail(email?: string): string {

View File

@@ -1,6 +1,7 @@
import 'dotenv/config';
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { AuditLogEntity } from '../audit/audit-log.entity';
import { UserEntity } from '../auth/user.entity';
import { RefreshTokenEntity } from '../auth/refresh-token.entity';
import { ListTemplateEntity } from '../list-templates/list-template.entity';
@@ -27,6 +28,7 @@ export default new DataSource({
logger: new DatabaseLogger(databaseLoggerOptionsFromEnv(process.env)),
maxQueryExecutionTime: slowQueryThresholdFromEnv(process.env),
entities: [
AuditLogEntity,
UserEntity,
RefreshTokenEntity,
ListTemplateEntity,

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateAuditLogs1781200000000 implements MigrationInterface {
name = 'CreateAuditLogs1781200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
if (await queryRunner.hasTable('audit_logs')) {
return;
}
await queryRunner.query(`
CREATE TABLE \`audit_logs\` (
\`id\` varchar(36) NOT NULL,
\`actorUserId\` varchar(36) NULL,
\`actorEmail\` varchar(320) NULL,
\`action\` varchar(100) NOT NULL,
\`entityType\` varchar(80) NULL,
\`entityId\` varchar(36) NULL,
\`metadata\` json NULL,
\`ipAddress\` varchar(64) NULL,
\`userAgent\` varchar(512) NULL,
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX \`IDX_audit_logs_actor_user_id\` (\`actorUserId\`),
INDEX \`IDX_audit_logs_action\` (\`action\`),
INDEX \`IDX_audit_logs_entity\` (\`entityType\`),
INDEX \`IDX_audit_logs_created_at\` (\`createdAt\`),
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
if (await queryRunner.hasTable('audit_logs')) {
await queryRunner.query('DROP TABLE `audit_logs`');
}
}
}

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { ListsModule } from '../lists/lists.module';
import { ListTemplatesController } from './list-templates.controller';
@@ -10,6 +11,7 @@ import { TemplateSeedEntity } from './template-seed.entity';
@Module({
imports: [
AuditModule,
AuthModule,
ListsModule,
TypeOrmModule.forFeature([

View File

@@ -3,10 +3,12 @@ import {
ForbiddenException,
Injectable,
NotFoundException,
Optional,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm';
import { AuditLogService } from '../audit/audit-log.service';
import {
AddListTemplateItemDto,
ReorderListTemplateItemsDto,
@@ -35,6 +37,8 @@ export class ListTemplatesService {
private readonly templateItemsRepository: Repository<ListTemplateItemEntity>,
@InjectRepository(TemplateSeedEntity)
private readonly templateSeedsRepository: Repository<TemplateSeedEntity>,
@Optional()
private readonly auditLogService?: AuditLogService,
) {}
async createTemplate(
@@ -50,7 +54,21 @@ export class ListTemplatesService {
items: this.createTemplateItems(createDto.items ?? []),
});
return this.toListTemplate(await this.templatesRepository.save(template));
const savedTemplate = await this.templatesRepository.save(template);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'template.created',
entityType: 'template',
entityId: savedTemplate.id,
metadata: {
name: savedTemplate.name,
kind: savedTemplate.kind,
itemCount: savedTemplate.items?.length ?? 0,
},
});
return this.toListTemplate(savedTemplate);
}
async listTemplates(ownerId: string): Promise<ListTemplate[]> {
@@ -99,7 +117,25 @@ export class ListTemplatesService {
});
}
return this.toListTemplate(await this.templatesRepository.save(template));
const savedTemplate = await this.templatesRepository.save(template);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'template.updated',
entityType: 'template',
entityId: savedTemplate.id,
metadata: {
changedFields: Object.keys(updateDto).filter(
(field) =>
updateDto[field as keyof UpdateListTemplateDto] !== undefined,
),
name: savedTemplate.name,
kind: savedTemplate.kind,
itemCount: savedTemplate.items?.length ?? 0,
},
});
return this.toListTemplate(savedTemplate);
}
async deleteTemplate(
@@ -107,8 +143,21 @@ export class ListTemplatesService {
templateId: string,
): Promise<{ message: string }> {
const template = await this.findOwnedTemplate(ownerId, templateId);
const metadata = {
name: template.name,
kind: template.kind,
itemCount: template.items.length,
};
await this.templatesRepository.remove(template);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'template.deleted',
entityType: 'template',
entityId: templateId,
metadata,
});
return { message: 'List template deleted.' };
}
@@ -125,6 +174,19 @@ export class ListTemplatesService {
template.items.push(savedItem);
await this.templatesRepository.save(template);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'template.item_created',
entityType: 'template_item',
entityId: savedItem.id,
metadata: {
templateId,
title: savedItem.title,
required: savedItem.required,
position: savedItem.position,
},
});
return this.getTemplate(ownerId, templateId);
}
@@ -156,6 +218,21 @@ export class ListTemplatesService {
await this.templateItemsRepository.save(item);
await this.templatesRepository.save(template);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'template.item_updated',
entityType: 'template_item',
entityId: item.id,
metadata: {
templateId,
title: item.title,
changedFields: Object.keys(updateDto).filter(
(field) =>
updateDto[field as keyof UpdateListTemplateItemDto] !== undefined,
),
},
});
return this.getTemplate(ownerId, templateId);
}
@@ -195,6 +272,16 @@ export class ListTemplatesService {
await this.templateItemsRepository.save(reorderedItems);
await this.templatesRepository.save(template);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'template.items_reordered',
entityType: 'template',
entityId: templateId,
metadata: {
itemIds,
},
});
return this.getTemplate(ownerId, templateId);
}
@@ -219,6 +306,18 @@ export class ListTemplatesService {
await this.templateItemsRepository.save(template.items);
await this.templatesRepository.save(template);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'template.item_deleted',
entityType: 'template_item',
entityId: itemId,
metadata: {
templateId,
title: itemToDelete.title,
position: itemToDelete.position,
},
});
return this.getTemplate(ownerId, templateId);
}

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { ListsController } from './lists.controller';
import { ListsService } from './lists.service';
@@ -7,7 +8,11 @@ import { UserListEntity } from './user-list.entity';
import { UserListItemEntity } from './user-list-item.entity';
@Module({
imports: [AuthModule, TypeOrmModule.forFeature([UserListEntity, UserListItemEntity])],
imports: [
AuditModule,
AuthModule,
TypeOrmModule.forFeature([UserListEntity, UserListItemEntity]),
],
controllers: [ListsController],
providers: [ListsService],
exports: [ListsService],

View File

@@ -3,10 +3,12 @@ import {
ForbiddenException,
Injectable,
NotFoundException,
Optional,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm';
import { AuditLogService } from '../audit/audit-log.service';
import {
ListTemplate,
ListTemplateKind,
@@ -27,6 +29,8 @@ export class ListsService {
private readonly listsRepository: Repository<UserListEntity>,
@InjectRepository(UserListItemEntity)
private readonly listItemsRepository: Repository<UserListItemEntity>,
@Optional()
private readonly auditLogService?: AuditLogService,
) {}
async createList(ownerId: string, createDto: CreateListDto): Promise<UserList> {
@@ -39,7 +43,20 @@ export class ListsService {
items: [],
});
return this.toUserList(await this.listsRepository.save(list));
const savedList = await this.listsRepository.save(list);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'list.created',
entityType: 'list',
entityId: savedList.id,
metadata: {
name: savedList.name,
kind: savedList.kind,
},
});
return this.toUserList(savedList);
}
async createListFromTemplate(
@@ -75,7 +92,23 @@ export class ListsService {
),
});
return this.toUserList(await this.listsRepository.save(list));
const savedList = await this.listsRepository.save(list);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'list.created_from_template',
entityType: 'list',
entityId: savedList.id,
metadata: {
name: savedList.name,
kind: savedList.kind,
sourceTemplateId: template.id,
sourceTemplateName: template.name,
itemCount: savedList.items?.length ?? 0,
},
});
return this.toUserList(savedList);
}
async listLists(ownerId: string): Promise<UserList[]> {
@@ -111,13 +144,42 @@ export class ListsService {
list.kind = this.normalizeKind(updateDto.kind);
}
return this.toUserList(await this.listsRepository.save(list));
const savedList = await this.listsRepository.save(list);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'list.updated',
entityType: 'list',
entityId: savedList.id,
metadata: {
changedFields: Object.keys(updateDto).filter(
(field) => updateDto[field as keyof UpdateListDto] !== undefined,
),
name: savedList.name,
kind: savedList.kind,
},
});
return this.toUserList(savedList);
}
async deleteList(ownerId: string, listId: string): Promise<{ message: string }> {
const list = await this.findOwnedList(ownerId, listId);
const metadata = {
name: list.name,
kind: list.kind,
itemCount: list.items.length,
};
await this.listsRepository.remove(list);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'list.deleted',
entityType: 'list',
entityId: listId,
metadata,
});
return { message: 'List deleted.' };
}
@@ -134,6 +196,19 @@ export class ListsService {
list.items.push(savedItem);
await this.listsRepository.save(list);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'list.item_created',
entityType: 'list_item',
entityId: savedItem.id,
metadata: {
listId,
title: savedItem.title,
required: savedItem.required,
position: savedItem.position,
},
});
return this.getList(ownerId, listId);
}
@@ -146,6 +221,7 @@ export class ListsService {
): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId);
const item = this.findListItem(list, itemId);
const wasChecked = item.checked;
if (updateDto.title !== undefined) {
item.title = this.requireItemTitle(updateDto.title);
@@ -180,6 +256,29 @@ export class ListsService {
await this.listItemsRepository.save(item);
await this.listsRepository.save(list);
await this.auditLogService?.record({
actorUserId: ownerId,
action:
updateDto.checked === true
? 'list.item_checked'
: updateDto.checked === false
? 'list.item_unchecked'
: 'list.item_updated',
entityType: 'list_item',
entityId: item.id,
metadata: {
listId,
title: item.title,
changedFields: Object.keys(updateDto).filter(
(field) => updateDto[field as keyof UpdateListItemDto] !== undefined,
),
previousChecked: wasChecked,
checked: item.checked,
checkedAt: item.checkedAt,
checkedByName: item.checkedByName,
},
});
return this.getList(ownerId, listId);
}
@@ -204,6 +303,18 @@ export class ListsService {
await this.listItemsRepository.save(list.items);
await this.listsRepository.save(list);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'list.item_deleted',
entityType: 'list_item',
entityId: itemId,
metadata: {
listId,
title: itemToDelete.title,
position: itemToDelete.position,
},
});
return this.getList(ownerId, listId);
}

View File

@@ -0,0 +1,30 @@
<h2 mat-dialog-title>Liste aus Template erstellen</h2>
<form [formGroup]="form" (ngSubmit)="submit()">
<mat-dialog-content>
<p class="dialog-copy">
Vergib einen individuellen Namen für die neue Liste.
</p>
<mat-form-field appearance="outline">
<mat-label>Listenname</mat-label>
<input matInput formControlName="name" autocomplete="off" />
@if (form.controls.name.hasError('required')) {
<mat-error>Listenname ist erforderlich.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Beschreibung</mat-label>
<textarea matInput formControlName="description" rows="3"></textarea>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" mat-dialog-close>Abbrechen</button>
<button mat-flat-button type="submit">
<mat-icon aria-hidden="true">content_copy</mat-icon>
Als Liste erstellen
</button>
</mat-dialog-actions>
</form>

View File

@@ -0,0 +1,14 @@
.dialog-copy {
margin: 0 0 1rem;
color: var(--mat-sys-on-surface-variant);
}
mat-form-field {
width: 100%;
}
mat-dialog-content {
display: grid;
gap: 0.75rem;
min-width: min(100vw - 64px, 360px);
}

View File

@@ -0,0 +1,60 @@
import { Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
export interface CopyTemplateDialogData {
templateName: string;
templateDescription?: string;
}
export interface CopyTemplateDialogResult {
name: string;
description?: string;
}
@Component({
selector: 'app-copy-template-dialog',
imports: [
ReactiveFormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
],
templateUrl: './copy-template-dialog.component.html',
styleUrl: './copy-template-dialog.component.scss',
})
export class CopyTemplateDialogComponent {
protected readonly data = inject<CopyTemplateDialogData>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(
MatDialogRef<CopyTemplateDialogComponent, CopyTemplateDialogResult>,
);
private readonly formBuilder = inject(NonNullableFormBuilder);
protected readonly form = this.formBuilder.group({
name: [this.data.templateName, [Validators.required]],
description: [this.data.templateDescription ?? ''],
});
protected submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const value = this.form.getRawValue();
this.dialogRef.close({
name: value.name.trim(),
description: value.description.trim() || undefined,
});
}
}

View File

@@ -15,6 +15,10 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { getAuthErrorMessage } from '../../auth/error-message';
import { OnboardingService } from '../../onboarding/onboarding.service';
import { ConfirmDeleteDialogComponent } from '../confirm-delete-dialog/confirm-delete-dialog.component';
import {
CopyTemplateDialogComponent,
CopyTemplateDialogResult,
} from '../copy-template-dialog/copy-template-dialog.component';
import { ListTemplate } from '../templates.models';
import { TemplatesService } from '../templates.service';
@@ -239,27 +243,48 @@ export class TemplateDetailComponent implements OnInit {
protected copyTemplateToList(): void {
const templateId = this.templateId();
const template = this.template();
if (!templateId || !this.canEditItems() || this.copyingTemplate()) {
if (!templateId || !template || !this.canEditItems() || this.copyingTemplate()) {
return;
}
this.copyingTemplate.set(true);
this.dialog
.open<
CopyTemplateDialogComponent,
{ templateName: string; templateDescription?: string },
CopyTemplateDialogResult
>(CopyTemplateDialogComponent, {
data: {
templateName: template.name,
templateDescription: template.description,
},
maxWidth: '480px',
width: 'calc(100vw - 32px)',
})
.afterClosed()
.subscribe((result) => {
if (!result) {
return;
}
this.templatesService
.createListFromTemplate(templateId)
.pipe(finalize(() => this.copyingTemplate.set(false)))
.subscribe({
next: (list) => {
this.snackBar.open('Liste aus Template erstellt.', 'OK', {
duration: 3000,
this.copyingTemplate.set(true);
this.templatesService
.createListFromTemplate(templateId, result)
.pipe(finalize(() => this.copyingTemplate.set(false)))
.subscribe({
next: (list) => {
this.snackBar.open('Liste aus Template erstellt.', 'OK', {
duration: 3000,
});
this.onboarding.templateCopiedToList(list.id);
void this.router.navigateByUrl('/lists');
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
this.onboarding.templateCopiedToList(list.id);
void this.router.navigateByUrl('/lists');
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}

View File

@@ -11,6 +11,10 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { getAuthErrorMessage } from '../auth/error-message';
import { OnboardingService } from '../onboarding/onboarding.service';
import { ConfirmDeleteDialogComponent } from './confirm-delete-dialog/confirm-delete-dialog.component';
import {
CopyTemplateDialogComponent,
CopyTemplateDialogResult,
} from './copy-template-dialog/copy-template-dialog.component';
import { ListTemplate, ListTemplateKind } from './templates.models';
import { TemplatesService } from './templates.service';
@@ -79,23 +83,43 @@ export class TemplatesComponent implements OnInit {
return;
}
this.copyingTemplateId.set(template.id);
this.dialog
.open<
CopyTemplateDialogComponent,
{ templateName: string; templateDescription?: string },
CopyTemplateDialogResult
>(CopyTemplateDialogComponent, {
data: {
templateName: template.name,
templateDescription: template.description,
},
maxWidth: '480px',
width: 'calc(100vw - 32px)',
})
.afterClosed()
.subscribe((result) => {
if (!result) {
return;
}
this.templatesService
.createListFromTemplate(template.id)
.pipe(finalize(() => this.copyingTemplateId.set(null)))
.subscribe({
next: () => {
this.snackBar.open('Liste aus Template erstellt.', 'OK', {
duration: 3000,
this.copyingTemplateId.set(template.id);
this.templatesService
.createListFromTemplate(template.id, result)
.pipe(finalize(() => this.copyingTemplateId.set(null)))
.subscribe({
next: () => {
this.snackBar.open('Liste aus Template erstellt.', 'OK', {
duration: 3000,
});
void this.router.navigateByUrl('/lists');
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
duration: 5000,
});
},
});
void this.router.navigateByUrl('/lists');
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
duration: 5000,
});
},
});
}