Browse Source

add data export

main
Christoph Marzell 1 month ago
parent
commit
910e3090e5
  1. 4
      README.md
  2. 85
      RELEASE_2026.10.02.1.md
  3. 5
      app/controllers/api/v1/base_controller.rb
  4. 31
      app/controllers/api/v1/practice_transfers_controller.rb
  5. 48
      app/services/practice_transfer_export.rb
  6. 57
      app/services/professional_transfer_envelope.rb
  7. 2
      config/application.rb
  8. 2
      config/database.yml
  9. 2
      config/environments/production.rb
  10. 4
      config/initializers/build_version.rb
  11. 2
      config/initializers/filter_parameter_logging.rb
  12. 1
      config/routes.rb
  13. 6
      db/migrate/20261002080000_add_practice_transfer_id_to_users.rb
  14. 2
      docker-compose-dev.yml
  15. 1
      docker-compose.yml
  16. 74
      frontend/angular.json
  17. 1839
      frontend/package-lock.json
  18. 36
      frontend/package.json
  19. 2
      frontend/src/app/app.component.ts
  20. 11
      frontend/src/app/core/secure-transfer-origin.spec.ts
  21. 4
      frontend/src/app/core/secure-transfer-origin.ts
  22. 17
      frontend/src/app/pages/practice-transfer-export.component.html
  23. 1
      frontend/src/app/pages/practice-transfer-export.component.scss
  24. 30
      frontend/src/app/pages/practice-transfer-export.component.spec.ts
  25. 29
      frontend/src/app/pages/practice-transfer-export.component.ts
  26. 4
      frontend/src/app/pages/reports.component.ts
  27. 13
      frontend/tsconfig.spec.json
  28. 41
      test/practice_transfer_test.rb

4
README.md

@ -1,3 +1,7 @@
## Build 2026.10.02.1 – Praxisexport
Im Monatsbericht Fahrten und Ausbildungsaktivitäten passwortverschlüsselt an Praxisverwaltung 0.118.0 übertragen. [Bedienung, Installation und Prüfstatus](RELEASE_2026.10.02.1.md).
# Ausbildungsnachweis
Die bisherige Rails-Webanwendung wurde in zwei klar getrennte Anwendungen umgebaut:

85
RELEASE_2026.10.02.1.md

