29 changed files with 1403 additions and 2334 deletions
@ -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. |
||||
@ -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. |
||||
@ -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") |
||||
3236
frontend/package-lock.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -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.`); |
||||
@ -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));}} |
||||
@ -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));}} |
||||
@ -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');}});}} |
||||
@ -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); |
||||
|
}); |
||||
|
}); |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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…
Reference in new issue