Browse Source

add data export

main
Christoph Marzell 1 month ago
parent
commit
c39861e0dc
  1. 55
      HTTPS-PROXY.md
  2. 10
      RELEASE_2026.10.10.1.md
  3. 4
      config/initializers/build_version.rb
  4. 6
      docker-compose.yml
  5. 4
      frontend/Dockerfile
  6. 21
      frontend/nginx.conf
  7. 3236
      frontend/package-lock.json
  8. 16
      frontend/package.json
  9. 22
      frontend/scripts/check-form-fields.mjs
  10. 2
      frontend/src/app/app.component.ts
  11. 8
      frontend/src/app/pages/admin-database.component.ts
  12. 4
      frontend/src/app/pages/calendar.component.ts
  13. 12
      frontend/src/app/pages/entries.component.ts
  14. 20
      frontend/src/app/pages/entry-form.component.ts
  15. 2
      frontend/src/app/pages/forgot-password.component.ts
  16. 4
      frontend/src/app/pages/login.component.ts
  17. 4
      frontend/src/app/pages/mileage-rates.component.ts
  18. 6
      frontend/src/app/pages/practice-transfer-export.component.html
  19. 3
      frontend/src/app/pages/practice-transfer-export.component.ts
  20. 2
      frontend/src/app/pages/register.component.ts
  21. 2
      frontend/src/app/pages/resend-confirmation.component.ts
  22. 2
      frontend/src/app/pages/reset-password.component.ts
  23. 12
      frontend/src/app/pages/settings.component.ts
  24. 55
      frontend/src/app/shared/date-picker-button.component.spec.ts
  25. 103
      frontend/src/app/shared/date-picker-button.component.ts
  26. 20
      frontend/src/form-field-layout.scss
  27. 3
      frontend/src/main.ts
  28. 1
      frontend/src/styles.scss
  29. 98
      test/proxy_transport_test.py

55
HTTPS-PROXY.md

@ -0,0 +1,55 @@
# HTTPS-Erkennung beim Praxisexport / HTTPS proxy setup
## Ursache und Korrektur
Der Browser ruft die Anwendung über HTTPS auf. Ein äußerer Reverse Proxy beendet TLS und leitet intern über HTTP weiter. Die bisherige Frontend-Nginx-Konfiguration ersetzte dessen `X-Forwarded-Proto: https` durch `$scheme`, also `http`. Rails lehnte den Export deshalb mit HTTP 426 ab.
Das neue Frontend übernimmt einen **einzelnen, exakt passenden** `X-Forwarded-Proto`-Wert nur vom ausdrücklich konfigurierten unmittelbaren Proxy. Ohne passenden Absender bleibt das tatsächlich am internen Nginx verwendete Protokoll maßgeblich. Alternative Protokoll-Header werden entfernt. Die Rails-HTTPS-Prüfung bleibt unverändert aktiv. `APP_PROTOCOL=https` allein betrifft URL-Erzeugung und behebt die Anfrageerkennung nicht.
## Einrichtung auf dem Server
1. Die vorhandene `.env` und Schlüssel behalten. Den äußersten Proxy so konfigurieren, dass er HTTP nach HTTPS umleitet und den Header **überschreibt**, statt einen vom Browser gelieferten Wert durchzureichen. Bei Nginx im HTTPS-Serverblock:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:13131;
```
2. In der bestehenden `.env` `TRUSTED_TLS_PROXY_CIDR` auf die Quell-IP des unmittelbaren Proxys setzen, **wie sie im Frontend-Container ankommt**. Diese lässt sich mit `docker compose logs --tail=30 frontend` bei einem eigenen Aufruf prüfen. Hinter Docker kann es die Bridge-Gateway-IP sein; diese nicht raten. IPv4 mit `/32`, IPv6 mit `/128`; ein größeres Netz nur, wenn ausschließlich vertrauenswürdige Proxys darin liegen. Niemals `0.0.0.0/0` oder `::/0`. Vorgabe ist `127.0.0.1/32`; das passt nicht automatisch zu Docker-Bridge-Netzen.
```dotenv
# Beispielstruktur, IP durch den tatsächlich geprüften Proxy-Absender ersetzen:
TRUSTED_TLS_PROXY_CIDR=192.0.2.10/32
ASSUME_SSL=false
API_BIND_ADDRESS=127.0.0.1
FRONTEND_BIND_ADDRESS=127.0.0.1
```
Die neue Compose-Datei veröffentlicht API und Frontend standardmäßig nur auf Loopback. Für einen Proxy in einem anderen Container ein gemeinsames privates Docker-Netz und `frontend:80` verwenden; für einen separaten Proxy-Server eine private Bind-Adresse und Firewall-Freigabe ausschließlich für diesen Proxy konfigurieren. Nicht blind auf alle Schnittstellen umstellen. Direkten öffentlichen HTTP-Zugriff auf API und Frontend ausschließen.
3. Im Verzeichnis `praktikumsverwaltung` bauen und Container neu erstellen:
```sh
docker compose up -d --build api frontend
docker compose exec frontend nginx -t
```
Ein bloßes `docker compose restart` übernimmt geänderte Umgebungsvariablen nicht. Es gibt für dieses Patch-Release keine neue Datenbankmigration.
4. Anwendung über `https://praktikum.marzell.net` öffnen und den Export mit einem kleinen Testzeitraum erneut ausführen. Erwartet: HTTP 200 mit verschlüsselter `.pcmtransfer`-Datei. Ohne gültige Anmeldung bleibt der Zugriff gesperrt. Die Browser-URL allein beweist nicht, dass der interne Header korrekt ist.
## Soforthilfe für den bisherigen Build
Der bisherige Build unterstützt bereits `ASSUME_SSL=true`. **Nur wenn sämtliche externen Anfragen ausschließlich über einen vertrauenswürdigen HTTPS-Proxy eintreffen und die internen HTTP-Ports nicht öffentlich erreichbar sind**, kann in der bestehenden `.env` dieser Wert gesetzt und `docker compose up -d --force-recreate api` ausgeführt werden. Rails behandelt dann alle Anfragen als HTTPS. Diese Annahme ist ungeeignet bei gemischtem HTTP-/HTTPS-Zugriff. Die neue, absendergebundene Headerweitergabe mit `ASSUME_SSL=false` ist vorzuziehen.
## Test und Rollback
`python3 test/proxy_transport_test.py /path/to/nginx` prüft 14 Kombinationen gegen einen lokalen Echo-Upstream: vertrauenswürdiges HTTPS/HTTP, fehlende und ungültige Header sowie gefälschte HTTPS-Behauptungen von anderen Absendern. Es werden keine Produktivdaten verwendet. Ein durchgehender Exporttest mit der realen TLS- und Rails-Installation ist zusätzlich erforderlich.
Rollback: zuvor gesicherte Anwendungsversion und passende Compose-Konfiguration wiederherstellen, Images neu bauen oder zuvor gesicherte Images verwenden und Container neu erstellen. Die eigenen `.env`-Werte und Datenbankvolumes behalten. HTTPS-Schutz nicht entfernen. Keine Datenbankrückmigration nötig.
## English
The internal Nginx previously overwrote the TLS proxy's HTTPS scheme with HTTP, causing Rails to reject the export with 426. Configure `TRUSTED_TLS_PROXY_CIDR` with the verified immediate proxy address as seen inside the frontend container. Only this sender may assert an exact `X-Forwarded-Proto: https`; other scheme headers are removed. Keep `ASSUME_SSL=false`, enforce HTTPS at the public edge and prevent direct public access to internal ports. Ports now bind to loopback by default; container or remote proxies need an explicitly configured private network path. Rebuild/recreate `api` and `frontend`, validate Nginx and test a small encrypted export. No new migration. The previous build's `ASSUME_SSL=true` workaround is safe only behind an exclusively HTTPS, trusted, isolated ingress. Never remove the export's HTTPS check.

10
RELEASE_2026.10.10.1.md