@ -0,0 +1,85 @@
# Praktikumssoftware 2026.10.02.1 – Geschützter Praxisexport
Stand: 02.10.2026. Ausgangspunkt Praxisverwaltung 0.117.0 und Praktikumssoftware Build 2026.08.20.1. Die Änderungen aus 0.117.0 bleiben enthalten. Passender neuer Praktikums-Build: 2026.10.02.1 (Frontend-Paket 1.1.0).
## Bedienung
1. Beide Anwendungen aktualisieren und ihre Datenbankmigrationen ausführen.
2. In der Praktikumssoftware anmelden und **Monatsbericht** öffnen. Zeitraum und ein eigenes Exportpasswort mit mindestens zwölf Zeichen angeben. **Verschlüsselten Praxisexport herunterladen** wählen.
3. In der Praxissoftware im gewünschten eigenen Konto anmelden. Über **Schnell finden → Praktikumsdaten importieren** oder **Fahrten & Fortbildung → Daten & Notfallplan** den Import öffnen.
4. `.pcmtransfer`-Datei und Exportpasswort eingeben. **Vorschau prüfen**, neue Einträge auswählen und die fachliche Prüfung bestätigen. **Auswahl verbindlich importieren** legt nur diese Datensätze an.
5. Fahrten erscheinen im Fahrtenbuch, Lernaktivitäten unter Bildung & Nachweise. Kategorien, Ausbildungsphase, Kilometeransatz und fehlende Strecken/Fahrzeugdaten prüfen. Nachweisdateien können anschließend am Bildungseintrag ergänzt werden.
Dies ist ein geschützter, bewusst ausgelöster Dateiaustausch. Es handelt sich nicht um eine laufende Synchronisation oder um ein vollständiges Backup.
## Daten und fachliche Grenzen
- Datum, Ausbildungsbereich, Tätigkeitsart und Originalminuten werden übernommen. Stunden werden auf zwei Dezimalstellen umgerechnet; Originalminuten bleiben in den Notizen.
- Kilometer und der im Quellsystem konfigurierte Jahressatz werden übernommen. Fehlt dort ein Satz, verwendet der Export den bestehenden Quellsystem-Ersatzwert 0,42 EUR/km und markiert ihn intern als Ersatzwert. **Jeder importierte Kilometeransatz ist selbst zu prüfen**; die Software ermittelt hier keine steuerliche Zulässigkeit.
- Kosten bleiben Herkunftsnotizen. Es entstehen weder Ausgabenbuchungen noch Zahlungszuordnungen.
- Ausbildungsaktivitäten werden zunächst als `training` erfasst. Vergangene Aktivitäten sind `completed`, zukünftige `planned`. Keine ECTS-Umrechnung, keine automatische Mediationsstunden-Zuordnung oder behördliche Anerkennung.
- Unbekannte Tätigkeitsarten werden als Sonstiges gespeichert. Quelle und Originalart bleiben nachvollziehbar.
- Beschreibungen werden nur bei ausdrücklicher Auswahl im Export mitgenommen. Standardmäßig sind sie ausgeschlossen. Namen von Klienten, Kontopasswörter, E-Mail-Adressen und API-Tokens sind keine Exportfelder.
- Das vorhandene Quellmodell besitzt keine angehängten Nachweisdateien. Diese werden deshalb nicht übertragen und können im Ziel ergänzt werden.
- Laufende Zeiterfassungen werden ausgelassen und als Anzahl angezeigt. Einträge ohne Minuten und ohne Kilometer erzeugen keinen Ziel-Datensatz. Maximal 500 Quelleinträge beziehungsweise 1.000 Zielzeilen pro Export, maximal 4 MiB Datei.
- Ein Quelleintrag mit Kilometern und Zeit wird zu zwei unabhängig auswählbaren Zielzeilen: Fahrt und Lernaktivität.
## Schutzmaßnahmen
AES-256-GCM mit zufälligem Salt und IV; Schlüsselableitung PBKDF2-HMAC-SHA256 mit 600.000 Iterationen. Das Passwort steht nicht in der Datei. Veränderung und falsches Passwort führen zur Ablehnung. Algorithmus und KDF-Aufwand sind fest vorgegeben. Das Format ist versioniert.
Export und Import benötigen jeweils die Anmeldung im betreffenden Konto. Die Quelle exportiert nur eigene Einträge. Das Ziel ignoriert keine fremden Benutzerangaben, sondern lehnt nicht unterstützte Felder ab; sämtliche Zielzuordnungen stammen vom angemeldeten Benutzer. Nur klinisch berechtigte Praxisrollen dürfen importieren. Vorhandene Verschlüsselung der Zielmodelle bleibt erhalten.
Eine signierte Vorschau ist 15 Minuten gültig und an Benutzer und Dateihash gebunden. Auswahl und Dubletten werden beim Speichern erneut unter Kontosperre geprüft. Datensätze, Importbelege und Audit-Ereignis werden transaktional gespeichert. Bereits importierte Datensätze werden übersprungen; Änderungen erzeugen einen Konflikt. Gelöschte Ziel-Datensätze werden nicht unbemerkt erneut angelegt. Importbelege enthalten nur gehashte Quellidentität/Fingerabdruck und Zielreferenzen, keine Beschreibung. Sie bleiben nach einer einzelnen Datensatzlöschung als Dublettenschutz erhalten, bis das Konto gelöscht wird.
Passwörter/Vorschautoken sind aus Rails-Parameterlogs gefiltert. Antworten haben `Cache-Control: no-store`. Kein Passwort oder entschlüsselter Vorschauinhalt wird in Local Storage gespeichert. Passwortfelder werden nach erfolgreicher Übernahme geleert. Die Benutzeroberfläche verweigert Datenaustausch auf unverschlüsselten entfernten HTTP-Seiten; lokale Entwicklungsadressen sind ausgenommen. Die Produktions-API verlangt HTTPS zusätzlich serverseitig.
In der Praktikums-API wurde außerdem behoben, dass ein fehlender Token-Digest bei einem Konto ohne Token passen konnte. Fehlende Tokens und andere Authentifizierungsschemata als Bearer werden jetzt abgelehnt.
## Installation und HTTPS
Vor dem Update **jede Datenbank separat sichern** und vorhandene Konfiguration/Schlüssel behalten. Die beiden Projektordner gehören in ihre jeweiligen bestehenden Installationen, nicht ineinander. Keine neue `.env` über eine bestehende Serverkonfiguration kopieren.
Neue Migrationen:
- Praxis: `20261002090000_create_professional_import_receipts.rb`
- Praktikum: `20261002080000_add_practice_transfer_id_to_users.rb`
In jedem Projekt die neuen Images bauen, `db:prepare` vor dem Neustart ausführen und erst nach erfolgreichem Healthcheck wieder freigeben. Die Praxis verwendet weiterhin ihren vorhandenen Deploy-/Rollback-Prozess. Bei einer manuellen Wartungsinstallation sind die Kernschritte je Projekt:
```sh
docker compose build
docker compose run --rm api bin/rails db:prepare
docker compose up -d
```
Diese Befehle setzen laufende Datenbank/Redis und korrekt gesetzte bestehende Umgebungsvariablen voraus. Vorherige Images und Datenbanksicherung für einen kontrollierten Rollback behalten. Bei Rückkehr zum alten Code die neuen Importtabellen/-spalten stehen lassen; **keine automatische Down-Migration**, da sie den Dublettenschutz entfernen würde. Vor einem erneuten Import nach einer Datenbankwiederherstellung beide Datenstände prüfen.
Beide öffentlichen Anwendungen über HTTPS bereitstellen. API-Ports dürfen nur dem vertrauenswürdigen Reverse Proxy zugänglich sein. In der Praxis muss der bestehende sichere Cookie-/TLS-Proxy-Betrieb korrekt konfiguriert sein. In der Praktikumssoftware ist neu `ASSUME_SSL` an den API-Container durchgereicht: nur hinter einem tatsächlich TLS-erzwingenden, vertrauenswürdigen Proxy auf `true` setzen. Alternativ den ursprünglichen HTTPS-Status korrekt bis Rails durchreichen. `ASSUME_SSL=true` schaltet selbst kein TLS ein. Ein HTTP-Produktionsaufruf wird mit HTTP 426 und verständlichem Hinweis abgelehnt.
Keine Produktionsgeheimnisse im Paket. Vorhandene private Credentials-Dateien bleiben Teil der jeweiligen Installation. Entwicklungs-Datenbankpasswörter werden ebenfalls über Umgebungsvariablen gesetzt.
## Geprüft und offen
- Praxis: Produktionsbuild erfolgreich; 143 Frontend-Tests in 37 Dateien erfolgreich.
- Praktikum: Produktionsbuild erfolgreich; 4 Frontend-Tests in 2 Dateien erfolgreich.
- Praxis: 51 HTML-Templates auf Übersetzungsabdeckung geprüft, keine fehlende englische Übersetzung im automatisierten Template-Check. UI-Konsistenzprüfung erfolgreich.
- `npm audit`: in beiden aktualisierten Frontends 0 gemeldete Schwachstellen. Praktikum: Angular-Runtime 22.2.1, Build/CLI 22.1.4, gezielter Piscina-Override 5.3.2. Keine erzwungene Major-Aktualisierung. Lockfiles enthalten die geprüften Versionen.
- Praxisbuild: fünf bestehende SCSS-Budgetwarnungen außerhalb der neuen Importkomponente; kein Buildfehler.
- Rails-Spezifikationen für Verschlüsselung, Kontotrennung, Auswahl, Dubletten, Konflikte, Löschschutz, Transaktion und Vorschau-Bindung sowie Quell-API-Authentifizierung ergänzt. **Nicht ausgeführt:** Ruby/Bundler und PostgreSQL sind hier nicht verfügbar. `bundle exec rspec` und `ruby test/practice_transfer_test.rb` konnten deshalb nicht starten.
- Kein Docker-Build, kein Produktivdeployment und kein browsergestützter Ende-zu-Ende-Test gegen laufende Backends ausgeführt. Migrationen und verschlüsselter Roundtrip sind vor Freigabe auf einer Testinstallation auszuführen.
Backend-Prüfung auf einer geeigneten Testumgebung:
```sh
# Praxis/backend, ausschließlich separate Testdatenbank:
RAILS_ENV=test bundle exec rails db:prepare
bundle exec rspec spec/services/professional_transfer_envelope_spec.rb spec/services/professional_transfer_import_spec.rb spec/requests/api/v1/professional_transfers_spec.rb
# Praktikumsprojekt, ausschließlich separate Testdatenbank:
RAILS_ENV=test bundle exec rails db:prepare
bundle exec ruby test/practice_transfer_test.rb
```
## English summary
This release adds an authenticated, password-encrypted file transfer from the internship application to the practice application. Open Monthly report in the updated source app to export a date range, then use **Import internship records** in practice quick find. Review and select records before confirming. Repeated imports are skipped; changed sources are marked for manual review. Learning activities initially use the training phase. No automatic professional credit, certificate transfer, accounting entry or ongoing synchronization is performed. Both applications and migrations are required. Frontend builds/tests and dependency audits pass; Rails, migration and live end-to-end verification remain pending because the required runtimes are unavailable here.

