verifizierung
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
FROM node:22-alpine AS api-builder
|
FROM node:22-alpine AS build-base
|
||||||
|
RUN npm install -g npm@11.16.0 --no-audit --no-fund
|
||||||
|
|
||||||
|
FROM build-base AS api-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY listify-api/package*.json ./
|
COPY listify-api/package*.json ./
|
||||||
RUN npm ci --no-audit --no-fund
|
RUN npm ci --no-audit --no-fund
|
||||||
COPY listify-api/ ./
|
COPY listify-api/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS web-builder
|
FROM build-base AS web-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY listify-client/package*.json ./
|
COPY listify-client/package*.json ./
|
||||||
RUN npm ci --no-audit --no-fund
|
RUN npm ci --no-audit --no-fund
|
||||||
@@ -22,6 +25,7 @@ ENV PORT=3000
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=api-builder /app/dist ./dist
|
COPY --from=api-builder /app/dist ./dist
|
||||||
|
COPY --from=api-builder /app/src/mail/templates ./dist/mail/templates
|
||||||
|
|
||||||
COPY --from=web-builder /app/dist/listify-client/browser /usr/share/nginx/html
|
COPY --from=web-builder /app/dist/listify-client/browser /usr/share/nginx/html
|
||||||
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
|
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
|
||||||
|
|||||||
@@ -14,3 +14,12 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
|||||||
|
|
||||||
# Browser-URL, unter der der Container erreichbar ist.
|
# Browser-URL, unter der der Container erreichbar ist.
|
||||||
CLIENT_URL=http://localhost:8080
|
CLIENT_URL=http://localhost:8080
|
||||||
|
|
||||||
|
MAIL_ENABLED=true
|
||||||
|
SMTP_HOST=host.docker.internal
|
||||||
|
SMTP_PORT=1025
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
MAIL_FROM=no-reply@listify.local
|
||||||
|
MAIL_FROM_NAME=Listify
|
||||||
|
|||||||
@@ -11,3 +11,12 @@ JWT_ACCESS_SECRET=change-me-access-secret
|
|||||||
JWT_REFRESH_SECRET=change-me-refresh-secret
|
JWT_REFRESH_SECRET=change-me-refresh-secret
|
||||||
|
|
||||||
CLIENT_URL=http://localhost:4200
|
CLIENT_URL=http://localhost:4200
|
||||||
|
|
||||||
|
MAIL_ENABLED=true
|
||||||
|
SMTP_HOST=localhost
|
||||||
|
SMTP_PORT=1025
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
MAIL_FROM=no-reply@listify.local
|
||||||
|
MAIL_FROM_NAME=Listify
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true,
|
"deleteOutDir": true,
|
||||||
"webpack": true,
|
"webpack": true,
|
||||||
"webpackConfigPath": "webpack.config.js"
|
"webpackConfigPath": "webpack.config.js",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"include": "mail/templates/**/*",
|
||||||
|
"outDir": "dist"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"watchAssets": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3692
listify-api/package-lock.json
generated
3692
listify-api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nestjs-modules/mailer": "^2.3.6",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
@@ -31,8 +32,10 @@
|
|||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
|
"handlebars": "^4.7.9",
|
||||||
"helmet": "^8.2.0",
|
"helmet": "^8.2.0",
|
||||||
"mysql2": "^3.22.5",
|
"mysql2": "^3.22.5",
|
||||||
|
"nodemailer": "^8.0.10",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { AuthenticatedRequest } from './auth.types';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
|
import { ResendVerificationDto } from './dto/resend-verification.dto';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
@@ -31,6 +32,12 @@ export class AuthController {
|
|||||||
return this.authService.verifyEmail(token);
|
return this.authService.verifyEmail(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('resend-verification')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
resendVerification(@Body() resendVerificationDto: ResendVerificationDto) {
|
||||||
|
return this.authService.resendVerificationEmail(resendVerificationDto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
login(@Body() loginDto: LoginDto) {
|
login(@Body() loginDto: LoginDto) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Repository } from 'typeorm';
|
|||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
|
import { ResendVerificationDto } from './dto/resend-verification.dto';
|
||||||
import {
|
import {
|
||||||
AuthTokenResponse,
|
AuthTokenResponse,
|
||||||
AuthTokens,
|
AuthTokens,
|
||||||
@@ -106,6 +107,29 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resendVerificationEmail(
|
||||||
|
resendVerificationDto: ResendVerificationDto,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const email = this.normalizeEmail(resendVerificationDto.email);
|
||||||
|
const message =
|
||||||
|
'Falls ein unverifiziertes Konto mit dieser E-Mail existiert, wurde eine neue Verifizierungsmail versendet.';
|
||||||
|
const user = await this.usersRepository.findOne({ where: { email } });
|
||||||
|
|
||||||
|
if (!user || user.verified) {
|
||||||
|
return { message };
|
||||||
|
}
|
||||||
|
|
||||||
|
user.verificationToken = this.createToken();
|
||||||
|
const savedUser = await this.usersRepository.save(user);
|
||||||
|
|
||||||
|
this.eventEmitter.emit(AppEvents.UserRegistered, {
|
||||||
|
email: savedUser.email,
|
||||||
|
verificationUrl: this.createVerificationUrl(savedUser.verificationToken!),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message };
|
||||||
|
}
|
||||||
|
|
||||||
async login(loginDto: LoginDto): Promise<AuthTokenResponse> {
|
async login(loginDto: LoginDto): Promise<AuthTokenResponse> {
|
||||||
const email = this.normalizeEmail(loginDto.email);
|
const email = this.normalizeEmail(loginDto.email);
|
||||||
const password = this.requirePassword(loginDto.password);
|
const password = this.requirePassword(loginDto.password);
|
||||||
|
|||||||
3
listify-api/src/auth/dto/resend-verification.dto.ts
Normal file
3
listify-api/src/auth/dto/resend-verification.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export class ResendVerificationDto {
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
@@ -258,11 +258,11 @@ export class ListTemplatesService {
|
|||||||
{
|
{
|
||||||
name: 'Urlaub',
|
name: 'Urlaub',
|
||||||
description:
|
description:
|
||||||
'Grundlage fuer Packlisten wie Sommerurlaub oder Staedtetrip.',
|
'Grundlage für Packlisten wie Sommerurlaub oder Städtetrip.',
|
||||||
kind: 'packing' as const,
|
kind: 'packing' as const,
|
||||||
items: [
|
items: [
|
||||||
{ title: 'Pass oder Ausweis' },
|
{ title: 'Pass oder Ausweis' },
|
||||||
{ title: 'Tickets und Buchungsbestaetigungen' },
|
{ title: 'Tickets und Buchungsbestätigungen' },
|
||||||
{ title: 'Ladegeraete' },
|
{ title: 'Ladegeraete' },
|
||||||
{ title: 'Reiseapotheke' },
|
{ title: 'Reiseapotheke' },
|
||||||
{ title: 'Sonnencreme', required: false },
|
{ title: 'Sonnencreme', required: false },
|
||||||
@@ -270,7 +270,7 @@ export class ListTemplatesService {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Wocheneinkauf',
|
name: 'Wocheneinkauf',
|
||||||
description: 'Basis fuer wiederkehrende Einkaufslisten.',
|
description: 'Basis für wiederkehrende Einkaufslisten.',
|
||||||
kind: 'shopping' as const,
|
kind: 'shopping' as const,
|
||||||
items: [
|
items: [
|
||||||
{ title: 'Milch' },
|
{ title: 'Milch' },
|
||||||
@@ -282,7 +282,7 @@ export class ListTemplatesService {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Wochenplanung',
|
name: 'Wochenplanung',
|
||||||
description: 'Einfache Todo-Vorlage fuer die persoenliche Planung.',
|
description: 'Einfache Todo-Vorlage für die persönliche Planung.',
|
||||||
kind: 'todo' as const,
|
kind: 'todo' as const,
|
||||||
items: [
|
items: [
|
||||||
{ title: 'Prioritaeten festlegen' },
|
{ title: 'Prioritaeten festlegen' },
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ export class MailEventsListener {
|
|||||||
constructor(private readonly mailService: MailService) {}
|
constructor(private readonly mailService: MailService) {}
|
||||||
|
|
||||||
@OnEvent(AppEvents.UserRegistered)
|
@OnEvent(AppEvents.UserRegistered)
|
||||||
handleUserRegistered(event: UserRegisteredEvent): void {
|
async handleUserRegistered(event: UserRegisteredEvent): Promise<void> {
|
||||||
this.mailService.sendVerificationEmail(event.email, event.verificationUrl);
|
await this.mailService.sendVerificationEmail(
|
||||||
|
event.email,
|
||||||
|
event.verificationUrl,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,38 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { MailerModule } from '@nestjs-modules/mailer';
|
||||||
import { MailEventsListener } from './mail-events.listener';
|
import { MailEventsListener } from './mail-events.listener';
|
||||||
import { MailService } from './mail.service';
|
import { MailService } from './mail.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MailerModule.forRootAsync({
|
||||||
|
imports: [ConfigModule.forRoot({ isGlobal: true })],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
transport: {
|
||||||
|
host: configService.get<string>('SMTP_HOST', 'localhost'),
|
||||||
|
port: Number(configService.get<string>('SMTP_PORT', '1025')),
|
||||||
|
secure: configService.get<string>('SMTP_SECURE', 'false') === 'true',
|
||||||
|
auth: configService.get<string>('SMTP_USER')
|
||||||
|
? {
|
||||||
|
user: configService.get<string>('SMTP_USER'),
|
||||||
|
pass: configService.get<string>('SMTP_PASSWORD', ''),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
from: {
|
||||||
|
name: configService.get<string>('MAIL_FROM_NAME', 'Listify'),
|
||||||
|
address: configService.get<string>(
|
||||||
|
'MAIL_FROM',
|
||||||
|
'no-reply@listify.local',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
providers: [MailService, MailEventsListener],
|
providers: [MailService, MailEventsListener],
|
||||||
exports: [MailService],
|
exports: [MailService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,26 +1,115 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { MailerService } from '@nestjs-modules/mailer';
|
||||||
|
import { existsSync, readFileSync } from 'fs';
|
||||||
|
import { compile, TemplateDelegate } from 'handlebars';
|
||||||
|
import { join } from 'path';
|
||||||
import { SentEmail } from './mail.types';
|
import { SentEmail } from './mail.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MailService {
|
export class MailService {
|
||||||
private readonly logger = new Logger(MailService.name);
|
private readonly logger = new Logger(MailService.name);
|
||||||
private readonly sentEmails: SentEmail[] = [];
|
private readonly sentEmails: SentEmail[] = [];
|
||||||
|
private verificationTemplate?: TemplateDelegate;
|
||||||
|
|
||||||
sendVerificationEmail(to: string, verificationUrl: string): void {
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly mailerService: MailerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async sendVerificationEmail(
|
||||||
|
to: string,
|
||||||
|
verificationUrl: string,
|
||||||
|
): Promise<void> {
|
||||||
const email: SentEmail = {
|
const email: SentEmail = {
|
||||||
to,
|
to,
|
||||||
subject: 'Verify your Listify account',
|
subject: 'Bestätige deinen Listify Account',
|
||||||
text: `Please verify your account by opening this link: ${verificationUrl}`,
|
text: `Bestätige deinen Listify Account mit diesem Link: ${verificationUrl}`,
|
||||||
verificationUrl,
|
verificationUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.sentEmails.push(email);
|
this.sentEmails.push(email);
|
||||||
this.logger.log(
|
|
||||||
`Verification email sent to ${to}: ${email.verificationUrl}`,
|
if (!this.mailEnabled()) {
|
||||||
|
this.logger.log(`Verification email queued for ${to}. Mail sending is disabled.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.mailerService.sendMail({
|
||||||
|
to,
|
||||||
|
subject: email.subject,
|
||||||
|
text: email.text,
|
||||||
|
html: this.renderVerificationTemplate({
|
||||||
|
appName: this.configService.get<string>('MAIL_FROM_NAME', 'Listify'),
|
||||||
|
clientUrl: this.configService.get<string>(
|
||||||
|
'CLIENT_URL',
|
||||||
|
'http://localhost:4200',
|
||||||
|
),
|
||||||
|
email: to,
|
||||||
|
verificationUrl,
|
||||||
|
currentYear: new Date().getFullYear(),
|
||||||
|
}),
|
||||||
|
context: {
|
||||||
|
appName: this.configService.get<string>('MAIL_FROM_NAME', 'Listify'),
|
||||||
|
clientUrl: this.configService.get<string>(
|
||||||
|
'CLIENT_URL',
|
||||||
|
'http://localhost:4200',
|
||||||
|
),
|
||||||
|
email: to,
|
||||||
|
verificationUrl,
|
||||||
|
currentYear: new Date().getFullYear(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`Verification email sent to ${to}.`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Verification email could not be sent to ${to}.`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getSentEmails(): SentEmail[] {
|
getSentEmails(): SentEmail[] {
|
||||||
return [...this.sentEmails];
|
return [...this.sentEmails];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderVerificationTemplate(context: {
|
||||||
|
appName: string;
|
||||||
|
clientUrl: string;
|
||||||
|
email: string;
|
||||||
|
verificationUrl: string;
|
||||||
|
currentYear: number;
|
||||||
|
}): string {
|
||||||
|
this.verificationTemplate ??= compile(
|
||||||
|
readFileSync(this.resolveTemplatePath('verification.hbs'), 'utf8'),
|
||||||
|
{ strict: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.verificationTemplate(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTemplatePath(fileName: string): string {
|
||||||
|
const candidates = [
|
||||||
|
join(process.cwd(), 'dist', 'mail', 'templates', fileName),
|
||||||
|
join(process.cwd(), 'src', 'mail', 'templates', fileName),
|
||||||
|
join(__dirname, 'templates', fileName),
|
||||||
|
];
|
||||||
|
|
||||||
|
const templatePath = candidates.find((candidate) => existsSync(candidate));
|
||||||
|
|
||||||
|
if (!templatePath) {
|
||||||
|
throw new Error(`Mail template not found: ${fileName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return templatePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mailEnabled(): boolean {
|
||||||
|
if (process.env.NODE_ENV === 'test') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.configService.get<string>('MAIL_ENABLED', 'true') === 'true';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
listify-api/src/mail/templates/verification.hbs
Normal file
67
listify-api/src/mail/templates/verification.hbs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Listify Account bestätigen</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;background:#f4f7f5;font-family:Arial,Helvetica,sans-serif;color:#17211b;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7f5;padding:24px 12px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border:1px solid #dce5df;border-radius:8px;overflow:hidden;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 28px 18px;background:#0f5f3d;color:#ffffff;">
|
||||||
|
<div style="font-size:14px;letter-spacing:.08em;text-transform:uppercase;opacity:.85;">{{appName}}</div>
|
||||||
|
<h1 style="margin:10px 0 0;font-size:26px;line-height:1.2;font-weight:700;">Bestätige deinen Account</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px;">
|
||||||
|
<p style="margin:0 0 16px;font-size:16px;line-height:1.55;">
|
||||||
|
Hallo,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 18px;font-size:16px;line-height:1.55;">
|
||||||
|
du hast dich mit <strong>{{email}}</strong> bei {{appName}} registriert. Bestätige deine E-Mail-Adresse, damit du Templates und Listen sicher nutzen kannst.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="margin:24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{verificationUrl}}" style="display:inline-block;background:#0f5f3d;color:#ffffff;text-decoration:none;border-radius:6px;padding:14px 20px;font-size:16px;font-weight:700;">
|
||||||
|
Account bestätigen
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0 0 10px;font-size:14px;line-height:1.5;color:#52645a;">
|
||||||
|
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;padding:12px;background:#eef4f0;border:1px solid #dce5df;border-radius:6px;font-size:13px;line-height:1.45;word-break:break-all;">
|
||||||
|
<a href="{{verificationUrl}}" style="color:#0f5f3d;">{{verificationUrl}}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin:22px 0 0;font-size:14px;line-height:1.5;color:#52645a;">
|
||||||
|
Wenn du dich nicht bei {{appName}} registriert hast, kannst du diese E-Mail ignorieren.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 28px;background:#f8faf8;border-top:1px solid #e7eee9;color:#6a7b71;font-size:12px;line-height:1.5;">
|
||||||
|
<div>{{appName}} - Listen, Templates und gemeinsame Planung.</div>
|
||||||
|
<div style="margin-top:4px;">© {{currentYear}} {{appName}}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="max-width:560px;margin:14px auto 0;color:#7a8a81;font-size:12px;line-height:1.4;">
|
||||||
|
Diese E-Mail wurde automatisch von {{appName}} versendet.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
listify-client/package-lock.json
generated
20
listify-client/package-lock.json
generated
@@ -1143,22 +1143,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
"integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
"@emnapi/wasi-threads": "1.2.2",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.10.0",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -1168,9 +1168,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
mat-icon-button
|
mat-icon-button
|
||||||
type="button"
|
type="button"
|
||||||
class="menu-button"
|
class="menu-button"
|
||||||
aria-label="Menue oeffnen"
|
aria-label="Menü öffnen"
|
||||||
(click)="toggleSidebar()"
|
(click)="toggleSidebar()"
|
||||||
>
|
>
|
||||||
<mat-icon aria-hidden="true">menu</mat-icon>
|
<mat-icon aria-hidden="true">menu</mat-icon>
|
||||||
|
|||||||
@@ -34,6 +34,16 @@
|
|||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-card mat-card-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card mat-card-actions mat-progress-spinner {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.verify-card mat-card-content {
|
.verify-card mat-card-content {
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export interface VerifyEmailResponse {
|
|||||||
user: PublicUser;
|
user: PublicUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResendVerificationResponse {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
PublicUser,
|
PublicUser,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
|
ResendVerificationResponse,
|
||||||
VerifyEmailResponse,
|
VerifyEmailResponse,
|
||||||
} from './auth.models';
|
} from './auth.models';
|
||||||
|
|
||||||
@@ -39,6 +40,13 @@ export class AuthService {
|
|||||||
return this.http.get<VerifyEmailResponse>(`${this.apiUrl}/verify-email`, { params });
|
return this.http.get<VerifyEmailResponse>(`${this.apiUrl}/verify-email`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resendVerificationEmail(email: string): Observable<ResendVerificationResponse> {
|
||||||
|
return this.http.post<ResendVerificationResponse>(
|
||||||
|
`${this.apiUrl}/resend-verification`,
|
||||||
|
{ email },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
loadCurrentUser(): Observable<PublicUser> {
|
loadCurrentUser(): Observable<PublicUser> {
|
||||||
return this.http
|
return this.http
|
||||||
.get<PublicUser>(`${this.apiUrl}/me`)
|
.get<PublicUser>(`${this.apiUrl}/me`)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
@if (form.controls.email.hasError('required')) {
|
@if (form.controls.email.hasError('required')) {
|
||||||
<mat-error>E-Mail ist erforderlich.</mat-error>
|
<mat-error>E-Mail ist erforderlich.</mat-error>
|
||||||
} @else if (form.controls.email.hasError('email')) {
|
} @else if (form.controls.email.hasError('email')) {
|
||||||
<mat-error>Bitte gib eine gueltige E-Mail ein.</mat-error>
|
<mat-error>Bitte gib eine gültige E-Mail ein.</mat-error>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
@@ -54,6 +54,19 @@
|
|||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
|
|
||||||
<mat-card-actions align="end">
|
<mat-card-actions align="end">
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
type="button"
|
||||||
|
[disabled]="resendingVerification || loading"
|
||||||
|
(click)="resendVerificationEmail()"
|
||||||
|
>
|
||||||
|
@if (resendingVerification) {
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||||
|
} @else {
|
||||||
|
<mat-icon aria-hidden="true">mark_email_unread</mat-icon>
|
||||||
|
}
|
||||||
|
Verifizierungsmail erneut senden
|
||||||
|
</button>
|
||||||
<a mat-button routerLink="/register">Konto erstellen</a>
|
<a mat-button routerLink="/register">Konto erstellen</a>
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export class LoginComponent {
|
|||||||
password: ['', [Validators.required, Validators.minLength(8)]],
|
password: ['', [Validators.required, Validators.minLength(8)]],
|
||||||
});
|
});
|
||||||
protected loading = false;
|
protected loading = false;
|
||||||
|
protected resendingVerification = false;
|
||||||
protected hidePassword = true;
|
protected hidePassword = true;
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
@@ -65,4 +66,26 @@ export class LoginComponent {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resendVerificationEmail(): void {
|
||||||
|
const emailControl = this.form.controls.email;
|
||||||
|
|
||||||
|
if (emailControl.invalid) {
|
||||||
|
emailControl.markAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resendingVerification = true;
|
||||||
|
this.auth
|
||||||
|
.resendVerificationEmail(emailControl.value)
|
||||||
|
.pipe(finalize(() => (this.resendingVerification = false)))
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.snackBar.open(response.message, 'OK', { duration: 6000 });
|
||||||
|
},
|
||||||
|
error: (error: unknown) => {
|
||||||
|
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
@if (form.controls.email.hasError('required')) {
|
@if (form.controls.email.hasError('required')) {
|
||||||
<mat-error>E-Mail ist erforderlich.</mat-error>
|
<mat-error>E-Mail ist erforderlich.</mat-error>
|
||||||
} @else if (form.controls.email.hasError('email')) {
|
} @else if (form.controls.email.hasError('email')) {
|
||||||
<mat-error>Bitte gib eine gueltige E-Mail ein.</mat-error>
|
<mat-error>Bitte gib eine gültige E-Mail ein.</mat-error>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<mat-card class="auth-card verify-card" appearance="outlined">
|
<mat-card class="auth-card verify-card" appearance="outlined">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>E-Mail-Verifikation</mat-card-title>
|
<mat-card-title>E-Mail-Verifikation</mat-card-title>
|
||||||
<mat-card-subtitle>{{ email() || 'Listify-Konto bestaetigen' }}</mat-card-subtitle>
|
<mat-card-subtitle>{{ email() || 'Listify-Konto bestätigen' }}</mat-card-subtitle>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
|
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class VerifyEmailComponent implements OnInit {
|
|||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
|
||||||
protected readonly state = signal<VerificationState>('loading');
|
protected readonly state = signal<VerificationState>('loading');
|
||||||
protected readonly message = signal('E-Mail wird bestaetigt.');
|
protected readonly message = signal('E-Mail wird bestätigt.');
|
||||||
protected readonly email = signal<string | null>(null);
|
protected readonly email = signal<string | null>(null);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -34,7 +34,7 @@ export class VerifyEmailComponent implements OnInit {
|
|||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
this.state.set('missing-token');
|
this.state.set('missing-token');
|
||||||
this.message.set('Der Verifikationslink enthaelt keinen Token.');
|
this.message.set('Der Verifikationslink enthält keinen Token.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<section class="list-detail-page">
|
<section class="list-detail-page">
|
||||||
<header class="detail-header">
|
<header class="detail-header">
|
||||||
<button mat-icon-button type="button" aria-label="Zurueck" (click)="backToLists()">
|
<button mat-icon-button type="button" aria-label="Zurück" (click)="backToLists()">
|
||||||
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
@@ -70,9 +70,9 @@
|
|||||||
<mat-card-title>Items</mat-card-title>
|
<mat-card-title>Items</mat-card-title>
|
||||||
<mat-card-subtitle>
|
<mat-card-subtitle>
|
||||||
@if (canEditItems()) {
|
@if (canEditItems()) {
|
||||||
{{ list()?.items?.length || 0 }} Eintraege
|
{{ list()?.items?.length || 0 }} Einträge
|
||||||
} @else {
|
} @else {
|
||||||
Nach dem Speichern verfuegbar
|
Nach dem Speichern verfügbar
|
||||||
}
|
}
|
||||||
</mat-card-subtitle>
|
</mat-card-subtitle>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
@@ -95,14 +95,14 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<mat-icon aria-hidden="true">add</mat-icon>
|
<mat-icon aria-hidden="true">add</mat-icon>
|
||||||
}
|
}
|
||||||
Hinzufuegen
|
Hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@if (!canEditItems()) {
|
@if (!canEditItems()) {
|
||||||
<div class="inline-empty">
|
<div class="inline-empty">
|
||||||
<mat-icon aria-hidden="true">save</mat-icon>
|
<mat-icon aria-hidden="true">save</mat-icon>
|
||||||
<span>Speichere die Liste, bevor du Items hinzufuegst.</span>
|
<span>Speichere die Liste, bevor du Items hinzufügst.</span>
|
||||||
</div>
|
</div>
|
||||||
} @else if (list()?.items?.length) {
|
} @else if (list()?.items?.length) {
|
||||||
<ul class="check-items">
|
<ul class="check-items">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Listen</h1>
|
<h1>Listen</h1>
|
||||||
<p>Deine persoenlichen Listify-Listen.</p>
|
<p>Deine persönlichen Listify-Listen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a mat-flat-button routerLink="/lists/new">
|
<a mat-flat-button routerLink="/lists/new">
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
mat-icon-button
|
mat-icon-button
|
||||||
matSuffix
|
matSuffix
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Suche loeschen"
|
aria-label="Suche löschen"
|
||||||
(click)="searchTerm.set('')"
|
(click)="searchTerm.set('')"
|
||||||
>
|
>
|
||||||
<mat-icon aria-hidden="true">close</mat-icon>
|
<mat-icon aria-hidden="true">close</mat-icon>
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
@if (activeFilterCount() > 0 || sortOption() !== 'updated-desc') {
|
@if (activeFilterCount() > 0 || sortOption() !== 'updated-desc') {
|
||||||
<button mat-button type="button" (click)="resetFilters()">
|
<button mat-button type="button" (click)="resetFilters()">
|
||||||
<mat-icon aria-hidden="true">restart_alt</mat-icon>
|
<mat-icon aria-hidden="true">restart_alt</mat-icon>
|
||||||
Zuruecksetzen
|
Zurücksetzen
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
<p>Mit den aktuellen Filtern wurde keine Liste gefunden.</p>
|
<p>Mit den aktuellen Filtern wurde keine Liste gefunden.</p>
|
||||||
<button mat-stroked-button type="button" (click)="resetFilters()">
|
<button mat-stroked-button type="button" (click)="resetFilters()">
|
||||||
<mat-icon aria-hidden="true">restart_alt</mat-icon>
|
<mat-icon aria-hidden="true">restart_alt</mat-icon>
|
||||||
Filter zuruecksetzen
|
Filter zurücksetzen
|
||||||
</button>
|
</button>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
[attr.data-onboarding]="onboarding.isListOpenTarget(list.id) ? 'open-list' : null"
|
[attr.data-onboarding]="onboarding.isListOpenTarget(list.id) ? 'open-list' : null"
|
||||||
>
|
>
|
||||||
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
||||||
Oeffnen
|
Öffnen
|
||||||
</a>
|
</a>
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
>
|
>
|
||||||
<div class="step-line">
|
<div class="step-line">
|
||||||
<span>Schritt {{ onboarding.currentStepNumber() }} von {{ onboarding.totalSteps }}</span>
|
<span>Schritt {{ onboarding.currentStepNumber() }} von {{ onboarding.totalSteps }}</span>
|
||||||
<button mat-icon-button type="button" aria-label="Onboarding schliessen" (click)="onboarding.skip()">
|
<button mat-icon-button type="button" aria-label="Onboarding schließen" (click)="onboarding.skip()">
|
||||||
<mat-icon aria-hidden="true">close</mat-icon>
|
<mat-icon aria-hidden="true">close</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
Fertig
|
Fertig
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
<button mat-button type="button" (click)="onboarding.skip()">Ueberspringen</button>
|
<button mat-button type="button" (click)="onboarding.skip()">Überspringen</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ const STEPS: Record<OnboardingStepKey, OnboardingStep> = {
|
|||||||
'template-details': {
|
'template-details': {
|
||||||
key: 'template-details',
|
key: 'template-details',
|
||||||
title: 'Template benennen',
|
title: 'Template benennen',
|
||||||
body: 'Gib deinem Template einen Titel und speichere es. Danach kannst du Items hinzufuegen.',
|
body: 'Gib deinem Template einen Titel und speichere es. Danach kannst du Items hinzufügen.',
|
||||||
targetSelector: '[data-onboarding="template-details"]',
|
targetSelector: '[data-onboarding="template-details"]',
|
||||||
},
|
},
|
||||||
'template-item': {
|
'template-item': {
|
||||||
key: 'template-item',
|
key: 'template-item',
|
||||||
title: 'Erstes Item hinzufuegen',
|
title: 'Erstes Item hinzufügen',
|
||||||
body: 'Trage ein Item ein und fuege es hinzu. Items sind spaeter die Punkte deiner Liste.',
|
body: 'Trage ein Item ein und fuege es hinzu. Items sind spaeter die Punkte deiner Liste.',
|
||||||
targetSelector: '[data-onboarding="template-item"]',
|
targetSelector: '[data-onboarding="template-item"]',
|
||||||
},
|
},
|
||||||
@@ -51,7 +51,7 @@ const STEPS: Record<OnboardingStepKey, OnboardingStep> = {
|
|||||||
},
|
},
|
||||||
'list-open': {
|
'list-open': {
|
||||||
key: 'list-open',
|
key: 'list-open',
|
||||||
title: 'Liste oeffnen',
|
title: 'Liste öffnen',
|
||||||
body: 'Oeffne die neu erstellte Liste. Dort kannst du die Items abhaken.',
|
body: 'Oeffne die neu erstellte Liste. Dort kannst du die Items abhaken.',
|
||||||
targetSelector: '[data-onboarding="open-list"]',
|
targetSelector: '[data-onboarding="open-list"]',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
<mat-icon aria-hidden="true">delete_forever</mat-icon>
|
<mat-icon aria-hidden="true">delete_forever</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 mat-dialog-title>Template loeschen?</h2>
|
<h2 mat-dialog-title>Template löschen?</h2>
|
||||||
|
|
||||||
<mat-dialog-content>
|
<mat-dialog-content>
|
||||||
<p>
|
<p>
|
||||||
<strong>{{ data.templateName }}</strong> wird dauerhaft geloescht. Bereits daraus erstellte
|
<strong>{{ data.templateName }}</strong> wird dauerhaft gelöscht. Bereits daraus erstellte
|
||||||
Listen bleiben erhalten.
|
Listen bleiben erhalten.
|
||||||
</p>
|
</p>
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
@@ -15,6 +15,6 @@
|
|||||||
<button mat-button type="button" mat-dialog-close>Abbrechen</button>
|
<button mat-button type="button" mat-dialog-close>Abbrechen</button>
|
||||||
<button mat-flat-button type="button" color="warn" [mat-dialog-close]="true">
|
<button mat-flat-button type="button" color="warn" [mat-dialog-close]="true">
|
||||||
<mat-icon aria-hidden="true">delete</mat-icon>
|
<mat-icon aria-hidden="true">delete</mat-icon>
|
||||||
Loeschen
|
Löschen
|
||||||
</button>
|
</button>
|
||||||
</mat-dialog-actions>
|
</mat-dialog-actions>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<section class="template-detail-page">
|
<section class="template-detail-page">
|
||||||
<header class="detail-header">
|
<header class="detail-header">
|
||||||
<button mat-icon-button type="button" aria-label="Zurueck" (click)="backToTemplates()">
|
<button mat-icon-button type="button" aria-label="Zurück" (click)="backToTemplates()">
|
||||||
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
}
|
}
|
||||||
Als Liste
|
Als Liste
|
||||||
</button>
|
</button>
|
||||||
<button mat-icon-button type="button" aria-label="Template loeschen" [disabled]="deletingTemplate()" (click)="deleteTemplate()">
|
<button mat-icon-button type="button" aria-label="Template löschen" [disabled]="deletingTemplate()" (click)="deleteTemplate()">
|
||||||
@if (deletingTemplate()) {
|
@if (deletingTemplate()) {
|
||||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||||
} @else {
|
} @else {
|
||||||
@@ -91,12 +91,12 @@
|
|||||||
<mat-card-title>Items</mat-card-title>
|
<mat-card-title>Items</mat-card-title>
|
||||||
<mat-card-subtitle>
|
<mat-card-subtitle>
|
||||||
@if (canEditItems()) {
|
@if (canEditItems()) {
|
||||||
{{ template()?.items?.length || 0 }} Eintraege
|
{{ template()?.items?.length || 0 }} Einträge
|
||||||
@if (reordering()) {
|
@if (reordering()) {
|
||||||
- Reihenfolge wird gespeichert
|
- Reihenfolge wird gespeichert
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
Nach dem Speichern verfuegbar
|
Nach dem Speichern verfügbar
|
||||||
}
|
}
|
||||||
</mat-card-subtitle>
|
</mat-card-subtitle>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
@@ -119,14 +119,14 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<mat-icon aria-hidden="true">add</mat-icon>
|
<mat-icon aria-hidden="true">add</mat-icon>
|
||||||
}
|
}
|
||||||
Hinzufuegen
|
Hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@if (!canEditItems()) {
|
@if (!canEditItems()) {
|
||||||
<div class="inline-empty">
|
<div class="inline-empty">
|
||||||
<mat-icon aria-hidden="true">save</mat-icon>
|
<mat-icon aria-hidden="true">save</mat-icon>
|
||||||
<span>Speichere das Template, bevor du Items hinzufuegst.</span>
|
<span>Speichere das Template, bevor du Items hinzufügst.</span>
|
||||||
</div>
|
</div>
|
||||||
} @else if (template()?.items?.length) {
|
} @else if (template()?.items?.length) {
|
||||||
<ul
|
<ul
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ export class TemplateDetailComponent implements OnInit {
|
|||||||
.pipe(finalize(() => this.deletingTemplate.set(false)))
|
.pipe(finalize(() => this.deletingTemplate.set(false)))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.snackBar.open('Template geloescht.', 'OK', { duration: 3000 });
|
this.snackBar.open('Template gelöscht.', 'OK', { duration: 3000 });
|
||||||
void this.router.navigateByUrl('/templates');
|
void this.router.navigateByUrl('/templates');
|
||||||
},
|
},
|
||||||
error: (error: unknown) => {
|
error: (error: unknown) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Templates</h1>
|
<h1>Templates</h1>
|
||||||
<p>Vorlagen fuer wiederkehrende Listen.</p>
|
<p>Vorlagen für wiederkehrende Listen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<div class="template-meta">
|
<div class="template-meta">
|
||||||
<span>
|
<span>
|
||||||
<mat-icon aria-hidden="true">checklist</mat-icon>
|
<mat-icon aria-hidden="true">checklist</mat-icon>
|
||||||
{{ template.items.length }} Eintraege
|
{{ template.items.length }} Einträge
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<mat-icon aria-hidden="true">schedule</mat-icon>
|
<mat-icon aria-hidden="true">schedule</mat-icon>
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
type="button"
|
type="button"
|
||||||
[attr.aria-label]="template.name + ' loeschen'"
|
[attr.aria-label]="template.name + ' löschen'"
|
||||||
[disabled]="deletingTemplateId() === template.id"
|
[disabled]="deletingTemplateId() === template.id"
|
||||||
(click)="deleteTemplate(template)"
|
(click)="deleteTemplate(template)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export class TemplatesComponent implements OnInit {
|
|||||||
(existingTemplate) => existingTemplate.id !== template.id,
|
(existingTemplate) => existingTemplate.id !== template.id,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
this.snackBar.open('Template geloescht.', 'OK', { duration: 3000 });
|
this.snackBar.open('Template gelöscht.', 'OK', { duration: 3000 });
|
||||||
},
|
},
|
||||||
error: (error: unknown) => {
|
error: (error: unknown) => {
|
||||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
|
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
|
||||||
|
|||||||
Reference in New Issue
Block a user