@ -0,0 +1,10 @@
# Praktikumsverwaltung 2026.10.10.1 / Frontend 1.1.1
- HTTPS-Erkennung des Praxisexports hinter dem Reverse Proxy korrigiert. Einrichtung und sichere Soforthilfe für den vorherigen Build: `HTTPS-PROXY.md`.
- Exportzeitraum von/bis jetzt zusätzlich mit Kalenderauswahl. Bestehende Kalender im Eintragsformular bleiben erhalten.
- Alle 45 Material-Felder haben eine Hinweiszeile, bei fehlendem Text leer. Mehrzeilige Hinweise dürfen wachsen; Buttons strecken sich nicht auf die Höhe inklusive Hinweis.
- Angular CLI/Build 22.2.2, aktualisierte Lockfiles, npm audit 0.
- 8 Frontend-Tests, Produktionsbuild, Formfeld-Prüfung und 14 lokale Proxy-Testfälle erfolgreich. Browser-Stichproben in Desktop/Hell und Mobil/Dunkel inklusive Tastatur/Escape erfolgreich.
- Keine neue Migration, kein Live-Deployment. Rails/Docker und durchgehender realer Export/Import nicht geprüft. Details in `../praxisverwaltung/RELEASE_0.118.1.md`.
English: HTTPS detection preserves the scheme only from the explicitly configured trusted proxy. Configure it before deployment; TLS enforcement remains active. Export date fields have calendar buttons, all Material fields have hint spacing, and buttons align with inputs. Security updates applied, 8 frontend tests and production build passed. No new migration or live deployment.

4
config/initializers/build_version.rb

@ -1,2 +1,2 @@
Rails.application.config.x.build_version = "2026.10.02.1"
Rails.logger.info("Praktikum API build 2026.10.02.1 loaded")
Rails.application.config.x.build_version = "2026.10.10.1"
Rails.logger.info("Praktikum API build 2026.10.10.1 loaded")

6
docker-compose.yml

@ -49,12 +49,14 @@ services:
TRAINING_WATCH_NOTIFY_EMAIL: ${TRAINING_WATCH_NOTIFY_EMAIL:-christoph@marzell.net} TRAINING_WATCH_NOTIFY_EMAIL: ${TRAINING_WATCH_NOTIFY_EMAIL:-christoph@marzell.net}
PGHERO_USERNAME: ${PGHERO_USERNAME:-admin} PGHERO_USERNAME: ${PGHERO_USERNAME:-admin}
PGHERO_PASSWORD: ${PGHERO_PASSWORD:-} PGHERO_PASSWORD: ${PGHERO_PASSWORD:-}
ports: ["${API_PORT:-13000}:3000"]
ports: ["${API_BIND_ADDRESS:-127.0.0.1}:${API_PORT:-13000}:3000"]
frontend: frontend:
build: ./frontend build: ./frontend
environment:
TRUSTED_TLS_PROXY_CIDR: ${TRUSTED_TLS_PROXY_CIDR:-127.0.0.1/32}
restart: unless-stopped restart: unless-stopped
depends_on: [api] depends_on: [api]
ports: ["${FRONTEND_PORT:-13131}:80"]
ports: ["${FRONTEND_BIND_ADDRESS:-127.0.0.1}:${FRONTEND_PORT:-13131}:80"]
volumes: volumes:
pgdata: pgdata:
redis: redis:

4
frontend/Dockerfile

@ -6,6 +6,8 @@ COPY . .
RUN npm run build RUN npm run build
FROM nginx:1.29-alpine FROM nginx:1.29-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
ENV TRUSTED_TLS_PROXY_CIDR=127.0.0.1/32
ENV NGINX_ENVSUBST_FILTER=TRUSTED_TLS_PROXY_CIDR
COPY nginx.conf /etc/nginx/templates/default.conf.template
COPY --from=build /app/dist/praktikum/browser /usr/share/nginx/html COPY --from=build /app/dist/praktikum/browser /usr/share/nginx/html
EXPOSE 80 EXPOSE 80

21
frontend/nginx.conf

@ -1,3 +1,14 @@
# Only the configured immediate TLS proxy may assert the original scheme.
# The Docker entrypoint substitutes TRUSTED_TLS_PROXY_CIDR, not Nginx variables.
geo $pcm_trusted_tls_proxy {
default 0;
${TRUSTED_TLS_PROXY_CIDR} 1;
}
map "$pcm_trusted_tls_proxy:$http_x_forwarded_proto" $pcm_forwarded_proto {
default $scheme;
"1:https" https;
"1:http" http;
}
server { server {
listen 80; listen 80;
server_name _; server_name _;
@ -8,13 +19,19 @@ server {
proxy_pass http://api:3000; proxy_pass http://api:3000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $pcm_forwarded_proto;
proxy_set_header Forwarded "";
proxy_set_header X-Forwarded-Ssl "";
proxy_set_header X-Forwarded-Scheme "";
} }
location /admin/pghero { location /admin/pghero {
proxy_pass http://api:3000; proxy_pass http://api:3000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $pcm_forwarded_proto;
proxy_set_header Forwarded "";
proxy_set_header X-Forwarded-Ssl "";
proxy_set_header X-Forwarded-Scheme "";
} }
location / { try_files $uri $uri/ /index.html; } location / { try_files $uri $uri/ /index.html; }
} }

3236
frontend/package-lock.json
File diff suppressed because it is too large
View File

16
frontend/package.json