5
app/controllers/api/v1/base_controller.rb

@ -11,9 +11,10 @@ module Api
attr_reader :current_user
def authenticate_api_user!
token = request.authorization.to_s.delete_prefix("Bearer ").strip
scheme, value = request.authorization.to_s.split(" ", 2)
token = value.to_s.strip if scheme&.casecmp("Bearer")&.zero?
digest = Digest::SHA256.hexdigest(token) if token.present?
@current_user = User.find_by(api_token_digest: digest)
@current_user = User.find_by(api_token_digest: digest) if digest.present?
render json: { error: "Nicht angemeldet" }, status: :unauthorized unless @current_user
end

31
app/controllers/api/v1/practice_transfers_controller.rb

@ -0,0 +1,31 @@
module Api
module V1
class PracticeTransfersController < BaseController
before_action { response.set_header("Cache-Control", "no-store") }
before_action :require_secure_transfer
def create
payload = PracticeTransferExport.new(user: current_user, from: params[:from], to: params[:to],
descriptions: ActiveModel::Type::Boolean.new.cast(params[:include_descriptions])).call
encrypted = ProfessionalTransferEnvelope.encrypt(payload, params[:transfer_password])
response.set_header("Cache-Control", "no-store")
response.set_header("X-Transfer-Skipped-Running", payload[:skipped_running].to_s)
send_data encrypted, type: "application/octet-stream", disposition: "attachment",
filename: "praktikum-praxis-#{Date.current.iso8601}.pcmtransfer"
rescue PracticeTransferExport::Invalid => error
render json: { error: error.message }, status: :unprocessable_entity
rescue ProfessionalTransferEnvelope::Invalid => error
message = error.message == "password" ? "Das Transferpasswort muss mindestens 12 Zeichen lang sein (höchstens 256 Bytes)." : "Der Export ist zu groß. Bitte den Zeitraum verkleinern."
render json: { error: message }, status: :unprocessable_entity
end
private
def require_secure_transfer
if Rails.env.production? && !request.ssl?
render json: { error: "Der Praxisexport ist nur über HTTPS erlaubt. Bitte die TLS-Proxy-Konfiguration prüfen." }, status: :upgrade_required
end
end
end
end
end

48
app/services/practice_transfer_export.rb

@ -0,0 +1,48 @@
require "date"
require "bigdecimal"
require "securerandom"
class PracticeTransferExport
class Invalid < StandardError; end
MAX_ENTRIES = 500
def initialize(user:, from:, to:, descriptions: false)
@user, @from, @to, @descriptions = user, Date.iso8601(from.to_s), Date.iso8601(to.to_s), descriptions
raise Invalid, "Der Zeitraum ist ungültig." if @from > @to || !(1900..2200).cover?(@from.year) || !(1900..2200).cover?(@to.year)
rescue Date::Error
raise Invalid, "Bitte Beginn und Ende des Zeitraums angeben."
end
def call
entries = @user.entries.where(date: @from..@to).order(:id).limit(MAX_ENTRIES + 1).to_a
raise Invalid, "Höchstens 500 Einträge je Export. Bitte den Zeitraum verkleinern." if entries.length > MAX_ENTRIES
namespace = @user.with_lock do
@user.update!(practice_transfer_id: SecureRandom.uuid) if @user.practice_transfer_id.blank?
@user.practice_transfer_id
end
rates = MileageRate.where(year: (@from.year..@to.year)).pluck(:year, :rate_per_km).to_h
skipped = 0
rows = entries.flat_map do |entry|
if entry.start_time.present? && entry.end_time.nil?
skipped += 1
next []
end
hours, minutes = entry.hours_and_minutes
total_minutes = hours * 60 + minutes
common = { source_id: entry.id.to_s, date: entry.date.iso8601, education: entry.praktikums_typ,
activity: entry.entry_art, duration_minutes: total_minutes,
description: @descriptions ? entry.beschreibung.to_s.first(4_000) : nil,
cost_cents: entry.kosten ? (entry.kosten.to_d * 100).round.to_i : nil }
result = []
if entry.distance_km.to_i.positive?
rate = rates[entry.date.year] || BigDecimal("0.42") # Existing source application fallback, not a tax determination.
result << common.merge(type: "mileage", kilometers: entry.distance_km, rate_cents: (rate.to_d * 100).round.to_i,
rate_origin: rates.key?(entry.date.year) ? "configured" : "source_default")
end
result << common.merge(type: "development") if total_minutes.positive?
result
end
{ format: "pcm-professional-transfer-v1", source_namespace: namespace, exported_at: Time.current.iso8601,
from: @from.iso8601, to: @to.iso8601, skipped_running: skipped, records: rows }
end
end

57
app/services/professional_transfer_envelope.rb