@ -1,11 +1,13 @@
{ {
"name": "praktikum-frontend", "name": "praktikum-frontend",
"version": "1.1.0",
"version": "1.1.1",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build", "build": "ng build",
"test": "ng test --watch=false"
"test": "ng test --watch=false",
"forms:check": "node scripts/check-form-fields.mjs",
"prebuild": "npm run forms:check"
}, },
"dependencies": { "dependencies": {
"@angular/animations": "22.2.1", "@angular/animations": "22.2.1",
@ -23,13 +25,13 @@
"tslib": "^2.8.1" "tslib": "^2.8.1"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "22.1.4",
"@angular/cli": "22.1.4",
"@angular/build": "22.2.2",
"@angular/cli": "22.2.2",
"@angular/compiler-cli": "22.2.1", "@angular/compiler-cli": "22.2.1",
"typescript": "~6.0.2",
"vitest": "^4.0.8",
"@types/node": "^24.10.1",
"jsdom": "^27.0.1", "jsdom": "^27.0.1",
"@types/node": "^24.10.1"
"typescript": "~6.0.2",
"vitest": "^4.0.8"
}, },
"overrides": { "overrides": {
"@angular/build": { "@angular/build": {

22
frontend/scripts/check-form-fields.mjs

@ -0,0 +1,22 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
function files(path) {
return readdirSync(path, { withFileTypes: true }).flatMap(entry => entry.isDirectory()
? files(join(path, entry.name)) : [join(path, entry.name)]);
}
let fields = 0, dates = 0;
const errors = [];
for (const path of files('src/app').filter(path => /\.(html|ts)$/.test(path) && !path.endsWith('.spec.ts'))) {
const source = readFileSync(path, 'utf8');
for (const match of source.matchAll(/<mat-form-field\b[^>]*>[\s\S]*?<\/mat-form-field\s*>/g)) {
fields++;
if (!/<mat-hint\b/.test(match[0])) errors.push(`${path}: missing hint slot`);
}
for (const match of source.matchAll(/<input\b(?=[^>]*\btype="(?:date|datetime-local)")[^>]*>/g)) {
dates++;
if (!source.slice(match.index + match[0].length).startsWith('<app-date-picker-button ')) errors.push(`${path}: missing calendar companion`);
}
}
if (errors.length) { console.error(errors.join('\n')); process.exitCode = 1; }
else console.log(`${fields} Material fields with hint slots; ${dates} ISO date inputs with calendar buttons.`);

2
frontend/src/app/app.component.ts

@ -77,7 +77,7 @@ import { ApiService } from "./core/api.service";
<button mat-button (click)="logout()"> <button mat-button (click)="logout()">
<mat-icon>logout</mat-icon> Abmelden <mat-icon>logout</mat-icon> Abmelden
</button> </button>
<small class="build-version">Build 2026.10.02.1</small>
<small class="build-version">Build 2026.10.10.1</small>
</div> </div>
</div> </div>
</mat-sidenav </mat-sidenav

8
frontend/src/app/pages/admin-database.component.ts

@ -70,7 +70,7 @@ import { MATERIAL } from "../shared/material";
</div> </div>
} }
@if (section() === "users") { @if (section() === "users") {
<mat-card class="source-form"><mat-card-content><mat-form-field appearance="outline"><mat-label>E-Mail</mat-label><input matInput type="email" [(ngModel)]="newUser.email"></mat-form-field><mat-form-field appearance="outline"><mat-label>Startpasswort</mat-label><input matInput type="password" [(ngModel)]="newUser.password"></mat-form-field><mat-checkbox [(ngModel)]="newUser.confirmed">Sofort bestätigen</mat-checkbox><button mat-flat-button (click)="createUser()">Benutzer anlegen</button></mat-card-content></mat-card>
<mat-card class="source-form"><mat-card-content><mat-form-field appearance="outline"><mat-label>E-Mail</mat-label><input matInput type="email" [(ngModel)]="newUser.email"><mat-hint></mat-hint></mat-form-field><mat-form-field appearance="outline"><mat-label>Startpasswort</mat-label><input matInput type="password" [(ngModel)]="newUser.password"><mat-hint></mat-hint></mat-form-field><mat-checkbox [(ngModel)]="newUser.confirmed">Sofort bestätigen</mat-checkbox><button mat-flat-button (click)="createUser()">Benutzer anlegen</button></mat-card-content></mat-card>
<mat-card <mat-card
><div class="table-wrap"> ><div class="table-wrap">
<table mat-table [dataSource]="users()"> <table mat-table [dataSource]="users()">
@ -159,16 +159,16 @@ import { MATERIAL } from "../shared/material";
><mat-card-content ><mat-card-content
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>Name</mat-label ><mat-label>Name</mat-label
><input matInput [(ngModel)]="sourceDraft.name" /></mat-form-field
><input matInput [(ngModel)]="sourceDraft.name" /><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>URL</mat-label ><mat-label>URL</mat-label
><input matInput [(ngModel)]="sourceDraft.url" /></mat-form-field
><input matInput [(ngModel)]="sourceDraft.url" /><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>Typ</mat-label ><mat-label>Typ</mat-label
><mat-select [(ngModel)]="sourceDraft.kind" ><mat-select [(ngModel)]="sourceDraft.kind"
><mat-option value="html">HTML</mat-option ><mat-option value="html">HTML</mat-option
><mat-option value="rss">RSS</mat-option></mat-select ><mat-option value="rss">RSS</mat-option></mat-select
></mat-form-field
><mat-hint></mat-hint></mat-form-field
><mat-checkbox [(ngModel)]="sourceDraft.enabled">Aktiv</mat-checkbox ><mat-checkbox [(ngModel)]="sourceDraft.enabled">Aktiv</mat-checkbox
><button mat-flat-button (click)="saveSource()"> ><button mat-flat-button (click)="saveSource()">
Quelle speichern Quelle speichern

4
frontend/src/app/pages/calendar.component.ts

@ -28,11 +28,11 @@ import { Entry, Settings } from "../core/models";
<mat-form-field appearance="outline"><mat-label>Ausbildung</mat-label><mat-select [(ngModel)]="typeFilter"> <mat-form-field appearance="outline"><mat-label>Ausbildung</mat-label><mat-select [(ngModel)]="typeFilter">
<mat-option value="">Alle</mat-option> <mat-option value="">Alle</mat-option>
@for (type of types(); track type) { <mat-option [value]="type">{{ typeLabel(type) }}</mat-option> } @for (type of types(); track type) { <mat-option [value]="type">{{ typeLabel(type) }}</mat-option> }
</mat-select></mat-form-field>
</mat-select><mat-hint></mat-hint></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Art</mat-label><mat-select [(ngModel)]="artFilter"> <mat-form-field appearance="outline"><mat-label>Art</mat-label><mat-select [(ngModel)]="artFilter">
<mat-option value="">Alle</mat-option> <mat-option value="">Alle</mat-option>
@for (art of arts(); track art) { <mat-option [value]="art">{{ art }}</mat-option> } @for (art of arts(); track art) { <mat-option [value]="art">{{ art }}</mat-option> }
</mat-select></mat-form-field>
</mat-select><mat-hint></mat-hint></mat-form-field>
<button mat-button (click)="clearFilters()">Zurücksetzen</button> <button mat-button (click)="clearFilters()">Zurücksetzen</button>
</mat-card-content></mat-card> </mat-card-content></mat-card>
@if (loading()) { @if (loading()) {

12
frontend/src/app/pages/entries.component.ts

@ -30,8 +30,8 @@ import { Dashboard, Entry, Settings } from "../core/models";
<button class="timer-stop" mat-flat-button color="warn" (click)="stopTimer(timer)"><mat-icon>stop</mat-icon> Timer stoppen</button> <button class="timer-stop" mat-flat-button color="warn" (click)="stopTimer(timer)"><mat-icon>stop</mat-icon> Timer stoppen</button>
} @else { } @else {
<div><mat-icon>timer</mat-icon><span>Zeiterfassung</span><strong>Timer starten</strong></div> <div><mat-icon>timer</mat-icon><span>Zeiterfassung</span><strong>Timer starten</strong></div>
<mat-form-field appearance="outline"><mat-label>Ausbildung</mat-label><mat-select [(ngModel)]="timerType">@for(t of types();track t){<mat-option [value]="t">{{typeLabel(t)}}</mat-option>}</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Art</mat-label><mat-select [(ngModel)]="timerArt">@for(a of timerArts();track a){<mat-option [value]="a">{{a}}</mat-option>}</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Ausbildung</mat-label><mat-select [(ngModel)]="timerType">@for(t of types();track t){<mat-option [value]="t">{{typeLabel(t)}}</mat-option>}</mat-select><mat-hint></mat-hint></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Art</mat-label><mat-select [(ngModel)]="timerArt">@for(a of timerArts();track a){<mat-option [value]="a">{{a}}</mat-option>}</mat-select><mat-hint></mat-hint></mat-form-field>
<button class="timer-start" mat-flat-button [disabled]="!timerType || !timerArt" (click)="startTimer()"><mat-icon>play_arrow</mat-icon> Timer starten</button> <button class="timer-start" mat-flat-button [disabled]="!timerType || !timerArt" (click)="startTimer()"><mat-icon>play_arrow</mat-icon> Timer starten</button>
} }
</mat-card-content> </mat-card-content>
@ -44,7 +44,7 @@ import { Dashboard, Entry, Settings } from "../core/models";
matInput matInput
[(ngModel)]="search" [(ngModel)]="search"
(keyup.enter)="load()" (keyup.enter)="load()"
/><mat-icon matSuffix>search</mat-icon></mat-form-field
/><mat-icon matSuffix>search</mat-icon><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>Ausbildung</mat-label ><mat-label>Ausbildung</mat-label
><mat-select [(ngModel)]="typ" (selectionChange)="load()" ><mat-select [(ngModel)]="typ" (selectionChange)="load()"
@ -52,9 +52,9 @@ import { Dashboard, Entry, Settings } from "../core/models";
@for (t of filterTypes(); track t) { @for (t of filterTypes(); track t) {
<mat-option [value]="t">{{ typeLabel(t) }}</mat-option> <mat-option [value]="t">{{ typeLabel(t) }}</mat-option>
} }
</mat-select></mat-form-field
><mat-form-field appearance="outline"><mat-label>Von Jahr</mat-label><mat-select [(ngModel)]="minYear"><mat-option value="">Alle</mat-option>@for(y of years();track y){<mat-option [value]="y">{{y}}</mat-option>}</mat-select></mat-form-field
><mat-form-field appearance="outline"><mat-label>Bis Jahr</mat-label><mat-select [(ngModel)]="maxYear"><mat-option value="">Alle</mat-option>@for(y of years();track y){<mat-option [value]="y">{{y}}</mat-option>}</mat-select></mat-form-field
</mat-select><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline"><mat-label>Von Jahr</mat-label><mat-select [(ngModel)]="minYear"><mat-option value="">Alle</mat-option>@for(y of years();track y){<mat-option [value]="y">{{y}}</mat-option>}</mat-select><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline"><mat-label>Bis Jahr</mat-label><mat-select [(ngModel)]="maxYear"><mat-option value="">Alle</mat-option>@for(y of years();track y){<mat-option [value]="y">{{y}}</mat-option>}</mat-select><mat-hint></mat-hint></mat-form-field
><button mat-button (click)="reset()"> ><button mat-button (click)="reset()">
Zurücksetzen Zurücksetzen
</button></mat-card-content </button></mat-card-content

20
frontend/src/app/pages/entry-form.component.ts

@ -44,7 +44,7 @@ import {
matIconSuffix matIconSuffix
[for]="datePicker" [for]="datePicker"
aria-label="Datum auswählen" aria-label="Datum auswählen"
></mat-datepicker-toggle></mat-form-field
></mat-datepicker-toggle><mat-hint></mat-hint></mat-form-field
><mat-datepicker #datePicker></mat-datepicker ><mat-datepicker #datePicker></mat-datepicker
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>Beginn</mat-label ><mat-label>Beginn</mat-label
@ -57,7 +57,7 @@ import {
matIconSuffix matIconSuffix
[for]="startTimePicker" [for]="startTimePicker"
aria-label="Beginn auswählen" aria-label="Beginn auswählen"
></mat-timepicker-toggle></mat-form-field
></mat-timepicker-toggle><mat-hint></mat-hint></mat-form-field
><mat-timepicker #startTimePicker interval="15m"></mat-timepicker ><mat-timepicker #startTimePicker interval="15m"></mat-timepicker
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>Ende</mat-label ><mat-label>Ende</mat-label
@ -70,7 +70,7 @@ import {
matIconSuffix matIconSuffix
[for]="endTimePicker" [for]="endTimePicker"
aria-label="Ende auswählen" aria-label="Ende auswählen"
></mat-timepicker-toggle></mat-form-field
></mat-timepicker-toggle><mat-hint></mat-hint></mat-form-field
><mat-timepicker #endTimePicker interval="15m"></mat-timepicker ><mat-timepicker #endTimePicker interval="15m"></mat-timepicker
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>Stunden</mat-label ><mat-label>Stunden</mat-label
@ -78,7 +78,7 @@ import {
matInput matInput
type="number" type="number"
min="0" min="0"
formControlName="hours" /></mat-form-field
formControlName="hours" /><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>Minuten</mat-label ><mat-label>Minuten</mat-label
><input ><input
@ -86,7 +86,7 @@ import {
type="number" type="number"
min="0" min="0"
max="59" max="59"
formControlName="minutes" /></mat-form-field
formControlName="minutes" /><mat-hint></mat-hint></mat-form-field
><mat-checkbox formControlName="break" (change)="calculateTime()" ><mat-checkbox formControlName="break" (change)="calculateTime()"
>30 Minuten Mittagspause abziehen</mat-checkbox >30 Minuten Mittagspause abziehen</mat-checkbox
></mat-card-content ></mat-card-content
@ -104,21 +104,21 @@ import {
@for (t of types(); track t) { @for (t of types(); track t) {
<mat-option [value]="t">{{ typeLabel(t) }}</mat-option> <mat-option [value]="t">{{ typeLabel(t) }}</mat-option>
} }
</mat-select></mat-form-field
</mat-select><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>Art</mat-label ><mat-label>Art</mat-label
><mat-select formControlName="entry_art"> ><mat-select formControlName="entry_art">
@for (a of availableArts(); track a) { @for (a of availableArts(); track a) {
<mat-option [value]="a">{{ a }}</mat-option> <mat-option [value]="a">{{ a }}</mat-option>
} }
</mat-select></mat-form-field
</mat-select><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline" class="wide" ><mat-form-field appearance="outline" class="wide"
><mat-label>Beschreibung</mat-label ><mat-label>Beschreibung</mat-label
><textarea ><textarea
matInput matInput
rows="3" rows="3"
formControlName="beschreibung" formControlName="beschreibung"
></textarea></mat-form-field
></textarea><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>Entfernung</mat-label ><mat-label>Entfernung</mat-label
><input ><input
@ -126,7 +126,7 @@ import {
type="number" type="number"
min="0" min="0"
formControlName="distance_km" formControlName="distance_km"
/><span matTextSuffix>km</span></mat-form-field
/><span matTextSuffix>km</span><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>Kosten</mat-label ><mat-label>Kosten</mat-label
><input ><input
@ -135,7 +135,7 @@ import {
min="0" min="0"
step="0.01" step="0.01"
formControlName="kosten" formControlName="kosten"
/><span matTextSuffix>€</span></mat-form-field
/><span matTextSuffix>€</span><mat-hint></mat-hint></mat-form-field
><mat-checkbox formControlName="zaehlt_als_fortbildung" ><mat-checkbox formControlName="zaehlt_als_fortbildung"
>Zählt als Fortbildung</mat-checkbox >Zählt als Fortbildung</mat-checkbox
></mat-card-content ></mat-card-content

2
frontend/src/app/pages/forgot-password.component.ts

@ -1,3 +1,3 @@
import { Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { ApiService } from '../core/api.service'; import { MATERIAL } from '../shared/material'; import { authStyles } from './register.component'; import { Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { ApiService } from '../core/api.service'; import { MATERIAL } from '../shared/material'; import { authStyles } from './register.component';
@Component({standalone:true,imports:[ReactiveFormsModule,RouterLink,...MATERIAL],template:`<main class="auth-page"><mat-card><mat-card-content><mat-icon class="hero-icon">lock_reset</mat-icon><h1>Passwort zurücksetzen</h1><p class="muted">Wir senden dir einen Link zum Festlegen eines neuen Passworts.</p>@if(message()){<div class="success">{{message()}}</div>} @else {<form [formGroup]="form" (ngSubmit)="submit()"><mat-form-field appearance="outline" class="full"><mat-label>E-Mail</mat-label><input matInput type="email" formControlName="email"></mat-form-field><button mat-flat-button class="full" [disabled]="form.invalid||loading()">Link anfordern</button></form>}<a mat-button routerLink="/login">Zur Anmeldung</a></mat-card-content></mat-card></main>`,styles:[authStyles()]})
@Component({standalone:true,imports:[ReactiveFormsModule,RouterLink,...MATERIAL],template:`<main class="auth-page"><mat-card><mat-card-content><mat-icon class="hero-icon">lock_reset</mat-icon><h1>Passwort zurücksetzen</h1><p class="muted">Wir senden dir einen Link zum Festlegen eines neuen Passworts.</p>@if(message()){<div class="success">{{message()}}</div>} @else {<form [formGroup]="form" (ngSubmit)="submit()"><mat-form-field appearance="outline" class="full"><mat-label>E-Mail</mat-label><input matInput type="email" formControlName="email"><mat-hint></mat-hint></mat-form-field><button mat-flat-button class="full" [disabled]="form.invalid||loading()">Link anfordern</button></form>}<a mat-button routerLink="/login">Zur Anmeldung</a></mat-card-content></mat-card></main>`,styles:[authStyles()]})
export class ForgotPasswordComponent{private fb=inject(FormBuilder);loading=signal(false);message=signal('');form=this.fb.nonNullable.group({email:['',[Validators.required,Validators.email]]});constructor(private api:ApiService){}submit(){this.loading.set(true);this.api.forgotPassword(this.form.getRawValue().email).subscribe(r=>this.message.set(r.message));}} export class ForgotPasswordComponent{private fb=inject(FormBuilder);loading=signal(false);message=signal('');form=this.fb.nonNullable.group({email:['',[Validators.required,Validators.email]]});constructor(private api:ApiService){}submit(){this.loading.set(true);this.api.forgotPassword(this.form.getRawValue().email).subscribe(r=>this.message.set(r.message));}}

4
frontend/src/app/pages/login.component.ts

@ -20,7 +20,7 @@ import { ApiService } from "../core/api.service";
type="email" type="email"
formControlName="email" formControlName="email"
autocomplete="email" autocomplete="email"
/><mat-icon matSuffix>mail</mat-icon></mat-form-field
/><mat-icon matSuffix>mail</mat-icon><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline" class="full" ><mat-form-field appearance="outline" class="full"
><mat-label>Passwort</mat-label ><mat-label>Passwort</mat-label
><input ><input
@ -28,7 +28,7 @@ import { ApiService } from "../core/api.service";
type="password" type="password"
formControlName="password" formControlName="password"
autocomplete="current-password" autocomplete="current-password"
/><mat-icon matSuffix>lock</mat-icon></mat-form-field
/><mat-icon matSuffix>lock</mat-icon><mat-hint></mat-hint></mat-form-field
> >
@if (error()) { @if (error()) {
<p class="error">{{ error() }}</p> <p class="error">{{ error() }}</p>

4
frontend/src/app/pages/mileage-rates.component.ts

@ -20,7 +20,7 @@ import { MileageRate } from "../core/models";
><input ><input
matInput matInput
type="number" type="number"
[(ngModel)]="draft.year" /></mat-form-field
[(ngModel)]="draft.year" /><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>Satz pro km</mat-label ><mat-label>Satz pro km</mat-label
><input ><input
@ -28,7 +28,7 @@ import { MileageRate } from "../core/models";
type="number" type="number"
step="0.01" step="0.01"
[(ngModel)]="draft.rate_per_km" [(ngModel)]="draft.rate_per_km"
/><span matTextSuffix>€</span></mat-form-field
/><span matTextSuffix>€</span><mat-hint></mat-hint></mat-form-field
><button mat-flat-button (click)="save()"> ><button mat-flat-button (click)="save()">
Speichern Speichern
</button></mat-card-content </button></mat-card-content

6
frontend/src/app/pages/practice-transfer-export.component.html

@ -3,10 +3,10 @@
<p>Für den Datenaustausch beide Anwendungen über HTTPS öffnen.</p> <p>Für den Datenaustausch beide Anwendungen über HTTPS öffnen.</p>
@if(!transportSafe){<p role="alert">Bitte die Anwendung über HTTPS öffnen. Der Export ist über eine unverschlüsselte Verbindung gesperrt.</p>} @if(!transportSafe){<p role="alert">Bitte die Anwendung über HTTPS öffnen. Der Export ist über eine unverschlüsselte Verbindung gesperrt.</p>}
<form [formGroup]="form" (ngSubmit)="export()"> <form [formGroup]="form" (ngSubmit)="export()">
<mat-form-field appearance="outline" subscriptSizing="dynamic"><mat-label>Zeitraum von</mat-label><input matInput type="date" formControlName="from" [readonly]="busy()||!transportSafe"/></mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"><mat-label>Zeitraum bis</mat-label><input matInput type="date" formControlName="to" [readonly]="busy()||!transportSafe"/></mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"><mat-label>Zeitraum von</mat-label><input data-calendar-companion #calendarInput1 matInput type="date" formControlName="from" [readonly]="busy()||!transportSafe"/><app-date-picker-button matIconSuffix [target]="calendarInput1"/><mat-hint></mat-hint></mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"><mat-label>Zeitraum bis</mat-label><input data-calendar-companion #calendarInput2 matInput type="date" formControlName="to" [readonly]="busy()||!transportSafe"/><app-date-picker-button matIconSuffix [target]="calendarInput2"/><mat-hint></mat-hint></mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"><mat-label>Eigenes Exportpasswort</mat-label><input matInput type="password" autocomplete="new-password" formControlName="password" [readonly]="busy()||!transportSafe"/><mat-hint>Mindestens 12 Zeichen. Kein Kontopasswort verwenden.</mat-hint></mat-form-field> <mat-form-field appearance="outline" subscriptSizing="dynamic"><mat-label>Eigenes Exportpasswort</mat-label><input matInput type="password" autocomplete="new-password" formControlName="password" [readonly]="busy()||!transportSafe"/><mat-hint>Mindestens 12 Zeichen. Kein Kontopasswort verwenden.</mat-hint></mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"><mat-label>Exportpasswort wiederholen</mat-label><input matInput type="password" autocomplete="new-password" formControlName="confirmation" [readonly]="busy()||!transportSafe"/></mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"><mat-label>Exportpasswort wiederholen</mat-label><input matInput type="password" autocomplete="new-password" formControlName="confirmation" [readonly]="busy()||!transportSafe"/><mat-hint></mat-hint></mat-form-field>
<div class="full"><mat-checkbox formControlName="include_descriptions">Beschreibungen bewusst mitnehmen</mat-checkbox><p>Standardmäßig werden nur Datum, Kategorie, Minuten, Kilometer und Kosten übertragen. Beschreibungen können persönliche Angaben enthalten. Nicht benötigte Angaben vor dem Export entfernen.</p></div> <div class="full"><mat-checkbox formControlName="include_descriptions">Beschreibungen bewusst mitnehmen</mat-checkbox><p>Standardmäßig werden nur Datum, Kategorie, Minuten, Kilometer und Kosten übertragen. Beschreibungen können persönliche Angaben enthalten. Nicht benötigte Angaben vor dem Export entfernen.</p></div>
<p class="full">Maximal 500 Einträge je Export. Laufende Zeiterfassungen werden ausgelassen. Kosten werden als Notiz übernommen, ohne Buchung. Nachweise und fachliche Anrechnung anschließend in der Praxisverwaltung prüfen. Passwort und Datei getrennt aufbewahren.</p> <p class="full">Maximal 500 Einträge je Export. Laufende Zeiterfassungen werden ausgelassen. Kosten werden als Notiz übernommen, ohne Buchung. Nachweise und fachliche Anrechnung anschließend in der Praxisverwaltung prüfen. Passwort und Datei getrennt aufbewahren.</p>
<button mat-flat-button type="submit" [disabled]="busy()||!transportSafe">Verschlüsselten Praxisexport herunterladen</button> <button mat-flat-button type="submit" [disabled]="busy()||!transportSafe">Verschlüsselten Praxisexport herunterladen</button>

3
frontend/src/app/pages/practice-transfer-export.component.ts

@ -1,9 +1,10 @@
import { DatePickerButtonComponent } from '../shared/date-picker-button.component';
import { secureTransferOrigin } from '../core/secure-transfer-origin'; import { secureTransferOrigin } from '../core/secure-transfer-origin';
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MATERIAL } from '../shared/material'; import { MATERIAL } from '../shared/material';
@Component({selector:'app-practice-transfer-export',standalone:true,imports:[ReactiveFormsModule,...MATERIAL],
@Component({selector:'app-practice-transfer-export',standalone:true,imports: [DatePickerButtonComponent, ReactiveFormsModule,...MATERIAL],
templateUrl:'./practice-transfer-export.component.html',styleUrl:'./practice-transfer-export.component.scss'}) templateUrl:'./practice-transfer-export.component.html',styleUrl:'./practice-transfer-export.component.scss'})
export class PracticeTransferExportComponent { export class PracticeTransferExportComponent {
private readonly http=inject(HttpClient); private readonly http=inject(HttpClient);

2
frontend/src/app/pages/register.component.ts

@ -4,6 +4,6 @@ import { RouterLink } from '@angular/router';
import { ApiService } from '../core/api.service'; import { ApiService } from '../core/api.service';
import { MATERIAL } from '../shared/material'; import { MATERIAL } from '../shared/material';
@Component({standalone:true,imports:[ReactiveFormsModule,RouterLink,...MATERIAL],template:`<main class="auth-page"><mat-card><mat-card-content><mat-icon class="hero-icon">person_add</mat-icon><h1>Konto registrieren</h1><p class="muted">Nach der Registrierung erhältst du einen Bestätigungslink per E-Mail.</p>@if(message()){<div class="success">{{message()}}</div>} @else {<form [formGroup]="form" (ngSubmit)="submit()"><mat-form-field appearance="outline" class="full"><mat-label>E-Mail</mat-label><input matInput type="email" formControlName="email"></mat-form-field><mat-form-field appearance="outline" class="full"><mat-label>Passwort</mat-label><input matInput type="password" formControlName="password"></mat-form-field><mat-form-field appearance="outline" class="full"><mat-label>Passwort wiederholen</mat-label><input matInput type="password" formControlName="password_confirmation"></mat-form-field>@if(error()){<p class="error">{{error()}}</p>}<button mat-flat-button class="full" [disabled]="form.invalid||loading()">Registrieren</button></form>}<a mat-button routerLink="/login">Zur Anmeldung</a></mat-card-content></mat-card></main>`,styles:[authStyles()]})
@Component({standalone:true,imports:[ReactiveFormsModule,RouterLink,...MATERIAL],template:`<main class="auth-page"><mat-card><mat-card-content><mat-icon class="hero-icon">person_add</mat-icon><h1>Konto registrieren</h1><p class="muted">Nach der Registrierung erhältst du einen Bestätigungslink per E-Mail.</p>@if(message()){<div class="success">{{message()}}</div>} @else {<form [formGroup]="form" (ngSubmit)="submit()"><mat-form-field appearance="outline" class="full"><mat-label>E-Mail</mat-label><input matInput type="email" formControlName="email"><mat-hint></mat-hint></mat-form-field><mat-form-field appearance="outline" class="full"><mat-label>Passwort</mat-label><input matInput type="password" formControlName="password"><mat-hint></mat-hint></mat-form-field><mat-form-field appearance="outline" class="full"><mat-label>Passwort wiederholen</mat-label><input matInput type="password" formControlName="password_confirmation"><mat-hint></mat-hint></mat-form-field>@if(error()){<p class="error">{{error()}}</p>}<button mat-flat-button class="full" [disabled]="form.invalid||loading()">Registrieren</button></form>}<a mat-button routerLink="/login">Zur Anmeldung</a></mat-card-content></mat-card></main>`,styles:[authStyles()]})
export class RegisterComponent{private fb=inject(FormBuilder);loading=signal(false);error=signal('');message=signal('');form=this.fb.nonNullable.group({email:['',[Validators.required,Validators.email]],password:['',[Validators.required,Validators.minLength(6)]],password_confirmation:['',Validators.required]});constructor(private api:ApiService){}submit(){const v=this.form.getRawValue();if(v.password!==v.password_confirmation){this.error.set('Die Passwörter stimmen nicht überein.');return;}this.loading.set(true);this.api.register(v.email,v.password,v.password_confirmation).subscribe({next:r=>this.message.set(r.message),error:e=>{this.loading.set(false);this.error.set(e.error?.errors?.join(', ')||'Registrierung fehlgeschlagen');}});}} export class RegisterComponent{private fb=inject(FormBuilder);loading=signal(false);error=signal('');message=signal('');form=this.fb.nonNullable.group({email:['',[Validators.required,Validators.email]],password:['',[Validators.required,Validators.minLength(6)]],password_confirmation:['',Validators.required]});constructor(private api:ApiService){}submit(){const v=this.form.getRawValue();if(v.password!==v.password_confirmation){this.error.set('Die Passwörter stimmen nicht überein.');return;}this.loading.set(true);this.api.register(v.email,v.password,v.password_confirmation).subscribe({next:r=>this.message.set(r.message),error:e=>{this.loading.set(false);this.error.set(e.error?.errors?.join(', ')||'Registrierung fehlgeschlagen');}});}}
export function authStyles(){return `.auth-page{min-height:100%;display:grid;place-items:center;padding:20px;background:#f4f7fb}.auth-page mat-card{width:min(460px,100%);padding:24px;border-radius:24px!important}.hero-icon{font-size:38px;width:42px;height:42px;color:#155bd7}.full{width:100%;margin-top:10px}button.full{height:48px}.error{color:#b3261e}.success{padding:16px;border-radius:12px;background:#dff6e7;color:#125c2d;margin:18px 0}h1{margin-bottom:6px}`;} export function authStyles(){return `.auth-page{min-height:100%;display:grid;place-items:center;padding:20px;background:#f4f7fb}.auth-page mat-card{width:min(460px,100%);padding:24px;border-radius:24px!important}.hero-icon{font-size:38px;width:42px;height:42px;color:#155bd7}.full{width:100%;margin-top:10px}button.full{height:48px}.error{color:#b3261e}.success{padding:16px;border-radius:12px;background:#dff6e7;color:#125c2d;margin:18px 0}h1{margin-bottom:6px}`;}

2
frontend/src/app/pages/resend-confirmation.component.ts

@ -1,3 +1,3 @@
import { Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { ApiService } from '../core/api.service'; import { MATERIAL } from '../shared/material'; import { authStyles } from './register.component'; import { Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { ApiService } from '../core/api.service'; import { MATERIAL } from '../shared/material'; import { authStyles } from './register.component';
@Component({standalone:true,imports:[ReactiveFormsModule,RouterLink,...MATERIAL],template:`<main class="auth-page"><mat-card><mat-card-content><mat-icon class="hero-icon">forward_to_inbox</mat-icon><h1>Bestätigungslink erneut senden</h1>@if(message()){<div class="success">{{message()}}</div>} @else {<form [formGroup]="form" (ngSubmit)="submit()"><mat-form-field appearance="outline" class="full"><mat-label>E-Mail</mat-label><input matInput type="email" formControlName="email"></mat-form-field><button mat-flat-button class="full" [disabled]="form.invalid||loading()">Link senden</button></form>}<a mat-button routerLink="/login">Zur Anmeldung</a></mat-card-content></mat-card></main>`,styles:[authStyles()]})
@Component({standalone:true,imports:[ReactiveFormsModule,RouterLink,...MATERIAL],template:`<main class="auth-page"><mat-card><mat-card-content><mat-icon class="hero-icon">forward_to_inbox</mat-icon><h1>Bestätigungslink erneut senden</h1>@if(message()){<div class="success">{{message()}}</div>} @else {<form [formGroup]="form" (ngSubmit)="submit()"><mat-form-field appearance="outline" class="full"><mat-label>E-Mail</mat-label><input matInput type="email" formControlName="email"><mat-hint></mat-hint></mat-form-field><button mat-flat-button class="full" [disabled]="form.invalid||loading()">Link senden</button></form>}<a mat-button routerLink="/login">Zur Anmeldung</a></mat-card-content></mat-card></main>`,styles:[authStyles()]})
export class ResendConfirmationComponent{private fb=inject(FormBuilder);loading=signal(false);message=signal('');form=this.fb.nonNullable.group({email:['',[Validators.required,Validators.email]]});constructor(private api:ApiService){}submit(){this.loading.set(true);this.api.resendConfirmation(this.form.getRawValue().email).subscribe(r=>this.message.set(r.message));}} export class ResendConfirmationComponent{private fb=inject(FormBuilder);loading=signal(false);message=signal('');form=this.fb.nonNullable.group({email:['',[Validators.required,Validators.email]]});constructor(private api:ApiService){}submit(){this.loading.set(true);this.api.resendConfirmation(this.form.getRawValue().email).subscribe(r=>this.message.set(r.message));}}

2
frontend/src/app/pages/reset-password.component.ts

@ -1,3 +1,3 @@
import { Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { ApiService } from '../core/api.service'; import { MATERIAL } from '../shared/material'; import { authStyles } from './register.component'; import { Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { ApiService } from '../core/api.service'; import { MATERIAL } from '../shared/material'; import { authStyles } from './register.component';
@Component({standalone:true,imports:[ReactiveFormsModule,RouterLink,...MATERIAL],template:`<main class="auth-page"><mat-card><mat-card-content><mat-icon class="hero-icon">password</mat-icon><h1>Neues Passwort</h1>@if(message()){<div class="success">{{message()}}</div>} @else {<form [formGroup]="form" (ngSubmit)="submit()"><mat-form-field appearance="outline" class="full"><mat-label>Neues Passwort</mat-label><input matInput type="password" formControlName="password"></mat-form-field><mat-form-field appearance="outline" class="full"><mat-label>Passwort wiederholen</mat-label><input matInput type="password" formControlName="password_confirmation"></mat-form-field>@if(error()){<p class="error">{{error()}}</p>}<button mat-flat-button class="full" [disabled]="form.invalid||loading()">Passwort speichern</button></form>}<a mat-button routerLink="/login">Zur Anmeldung</a></mat-card-content></mat-card></main>`,styles:[authStyles()]})
@Component({standalone:true,imports:[ReactiveFormsModule,RouterLink,...MATERIAL],template:`<main class="auth-page"><mat-card><mat-card-content><mat-icon class="hero-icon">password</mat-icon><h1>Neues Passwort</h1>@if(message()){<div class="success">{{message()}}</div>} @else {<form [formGroup]="form" (ngSubmit)="submit()"><mat-form-field appearance="outline" class="full"><mat-label>Neues Passwort</mat-label><input matInput type="password" formControlName="password"><mat-hint></mat-hint></mat-form-field><mat-form-field appearance="outline" class="full"><mat-label>Passwort wiederholen</mat-label><input matInput type="password" formControlName="password_confirmation"><mat-hint></mat-hint></mat-form-field>@if(error()){<p class="error">{{error()}}</p>}<button mat-flat-button class="full" [disabled]="form.invalid||loading()">Passwort speichern</button></form>}<a mat-button routerLink="/login">Zur Anmeldung</a></mat-card-content></mat-card></main>`,styles:[authStyles()]})
export class ResetPasswordComponent{private fb=inject(FormBuilder);token=inject(ActivatedRoute).snapshot.queryParamMap.get('token')||'';loading=signal(false);error=signal('');message=signal('');form=this.fb.nonNullable.group({password:['',[Validators.required,Validators.minLength(6)]],password_confirmation:['',Validators.required]});constructor(private api:ApiService){}submit(){const v=this.form.getRawValue();if(!this.token){this.error.set('Der Reset-Link ist ungültig.');return;}if(v.password!==v.password_confirmation){this.error.set('Die Passwörter stimmen nicht überein.');return;}this.loading.set(true);this.api.resetPassword(this.token,v.password,v.password_confirmation).subscribe({next:r=>this.message.set(r.message),error:e=>{this.loading.set(false);this.error.set(e.error?.errors?.join(', ')||'Passwort konnte nicht geändert werden');}});}} export class ResetPasswordComponent{private fb=inject(FormBuilder);token=inject(ActivatedRoute).snapshot.queryParamMap.get('token')||'';loading=signal(false);error=signal('');message=signal('');form=this.fb.nonNullable.group({password:['',[Validators.required,Validators.minLength(6)]],password_confirmation:['',Validators.required]});constructor(private api:ApiService){}submit(){const v=this.form.getRawValue();if(!this.token){this.error.set('Der Reset-Link ist ungültig.');return;}if(v.password!==v.password_confirmation){this.error.set('Die Passwörter stimmen nicht überein.');return;}this.loading.set(true);this.api.resetPassword(this.token,v.password,v.password_confirmation).subscribe({next:r=>this.message.set(r.message),error:e=>{this.loading.set(false);this.error.set(e.error?.errors?.join(', ')||'Passwort konnte nicht geändert werden');}});}}

12
frontend/src/app/pages/settings.component.ts

@ -61,7 +61,7 @@ import { Settings } from "../core/models";
><mat-form-field appearance="outline" class="full" ><mat-form-field appearance="outline" class="full"
><mat-label>E-Mail-Adresse</mat-label ><mat-label>E-Mail-Adresse</mat-label
><input matInput type="email" [(ngModel)]="s.email" ><input matInput type="email" [(ngModel)]="s.email"
/></mat-form-field>
/><mat-hint></mat-hint></mat-form-field>
<p class="muted"> <p class="muted">
Bei einer Änderung muss die neue Adresse erneut bestätigt werden. Bei einer Änderung muss die neue Adresse erneut bestätigt werden.
</p></mat-card-content </p></mat-card-content
@ -86,7 +86,7 @@ import { Settings } from "../core/models";
type="number" type="number"
min="0" min="0"
[(ngModel)]="s.required_hours_matrix[typ][art]" [(ngModel)]="s.required_hours_matrix[typ][art]"
/><span matTextSuffix>h</span></mat-form-field
/><span matTextSuffix>h</span><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>pro Woche</mat-label ><mat-label>pro Woche</mat-label
><input ><input
@ -95,7 +95,7 @@ import { Settings } from "../core/models";
min="0" min="0"
step="0.25" step="0.25"
[(ngModel)]="s.weekly_target_matrix[typ][art]" [(ngModel)]="s.weekly_target_matrix[typ][art]"
/><span matTextSuffix>h</span></mat-form-field
/><span matTextSuffix>h</span><mat-hint></mat-hint></mat-form-field
> >
</div> </div>
} }
@ -112,19 +112,19 @@ import { Settings } from "../core/models";
><input ><input
matInput matInput
type="password" type="password"
[(ngModel)]="currentPassword" /></mat-form-field
[(ngModel)]="currentPassword" /><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>Neues Passwort</mat-label ><mat-label>Neues Passwort</mat-label
><input ><input
matInput matInput
type="password" type="password"
[(ngModel)]="newPassword" /></mat-form-field
[(ngModel)]="newPassword" /><mat-hint></mat-hint></mat-form-field
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
><mat-label>Neues Passwort wiederholen</mat-label ><mat-label>Neues Passwort wiederholen</mat-label
><input ><input
matInput matInput
type="password" type="password"
[(ngModel)]="confirmation" /></mat-form-field
[(ngModel)]="confirmation" /><mat-hint></mat-hint></mat-form-field
><button mat-stroked-button (click)="changePassword()"> ><button mat-stroked-button (click)="changePassword()">
Passwort ändern Passwort ändern
</button></mat-card-content </button></mat-card-content

55
frontend/src/app/shared/date-picker-button.component.spec.ts

@ -0,0 +1,55 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { afterEach, describe, expect, it } from 'vitest';
import { DatePickerButtonComponent } from './date-picker-button.component';
@Component({ imports: [DatePickerButtonComponent, ReactiveFormsModule], template: `
<input #date type="date" [formControl]="control" min="2020-01-01" max="2030-12-31" (change)="changes = changes + 1"/>
<app-date-picker-button [target]="date"/>
` })
class Host { control = new FormControl('2026-10-10'); changes = 0; }
describe('DatePickerButtonComponent', () => {
afterEach(() => { document.documentElement.lang = 'de'; TestBed.resetTestingModule(); });
function setup() {
const fixture = TestBed.createComponent(Host); fixture.detectChanges();
const component = fixture.debugElement.children[1].componentInstance as DatePickerButtonComponent;
return { fixture, component, target: fixture.nativeElement.querySelector('input') as HTMLInputElement };
}
it('updates the existing reactive control and change-based filters with a local ISO date', () => {
const {fixture, component} = setup();
component.select(new Date(2028, 1, 29));
expect(fixture.componentInstance.control.value).toBe('2028-02-29');
expect(fixture.componentInstance.control.dirty).toBe(true);
expect(fixture.componentInstance.control.touched).toBe(true);
expect(fixture.componentInstance.changes).toBe(1);
});
it('preserves typed time including minutes when choosing a calendar day', () => {
const {component, target} = setup(); target.type='datetime-local'; target.value='2026-10-10T14:35';
component.select(new Date(2026, 9, 12));
expect(target.value).toBe('2026-10-12T14:35');
});
it('rejects out-of-range selections and leaves disabled or readonly fields unchanged', async () => {
const {fixture, component, target} = setup();
component.select(new Date(2031, 0, 1)); expect(target.value).toBe('2026-10-10');
target.readOnly=true; component.select(new Date(2026, 0, 1)); component.open();
expect(document.querySelector('.mat-datepicker-content')).toBeNull();
target.readOnly=false; fixture.componentInstance.control.disable(); fixture.detectChanges();
component.select(new Date(2026, 0, 1));
expect(target.value).toBe('2026-10-10');
await fixture.whenStable(); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button').disabled).toBe(true);
});
it('opens with current value and boundaries, localizes labels, and restores keyboard focus', async () => {
const {fixture, component} = setup(); document.documentElement.lang='en'; fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
button.focus(); button.click(); fixture.detectChanges(); await fixture.whenStable();
expect(component.selected()?.getDate()).toBe(10);
expect(component.minimum()?.getFullYear()).toBe(2020);
expect(component.maximum()?.getFullYear()).toBe(2030);
expect(document.querySelector('.mat-datepicker-content')).not.toBeNull();
expect(button.getAttribute('aria-label')).toBe('Choose date');
component.restoreFocus(); expect(document.activeElement).toBe(button);
});
});

103
frontend/src/app/shared/date-picker-button.component.ts

@ -0,0 +1,103 @@
import { DOCUMENT } from '@angular/common';
import { afterRenderEffect, ChangeDetectorRef, Component, inject, input, signal, viewChild } from '@angular/core';
import { DateAdapter, provideNativeDateAdapter } from '@angular/material/core';
import { MatDatepicker, MatDatepickerIntl, MatDatepickerModule } from '@angular/material/datepicker';
/** Calendar companion for ISO string inputs; the original control remains authoritative. */
@Component({
selector: 'app-date-picker-button',
standalone: true,
imports: [MatDatepickerModule],
providers: [provideNativeDateAdapter(), MatDatepickerIntl],
template: `
<button type="button" class="calendar-button" [disabled]="unavailable()"
[attr.aria-label]="label()" [title]="label()" aria-haspopup="dialog" (click)="open()">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 2v4m8-4v4M3 9h18M5 4h14a2 2 0 0 1 2 2v14H3V6a2 2 0 0 1 2-2Z"/><path d="M7 13h2m3 0h2m3 0h1M7 17h2m3 0h2"/></svg>
</button>
<input class="calendar-anchor" tabindex="-1" aria-hidden="true" readonly
[matDatepicker]="picker" [value]="selected()" [min]="minimum()" [max]="maximum()"
(dateChange)="select($event.value)"/>
<mat-datepicker #picker [startAt]="selected()" (closed)="restoreFocus()"/>
`,
styles: [`
:host{display:inline-flex;position:relative;align-items:center;vertical-align:middle}
.calendar-button{display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;flex:none;padding:10px;border:0;border-radius:50%;background:transparent;color:var(--green-700,var(--mat-sys-primary,currentColor));cursor:pointer}
.calendar-button:hover:not(:disabled){background:var(--mint-100,var(--mat-sys-surface-container,transparent))}
.calendar-button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
.calendar-button:disabled{opacity:.4;cursor:default}
svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
.calendar-anchor{position:absolute;bottom:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;padding:0;border:0}
`]
})
export class DatePickerButtonComponent {
readonly target = input.required<HTMLInputElement>();
readonly selected = signal<Date | null>(null);
readonly minimum = signal<Date | null>(null);
readonly maximum = signal<Date | null>(null);
readonly unavailable = signal(false);
private readonly document = inject(DOCUMENT);
private readonly changeDetector = inject(ChangeDetectorRef);
private readonly adapter = inject(DateAdapter<Date>);
private readonly intl = inject(MatDatepickerIntl);
private readonly picker = viewChild.required<MatDatepicker<Date>>('picker');
private opener: HTMLElement | null = null;
constructor() {
afterRenderEffect(onCleanup => {
const target = this.target();
const update = () => {
this.unavailable.set(target.disabled || target.readOnly);
if (this.unavailable()) this.picker().close();
};
update();
const observer = new MutationObserver(update);
observer.observe(target, { attributes: true, attributeFilter: ['disabled', 'readonly'] });
onCleanup(() => observer.disconnect());
});
}
label(): string { return this.english() ? 'Choose date' : 'Datum auswählen'; }
open(): void {
const target = this.target();
if (target.disabled || target.readOnly) return;
this.opener = this.document.activeElement as HTMLElement;
const english = this.english();
this.adapter.setLocale(english ? 'en-GB' : 'de-AT');
Object.assign(this.intl, english ? {
calendarLabel: 'Calendar', openCalendarLabel: 'Choose date', closeCalendarLabel: 'Close calendar',
prevMonthLabel: 'Previous month', nextMonthLabel: 'Next month', prevYearLabel: 'Previous year', nextYearLabel: 'Next year',
prevMultiYearLabel: 'Previous years', nextMultiYearLabel: 'Next years', switchToMonthViewLabel: 'Choose date', switchToMultiYearViewLabel: 'Choose month and year'
} : {
calendarLabel: 'Kalender', openCalendarLabel: 'Datum auswählen', closeCalendarLabel: 'Kalender schließen',
prevMonthLabel: 'Vorheriger Monat', nextMonthLabel: 'Nächster Monat', prevYearLabel: 'Vorheriges Jahr', nextYearLabel: 'Nächstes Jahr',
prevMultiYearLabel: 'Vorherige Jahre', nextMultiYearLabel: 'Nächste Jahre', switchToMonthViewLabel: 'Datum auswählen', switchToMultiYearViewLabel: 'Monat und Jahr auswählen'
});
this.intl.changes.next();
this.selected.set(this.parse(target.value));
this.minimum.set(this.parse(target.min));
this.maximum.set(this.parse(target.max));
this.changeDetector.detectChanges();
this.picker().open();
}
select(date: Date | null): void {
const target = this.target();
if (!date || target.disabled || target.readOnly || Number.isNaN(date.getTime())) return;
const value = `${String(date.getFullYear()).padStart(4, '0')}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
if ((target.min && value < target.min.slice(0, 10)) || (target.max && value > target.max.slice(0, 10))) return;
target.value = target.type === 'datetime-local' ? `${value}T${target.value.split('T')[1] || '00:00'}` : value;
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
target.dispatchEvent(new Event('blur'));
}
restoreFocus(): void { if (this.opener?.isConnected) this.opener.focus(); }
private english(): boolean { return this.document.documentElement.lang.startsWith('en'); }
private parse(value: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T|$)/.exec(value);
if (!match) return null;
const date = new Date(0); date.setFullYear(+match[1], +match[2] - 1, +match[3]); date.setHours(0, 0, 0, 0);
return date.getFullYear() === +match[1] && date.getMonth() === +match[2] - 1 && date.getDate() === +match[3] ? date : null;
}
}

20
frontend/src/form-field-layout.scss

@ -0,0 +1,20 @@
// Reserve a hint line even when empty; longer hints/errors can grow on mobile.
.mat-mdc-form-field .mat-mdc-form-field-subscript-wrapper {
min-height: 22px;
}
.mat-mdc-form-field .mat-mdc-form-field-hint-wrapper,
.mat-mdc-form-field .mat-mdc-form-field-error-wrapper {
position: static;
}
// Align adjacent actions with the input, not the input plus its hint row.
:is(form, .filters, .filter-row, .filter-bar, .filter-controls, .help-search):has(> mat-form-field) > button {
align-self: start;
min-height: 56px;
height: auto;
}
input[data-calendar-companion]::-webkit-calendar-picker-indicator { display: none; }
.mat-mdc-form-field-icon-suffix:has(app-date-picker-button) {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
}

3
frontend/src/main.ts

@ -5,6 +5,7 @@ import { provideAnimationsAsync } from '@angular/platform-browser/animations/asy
import { AppComponent } from './app/app.component'; import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes'; import { routes } from './app/app.routes';
import { authInterceptor } from './app/core/auth.interceptor'; import { authInterceptor } from './app/core/auth.interceptor';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
bootstrapApplication(AppComponent, { providers: [provideRouter(routes), provideHttpClient(withInterceptors([authInterceptor])), provideAnimationsAsync()] })
bootstrapApplication(AppComponent, { providers: [{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { subscriptSizing: 'dynamic' } }, provideRouter(routes), provideHttpClient(withInterceptors([authInterceptor])), provideAnimationsAsync()] })
.catch(console.error); .catch(console.error);

1
frontend/src/styles.scss

@ -1,4 +1,5 @@
@use '@angular/material' as mat; @use '@angular/material' as mat;
@use './form-field-layout';
@import 'material-icons/iconfont/material-icons.css'; @import 'material-icons/iconfont/material-icons.css';
$font: 'Inter Variable', 'Segoe UI', system-ui, sans-serif; $font: 'Inter Variable', 'Segoe UI', system-ui, sans-serif;

98
test/proxy_transport_test.py

@ -0,0 +1,98 @@
"""Exercise the shipped Nginx template with a local header-echo upstream.
Run: python3 test/proxy_transport_test.py /path/to/nginx
No production data, credentials, TLS assumption or Rails runtime required.
"""
import http.server
import json
import os
import pwd
from pathlib import Path
import socket
import subprocess
import sys
import tempfile
import threading
import time
import urllib.request
class Echo(http.server.BaseHTTPRequestHandler):
def do_GET(self):
body = json.dumps(dict(self.headers)).encode()
self.send_response(200)
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
def port():
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
return sock.getsockname()[1]
def check(nginx, trusted_cidr, upstream, cases):
template = (Path(__file__).resolve().parents[1] / 'frontend/nginx.conf').read_text()
listen = port()
with tempfile.TemporaryDirectory(prefix='pcm-proxy-test-') as directory:
config = Path(directory) / 'nginx.conf'
rendered = template.replace('${TRUSTED_TLS_PROXY_CIDR}', trusted_cidr)
rendered = rendered.replace('listen 80;', f'listen 127.0.0.1:{listen};')
rendered = rendered.replace('http://api:3000', f'http://127.0.0.1:{upstream}')
current_user = pwd.getpwuid(os.geteuid()).pw_name
config.write_text(f'user {current_user}; master_process off; daemon off; pid {directory}/nginx.pid;\n'
f'error_log {directory}/error.log; events {{}}\nhttp {{ access_log off; '
f'client_body_temp_path {directory}/body; proxy_temp_path {directory}/proxy; '
f'fastcgi_temp_path {directory}/fastcgi; uwsgi_temp_path {directory}/uwsgi; '
f'scgi_temp_path {directory}/scgi; {rendered} }}')
validation = subprocess.run([nginx, '-t', '-c', str(config), '-p', directory], capture_output=True, text=True)
if validation.returncode:
raise RuntimeError(validation.stderr)
process = subprocess.Popen([nginx, '-c', str(config), '-p', directory], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
try:
for attempt in range(100):
try:
with socket.create_connection(('127.0.0.1', listen), timeout=.1):
break
except OSError:
if process.poll() is not None:
raise RuntimeError(process.stderr.read().decode())
time.sleep(.02)
for headers, expected in cases:
for path in ['/api/v1/practice_transfer', '/admin/pghero']:
request = urllib.request.Request(f'http://127.0.0.1:{listen}{path}', headers=headers)
with urllib.request.urlopen(request, timeout=3) as response:
forwarded = json.load(response)
assert forwarded['X-Forwarded-Proto'] == expected, forwarded
assert 'Forwarded' not in forwarded, forwarded
assert 'X-Forwarded-Ssl' not in forwarded, forwarded
assert 'X-Forwarded-Scheme' not in forwarded, forwarded
finally:
process.terminate()
process.communicate(timeout=5)
if __name__ == '__main__':
nginx = str(Path(sys.argv[1]).resolve()) if len(sys.argv) > 1 else 'nginx'
upstream = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Echo)
thread = threading.Thread(target=upstream.serve_forever, daemon=True)
thread.start()
try:
check(nginx, '127.0.0.1/32', upstream.server_port, [
({'X-Forwarded-Proto': 'https'}, 'https'),
({'X-Forwarded-Proto': 'http'}, 'http'),
({}, 'http'),
({'X-Forwarded-Proto': 'https,http'}, 'http'),
({'Forwarded': 'proto=https', 'X-Forwarded-Ssl': 'on', 'X-Forwarded-Scheme': 'https'}, 'http'),
])
check(nginx, '192.0.2.1/32', upstream.server_port, [
({'X-Forwarded-Proto': 'https', 'Forwarded': 'proto=https', 'X-Forwarded-Ssl': 'on'}, 'http'),
({}, 'http'),
])
print('PASS: 14 proxy transport cases; trusted HTTPS retained, untrusted assertions rejected.')
finally:
upstream.shutdown()
upstream.server_close()
Loading…
Cancel
Save