@ -0,0 +1,57 @@
require "openssl"
require "base64"
require "json"
# Portable authenticated encryption, identical in both applications.
# The file contains no password or key. Never accept client-selected algorithms/KDF costs.
class ProfessionalTransferEnvelope
FORMAT = "pcm-professional-transfer-encrypted-v1".freeze
AAD = "pcm-professional-transfer:v1".freeze
ITERATIONS = 600_000
MAX_FILE_BYTES = 4 * 1024 * 1024
class Invalid < StandardError; end
def self.encrypt(payload, password)
check_password!(password)
salt = OpenSSL::Random.random_bytes(16)
iv = OpenSSL::Random.random_bytes(12)
cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
cipher.key = key(password, salt)
cipher.iv = iv
cipher.auth_data = AAD
ciphertext = cipher.update(JSON.generate(payload)) + cipher.final
envelope = { format: FORMAT, salt: Base64.strict_encode64(salt), iv: Base64.strict_encode64(iv),
tag: Base64.strict_encode64(cipher.auth_tag), ciphertext: Base64.strict_encode64(ciphertext) }
output = JSON.generate(envelope)
raise Invalid, "too_large" if output.bytesize > MAX_FILE_BYTES
output
end
def self.decrypt(file, password)
check_password!(password)
raise Invalid, "too_large" if file.bytesize > MAX_FILE_BYTES
envelope = JSON.parse(file, max_nesting: 5)
raise Invalid, "invalid_file" unless envelope.is_a?(Hash) && envelope.keys.sort == %w[ciphertext format iv salt tag] && envelope["format"] == FORMAT
salt, iv, tag, ciphertext = %w[salt iv tag ciphertext].map { |name| Base64.strict_decode64(envelope.fetch(name)) }
raise Invalid, "invalid_file" unless salt.bytesize == 16 && iv.bytesize == 12 && tag.bytesize == 16 && ciphertext.bytesize.positive?
cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
cipher.key = key(password, salt)
cipher.iv = iv
cipher.auth_tag = tag
cipher.auth_data = AAD
JSON.parse(cipher.update(ciphertext) + cipher.final, max_nesting: 10)
rescue Invalid
raise
rescue JSON::ParserError, ArgumentError, TypeError, KeyError, OpenSSL::Cipher::CipherError
raise Invalid, "invalid_file"
end
def self.check_password!(password)
raise Invalid, "password" unless password.is_a?(String) && password.length >= 12 && password.bytesize <= 256
end
def self.key(password, salt)
OpenSSL::PKCS5.pbkdf2_hmac(password, salt, ITERATIONS, 32, "sha256")
end
private_class_method :key, :check_password!
end

2
config/application.rb

@ -12,7 +12,7 @@ require "action_mailbox/engine"
require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
# require "rails/test_unit/railtie"
require "rails/test_unit/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.

2
config/database.yml

@ -18,7 +18,7 @@ default: &default
# For details on connection pooling, see Rails configuration guide
# https://guides.rubyonrails.org/configuring.html#database-pooling
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
password: password
password: <%= ENV["POSTGRES_PASSWORD"] || ENV["PRAKTIKUM_DATABASE_PASSWORD"] %>
username: praktikum
host: <%= ENV['DB'] || 'localhost'%>
port: <%= ENV['DB_PORT'] || 35432%>

2
config/environments/production.rb

@ -45,7 +45,7 @@ Rails.application.configure do
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
# Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies.
# config.assume_ssl = true
config.assume_ssl = ENV.fetch("ASSUME_SSL", "false") == "true"
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
config.force_ssl = false

4
config/initializers/build_version.rb

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

2
config/initializers/filter_parameter_logging.rb

@ -6,3 +6,5 @@
Rails.application.config.filter_parameters += [
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
]
Rails.application.config.filter_parameters += [:transfer_password]

1
config/routes.rb

@ -18,6 +18,7 @@ Rails.application.routes.draw do
end
get "dashboard", to: "dashboard#show"
get "calendar", to: "calendar#index"
post "practice_transfer", to: "practice_transfers#create"
get "monthly_report", to: "reports#monthly"
resources :mileage_rates, except: %i[new edit destroy]
resource :settings, only: %i[show update]

6
db/migrate/20261002080000_add_practice_transfer_id_to_users.rb

@ -0,0 +1,6 @@
class AddPracticeTransferIdToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :practice_transfer_id, :uuid
add_index :users, :practice_transfer_id, unique: true
end
end

2
docker-compose-dev.yml

@ -5,7 +5,7 @@ services:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_PASSWORD: password
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set a local database password}
POSTGRES_USER: praktikum
volumes:
- pgdata:/var/lib/postgresql/data

1
docker-compose.yml

@ -28,6 +28,7 @@ services:
environment:
DATABASE_URL: postgres://praktikum:${DATABASE_PASSWORD:-password}@db:5432/praktikum
REDIS_URL: redis://redis:6379/0
ASSUME_SSL: ${ASSUME_SSL:-false}
RAILS_ENV: production
SECRET_KEY_BASE: ${SECRET_KEY_BASE:?SECRET_KEY_BASE fehlt. Bitte zuerst setup-env.ps1 ausfuehren oder eine .env anlegen.}
FRONTEND_ORIGINS: ${FRONTEND_ORIGINS:-http://localhost:13131}

74
frontend/angular.json

@ -1,9 +1,71 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "newProjectRoot": "projects",
"projects": { "praktikum": { "projectType": "application", "root": "", "sourceRoot": "src", "prefix": "app",
"architect": { "build": { "builder": "@angular/build:application", "options": { "browser": "src/main.ts", "tsConfig": "tsconfig.app.json", "assets": [{"glob":"**/*","input":"public"}], "styles": ["node_modules/@fontsource-variable/inter/index.css", "src/styles.scss"] },
"configurations": { "production": { "outputHashing": "all", "budgets": [{"type":"initial","maximumWarning":"1MB","maximumError":"2MB"}] } }, "defaultConfiguration": "production" },
"serve": { "builder": "@angular/build:dev-server", "configurations": { "production": { "buildTarget": "praktikum:build:production" }, "development": { "buildTarget": "praktikum:build" } }, "defaultConfiguration": "development" }
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"praktikum": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"node_modules/@fontsource-variable/inter/index.css",
"src/styles.scss"
]
},
"configurations": {
"production": {
"outputHashing": "all",
"budgets": [
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "2MB"
}
]
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "praktikum:build:production"
},
"development": {
"buildTarget": "praktikum:build"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json"
}
}
}
}
} }, "cli": { "analytics": false }
},
"cli": {
"analytics": false
}
}

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

36
frontend/package.json

@ -1,6 +1,6 @@
{
"name": "praktikum-frontend",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"scripts": {
"start": "ng serve --proxy-config proxy.conf.json",
@ -8,24 +8,32 @@
"test": "ng test --watch=false"
},
"dependencies": {
"@angular/animations": "^22.0.0",
"@angular/cdk": "^22.0.0",
"@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0",
"@angular/forms": "^22.0.0",
"@angular/material": "^22.0.0",
"@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0",
"@angular/animations": "22.2.1",
"@angular/cdk": "22.2.1",
"@angular/common": "22.2.1",
"@angular/compiler": "22.2.1",
"@angular/core": "22.2.1",
"@angular/forms": "22.2.1",
"@angular/material": "22.2.1",
"@angular/platform-browser": "22.2.1",
"@angular/router": "22.2.1",
"@fontsource-variable/inter": "^5.3.0",
"material-icons": "^1.13.14",
"rxjs": "~7.8.2",
"tslib": "^2.8.1"
},
"devDependencies": {
"@angular/build": "^22.0.0",
"@angular/cli": "^22.0.0",
"@angular/compiler-cli": "^22.0.0",
"typescript": "~6.0.0"
"@angular/build": "22.1.4",
"@angular/cli": "22.1.4",
"@angular/compiler-cli": "22.2.1",
"typescript": "~6.0.2",
"vitest": "^4.0.8",
"jsdom": "^27.0.1",
"@types/node": "^24.10.1"
},
"overrides": {
"@angular/build": {
"piscina": "5.3.2"
}
}
}

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

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

11
frontend/src/app/core/secure-transfer-origin.spec.ts

@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { secureTransferOrigin } from './secure-transfer-origin';
describe('Secure transfer origin',()=>{
it('accepts HTTPS and loopback development, and rejects remote plaintext pages',()=>{
expect(secureTransferOrigin({protocol:'https:',hostname:'practice.example'})).toBe(true);
expect(secureTransferOrigin({protocol:'http:',hostname:'localhost'})).toBe(true);
expect(secureTransferOrigin({protocol:'http:',hostname:'practice.example'})).toBe(false);
expect(secureTransferOrigin({protocol:'http:',hostname:'localhost.example'})).toBe(false);
expect(secureTransferOrigin({protocol:'file:',hostname:''})).toBe(false);
});
});

4
frontend/src/app/core/secure-transfer-origin.ts

@ -0,0 +1,4 @@
/** Never submit an export password from an unencrypted remote page. */
export function secureTransferOrigin(origin:Pick<Location,'protocol'|'hostname'>=globalThis.location):boolean {
return origin.protocol==='https:' || (origin.protocol==='http:' && ['localhost','127.0.0.1','[::1]'].includes(origin.hostname));
}

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

@ -0,0 +1,17 @@
<mat-card class="transfer-export">
<header><h2>Fahrten und Ausbildung an die Praxisverwaltung übergeben</h2><p>Verschlüsselter Export nur aus deinem angemeldeten Konto. In der Praxisverwaltung unter „Schnell finden → Praktikumsdaten importieren“ die Datei öffnen, Vorschau prüfen und Einträge auswählen.</p></header>
<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>}
<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>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>
<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>
<button mat-flat-button type="submit" [disabled]="busy()||!transportSafe">Verschlüsselten Praxisexport herunterladen</button>
</form>
@if(busy()){<mat-progress-bar mode="indeterminate"/><p role="status">Export wird verschlüsselt …</p>}
@if(error()){<p role="alert">{{error()}}</p>}
@if(success()){<p role="status">Export erstellt. Ausgelassene laufende Zeiterfassungen: {{skipped()}}. Die Datei kann jetzt in der Praxisverwaltung eingelesen werden.</p>}
</mat-card>

1
frontend/src/app/pages/practice-transfer-export.component.scss

@ -0,0 +1 @@
:host{display:block;margin:0 0 24px}.transfer-export{padding:24px;gap:20px}header{border-bottom:1px solid var(--app-border);padding-bottom:16px}h2,p{margin:0}p{line-height:1.6;margin-top:8px}form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;gap:24px}.full{grid-column:1/-1}.full p{font-size:.9rem;color:var(--app-muted)}button{justify-self:start;grid-column:1/-1}@media(max-width:600px){form{grid-template-columns:1fr}.transfer-export{padding:16px}button{width:100%;height:auto;min-height:48px}}

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

@ -0,0 +1,30 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { PracticeTransferExportComponent } from './practice-transfer-export.component';
describe('Protected practice export',()=>{
let component:PracticeTransferExportComponent;let http:HttpTestingController;
beforeEach(()=>{
TestBed.configureTestingModule({imports:[PracticeTransferExportComponent],providers:[provideHttpClient(),provideHttpClientTesting()]});
component=TestBed.createComponent(PracticeTransferExportComponent).componentInstance;http=TestBed.inject(HttpTestingController);
component.form.patchValue({from:'2024-01-01',to:'2024-12-31',password:'separate-transfer-passphrase',confirmation:'separate-transfer-passphrase'});
});
afterEach(()=>{http.verify();vi.restoreAllMocks();});
it('requires matching passwords and a valid date range before contacting the server',()=>{
component.form.controls.confirmation.setValue('different');component.export();http.expectNone('/api/v1/practice_transfer');expect(component.error()).toContain('stimmen nicht');
component.form.controls.confirmation.setValue('separate-transfer-passphrase');component.form.controls.to.setValue('2023-01-01');component.export();http.expectNone('/api/v1/practice_transfer');
});
it('excludes descriptions by default and clears passwords after an encrypted download',()=>{
Object.defineProperty(URL,'createObjectURL',{configurable:true,value:vi.fn(()=> 'blob:test')});Object.defineProperty(URL,'revokeObjectURL',{configurable:true,value:vi.fn()});
vi.spyOn(HTMLAnchorElement.prototype,'click').mockImplementation(()=>{});
component.export();const request=http.expectOne('/api/v1/practice_transfer');expect(request.request.body.include_descriptions).toBe(false);expect(request.request.body.confirmation).toBeUndefined();
request.flush(new Blob(['ciphertext']),{headers:{'X-Transfer-Skipped-Running':'2'}});
expect(component.skipped()).toBe(2);expect(component.success()).toBe(true);expect(component.form.controls.password.value).toBe('');expect(component.form.controls.confirmation.value).toBe('');
});
it('retains the form after a rejected export and allows correction',async ()=>{
component.export();http.expectOne('/api/v1/practice_transfer').flush(new Blob([JSON.stringify({error:'Nicht angemeldet'})],{type:'application/json'}),{status:401,statusText:'Unauthorized'});
await vi.waitFor(()=>expect(component.busy()).toBe(false));
expect(component.busy()).toBe(false);expect(component.success()).toBe(false);expect(component.form.controls.password.value).toBe('separate-transfer-passphrase');
});
});

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

@ -0,0 +1,29 @@
import { secureTransferOrigin } from '../core/secure-transfer-origin';
import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MATERIAL } from '../shared/material';
@Component({selector:'app-practice-transfer-export',standalone:true,imports:[ReactiveFormsModule,...MATERIAL],
templateUrl:'./practice-transfer-export.component.html',styleUrl:'./practice-transfer-export.component.scss'})
export class PracticeTransferExportComponent {
private readonly http=inject(HttpClient);
readonly transportSafe=secureTransferOrigin();
readonly busy=signal(false); readonly error=signal(''); readonly success=signal(false); readonly skipped=signal(0);
readonly form=inject(FormBuilder).nonNullable.group({from:[`${new Date().getFullYear()}-01-01`,Validators.required],to:[new Date().toLocaleDateString('sv-SE'),Validators.required],password:['',[Validators.required,Validators.minLength(12),Validators.maxLength(256)]],confirmation:['',Validators.required],include_descriptions:false});
export():void{
if(this.busy()||!this.transportSafe)return;this.error.set('');this.success.set(false);
const value=this.form.getRawValue();
if(this.form.invalid){this.form.markAllAsTouched();this.error.set('Bitte Zeitraum und Exportpasswort mit mindestens 12 Zeichen angeben.');return;}
if(value.password!==value.confirmation){this.error.set('Die Passwörter stimmen nicht überein.');return;}
if(value.from>value.to){this.error.set('Das Ende muss am oder nach dem Beginn liegen.');return;}
this.busy.set(true);
this.http.post('/api/v1/practice_transfer',{from:value.from,to:value.to,transfer_password:value.password,include_descriptions:value.include_descriptions},{observe:'response',responseType:'blob'}).subscribe({
next:response=>{
const file=response.body;if(file){const url=URL.createObjectURL(file);const link=document.createElement('a');link.href=url;link.download=`praktikum-praxis-${value.from}-${value.to}.pcmtransfer`;link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
this.skipped.set(Number(response.headers.get('X-Transfer-Skipped-Running'))||0);this.success.set(true);this.busy.set(false);
this.form.controls.password.reset();this.form.controls.confirmation.reset();
},
error:async response=>{let message='Export fehlgeschlagen. Bitte erneut versuchen.';try{const body=response.error instanceof Blob?JSON.parse(await response.error.text()):response.error;if(typeof body?.error==='string')message=body.error;}catch{}this.error.set(message);this.busy.set(false);}
});
}
}

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

@ -1,3 +1,4 @@
import { PracticeTransferExportComponent } from './practice-transfer-export.component';
import { Component, OnInit, signal } from "@angular/core";
import { DatePipe } from "@angular/common";
import { MATERIAL } from "../shared/material";
@ -12,7 +13,7 @@ interface Row {
@Component({
standalone: true,
imports: [DatePipe, ...MATERIAL],
imports: [PracticeTransferExportComponent, DatePipe, ...MATERIAL],
template: `<section class="page">
<div class="page-head">
<div>
@ -20,6 +21,7 @@ interface Row {
<p class="muted">Geleistete Zeiten nach Monat und Kategorie</p>
</div>
</div>
<app-practice-transfer-export />
@if (loading()) { <mat-progress-bar mode="indeterminate" /> }
<div class="grid report">
@for (group of groups(); track group.month) {

13
frontend/tsconfig.spec.json

@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"node"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

41
test/practice_transfer_test.rb

@ -0,0 +1,41 @@
ENV["RAILS_ENV"] = "test"
require_relative "../config/environment"
require "rails/test_help"
class PracticeTransferTest < ActionDispatch::IntegrationTest
setup do
@user=User.create!(email:"export@example.test",password:"secure-test-password-123",password_confirmation:"secure-test-password-123",api_token_digest:Digest::SHA256.hexdigest("test-transfer-token"))
@other=User.create!(email:"other-export@example.test",password:"secure-test-password-123",password_confirmation:"secure-test-password-123")
@entry=@user.entries.create!(date:"2024-06-15",praktikums_typ:"propädeutikum",entry_art:"Supervision",hours:1,minutes:31,distance_km:20,beschreibung:"Confidential description",kosten:120)
@other.entries.create!(date:"2024-06-15",praktikums_typ:"propädeutikum",entry_art:"Supervision",hours:9,minutes:0,distance_km:999)
@password="separate-transfer-passphrase"
@params={from:"2024-01-01",to:"2024-12-31",transfer_password:@password}
end
test "missing and non bearer tokens never select a user without a token" do
post "/api/v1/practice_transfer",params:@params
assert_response :unauthorized
post "/api/v1/practice_transfer",params:@params,headers:{"Authorization"=>"Basic test-transfer-token"}
assert_response :unauthorized
end
test "exports only own records, excludes descriptions by default and retains source identities" do
post "/api/v1/practice_transfer",params:@params,headers:{"Authorization"=>"Bearer test-transfer-token"}
assert_response :success
assert_includes response.headers["Cache-Control"],"no-store"
payload=ProfessionalTransferEnvelope.decrypt(response.body,@password)
assert_equal 2,payload["records"].length
assert_equal [@entry.id.to_s],payload["records"].map { |r| r["source_id"] }.uniq
assert payload["records"].all? { |r| r["description"].nil? }
assert payload["records"].all? { |r| r["duration_minutes"]==91 }
namespace=payload["source_namespace"]
post "/api/v1/practice_transfer",params:@params.merge(include_descriptions:true),headers:{"Authorization"=>"Bearer test-transfer-token"}
second=ProfessionalTransferEnvelope.decrypt(response.body,@password)
assert_equal namespace,second["source_namespace"]
assert_equal "Confidential description",second["records"].first["description"]
end
test "envelope rejects tampering and incorrect passwords" do
content=ProfessionalTransferEnvelope.encrypt({"value"=>42},@password)
assert_raises(ProfessionalTransferEnvelope::Invalid) { ProfessionalTransferEnvelope.decrypt(content,"wrong-transfer-password") }
envelope=JSON.parse(content);envelope["tag"]=Base64.strict_encode64("0"*16)
assert_raises(ProfessionalTransferEnvelope::Invalid) { ProfessionalTransferEnvelope.decrypt(JSON.generate(envelope),@password) }
end
end
Loading…
Cancel
Save