Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1b26541af | |||
| dfc4e9b781 | |||
| c07b0cfaf0 | |||
| 645c5e5887 | |||
| 2dd11b439d | |||
| 59b8cd43fc | |||
| 5919a55cbf | |||
| 7edaf8c00b | |||
| 61a971ccc0 | |||
| 471cfbf98b |
@@ -0,0 +1,34 @@
|
||||
---
|
||||
context: default
|
||||
phase: quick-auftraege-1.9.x (keine GSD-Phase)
|
||||
task: 0
|
||||
total_tasks: 0
|
||||
status: paused
|
||||
last_updated: 2026-09-30T18:00:00.000Z
|
||||
---
|
||||
|
||||
# BLOCKING CONSTRAINTS — Read Before Anything Else
|
||||
|
||||
- [ ] CONSTRAINT: live NICHT pushen/taggen, bis der User es verlangt.
|
||||
- [ ] CONSTRAINT: Gebündelt pushen, nicht nach jeder Kleinigkeit.
|
||||
- [ ] CONSTRAINT: Browser-Prüfungen im Dunkelmodus.
|
||||
- [ ] CONSTRAINT: Nie Wichtiges (Logo, Text) nur in Mailbilder packen – OWA zeigt eingebettete Bilder nicht.
|
||||
|
||||
<current_state>
|
||||
main = live = v1.9.0 (a257bc3), CI + Release grün. alpha zuletzt 714f731 (Inhalt identisch). Arbeitsbaum sauber.
|
||||
</current_state>
|
||||
|
||||
<completed_work>
|
||||
- 30.09.: Windows-Test bestanden, Review seit 26.09. + Fixes, v1.8.0.
|
||||
- Dashboard-Skalierung (nie scrollen), eigene Module Keep-Alive, Favoriten enger.
|
||||
- Sicherheit: Rolle/Aktiv-Status je Anfrage aus DB; /login-Weiterleitung.
|
||||
- Willkommensmail + eigene Vorlage (Platzhalter, Vorschau, Testmail, Anmeldehinweise), Spalte Letzte Anmeldung; v1.9.0.
|
||||
</completed_work>
|
||||
|
||||
<remaining_work>
|
||||
- User: live auf 1.9.0 ziehen (df -h / vorher), Willkommensmail in OWA prüfen.
|
||||
</remaining_work>
|
||||
|
||||
<next_action>
|
||||
Nachfragen, ob live gezogen und OWA ok; sonst neuen Auftrag abwarten.
|
||||
</next_action>
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"timestamp": "2026-09-30T18:00:00.000Z",
|
||||
"phase": null,
|
||||
"phase_name": "Quick-Auftraege nach Freigabe 1.9.0 (keine GSD-Phase)",
|
||||
"phase_dir": null,
|
||||
"plan": null,
|
||||
"task": 0,
|
||||
"total_tasks": 0,
|
||||
"status": "paused",
|
||||
"completed_tasks": [
|
||||
{"id": 1, "name": "30.09.: Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. + Fixes; Freigabe 1.8.0 (af78157)", "status": "done"},
|
||||
{"id": 2, "name": "Dashboard 1:1-Skalierung (__canvas, passt Inhalt ein = nie scrollen), eigene Module Keep-Alive (max 5), Favoriten-Kachelansicht enger + lange Namen klein/zweizeilig", "status": "done"},
|
||||
{"id": 3, "name": "Sicherheit: JwtStrategy liest Rolle/isActive/mustChangePassword je Anfrage aus DB; /login leitet Angemeldete aufs Dashboard", "status": "done"},
|
||||
{"id": 4, "name": "Willkommensmail (Briefsymbol Benutzerliste, jederzeit an jeden) + Spalte Letzte Anmeldung + eigene Vorlage je Mandant (Admin -> Willkommensmail, Platzhalter, Vorschau, Testmail, Anmeldehinweise editierbar); Freigabe 1.9.0 (a257bc3)", "status": "done"}
|
||||
],
|
||||
"remaining_tasks": [],
|
||||
"blockers": [],
|
||||
"async_jobs": [],
|
||||
"human_actions_pending": [
|
||||
{"action": "Live-Server auf v1.9.0 ziehen (vorher df -h /, ggf. docker image prune -f, nie -a)", "context": "CI 11 Laeufe gruen, Release Tessera 1.9.0 mit Setup.exe + AppImage", "blocking": false},
|
||||
{"action": "Willkommensmail in OWA (owa.ctl.de) pruefen: dunkler Kopf ohne weisse Luecke", "context": "OWA zeigt CID-Bilder nicht, Outlook-Programm schon", "blocking": false}
|
||||
],
|
||||
"decisions": [
|
||||
{"decision": "Dashboard: alles mitskalieren inkl. Schrift, nie scrollen; Leinwand = Flaeche beim ersten Oeffnen je Reiter", "rationale": "AskUserQuestion 30.09.", "phase": "quick"},
|
||||
{"decision": "Keine naechtliche Docker-Aufraeumung auf alpha", "rationale": "User 30.09.: passt so", "phase": "quick"},
|
||||
{"decision": "Willkommensmail jederzeit an jeden Benutzer; Link Passwort festlegen 7 Tage", "rationale": "User 30.09.", "phase": "quick"},
|
||||
{"decision": "PMG ohne API-Token (Proxmox kennt keine), eigener Auditor-Benutzer; Anleitung im Admin-Handbuch", "rationale": "Proxmox Bugzilla 5849 offen", "phase": "quick"}
|
||||
],
|
||||
"uncommitted_files": [],
|
||||
"next_action": "Nichts offen von Claudes Seite. Beim Start: fragen, ob live auf 1.9.0 gezogen ist und ob die Willkommensmail in OWA passt; sonst neuen Auftrag abwarten.",
|
||||
"context_notes": "main = live = v1.9.0 (a257bc3). alpha lief zuletzt auf 714f731 (= Inhalt 1.9.0). Lokaler Stack aus 714f731 gebaut. Test-Postfach MailHog nur bei Bedarf: docker run -d --rm --name mailhog --network tessera-ctl_backend-net --network-alias mailhog -p 127.0.0.1:8025:8025 mailhog/mailhog. Diagnose auf alpha ohne Passwort: JWT im api-Container mit crypto + process.env.JWT_SECRET signieren (nur lesend, kurzlebig)."
|
||||
}
|
||||
+6
-2
@@ -6,7 +6,7 @@ current_phase_name: desktop-client-fertigstellen
|
||||
status: verified
|
||||
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||
last_updated: "2026-09-23T15:30:00.000Z"
|
||||
last_activity: 2026-09-30
|
||||
last_activity: 2026-10-01
|
||||
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
|
||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||
progress:
|
||||
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
|
||||
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
||||
Plan: 6 of 6
|
||||
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
||||
Last activity: 2026-09-30 - Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. mit 4 Fix-Commits (be1e003, c2e4467, 0710829, 12214a9)
|
||||
Last activity: 2026-09-30 - v1.9.0 freigegeben (Willkommensmail + Vorlage, Rollen je Anfrage aus DB, Dashboard-Skalierung, Keep-Alive eigene Module); pausiert mit HANDOFF
|
||||
|
||||
Progress: [██████████] 99%
|
||||
|
||||
@@ -483,6 +483,10 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
|
||||
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
|
||||
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
|
||||
| 261001-cxo | Desktop-Client: Links mit target=_blank (Favoriten) oeffnen jetzt im System-Browser (DesktopExternalLinks -> window.open) | 2026-10-01 | 61a971c | [261001-cxo](./quick/261001-cxo-desktop-client-links-mit-target-blank-oe/) |
|
||||
| 261001-g68 | Erinnerung: Cursor sprang beim Schreiben der Beschreibung in den Titel (Fokus-Effekt hing an inline onClose, Kachel zeichnet alle 10 s neu) – Fokus nur beim Oeffnen | 2026-10-01 | siehe git log | [261001-g68](./quick/261001-g68-erinnerung-cursor-springt-aus-beschreibu/) |
|
||||
| 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) |
|
||||
| 261001-l4q | Zertifikat-Manager: Reiter Übersicht (Paket/ZIP hochladen, Teile erkennen/zuordnen, jedes Teil in jedem Format) + Desktop speichert blob-Downloads selbst | 2026-10-01 | siehe git log | [261001-l4q](./quick/261001-l4q-zertifikatsmodul-paket-hochladen-uebersi/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
quick_id: 261001-cxo
|
||||
description: "Desktop-Client: Links mit target=_blank oeffnen"
|
||||
date: 2026-10-01
|
||||
---
|
||||
|
||||
# Desktop-Client: Links mit target=_blank oeffnen
|
||||
|
||||
**Befund (VM 8233, Client 1.9.0 gegen alpha):** Klick auf Favorit (`<a target="_blank">`) tut nichts; Such-Widget (`window.open`) oeffnet Edge ueber `on_new_window` (lib.rs). Der Rust-Weg funktioniert also, nur der Link-Klick erreicht ihn nicht.
|
||||
|
||||
## Task 1 — DesktopExternalLinks
|
||||
- `apps/web/src/components/desktop/desktop-external-links.tsx`: im Desktop-Client (Cookie `tessera_desktop`) Links-/Mittelklick auf `a[href][target=_blank]` mit http/https per `window.open(href,'_blank','noopener,noreferrer')` oeffnen, `preventDefault`. Listener auf `window` (Bubble, nach React) -> von der Seite verhinderte Klicks (Favoriten im Bearbeiten-Modus) bleiben verhindert.
|
||||
- In `apps/web/src/app/layout.tsx` neben `DesktopContextMenuGuard` einhaengen.
|
||||
- Test `desktop-external-links.test.tsx`.
|
||||
- CHANGELOG „Unveröffentlicht → Behoben“.
|
||||
|
||||
**Verify:** vitest gruen, tsc, biome; nach alpha-Pull auf VM 8233: Favorit oeffnet Edge.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
---
|
||||
quick_id: 261001-cxo
|
||||
status: complete
|
||||
date: 2026-10-01
|
||||
commit: 61a971c
|
||||
---
|
||||
|
||||
# Summary: Desktop-Client – Links mit target=_blank
|
||||
|
||||
- Nachgestellt auf VM 8233 (Client 1.9.0, alpha): Favorit-Klick ohne Wirkung, Such-Widget (`window.open`) oeffnet Edge.
|
||||
- Neu `DesktopExternalLinks` (apps/web/src/components/desktop/desktop-external-links.tsx), in `app/layout.tsx` eingehaengt: im Client Links-/Mittelklick auf `a[target=_blank]` mit http/https -> `window.open(href,'_blank','noopener,noreferrer')`; verhinderte Klicks bleiben verhindert.
|
||||
- 4 Tests (desktop-external-links.test.tsx), tsc + biome sauber. CHANGELOG „Unveröffentlicht → Behoben“.
|
||||
- Reine Web-Aenderung: kein neuer Client noetig, wirkt nach Pull des web-Images.
|
||||
- Offen: Nachweis auf VM nach alpha-Pull (User).
|
||||
|
||||
## Nachtrag (gleicher Tag): erste Fassung wirkte nicht
|
||||
- Nach alpha-Pull weiter ohne Wirkung. Diagnose per temporaerem Klick-Protokoll (lokaler Stack, VM-Client per portproxy auf localhost:3000): `preventDefault` kam aus `<anonymous>:1:442` = Link-Skript von tauri-plugin-opener (init-iife.js, Listener auf `window`): faengt `target=_blank`-Klicks ab und ruft `plugin:opener|open_url` – von der Server-Seite nicht freigegeben, Klick verpufft. Unser Listener auf `window` lief danach und sah den Klick als verhindert.
|
||||
- Fix: Listener auf `document` (Bubble) – nach React (Wurzel document), vor dem Opener-Skript. Auf VM nachgewiesen: Favorit oeffnet Edge, im Bearbeiten-Modus nichts (React-onClick verhindert).
|
||||
- Commit siehe git log; Test „kommt dem Link-Skript des Clients auf window zuvor“.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
quick_id: 261001-g68
|
||||
description: "Erinnerung: Cursor springt aus Beschreibung in Titel"
|
||||
date: 2026-10-01
|
||||
---
|
||||
|
||||
# Erinnerung: Cursor springt aus Beschreibung in Titel
|
||||
|
||||
**Befund:** `ReminderFormModal` setzte den Fokus auf den Titel im selben Effekt wie den Escape-Listener, Abhaengigkeit `[onClose]`. `onClose` ist in der Kachel eine Inline-Funktion; die Kachel zeichnet alle 10 s neu (NOW_TICK_MS) und bei jedem Neuladen → Effekt laeuft erneut → Cursor springt in den Titel (User: beim Schreiben, und bei Loeschen-Taste in leerer Beschreibung).
|
||||
|
||||
## Task 1
|
||||
- Fokus-Effekt nur beim Oeffnen (`[]`), Escape-Listener ueber `onCloseRef`.
|
||||
- Test im Widget: Formular oeffnen, Beschreibung fokussieren, 30 s Takt → Fokus bleibt.
|
||||
- CHANGELOG „Unveröffentlicht → Behoben“.
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
quick_id: 261001-g68
|
||||
status: complete
|
||||
date: 2026-10-01
|
||||
---
|
||||
|
||||
# Summary
|
||||
- `reminder-form-modal.tsx`: Fokus auf Titel nur einmal beim Oeffnen; Escape ueber Ref statt `[onClose]`-Abhaengigkeit.
|
||||
- Neuer Test in `reminder-widget.test.tsx` – schlaegt ohne Fix fehl, mit Fix gruen; 28/28 Erinnerungs-Tests, tsc, biome sauber.
|
||||
- Andere Dialoge mit `[onClose]`-Fokus (Widget-Katalog, Bilderrahmen-Lightbox) fokussieren nur den Dialog ohne Eingabefelder – nicht betroffen.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
quick_id: 261001-hbi
|
||||
description: "Favoriten: Logo fuer per JavaScript gesetzte Symbole"
|
||||
date: 2026-10-01
|
||||
---
|
||||
|
||||
# Favoriten: Logo fuer per JavaScript gesetzte Symbole
|
||||
|
||||
**Befund:** https://www.hosteurope.de/ liefert im HTML nur `<link rel="icon" href="data:;base64,=">`; das echte Symbol (img1.wsimg.com/.../HostEurope.png) setzt erst JavaScript. `/favicon.ico`, `/apple-touch-icon.png`, `/favicon.svg` antworten 200 mit text/html. Die serverseitige Suche faellt auf `/favicon.ico` zurueck, der Abruf scheitert (kein Bild) → Buchstabe.
|
||||
|
||||
## Task 1
|
||||
- `IconDiscoveryService.fetchPublicServiceIconBytes(pageUrl)`: DuckDuckGo-Symboldienst (`icons.duckduckgo.com/ip3/<host>.ico`), NUR wenn die Seite oeffentlich ist (isPublicHttpUrl) — interne Hostnamen verlassen das Haus nicht; 404 fuer Unbekanntes → wirft → Buchstabe bleibt.
|
||||
- `FavoritesService.getIconBytes`: scheitert das gespeicherte Symbol, einmal den Dienst fragen, sonst 502 wie bisher. Repariert auch bestehende Favoriten ohne Neuanlage.
|
||||
- Tests in beiden Specs; CHANGELOG.
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
quick_id: 261001-hbi
|
||||
status: complete
|
||||
date: 2026-10-01
|
||||
---
|
||||
|
||||
# Summary
|
||||
- Rueckfall auf den oeffentlichen Symbol-Dienst beim Ausliefern (`getIconBytes`), nur fuer oeffentliche Seiten.
|
||||
- 5 neue Tests (Dienst-URL, interne Seite fragt nicht, 404 wirft, Service nutzt Rueckfall / nicht bei Erfolg); 111/111 Favoriten-Tests, tsc, biome-Stand unveraendert.
|
||||
- Lokal im Browser nachgewiesen: Favorit https://www.hosteurope.de/ zeigt das gruene H-Logo (32x32 ueber /api-proxy/favorites/<id>/icon).
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
---
|
||||
quick_id: 261001-l4q
|
||||
description: "Zertifikatsmodul: Paket hochladen, Uebersicht, Download in jedem Format"
|
||||
date: 2026-10-01
|
||||
---
|
||||
|
||||
# Zertifikatsmodul: Paket hochladen, Uebersicht, Download in jedem Format
|
||||
|
||||
**Auftrag (User):** Testdatei = ZIP vom Aussteller (pem mit Server+Zwischen, key, csr, pfx mit unbekanntem Passwort, .dnstxtrecord). Nach dem Hochladen soll angezeigt werden, welches Zertifikat was ist, darunter jedes Zertifikat in jedem Format herunterladbar.
|
||||
|
||||
**Befund vorher:** Modul nimmt nur EINE Datei; .key/.csr/.zip nicht waehlbar; keine Uebersicht; Labels teils englisch; Texte duzen.
|
||||
|
||||
## Tasks
|
||||
1. API `cert-bundle.ts`: `analyzeBundle` (Dateien + ZIP mit Grenzen vor dem Entpacken; PEM/DER/PFX/P7B; Duplikate per SHA-256/Modulus; Schluessel/CSR-Zuordnung; Kette) und `exportBundleItem` (crt, cer, fullchain, p7b, pfx inkl. Schluessel+Kette; key PKCS#8/PKCS#1/DER; csr PEM/DER). Endpunkte POST analyze / export.
|
||||
2. Web: Reiter „Übersicht“ (Standard), Mehrfach-Ablage, Karten je Teil mit Erklaerung, Status, Zuordnung, Download-Knoepfen, PFX-Passwort; geschuetzte PFX entsperren. Texte de/en, Sie-Form.
|
||||
3. Desktop: blob:/data:-Downloads in der App speichern (Downloads-Ordner) + Meldung; vorher landete der Klick als „blob-Link“ im System-Browser (VM gemessen).
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
quick_id: 261001-l4q
|
||||
status: complete
|
||||
date: 2026-10-01
|
||||
---
|
||||
|
||||
# Summary
|
||||
- Echte Aussteller-ZIP (nur lokal, nicht im Repo): 4 Teile erkannt (Server, Zwischen, Schluessel→Server, CSR→Server), PFX als geschuetzt gemeldet, .dnstxtrecord als nicht verwendet; alle 15 Exporte mit OpenSSL gueltig (PFX mit 3 Bloecken, Schluessel-Modulus passt).
|
||||
- Tests: API cert-bundle.spec 11 (selbst erzeugte PKI), Web OverviewTab.test 5 + Seitentest angepasst; gesamt Web 1189, API 1686, Rust 65 gruen; tsc/clippy/rustfmt sauber.
|
||||
- Browser lokal: Uebersicht + Downloads (fullchain, pfx, rsa.key, csr.der) geprueft.
|
||||
- Desktop-Client (VM, lokal): Upload/Anzeige ok; Download zeigte „blob-Link“-Fehler → Client-Fix (on_download speichert blob:/data: selbst, Meldung danach). Nachweis auf VM folgt mit neuem Client-Build.
|
||||
@@ -4,6 +4,23 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
### Neu
|
||||
|
||||
- Zertifikat-Manager: Neuer Reiter „Übersicht“. Ziehen Sie alle Dateien, die Sie vom Zertifikatsaussteller bekommen haben, auf einmal hinein – gern auch direkt die ZIP-Datei. Tessera zeigt, was jede Datei ist (Serverzertifikat, Zwischenzertifikat, Stammzertifikat, privater Schlüssel, Zertifikatsanfrage), wofür sie gebraucht wird, wie lange sie gültig ist und was zusammengehört. Unter jedem Teil können Sie es in jedem passenden Format herunterladen: Zertifikate als PEM (.crt), DER (.cer), mit Kette, PKCS#7 (.p7b) oder als PFX mit Schlüssel und Kette; den Schlüssel als PEM, RSA-PEM oder DER; die Anfrage als PEM oder DER. Passwortgeschützte PFX-Dateien lassen sich mit dem Passwort entsperren.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Desktop-App: Downloads, die Tessera erst im Fenster erstellt (zum Beispiel im Zertifikat-Manager), werden jetzt im Ordner „Downloads“ gespeichert; eine Meldung nennt den Dateinamen. Bisher öffnete Windows nur den Hinweis „Holen Sie sich eine App, um diesen ‚blob‘-Link zu öffnen“. Dafür ist die neue Version der Desktop-App nötig.
|
||||
- Zertifikat-Manager: Die Texte sprechen Sie jetzt durchgehend mit „Sie“ an.
|
||||
|
||||
## 1.9.1 – 2026-10-01
|
||||
|
||||
### Behoben
|
||||
|
||||
- Desktop-App: Favoriten und andere Links, die sich in einem neuen Fenster öffnen (etwa „In neuem Tab öffnen“ oder Quellen im Ausschreibungs-Radar), öffnen sich jetzt in Ihrem normalen Browser. Bisher passierte beim Klick in der Desktop-App nichts.
|
||||
- Erinnerungen: Beim Schreiben der Beschreibung springt der Cursor nicht mehr in die Titelzeile zurück. Bisher passierte das alle paar Sekunden, weil sich die Kachel regelmäßig neu aufbaut.
|
||||
- Favoriten: Auch Seiten, die ihr Logo erst beim Laden im Browser setzen (etwa Host Europe), zeigen jetzt ihr Logo statt nur des Anfangsbuchstabens. Findet Tessera auf der Seite selbst kein Logo, fragt es bei öffentlichen Adressen einen Logo-Dienst; interne Adressen werden dabei nie weitergegeben. Das gilt auch für bereits angelegte Favoriten.
|
||||
|
||||
## 1.9.0 – 2026-09-30
|
||||
|
||||
### Neu
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import AdmZip from 'adm-zip';
|
||||
import * as forge from 'node-forge';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { analyzeBundle, exportBundleItem, safeBaseName } from './cert-bundle';
|
||||
|
||||
/**
|
||||
* cert-bundle.spec (quick-261001-l4q) — Zertifikatspaket wie vom Aussteller:
|
||||
* Stamm -> Zwischen -> Server, dazu Schluessel, CSR und PFX, als ZIP.
|
||||
* Alles hier erzeugt (keine echten Kundendaten im Repo).
|
||||
*/
|
||||
|
||||
interface Pki {
|
||||
rootPem: string;
|
||||
interPem: string;
|
||||
leafPem: string;
|
||||
keyPem: string;
|
||||
csrPem: string;
|
||||
pfx: Buffer;
|
||||
leafModulus: string;
|
||||
}
|
||||
|
||||
let pki: Pki;
|
||||
|
||||
function makeCert(
|
||||
subjectCn: string,
|
||||
pub: forge.pki.PublicKey,
|
||||
signer: forge.pki.PrivateKey,
|
||||
issuer: forge.pki.CertificateField[] | null,
|
||||
ca: boolean,
|
||||
serial: string,
|
||||
): forge.pki.Certificate {
|
||||
const cert = forge.pki.createCertificate();
|
||||
cert.publicKey = pub;
|
||||
cert.serialNumber = serial;
|
||||
cert.validity.notBefore = new Date(Date.now() - 86_400_000);
|
||||
cert.validity.notAfter = new Date(Date.now() + 90 * 86_400_000);
|
||||
const subject = [{ name: 'commonName', value: subjectCn }];
|
||||
cert.setSubject(subject);
|
||||
cert.setIssuer(issuer ?? subject);
|
||||
const ext: object[] = [{ name: 'basicConstraints', cA: ca }];
|
||||
if (!ca) ext.push({ name: 'subjectAltName', altNames: [{ type: 2, value: subjectCn }] });
|
||||
cert.setExtensions(ext);
|
||||
cert.sign(signer as forge.pki.rsa.PrivateKey, forge.md.sha256.create());
|
||||
return cert;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
const rootKeys = forge.pki.rsa.generateKeyPair(1024);
|
||||
const interKeys = forge.pki.rsa.generateKeyPair(1024);
|
||||
const leafKeys = forge.pki.rsa.generateKeyPair(1024);
|
||||
const root = makeCert('Test Root CA', rootKeys.publicKey, rootKeys.privateKey, null, true, '01');
|
||||
const inter = makeCert(
|
||||
'Test Intermediate CA',
|
||||
interKeys.publicKey,
|
||||
rootKeys.privateKey,
|
||||
root.subject.attributes,
|
||||
true,
|
||||
'02',
|
||||
);
|
||||
const leaf = makeCert(
|
||||
'www.example.test',
|
||||
leafKeys.publicKey,
|
||||
interKeys.privateKey,
|
||||
inter.subject.attributes,
|
||||
false,
|
||||
'03',
|
||||
);
|
||||
|
||||
const csr = forge.pki.createCertificationRequest();
|
||||
csr.publicKey = leafKeys.publicKey;
|
||||
csr.setSubject([{ name: 'commonName', value: 'www.example.test' }]);
|
||||
csr.sign(leafKeys.privateKey, forge.md.sha256.create());
|
||||
|
||||
const p12 = forge.pkcs12.toPkcs12Asn1(leafKeys.privateKey, [leaf, inter], 'geheim', {
|
||||
algorithm: '3des',
|
||||
});
|
||||
|
||||
pki = {
|
||||
rootPem: forge.pki.certificateToPem(root),
|
||||
interPem: forge.pki.certificateToPem(inter),
|
||||
leafPem: forge.pki.certificateToPem(leaf),
|
||||
keyPem: forge.pki.privateKeyInfoToPem(
|
||||
forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(leafKeys.privateKey)),
|
||||
),
|
||||
csrPem: forge.pki.certificationRequestToPem(csr),
|
||||
pfx: Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary'),
|
||||
leafModulus: leafKeys.publicKey.n.toString(16),
|
||||
};
|
||||
}, 60_000);
|
||||
|
||||
function issuerZip(): Buffer {
|
||||
const zip = new AdmZip();
|
||||
zip.addFile('www.example.test/www.example.test.pem', Buffer.from(pki.leafPem + pki.interPem));
|
||||
zip.addFile('www.example.test/www.example.test.key', Buffer.from(pki.keyPem));
|
||||
zip.addFile('www.example.test/www.example.test.csr', Buffer.from(pki.csrPem));
|
||||
zip.addFile('www.example.test/www.example.test.pfx', pki.pfx);
|
||||
zip.addFile('www.example.test/.dnstxtrecord', Buffer.from('_dnsauth abc123'));
|
||||
return zip.toBuffer();
|
||||
}
|
||||
|
||||
describe('analyzeBundle', () => {
|
||||
it('ZIP vom Aussteller: erkennt jedes Teil, fasst PEM/PFX zusammen, ordnet Schluessel und Kette zu', () => {
|
||||
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim');
|
||||
|
||||
const kinds = r.items.map((i) => (i.kind === 'certificate' ? i.role : i.kind));
|
||||
expect(kinds).toEqual(['end-entity', 'intermediate', 'privateKey', 'csr']);
|
||||
|
||||
const [leaf, inter, key, csr] = r.items;
|
||||
expect(leaf.cn).toBe('www.example.test');
|
||||
expect(leaf.san).toEqual(['www.example.test']);
|
||||
// in PEM UND PFX enthalten -> ein Eintrag mit beiden Quellen
|
||||
expect(leaf.sources.sort()).toEqual(['www.example.test.pem', 'www.example.test.pfx']);
|
||||
expect(leaf.chainIds).toEqual([inter.id]);
|
||||
expect(leaf.matchId).toBe(key.id);
|
||||
expect(key.matchId).toBe(leaf.id);
|
||||
expect(key.sources.sort()).toEqual(['www.example.test.key', 'www.example.test.pfx']);
|
||||
expect(csr.matchId).toBe(leaf.id);
|
||||
expect(csr.cn).toBe('www.example.test');
|
||||
expect(leaf.baseName).toBe('www.example.test');
|
||||
|
||||
expect(r.locked).toEqual([]);
|
||||
expect(r.ignored).toEqual(['.dnstxtrecord']);
|
||||
});
|
||||
|
||||
it('PFX ohne passendes Passwort wird als gesperrt gemeldet, der Rest trotzdem erkannt', () => {
|
||||
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'falsch');
|
||||
expect(r.locked).toEqual(['www.example.test.pfx']);
|
||||
expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Stammzertifikat wird als root erkannt und an die Kette gehaengt', () => {
|
||||
const r = analyzeBundle(
|
||||
[
|
||||
{
|
||||
originalname: 'chain.pem',
|
||||
buffer: Buffer.from(pki.leafPem + pki.interPem + pki.rootPem),
|
||||
},
|
||||
],
|
||||
'',
|
||||
);
|
||||
expect(r.items.map((i) => i.role)).toEqual(['end-entity', 'intermediate', 'root']);
|
||||
expect(r.items[0].chainIds).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ohne Dateien -> 400', () => {
|
||||
expect(() => analyzeBundle([], '')).toThrow(/No files/);
|
||||
});
|
||||
|
||||
it('ZIP mit zu vielen Dateien -> 400', () => {
|
||||
const zip = new AdmZip();
|
||||
for (let i = 0; i < 101; i++) zip.addFile(`f${i}.txt`, Buffer.from('x'));
|
||||
expect(() =>
|
||||
analyzeBundle([{ originalname: 'gross.zip', buffer: zip.toBuffer() }], ''),
|
||||
).toThrow(/too many files/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportBundleItem', () => {
|
||||
function bundle() {
|
||||
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim');
|
||||
const byId = Object.fromEntries(r.items.map((i) => [i.id, i]));
|
||||
return { items: r.items, byId };
|
||||
}
|
||||
const decode = (b64: string) => Buffer.from(b64, 'base64');
|
||||
|
||||
it('Zertifikat in jedem Format liest sich wieder ein', () => {
|
||||
const { items, byId } = bundle();
|
||||
const leaf = items[0];
|
||||
const chain = leaf.chainIds.map((id) => byId[id].pem);
|
||||
const keyPem = byId[leaf.matchId!].pem;
|
||||
const base = { kind: leaf.kind, pem: leaf.pem, baseName: leaf.baseName, chain, keyPem };
|
||||
|
||||
const crt = exportBundleItem({ ...base, format: 'crt' });
|
||||
expect(crt.filename).toBe('www.example.test.crt');
|
||||
expect(
|
||||
forge.pki.certificateFromPem(decode(crt.content).toString()).subject.getField('CN').value,
|
||||
).toBe('www.example.test');
|
||||
|
||||
const cer = exportBundleItem({ ...base, format: 'cer' });
|
||||
expect(cer.filename).toBe('www.example.test.cer');
|
||||
forge.pki.certificateFromAsn1(forge.asn1.fromDer(decode(cer.content).toString('binary')));
|
||||
|
||||
const full = exportBundleItem({ ...base, format: 'fullchain' });
|
||||
expect(
|
||||
decode(full.content)
|
||||
.toString()
|
||||
.match(/BEGIN CERTIFICATE/g),
|
||||
).toHaveLength(2);
|
||||
|
||||
const p7b = exportBundleItem({ ...base, format: 'p7b' });
|
||||
const p7 = forge.pkcs7.messageFromPem(decode(p7b.content).toString());
|
||||
expect('certificates' in p7 ? p7.certificates : []).toHaveLength(2);
|
||||
|
||||
const pfx = exportBundleItem({ ...base, format: 'pfx', password: 'neu' });
|
||||
expect(pfx.filename).toBe('www.example.test.pfx');
|
||||
const p12 = forge.pkcs12.pkcs12FromAsn1(
|
||||
forge.asn1.fromDer(decode(pfx.content).toString('binary')),
|
||||
'neu',
|
||||
);
|
||||
expect(p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag]).toHaveLength(
|
||||
2,
|
||||
);
|
||||
const keyBag = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[
|
||||
forge.pki.oids.pkcs8ShroudedKeyBag
|
||||
]![0];
|
||||
expect((keyBag.key as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(pki.leafModulus);
|
||||
});
|
||||
|
||||
it('PFX ohne Passwort -> 400', () => {
|
||||
const { items } = bundle();
|
||||
expect(() =>
|
||||
exportBundleItem({ kind: 'certificate', pem: items[0].pem, format: 'pfx' }),
|
||||
).toThrow(/password is required/);
|
||||
});
|
||||
|
||||
it('Schluessel als PKCS#8, PKCS#1 und DER', () => {
|
||||
const { items } = bundle();
|
||||
const key = items.find((i) => i.kind === 'privateKey')!;
|
||||
const base = { kind: key.kind, pem: key.pem, baseName: key.baseName };
|
||||
expect(decode(exportBundleItem({ ...base, format: 'key' }).content).toString()).toContain(
|
||||
'BEGIN PRIVATE KEY',
|
||||
);
|
||||
const rsa = exportBundleItem({ ...base, format: 'key-rsa' });
|
||||
expect(rsa.filename).toBe('www.example.test.rsa.key');
|
||||
expect(decode(rsa.content).toString()).toContain('BEGIN RSA PRIVATE KEY');
|
||||
const der = exportBundleItem({ ...base, format: 'key-der' });
|
||||
const info = forge.asn1.fromDer(decode(der.content).toString('binary'));
|
||||
expect((forge.pki.privateKeyFromAsn1(info) as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(
|
||||
pki.leafModulus,
|
||||
);
|
||||
});
|
||||
|
||||
it('CSR als PEM und DER', () => {
|
||||
const { items } = bundle();
|
||||
const csr = items.find((i) => i.kind === 'csr')!;
|
||||
const der = exportBundleItem({
|
||||
kind: 'csr',
|
||||
pem: csr.pem,
|
||||
baseName: csr.baseName,
|
||||
format: 'csr-der',
|
||||
});
|
||||
expect(der.filename).toBe('www.example.test.csr.der');
|
||||
forge.pki.certificationRequestFromAsn1(
|
||||
forge.asn1.fromDer(decode(der.content).toString('binary')),
|
||||
);
|
||||
});
|
||||
|
||||
it('unpassendes Format -> 400', () => {
|
||||
const { items } = bundle();
|
||||
expect(() =>
|
||||
exportBundleItem({ kind: 'csr', pem: items[3].pem, format: 'pfx', password: 'x' }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
exportBundleItem({ kind: 'privateKey', pem: 'kein pem', format: 'key-rsa' }),
|
||||
).toThrow(/Failed to export/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeBaseName', () => {
|
||||
it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => {
|
||||
expect(safeBaseName('*.example.de', 'x')).toBe('wildcard.example.de');
|
||||
expect(safeBaseName('Encryption Everywhere DV TLS CA - G1', 'x')).toBe(
|
||||
'Encryption_Everywhere_DV_TLS_CA_-_G1',
|
||||
);
|
||||
expect(safeBaseName('../../etc/passwd', 'x')).toBe('etc_passwd');
|
||||
expect(safeBaseName('', 'fallback')).toBe('fallback');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,655 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import AdmZip from 'adm-zip';
|
||||
import * as forge from 'node-forge';
|
||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||
|
||||
/**
|
||||
* Zertifikatspaket (quick-261001-l4q): alles, was ein Aussteller liefert —
|
||||
* Zertifikat mit Kette (.pem/.crt/.cer/.p7b), privater Schluessel (.key),
|
||||
* Zertifikatsanfrage (.csr), PFX/P12, gern als ZIP — auf einmal hochladen,
|
||||
* erkennen, was was ist, und jedes Teil in jedem passenden Format
|
||||
* herunterladen.
|
||||
*
|
||||
* Zustandslos wie der Rest des Moduls: `analyzeBundle` gibt je Teil den
|
||||
* kanonischen PEM-Text zurueck, `exportBundleItem` baut daraus die Datei.
|
||||
* Nichts wird gespeichert, Passwoerter werden nie protokolliert.
|
||||
*/
|
||||
|
||||
type BundleFile = Pick<UploadedFileLike, 'buffer' | 'originalname'>;
|
||||
|
||||
export type BundleCertRole = 'end-entity' | 'intermediate' | 'root';
|
||||
export type BundleItemKind = 'certificate' | 'privateKey' | 'csr';
|
||||
|
||||
export interface BundleItem {
|
||||
id: string;
|
||||
kind: BundleItemKind;
|
||||
/** Nur bei Zertifikaten. */
|
||||
role?: BundleCertRole;
|
||||
/** Dateien, in denen dieses Teil gefunden wurde (Duplikate zusammengefasst). */
|
||||
sources: string[];
|
||||
/** Kanonischer PEM-Text — Grundlage fuer jeden Export. */
|
||||
pem: string;
|
||||
/** Vorschlag fuer den Dateinamen ohne Endung, aus dem CN abgeleitet. */
|
||||
baseName: string;
|
||||
cn: string;
|
||||
organization: string;
|
||||
issuerCn: string;
|
||||
notBefore: string | null;
|
||||
notAfter: string | null;
|
||||
isExpired: boolean | null;
|
||||
daysLeft: number | null;
|
||||
san: string[];
|
||||
keyType: string;
|
||||
keyBits: number;
|
||||
serialNumber: string;
|
||||
sha256: string;
|
||||
/** Zertifikat: id des passenden Schluessels; Schluessel/CSR: id des passenden Zertifikats. */
|
||||
matchId: string | null;
|
||||
/** Zertifikat: ids der Kette darueber (Aussteller, dessen Aussteller ...). */
|
||||
chainIds: string[];
|
||||
/** Formate, die `exportBundleItem` fuer dieses Teil liefern kann. */
|
||||
formats: BundleExportFormat[];
|
||||
}
|
||||
|
||||
export interface BundleAnalysis {
|
||||
items: BundleItem[];
|
||||
/** PFX/P12 oder verschluesselte Schluessel, die ohne (richtiges) Passwort nicht lesbar sind. */
|
||||
locked: string[];
|
||||
/** Dateien ohne erkennbares Zertifikat, Schluessel oder CSR. */
|
||||
ignored: string[];
|
||||
}
|
||||
|
||||
export type BundleExportFormat =
|
||||
| 'crt'
|
||||
| 'cer'
|
||||
| 'fullchain'
|
||||
| 'p7b'
|
||||
| 'pfx'
|
||||
| 'key'
|
||||
| 'key-rsa'
|
||||
| 'key-der'
|
||||
| 'csr'
|
||||
| 'csr-der';
|
||||
|
||||
export interface BundleExportInput {
|
||||
kind: BundleItemKind;
|
||||
pem: string;
|
||||
format: BundleExportFormat;
|
||||
baseName?: string;
|
||||
/** Zertifikat: PEMs der Kette darueber (fuer Fullchain/P7B/PFX). */
|
||||
chain?: string[];
|
||||
/** Zertifikat: PEM des passenden privaten Schluessels (fuer PFX). */
|
||||
keyPem?: string;
|
||||
/** PFX: Passwort fuer die neue Datei. */
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface BundleExportFile {
|
||||
filename: string;
|
||||
/** Base64 */
|
||||
content: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
const MAX_ZIP_ENTRIES = 100;
|
||||
const MAX_ENTRY_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----[\s\S]+?-----END \1-----/g;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hilfen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function binary(buffer: Buffer): forge.util.ByteStringBuffer {
|
||||
return forge.util.createBuffer(buffer.toString('binary'));
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: string): string {
|
||||
return Buffer.from(forge.util.bytesToHex(bytes), 'hex').toString('base64');
|
||||
}
|
||||
|
||||
function textToBase64(text: string): string {
|
||||
return Buffer.from(text, 'utf-8').toString('base64');
|
||||
}
|
||||
|
||||
function sha256Of(cert: forge.pki.Certificate): string {
|
||||
const md = forge.md.sha256.create();
|
||||
md.update(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes());
|
||||
return (md.digest().toHex().match(/.{2}/g) ?? []).join(':').toUpperCase();
|
||||
}
|
||||
|
||||
function field(name: forge.pki.Certificate['subject'], short: string): string {
|
||||
return (name.getField(short)?.value as string | undefined) ?? '';
|
||||
}
|
||||
|
||||
/** Dateiname ohne Pfad und ohne gefaehrliche Zeichen, z. B. „*.example.de“ -> „wildcard.example.de“. */
|
||||
export function safeBaseName(raw: string, fallback: string): string {
|
||||
const cleaned = raw
|
||||
.replace(/^\*\./, 'wildcard.')
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '_')
|
||||
.replace(/^[._]+/, '')
|
||||
.slice(0, 80);
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
function certRole(cert: forge.pki.Certificate): BundleCertRole {
|
||||
const bc = cert.getExtension('basicConstraints') as { cA?: boolean } | null;
|
||||
if (!bc?.cA) return 'end-entity';
|
||||
return cert.subject.hash === cert.issuer.hash ? 'root' : 'intermediate';
|
||||
}
|
||||
|
||||
function publicKeyInfo(key: unknown): { keyType: string; keyBits: number; modulus: string } {
|
||||
// node-forge liefert RSA-Schluessel mit `n`; EC-Schluessel kennt es nur
|
||||
// eingeschraenkt (siehe Kommentar in CertManagerService.parseCert).
|
||||
const k = key as { n?: forge.jsbn.BigInteger };
|
||||
if (k?.n) return { keyType: 'RSA', keyBits: k.n.bitLength(), modulus: k.n.toString(16) };
|
||||
return { keyType: 'EC', keyBits: 0, modulus: '' };
|
||||
}
|
||||
|
||||
function sanOf(extensions: unknown[] | undefined): string[] {
|
||||
const ext = (extensions ?? []).find((e) => (e as { name?: string }).name === 'subjectAltName') as
|
||||
| { altNames?: { type: number; value?: string; ip?: string }[] }
|
||||
| undefined;
|
||||
return (ext?.altNames ?? []).map((n) =>
|
||||
n.type === 2 ? (n.value ?? '') : `IP:${n.ip ?? n.value ?? ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Einsammeln: Dateien (inkl. ZIP) -> rohe Teile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RawKey {
|
||||
source: string;
|
||||
pem: string;
|
||||
modulus: string;
|
||||
keyType: string;
|
||||
keyBits: number;
|
||||
}
|
||||
|
||||
interface RawCsr {
|
||||
source: string;
|
||||
pem: string;
|
||||
}
|
||||
|
||||
interface Collected {
|
||||
certs: { source: string; cert: forge.pki.Certificate }[];
|
||||
keys: RawKey[];
|
||||
csrs: RawCsr[];
|
||||
locked: string[];
|
||||
ignored: string[];
|
||||
}
|
||||
|
||||
function expandZips(files: BundleFile[]): { name: string; buffer: Buffer }[] {
|
||||
const out: { name: string; buffer: Buffer }[] = [];
|
||||
for (const file of files) {
|
||||
if (!file.originalname.toLowerCase().endsWith('.zip')) {
|
||||
out.push({ name: file.originalname, buffer: file.buffer });
|
||||
continue;
|
||||
}
|
||||
let zip: AdmZip;
|
||||
try {
|
||||
zip = new AdmZip(file.buffer);
|
||||
} catch {
|
||||
throw new BadRequestException(`"${file.originalname}" is not a readable ZIP archive`);
|
||||
}
|
||||
const entries = zip
|
||||
.getEntries()
|
||||
.filter((e) => !e.isDirectory && !e.entryName.startsWith('__MACOSX/'));
|
||||
if (entries.length > MAX_ZIP_ENTRIES) {
|
||||
throw new BadRequestException(`"${file.originalname}" contains too many files`);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
// Groesse aus dem Kopf pruefen, BEVOR entpackt wird (Zip-Bombe).
|
||||
if (entry.header.size > MAX_ENTRY_BYTES) {
|
||||
throw new BadRequestException(
|
||||
`"${entry.entryName}" in "${file.originalname}" is too large`,
|
||||
);
|
||||
}
|
||||
const name = entry.entryName.split('/').pop() ?? entry.entryName;
|
||||
out.push({ name, buffer: entry.getData() });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function addKey(c: Collected, source: string, privateKey: forge.pki.rsa.PrivateKey): void {
|
||||
const info = publicKeyInfo(privateKey);
|
||||
const pem = forge.pki.privateKeyInfoToPem(
|
||||
forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(privateKey)),
|
||||
);
|
||||
c.keys.push({ source, pem, ...info });
|
||||
}
|
||||
|
||||
function collectPemText(c: Collected, source: string, text: string, password: string): boolean {
|
||||
let found = false;
|
||||
for (const match of text.matchAll(PEM_BLOCK)) {
|
||||
const [block, type] = match;
|
||||
try {
|
||||
if (type === 'CERTIFICATE' || type === 'TRUSTED CERTIFICATE') {
|
||||
c.certs.push({ source, cert: forge.pki.certificateFromPem(block) });
|
||||
found = true;
|
||||
} else if (type === 'PRIVATE KEY' || type === 'RSA PRIVATE KEY') {
|
||||
const key = forge.pki.privateKeyFromPem(block) as forge.pki.rsa.PrivateKey;
|
||||
addKey(c, source, key);
|
||||
found = true;
|
||||
} else if (type === 'ENCRYPTED PRIVATE KEY') {
|
||||
const key = password ? forge.pki.decryptRsaPrivateKey(block, password) : null;
|
||||
if (key) addKey(c, source, key as forge.pki.rsa.PrivateKey);
|
||||
else c.locked.push(source);
|
||||
found = true;
|
||||
} else if (type === 'EC PRIVATE KEY') {
|
||||
// node-forge kann EC nicht umrechnen — Teil bleibt im Original erhalten.
|
||||
c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 });
|
||||
found = true;
|
||||
} else if (type === 'CERTIFICATE REQUEST' || type === 'NEW CERTIFICATE REQUEST') {
|
||||
c.csrs.push({
|
||||
source,
|
||||
pem: block.replace(/NEW CERTIFICATE REQUEST/g, 'CERTIFICATE REQUEST'),
|
||||
});
|
||||
found = true;
|
||||
} else if (type === 'PKCS7') {
|
||||
const p7 = forge.pkcs7.messageFromPem(block);
|
||||
for (const cert of 'certificates' in p7 ? p7.certificates : [])
|
||||
c.certs.push({ source, cert });
|
||||
found = true;
|
||||
}
|
||||
} catch {
|
||||
// PKCS#8 mit EC-Schluessel o. ae.: node-forge kann ihn nicht lesen —
|
||||
// im Original behalten statt zu verwerfen.
|
||||
if (type === 'PRIVATE KEY') {
|
||||
c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 });
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function collectPfx(c: Collected, source: string, buffer: Buffer, password: string): void {
|
||||
let p12: forge.pkcs12.Pkcs12Pfx | null = null;
|
||||
for (const candidate of password ? [password, ''] : ['']) {
|
||||
try {
|
||||
p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(binary(buffer)), candidate);
|
||||
break;
|
||||
} catch {
|
||||
// naechstes Passwort versuchen
|
||||
}
|
||||
}
|
||||
if (!p12) {
|
||||
c.locked.push(source);
|
||||
return;
|
||||
}
|
||||
for (const bag of p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag] ??
|
||||
[]) {
|
||||
if (bag.cert) c.certs.push({ source, cert: bag.cert });
|
||||
}
|
||||
for (const oid of [forge.pki.oids.pkcs8ShroudedKeyBag, forge.pki.oids.keyBag]) {
|
||||
for (const bag of p12.getBags({ bagType: oid })[oid] ?? []) {
|
||||
if (bag.key) addKey(c, source, bag.key as forge.pki.rsa.PrivateKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectDer(c: Collected, source: string, buffer: Buffer): boolean {
|
||||
try {
|
||||
const asn1 = forge.asn1.fromDer(binary(buffer));
|
||||
try {
|
||||
c.certs.push({ source, cert: forge.pki.certificateFromAsn1(asn1) });
|
||||
return true;
|
||||
} catch {
|
||||
/* kein einzelnes Zertifikat */
|
||||
}
|
||||
try {
|
||||
const p7 = forge.pkcs7.messageFromAsn1(asn1);
|
||||
const certs = 'certificates' in p7 ? p7.certificates : [];
|
||||
for (const cert of certs) c.certs.push({ source, cert });
|
||||
if (certs.length > 0) return true;
|
||||
} catch {
|
||||
/* kein PKCS#7 */
|
||||
}
|
||||
try {
|
||||
forge.pki.certificationRequestFromAsn1(asn1);
|
||||
const body = forge.asn1.toDer(asn1).getBytes();
|
||||
c.csrs.push({ source, pem: forge.pem.encode({ type: 'CERTIFICATE REQUEST', body }) });
|
||||
return true;
|
||||
} catch {
|
||||
/* keine CSR */
|
||||
}
|
||||
} catch {
|
||||
/* kein DER */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collect(files: BundleFile[], password: string): Collected {
|
||||
const c: Collected = { certs: [], keys: [], csrs: [], locked: [], ignored: [] };
|
||||
for (const { name, buffer } of expandZips(files)) {
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
if (ext === 'pfx' || ext === 'p12') {
|
||||
collectPfx(c, name, buffer, password);
|
||||
continue;
|
||||
}
|
||||
const head = buffer.subarray(0, 4096).toString('latin1');
|
||||
const found = head.includes('-----BEGIN')
|
||||
? collectPemText(c, name, buffer.toString('utf-8'), password)
|
||||
: collectDer(c, name, buffer);
|
||||
if (!found) c.ignored.push(name);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// analyzeBundle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CERT_FORMATS: BundleExportFormat[] = ['crt', 'cer', 'fullchain', 'p7b', 'pfx'];
|
||||
|
||||
export function analyzeBundle(files: BundleFile[], password = ''): BundleAnalysis {
|
||||
if (files.length === 0) throw new BadRequestException('No files provided');
|
||||
const c = collect(files, password);
|
||||
|
||||
// Zertifikate nach Fingerabdruck zusammenfassen (PEM und PFX enthalten oft dieselben).
|
||||
const certMap = new Map<string, { cert: forge.pki.Certificate; sources: Set<string> }>();
|
||||
for (const { source, cert } of c.certs) {
|
||||
const fp = sha256Of(cert);
|
||||
const entry = certMap.get(fp) ?? { cert, sources: new Set<string>() };
|
||||
entry.sources.add(source);
|
||||
certMap.set(fp, entry);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const certItems: BundleItem[] = [...certMap.entries()].map(([fp, { cert, sources }]) => {
|
||||
const info = publicKeyInfo(cert.publicKey);
|
||||
const cn = field(cert.subject, 'CN');
|
||||
const notAfter = cert.validity.notAfter;
|
||||
const role = certRole(cert);
|
||||
return {
|
||||
id: `cert-${fp.replace(/:/g, '').slice(0, 16).toLowerCase()}`,
|
||||
kind: 'certificate',
|
||||
role,
|
||||
sources: [...sources],
|
||||
pem: forge.pki.certificateToPem(cert),
|
||||
baseName: safeBaseName(cn, role === 'end-entity' ? 'zertifikat' : 'ca'),
|
||||
cn,
|
||||
organization: field(cert.subject, 'O'),
|
||||
issuerCn: field(cert.issuer, 'CN'),
|
||||
notBefore: cert.validity.notBefore.toISOString(),
|
||||
notAfter: notAfter.toISOString(),
|
||||
isExpired: notAfter.getTime() < now,
|
||||
daysLeft: Math.ceil((notAfter.getTime() - now) / 86_400_000),
|
||||
san: sanOf(cert.extensions),
|
||||
keyType: info.keyType,
|
||||
keyBits: info.keyBits,
|
||||
serialNumber: cert.serialNumber,
|
||||
sha256: fp,
|
||||
matchId: null,
|
||||
chainIds: [],
|
||||
formats: CERT_FORMATS,
|
||||
// nur intern fuer Kette/Zuordnung, wird unten entfernt
|
||||
_cert: cert,
|
||||
_modulus: info.modulus,
|
||||
} as BundleItem & { _cert: forge.pki.Certificate; _modulus: string };
|
||||
});
|
||||
|
||||
// Kette: zu jedem Zertifikat den Aussteller im Paket suchen.
|
||||
type Internal = BundleItem & { _cert: forge.pki.Certificate; _modulus: string };
|
||||
const internals = certItems as Internal[];
|
||||
for (const item of internals) {
|
||||
let current = item._cert;
|
||||
const seen = new Set<string>([item.id]);
|
||||
for (let depth = 0; depth < 10; depth++) {
|
||||
if (current.subject.hash === current.issuer.hash) break;
|
||||
const issuer = internals.find(
|
||||
(o) => !seen.has(o.id) && o._cert.subject.hash === current.issuer.hash,
|
||||
);
|
||||
if (!issuer) break;
|
||||
item.chainIds.push(issuer.id);
|
||||
seen.add(issuer.id);
|
||||
current = issuer._cert;
|
||||
}
|
||||
}
|
||||
|
||||
// Schluessel: Duplikate zusammenfassen, dem Zertifikat zuordnen.
|
||||
const keyMap = new Map<string, { key: RawKey; sources: Set<string> }>();
|
||||
for (const key of c.keys) {
|
||||
const id = key.modulus || key.pem;
|
||||
const entry = keyMap.get(id) ?? { key, sources: new Set<string>() };
|
||||
entry.sources.add(key.source);
|
||||
keyMap.set(id, entry);
|
||||
}
|
||||
const keyItems: BundleItem[] = [...keyMap.values()].map(({ key, sources }, i) => {
|
||||
const cert = key.modulus ? internals.find((o) => o._modulus === key.modulus) : undefined;
|
||||
const id = `key-${i + 1}`;
|
||||
if (cert) cert.matchId = id;
|
||||
const isRsa = key.keyType === 'RSA';
|
||||
return {
|
||||
id,
|
||||
kind: 'privateKey',
|
||||
sources: [...sources],
|
||||
pem: key.pem,
|
||||
baseName:
|
||||
cert?.baseName ??
|
||||
safeBaseName(
|
||||
sources
|
||||
.values()
|
||||
.next()
|
||||
.value?.replace(/\.[^.]+$/, '') ?? '',
|
||||
'schluessel',
|
||||
),
|
||||
cn: cert?.cn ?? '',
|
||||
organization: '',
|
||||
issuerCn: '',
|
||||
notBefore: null,
|
||||
notAfter: null,
|
||||
isExpired: null,
|
||||
daysLeft: null,
|
||||
san: [],
|
||||
keyType: key.keyType,
|
||||
keyBits: key.keyBits,
|
||||
serialNumber: '',
|
||||
sha256: '',
|
||||
matchId: cert?.id ?? null,
|
||||
chainIds: [],
|
||||
formats: isRsa ? ['key', 'key-rsa', 'key-der'] : ['key'],
|
||||
};
|
||||
});
|
||||
|
||||
// CSRs: Duplikate zusammenfassen, Details lesen, dem Zertifikat zuordnen.
|
||||
const csrMap = new Map<string, { pem: string; sources: Set<string> }>();
|
||||
for (const csr of c.csrs) {
|
||||
const norm = csr.pem.replace(/\s+/g, '');
|
||||
const entry = csrMap.get(norm) ?? { pem: csr.pem, sources: new Set<string>() };
|
||||
entry.sources.add(csr.source);
|
||||
csrMap.set(norm, entry);
|
||||
}
|
||||
const csrItems: BundleItem[] = [...csrMap.values()].map(({ pem, sources }, i) => {
|
||||
let cn = '';
|
||||
let organization = '';
|
||||
let info = { keyType: '', keyBits: 0, modulus: '' };
|
||||
let san: string[] = [];
|
||||
try {
|
||||
const csr = forge.pki.certificationRequestFromPem(pem);
|
||||
cn = field(csr.subject as forge.pki.Certificate['subject'], 'CN');
|
||||
organization = field(csr.subject as forge.pki.Certificate['subject'], 'O');
|
||||
info = publicKeyInfo(csr.publicKey);
|
||||
const ext = csr.getAttribute({ name: 'extensionRequest' }) as {
|
||||
extensions?: unknown[];
|
||||
} | null;
|
||||
san = sanOf(ext?.extensions);
|
||||
} catch {
|
||||
// EC-CSR: node-forge liest sie nicht — Teil bleibt trotzdem herunterladbar.
|
||||
}
|
||||
const cert = info.modulus ? internals.find((o) => o._modulus === info.modulus) : undefined;
|
||||
return {
|
||||
id: `csr-${i + 1}`,
|
||||
kind: 'csr',
|
||||
sources: [...sources],
|
||||
pem,
|
||||
baseName: cert?.baseName ?? safeBaseName(cn, 'anfrage'),
|
||||
cn,
|
||||
organization,
|
||||
issuerCn: '',
|
||||
notBefore: null,
|
||||
notAfter: null,
|
||||
isExpired: null,
|
||||
daysLeft: null,
|
||||
san,
|
||||
keyType: info.keyType,
|
||||
keyBits: info.keyBits,
|
||||
serialNumber: '',
|
||||
sha256: '',
|
||||
matchId: cert?.id ?? null,
|
||||
chainIds: [],
|
||||
formats: ['csr', 'csr-der'],
|
||||
};
|
||||
});
|
||||
|
||||
// Reihenfolge: Serverzertifikat(e), Zwischen-, Stammzertifikate, Schluessel, CSR.
|
||||
const roleOrder: Record<BundleCertRole, number> = { 'end-entity': 0, intermediate: 1, root: 2 };
|
||||
internals.sort(
|
||||
(a, b) =>
|
||||
roleOrder[a.role ?? 'end-entity'] - roleOrder[b.role ?? 'end-entity'] ||
|
||||
b.chainIds.length - a.chainIds.length,
|
||||
);
|
||||
const certsClean: BundleItem[] = internals.map(({ _cert, _modulus, ...rest }) => rest);
|
||||
|
||||
return {
|
||||
items: [...certsClean, ...keyItems, ...csrItems],
|
||||
locked: [...new Set(c.locked)],
|
||||
ignored: [...new Set(c.ignored)],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// exportBundleItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MIME = {
|
||||
pem: 'application/x-pem-file',
|
||||
der: 'application/x-x509-ca-cert',
|
||||
p7b: 'application/x-pkcs7-certificates',
|
||||
pfx: 'application/x-pkcs12',
|
||||
key: 'application/x-pem-file',
|
||||
octet: 'application/octet-stream',
|
||||
} as const;
|
||||
|
||||
function pemBody(pem: string): string {
|
||||
const [msg] = forge.pem.decode(pem);
|
||||
if (!msg) throw new Error('no PEM block');
|
||||
return msg.body;
|
||||
}
|
||||
|
||||
export function exportBundleItem(input: BundleExportInput): BundleExportFile {
|
||||
const { kind, pem, format, chain = [], keyPem, password } = input;
|
||||
const base = safeBaseName(
|
||||
input.baseName ?? '',
|
||||
kind === 'csr' ? 'anfrage' : kind === 'privateKey' ? 'schluessel' : 'zertifikat',
|
||||
);
|
||||
|
||||
if (!pem || typeof pem !== 'string') throw new BadRequestException('No PEM provided');
|
||||
if (format === 'pfx' && (!password || password.trim() === '')) {
|
||||
throw new BadRequestException('A password is required for PFX output');
|
||||
}
|
||||
|
||||
try {
|
||||
if (kind === 'certificate') {
|
||||
const cert = forge.pki.certificateFromPem(pem);
|
||||
const chainCerts = chain.map((p) => forge.pki.certificateFromPem(p));
|
||||
switch (format) {
|
||||
case 'crt':
|
||||
return {
|
||||
filename: `${base}.crt`,
|
||||
content: textToBase64(forge.pki.certificateToPem(cert)),
|
||||
mimeType: MIME.pem,
|
||||
};
|
||||
case 'cer':
|
||||
return {
|
||||
filename: `${base}.cer`,
|
||||
content: bytesToBase64(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()),
|
||||
mimeType: MIME.der,
|
||||
};
|
||||
case 'fullchain': {
|
||||
const text = [cert, ...chainCerts].map((x) => forge.pki.certificateToPem(x)).join('');
|
||||
return {
|
||||
filename: `${base}-fullchain.pem`,
|
||||
content: textToBase64(text),
|
||||
mimeType: MIME.pem,
|
||||
};
|
||||
}
|
||||
case 'p7b': {
|
||||
const p7 = forge.pkcs7.createSignedData();
|
||||
for (const x of [cert, ...chainCerts]) p7.addCertificate(x);
|
||||
const text = forge.pem.encode({
|
||||
type: 'PKCS7',
|
||||
body: forge.asn1.toDer(p7.toAsn1()).getBytes(),
|
||||
});
|
||||
return { filename: `${base}.p7b`, content: textToBase64(text), mimeType: MIME.p7b };
|
||||
}
|
||||
case 'pfx': {
|
||||
const key = keyPem
|
||||
? (forge.pki.privateKeyFromPem(keyPem) as forge.pki.rsa.PrivateKey)
|
||||
: null;
|
||||
const p12 = forge.pkcs12.toPkcs12Asn1(
|
||||
// null = reines Zertifikatsbuendel ohne Schluessel (siehe
|
||||
// CertManagerService.mergeCerts).
|
||||
key,
|
||||
[cert, ...chainCerts],
|
||||
password as string, // oben geprueft: PFX verlangt ein Passwort
|
||||
{ algorithm: '3des', friendlyName: input.baseName || undefined },
|
||||
);
|
||||
return {
|
||||
filename: `${base}.pfx`,
|
||||
content: bytesToBase64(forge.asn1.toDer(p12).getBytes()),
|
||||
mimeType: MIME.pfx,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (kind === 'privateKey') {
|
||||
switch (format) {
|
||||
case 'key':
|
||||
return {
|
||||
filename: `${base}.key`,
|
||||
content: textToBase64(`${pem.trim()}\n`),
|
||||
mimeType: MIME.key,
|
||||
};
|
||||
case 'key-rsa': {
|
||||
const key = forge.pki.privateKeyFromPem(pem);
|
||||
return {
|
||||
filename: `${base}.rsa.key`,
|
||||
content: textToBase64(forge.pki.privateKeyToPem(key)),
|
||||
mimeType: MIME.key,
|
||||
};
|
||||
}
|
||||
case 'key-der': {
|
||||
const key = forge.pki.privateKeyFromPem(pem);
|
||||
const info = forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(key));
|
||||
return {
|
||||
filename: `${base}.key.der`,
|
||||
content: bytesToBase64(forge.asn1.toDer(info).getBytes()),
|
||||
mimeType: MIME.octet,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (kind === 'csr') {
|
||||
switch (format) {
|
||||
case 'csr':
|
||||
return {
|
||||
filename: `${base}.csr`,
|
||||
content: textToBase64(`${pem.trim()}\n`),
|
||||
mimeType: MIME.pem,
|
||||
};
|
||||
case 'csr-der':
|
||||
return {
|
||||
filename: `${base}.csr.der`,
|
||||
content: bytesToBase64(pemBody(pem)),
|
||||
mimeType: MIME.octet,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Passwort und Schluessel nie protokollieren oder zurueckgeben.
|
||||
throw new BadRequestException(`Failed to export ${kind} as ${format}`);
|
||||
}
|
||||
throw new BadRequestException(`Unsupported format "${format}" for ${kind}`);
|
||||
}
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { UseModule } from '../module-registry/module.guard';
|
||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||
import {
|
||||
analyzeBundle,
|
||||
type BundleExportFormat,
|
||||
type BundleItemKind,
|
||||
exportBundleItem,
|
||||
} from './cert-bundle';
|
||||
import { CertManagerService } from './cert-manager.service';
|
||||
|
||||
/**
|
||||
@@ -118,4 +124,50 @@ export class CertManagerController {
|
||||
}
|
||||
return this.certManagerService.convertCert({ file, pemText, targetFormat, password });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /modules/cert-manager/analyze (quick-261001-l4q)
|
||||
* Zertifikatspaket: mehrere Dateien oder ZIP hochladen, jedes Teil erkennen
|
||||
* (Server-/Zwischen-/Stammzertifikat, privater Schluessel, CSR), Duplikate
|
||||
* zusammenfassen, Schluessel und Kette zuordnen.
|
||||
*
|
||||
* T-09-03: 20 Dateien, je 5 MB; ZIP-Inhalt zusaetzlich begrenzt (cert-bundle.ts).
|
||||
* T-09-02: password is never passed to the logger
|
||||
*/
|
||||
@Post('analyze')
|
||||
@UseInterceptors(
|
||||
FilesInterceptor('files', 20, {
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
async analyze(
|
||||
@UploadedFiles() files: UploadedFileLike[] | undefined,
|
||||
@Body('password') password?: string,
|
||||
) {
|
||||
return analyzeBundle(files ?? [], password ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /modules/cert-manager/export (quick-261001-l4q)
|
||||
* Ein Teil aus `analyze` (PEM) in das gewuenschte Format bringen.
|
||||
* JSON-Body; PFX verlangt ein Passwort fuer die neue Datei.
|
||||
*/
|
||||
@Post('export')
|
||||
async export(
|
||||
@Body('kind') kind: BundleItemKind,
|
||||
@Body('pem') pem: string,
|
||||
@Body('format') format: BundleExportFormat,
|
||||
@Body('baseName') baseName?: string,
|
||||
@Body('chain') chain?: string[],
|
||||
@Body('keyPem') keyPem?: string,
|
||||
@Body('password') password?: string,
|
||||
) {
|
||||
if (
|
||||
chain !== undefined &&
|
||||
(!Array.isArray(chain) || chain.some((c) => typeof c !== 'string'))
|
||||
) {
|
||||
throw new BadRequestException('chain must be a list of PEM strings');
|
||||
}
|
||||
return exportBundleItem({ kind, pem, format, baseName, chain, keyPem, password });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +220,11 @@ function expectBoundCall(
|
||||
}
|
||||
|
||||
function makeIconDiscovery(
|
||||
overrides: Partial<{ discoverFavoriteIconUrl: any; fetchIconBytes: any }> = {},
|
||||
overrides: Partial<{
|
||||
discoverFavoriteIconUrl: any;
|
||||
fetchIconBytes: any;
|
||||
fetchPublicServiceIconBytes: any;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
discoverFavoriteIconUrl:
|
||||
@@ -229,6 +233,11 @@ function makeIconDiscovery(
|
||||
fetchIconBytes:
|
||||
overrides.fetchIconBytes ??
|
||||
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
|
||||
fetchPublicServiceIconBytes:
|
||||
overrides.fetchPublicServiceIconBytes ??
|
||||
vi.fn(async () => {
|
||||
throw new Error('icon service: unknown');
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -511,6 +520,35 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('quick-261001-hbi: gespeichertes Symbol scheitert -> Symbol-Dienst mit der Seiten-URL', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery({
|
||||
fetchIconBytes: vi.fn(async () => {
|
||||
throw new Error('not an image');
|
||||
}),
|
||||
fetchPublicServiceIconBytes: vi.fn(async () => ({
|
||||
contentType: 'image/png',
|
||||
body: Buffer.from('ddg'),
|
||||
})),
|
||||
});
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(iconDiscovery.fetchPublicServiceIconBytes).toHaveBeenCalledWith(baseRow.url);
|
||||
expect(result.body).toEqual(Buffer.from('ddg'));
|
||||
});
|
||||
|
||||
it('quick-261001-hbi: gespeichertes Symbol klappt -> Symbol-Dienst wird nicht gefragt', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(iconDiscovery.fetchPublicServiceIconBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery({
|
||||
|
||||
@@ -497,7 +497,9 @@ export class FavoritesService {
|
||||
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
||||
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
|
||||
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
|
||||
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image.
|
||||
* timeout, non-image, or SSRF-blocked) AND the public icon service fallback
|
||||
* (quick-261001-hbi, public pages only) has no icon either -- never returns
|
||||
* a placeholder image.
|
||||
*/
|
||||
async getIconBytes(
|
||||
tenantId: string,
|
||||
@@ -535,8 +537,15 @@ export class FavoritesService {
|
||||
|
||||
try {
|
||||
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
|
||||
} catch {
|
||||
// quick-261001-hbi: Seite liefert kein abrufbares Symbol (z. B. per
|
||||
// JavaScript gesetzt) -- einmal beim oeffentlichen Symbol-Dienst fragen,
|
||||
// nur fuer oeffentlich erreichbare Seiten.
|
||||
try {
|
||||
return await this.iconDiscovery.fetchPublicServiceIconBytes(link.url);
|
||||
} catch {
|
||||
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,57 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('IconDiscoveryService.fetchPublicServiceIconBytes (quick-261001-hbi)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('fragt fuer eine oeffentliche Seite den Symbol-Dienst mit dem Hostnamen', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png' }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
const result = await service.fetchPublicServiceIconBytes('http://8.8.8.8/start');
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('https://icons.duckduckgo.com/ip3/8.8.8.8.ico');
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('fragt fuer eine interne Seite NICHT (Hostname verlaesst das Haus nicht)', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
|
||||
await expect(
|
||||
service.fetchPublicServiceIconBytes('https://docuvita.ctl.local/x'),
|
||||
).rejects.toThrow(/not public/);
|
||||
await expect(service.fetchPublicServiceIconBytes('http://192.168.1.5/')).rejects.toThrow(
|
||||
/not public/,
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Dienst kennt kein Symbol (404) -> wirft', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
...mockResponse({ contentType: 'image/png' }),
|
||||
ok: false,
|
||||
status: 404,
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
|
||||
await expect(service.fetchPublicServiceIconBytes('http://8.8.8.8/')).rejects.toThrow(
|
||||
/blocked or failed/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -25,6 +25,18 @@ const MAX_REDIRECTS = 2;
|
||||
const MAX_HTML_CHARS = 200000;
|
||||
const MAX_ICON_BYTES = 1_000_000;
|
||||
|
||||
/**
|
||||
* quick-261001-hbi — oeffentlicher Symbol-Dienst als letzter Rueckfall. Manche
|
||||
* Seiten setzen ihr Symbol erst per JavaScript (hosteurope.de: im HTML nur
|
||||
* `<link rel="icon" href="data:;base64,=">`, `/favicon.ico` liefert eine
|
||||
* HTML-Seite) — ohne Browser findet die Suche dort nichts. DuckDuckGo kennt
|
||||
* das gerenderte Symbol und antwortet fuer Unbekanntes mit 404 (dann bleibt
|
||||
* der Buchstabe). Gefragt wird NUR fuer oeffentlich erreichbare Adressen,
|
||||
* damit interne Hostnamen (docuvita.ctl.local, private IPs) das Haus nie
|
||||
* verlassen; der Dienst erfaehrt nur den Hostnamen.
|
||||
*/
|
||||
const PUBLIC_ICON_SERVICE = 'https://icons.duckduckgo.com/ip3/';
|
||||
|
||||
/**
|
||||
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
|
||||
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
|
||||
@@ -426,6 +438,22 @@ export class IconDiscoveryService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* quick-261001-hbi: Symbol fuer die Seite `pageUrl` beim oeffentlichen
|
||||
* Symbol-Dienst holen (siehe PUBLIC_ICON_SERVICE). Wirft, wenn die Seite
|
||||
* nicht oeffentlich erreichbar ist (dann wird der Dienst NICHT gefragt) oder
|
||||
* der Dienst kein Symbol kennt (404) — wie `fetchIconBytes`.
|
||||
*/
|
||||
async fetchPublicServiceIconBytes(
|
||||
pageUrl: string,
|
||||
): Promise<{ contentType: string; body: Buffer }> {
|
||||
const page = new URL(normalizeUrl(pageUrl));
|
||||
if (!(await isPublicHttpUrl(page))) {
|
||||
throw new Error('Page is not public, icon service not asked');
|
||||
}
|
||||
return this.fetchIconBytes(`${PUBLIC_ICON_SERVICE}${page.hostname}.ico`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming
|
||||
* back to the browser from Tessera's own origin (avoids Cross-Origin-
|
||||
|
||||
@@ -939,6 +939,41 @@ fn external_target(url: &tauri::Url) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads, die die App selbst speichert statt sie an den System-Browser
|
||||
/// zu geben: Inhalte, die die Seite im Speicher erzeugt hat (`blob:`,
|
||||
/// `data:`) -- der Browser kann sie nicht abrufen.
|
||||
fn saves_in_app(url: &tauri::Url) -> bool {
|
||||
matches!(url.scheme(), "blob" | "data")
|
||||
}
|
||||
|
||||
/// Titel und Text der Meldung nach einem in der App gespeicherten Download.
|
||||
fn download_notice(path: Option<&std::path::Path>, success: bool) -> (String, String) {
|
||||
let name = path
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().into_owned());
|
||||
let folder = path
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().into_owned());
|
||||
match (success, name) {
|
||||
(true, Some(name)) => (
|
||||
"Download gespeichert".to_string(),
|
||||
match folder {
|
||||
Some(folder) => format!("„{name}“ liegt im Ordner {folder}."),
|
||||
None => format!("„{name}“ wurde gespeichert."),
|
||||
},
|
||||
),
|
||||
(true, None) => (
|
||||
"Download gespeichert".to_string(),
|
||||
"Die Datei wurde gespeichert.".to_string(),
|
||||
),
|
||||
(false, _) => (
|
||||
"Download fehlgeschlagen".to_string(),
|
||||
"Die Datei konnte nicht gespeichert werden.".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hauptfenster nach vorne holen. Ein minimiertes Fenster (z. B. per Win+D)
|
||||
/// wird zuerst wiederhergestellt.
|
||||
fn show_main_window(app: &AppHandle) {
|
||||
@@ -1009,15 +1044,36 @@ pub fn run() {
|
||||
tauri::webview::NewWindowResponse::Deny
|
||||
}
|
||||
})
|
||||
.on_download(|webview, event| {
|
||||
if let tauri::webview::DownloadEvent::Requested { url, .. } = event {
|
||||
.on_download(|webview, event| match event {
|
||||
// Dateien, die die Seite selbst erzeugt (blob:/data:, z. B.
|
||||
// Zertifikats-Downloads), kennt der System-Browser nicht --
|
||||
// Windows meldete „Holen Sie sich eine App, um diesen
|
||||
// ‚blob‘-Link zu öffnen“ (VM 8233, 01.10.2026). Die speichert
|
||||
// die App selbst im Ordner Downloads und meldet es danach.
|
||||
tauri::webview::DownloadEvent::Requested { url, .. } => {
|
||||
if saves_in_app(&url) {
|
||||
return true;
|
||||
}
|
||||
let _ = webview
|
||||
.app_handle()
|
||||
.opener()
|
||||
.open_url(url.to_string(), None::<&str>);
|
||||
return false;
|
||||
false
|
||||
}
|
||||
tauri::webview::DownloadEvent::Finished { url, path, success } => {
|
||||
if saves_in_app(&url) {
|
||||
let (title, body) = download_notice(path.as_deref(), success);
|
||||
let _ = webview
|
||||
.app_handle()
|
||||
.notification()
|
||||
.builder()
|
||||
.title(title)
|
||||
.body(body)
|
||||
.show();
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => true,
|
||||
})
|
||||
.build()?;
|
||||
|
||||
@@ -1500,6 +1556,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_in_app_nur_blob_und_data() {
|
||||
for (url, expected) in [
|
||||
("blob:http://localhost:3000/0c1d-11", true),
|
||||
("data:application/x-pem-file;base64,QUJD", true),
|
||||
("https://alpha.tessera.ctl.de/desktop/download/x.exe", false),
|
||||
("http://intranet.local/datei.pdf", false),
|
||||
] {
|
||||
let parsed = tauri::Url::parse(url).unwrap();
|
||||
assert_eq!(saves_in_app(&parsed), expected, "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_notice_nennt_datei_und_ordner() {
|
||||
let path = std::path::Path::new("/home/tessera/Downloads/www.example.de.crt");
|
||||
let (title, body) = download_notice(Some(path), true);
|
||||
assert_eq!(title, "Download gespeichert");
|
||||
assert_eq!(body, "„www.example.de.crt“ liegt im Ordner Downloads.");
|
||||
|
||||
let (title, _) = download_notice(None, false);
|
||||
assert_eq!(title, "Download fehlgeschlagen");
|
||||
|
||||
let (_, body) = download_notice(None, true);
|
||||
assert_eq!(body, "Die Datei wurde gespeichert.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_target_nur_http_und_https() {
|
||||
let https = tauri::Url::parse("https://www.google.com/search?q=tessera").unwrap();
|
||||
|
||||
@@ -208,3 +208,81 @@ export async function postForm(
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zertifikatspaket (quick-261001-l4q) — spiegelt apps/api/src/cert-manager/cert-bundle.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BundleItemKind = 'certificate' | 'privateKey' | 'csr';
|
||||
export type BundleExportFormat =
|
||||
| 'crt'
|
||||
| 'cer'
|
||||
| 'fullchain'
|
||||
| 'p7b'
|
||||
| 'pfx'
|
||||
| 'key'
|
||||
| 'key-rsa'
|
||||
| 'key-der'
|
||||
| 'csr'
|
||||
| 'csr-der';
|
||||
|
||||
export interface BundleItem {
|
||||
id: string;
|
||||
kind: BundleItemKind;
|
||||
role?: CertRole;
|
||||
sources: string[];
|
||||
pem: string;
|
||||
baseName: string;
|
||||
cn: string;
|
||||
organization: string;
|
||||
issuerCn: string;
|
||||
notBefore: string | null;
|
||||
notAfter: string | null;
|
||||
isExpired: boolean | null;
|
||||
daysLeft: number | null;
|
||||
san: string[];
|
||||
keyType: string;
|
||||
keyBits: number;
|
||||
serialNumber: string;
|
||||
sha256: string;
|
||||
matchId: string | null;
|
||||
chainIds: string[];
|
||||
formats: BundleExportFormat[];
|
||||
}
|
||||
|
||||
export interface BundleAnalysis {
|
||||
items: BundleItem[];
|
||||
locked: string[];
|
||||
ignored: string[];
|
||||
}
|
||||
|
||||
/** Mehrere Dateien (auch ZIP) analysieren — POST /modules/cert-manager/analyze. */
|
||||
export async function analyzeBundleAction(files: File[], password?: string): Promise<BundleAnalysis> {
|
||||
const form = new FormData();
|
||||
for (const file of files) form.append('files', file);
|
||||
if (password) form.append('password', password);
|
||||
return postForm('analyze', form) as Promise<BundleAnalysis>;
|
||||
}
|
||||
|
||||
/** Ein Teil des Pakets in ein Format bringen — POST /modules/cert-manager/export (JSON). */
|
||||
export async function exportBundleItemAction(input: {
|
||||
kind: BundleItemKind;
|
||||
pem: string;
|
||||
format: BundleExportFormat;
|
||||
baseName: string;
|
||||
chain?: string[];
|
||||
keyPem?: string;
|
||||
password?: string;
|
||||
}): Promise<FileResponse> {
|
||||
const response = await fetch(`${API_URL}/modules/cert-manager/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`${response.status} ${body}`.trim());
|
||||
}
|
||||
return response.json() as Promise<FileResponse>;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ vi.mock('next-intl', () => ({
|
||||
const certManager: Record<string, string> = {
|
||||
'title': 'Zertifikat-Manager',
|
||||
'description': 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.',
|
||||
'tabs.overview': 'Uebersicht',
|
||||
'tabs.inspect': 'Analysieren',
|
||||
'tabs.split': 'Aufteilen',
|
||||
'tabs.merge': 'Zusammenfuehren',
|
||||
@@ -76,8 +77,15 @@ describe('CertManagerPage shell', () => {
|
||||
expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Inspect tab empty state on initial render', () => {
|
||||
it('startet mit der Uebersicht ohne Einzeldatei-Eingabe (quick-261001-l4q)', () => {
|
||||
render(<CertManagerPage />);
|
||||
expect(screen.getByText('dropTitle')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Datei hierher ziehen oder klicken')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Inspect tab empty state when Inspect tab is active', () => {
|
||||
render(<CertManagerPage />);
|
||||
fireEvent.click(screen.getAllByText('Analysieren')[0]);
|
||||
expect(screen.getByText('Kein Zertifikat geladen.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lade eine Datei hoch oder fuege PEM-Text ein.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
||||
values ? `${key} ${JSON.stringify(values)}` : key,
|
||||
}));
|
||||
|
||||
vi.mock('../actions', () => ({
|
||||
analyzeBundleAction: vi.fn(),
|
||||
exportBundleItemAction: vi.fn(),
|
||||
downloadBase64: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
analyzeBundleAction,
|
||||
type BundleItem,
|
||||
downloadBase64,
|
||||
exportBundleItemAction,
|
||||
} from '../actions';
|
||||
import { OverviewTab } from './OverviewTab';
|
||||
|
||||
const mockAnalyze = analyzeBundleAction as ReturnType<typeof vi.fn>;
|
||||
const mockExport = exportBundleItemAction as ReturnType<typeof vi.fn>;
|
||||
const mockDownload = downloadBase64 as ReturnType<typeof vi.fn>;
|
||||
|
||||
function item(over: Partial<BundleItem>): BundleItem {
|
||||
return {
|
||||
id: 'x',
|
||||
kind: 'certificate',
|
||||
sources: ['a.pem'],
|
||||
pem: 'PEM',
|
||||
baseName: 'www.example.test',
|
||||
cn: 'www.example.test',
|
||||
organization: '',
|
||||
issuerCn: '',
|
||||
notBefore: null,
|
||||
notAfter: null,
|
||||
isExpired: null,
|
||||
daysLeft: null,
|
||||
san: [],
|
||||
keyType: 'RSA',
|
||||
keyBits: 2048,
|
||||
serialNumber: '',
|
||||
sha256: '',
|
||||
matchId: null,
|
||||
chainIds: [],
|
||||
formats: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const LEAF = item({
|
||||
id: 'leaf',
|
||||
role: 'end-entity',
|
||||
pem: 'LEAF-PEM',
|
||||
issuerCn: 'Test Intermediate CA',
|
||||
notBefore: '2026-01-01T00:00:00.000Z',
|
||||
notAfter: '2027-01-01T00:00:00.000Z',
|
||||
isExpired: false,
|
||||
daysLeft: 200,
|
||||
san: ['www.example.test', 'example.test'],
|
||||
matchId: 'key',
|
||||
chainIds: ['inter'],
|
||||
formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'],
|
||||
});
|
||||
const INTER = item({
|
||||
id: 'inter',
|
||||
role: 'intermediate',
|
||||
pem: 'INTER-PEM',
|
||||
cn: 'Test Intermediate CA',
|
||||
isExpired: false,
|
||||
daysLeft: 900,
|
||||
formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'],
|
||||
});
|
||||
const KEY = item({
|
||||
id: 'key',
|
||||
kind: 'privateKey',
|
||||
pem: 'KEY-PEM',
|
||||
matchId: 'leaf',
|
||||
formats: ['key', 'key-rsa', 'key-der'],
|
||||
});
|
||||
const CSR = item({
|
||||
id: 'csr',
|
||||
kind: 'csr',
|
||||
pem: 'CSR-PEM',
|
||||
matchId: 'leaf',
|
||||
formats: ['csr', 'csr-der'],
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function upload(files: File[]) {
|
||||
render(<OverviewTab />);
|
||||
fireEvent.change(screen.getByTestId('overview-file-input'), { target: { files } });
|
||||
await waitFor(() => expect(mockAnalyze).toHaveBeenCalled());
|
||||
}
|
||||
|
||||
describe('OverviewTab', () => {
|
||||
it('analysiert die abgelegten Dateien und zeigt je Teil Typ und Download-Knoepfe', async () => {
|
||||
mockAnalyze.mockResolvedValue({
|
||||
items: [LEAF, INTER, KEY, CSR],
|
||||
locked: [],
|
||||
ignored: ['.dnstxtrecord'],
|
||||
});
|
||||
const zip = new File(['zip'], 'paket.zip');
|
||||
await upload([zip]);
|
||||
|
||||
expect(mockAnalyze).toHaveBeenCalledWith([zip], undefined);
|
||||
const cards = await screen.findAllByTestId('bundle-item');
|
||||
expect(cards).toHaveLength(4);
|
||||
expect(within(cards[0]).getByText('type.end-entity')).toBeInTheDocument();
|
||||
expect(within(cards[1]).getByText('type.intermediate')).toBeInTheDocument();
|
||||
expect(within(cards[2]).getByText('type.privateKey')).toBeInTheDocument();
|
||||
expect(within(cards[3]).getByText('type.csr')).toBeInTheDocument();
|
||||
// Zuordnung und Gueltigkeit
|
||||
expect(within(cards[0]).getByText('www.example.test, example.test')).toBeInTheDocument();
|
||||
expect(within(cards[0]).getByText('valid')).toBeInTheDocument();
|
||||
expect(within(cards[2]).getByText(/type\.end-entity/)).toBeInTheDocument();
|
||||
// alle Formate als Knoepfe
|
||||
expect(
|
||||
within(cards[0])
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent),
|
||||
).toEqual(['format.crt', 'format.cer', 'format.fullchain', 'format.p7b', 'format.pfx']);
|
||||
expect(within(cards[2]).getAllByRole('button')).toHaveLength(3);
|
||||
expect(screen.getByText(/ignored/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Download schickt Kette und Schluessel mit und loest die Datei aus', async () => {
|
||||
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] });
|
||||
mockExport.mockResolvedValue({
|
||||
filename: 'www.example.test-fullchain.pem',
|
||||
content: 'Zm9v',
|
||||
mimeType: 'x',
|
||||
});
|
||||
await upload([new File(['x'], 'a.pem')]);
|
||||
const [leafCard] = await screen.findAllByTestId('bundle-item');
|
||||
|
||||
fireEvent.click(within(leafCard).getByText('format.fullchain'));
|
||||
await waitFor(() =>
|
||||
expect(mockDownload).toHaveBeenCalledWith('www.example.test-fullchain.pem', 'Zm9v', 'x'),
|
||||
);
|
||||
expect(mockExport).toHaveBeenCalledWith({
|
||||
kind: 'certificate',
|
||||
pem: 'LEAF-PEM',
|
||||
format: 'fullchain',
|
||||
baseName: 'www.example.test',
|
||||
chain: ['INTER-PEM'],
|
||||
keyPem: 'KEY-PEM',
|
||||
password: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('PFX fragt erst ein Passwort ab', async () => {
|
||||
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] });
|
||||
mockExport.mockResolvedValue({
|
||||
filename: 'www.example.test.pfx',
|
||||
content: 'Zm9v',
|
||||
mimeType: 'x',
|
||||
});
|
||||
await upload([new File(['x'], 'a.pem')]);
|
||||
const [leafCard] = await screen.findAllByTestId('bundle-item');
|
||||
|
||||
fireEvent.click(within(leafCard).getByText('format.pfx'));
|
||||
expect(mockExport).not.toHaveBeenCalled();
|
||||
expect(within(leafCard).getByText('pfxWithKey')).toBeInTheDocument();
|
||||
const download = within(leafCard).getByText('pfxDownload');
|
||||
expect(download).toBeDisabled();
|
||||
|
||||
fireEvent.change(within(leafCard).getByLabelText('pfxPassword'), {
|
||||
target: { value: 'geheim' },
|
||||
});
|
||||
fireEvent.click(download);
|
||||
await waitFor(() =>
|
||||
expect(mockExport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ format: 'pfx', password: 'geheim' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('geschuetzte PFX: Passwort eingeben und erneut pruefen', async () => {
|
||||
mockAnalyze.mockResolvedValueOnce({ items: [LEAF], locked: ['a.pfx'], ignored: [] });
|
||||
const pfx = new File(['x'], 'a.pfx');
|
||||
await upload([pfx]);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('lockedPassword'), { target: { value: 'pw' } });
|
||||
mockAnalyze.mockResolvedValueOnce({ items: [LEAF, KEY], locked: [], ignored: [] });
|
||||
fireEvent.click(screen.getByText('unlock'));
|
||||
await waitFor(() => expect(mockAnalyze).toHaveBeenLastCalledWith([pfx], 'pw'));
|
||||
expect(await screen.findAllByTestId('bundle-item')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Fehler beim Pruefen wird angezeigt', async () => {
|
||||
mockAnalyze.mockRejectedValue(new Error('400'));
|
||||
await upload([new File(['x'], 'a.txt')]);
|
||||
expect(await screen.findByText('error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
analyzeBundleAction,
|
||||
type BundleAnalysis,
|
||||
type BundleExportFormat,
|
||||
type BundleItem,
|
||||
downloadBase64,
|
||||
exportBundleItemAction,
|
||||
} from '../actions';
|
||||
|
||||
/**
|
||||
* OverviewTab (quick-261001-l4q) — Zertifikatspaket vom Aussteller auf einmal
|
||||
* hochladen (mehrere Dateien oder ZIP), sehen, welche Datei was ist, und jedes
|
||||
* Teil in jedem passenden Format herunterladen. Die Erkennung macht der Server
|
||||
* (POST analyze), der Export ebenfalls (POST export) — beides zustandslos.
|
||||
*
|
||||
* T-09-02: Passwoerter leben nur im lokalen Zustand, nie in URL oder Log.
|
||||
*/
|
||||
|
||||
const ACCEPT = '.zip,.pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c,.key,.csr';
|
||||
|
||||
const ROLE_STYLES: Record<string, string> = {
|
||||
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
intermediate: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
root: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
privateKey: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300',
|
||||
csr: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
function typeKey(item: BundleItem): string {
|
||||
return item.kind === 'certificate' ? (item.role ?? 'end-entity') : item.kind;
|
||||
}
|
||||
|
||||
/** In UTC wie im Zertifikat: „bis 23:59:59 UTC“ ist bei uns schon der Folgetag — der Aussteller nennt aber dieses Datum. */
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
export function OverviewTab() {
|
||||
const t = useTranslations('certManager.overview');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [password, setPassword] = useState('');
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<BundleAnalysis | null>(null);
|
||||
|
||||
async function analyze(next: File[], pw: string) {
|
||||
if (next.length === 0) {
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResult(await analyzeBundleAction(next, pw || undefined));
|
||||
} catch {
|
||||
setResult(null);
|
||||
setError(t('error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addFiles(list: FileList | null) {
|
||||
const added = Array.from(list ?? []);
|
||||
if (added.length === 0) return;
|
||||
const next = [...files, ...added];
|
||||
setFiles(next);
|
||||
void analyze(next, password);
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
const next = files.filter((_, i) => i !== index);
|
||||
setFiles(next);
|
||||
void analyze(next, password);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setFiles([]);
|
||||
setPassword('');
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const byId = Object.fromEntries((result?.items ?? []).map((i) => [i.id, i]));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
className="hidden"
|
||||
data-testid="overview-file-input"
|
||||
onChange={(e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
|
||||
isDragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm text-foreground">{t('dropTitle')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('dropHint')}</p>
|
||||
</button>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{files.map((file, i) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: dieselbe Datei darf zweimal in der Liste stehen; die Liste aendert sich nur durch Anhaengen/Entfernen
|
||||
key={`${file.name}-${i}`}
|
||||
className="inline-flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs"
|
||||
>
|
||||
{file.name}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('removeFile', { name: file.name })}
|
||||
onClick={() => removeFile(i)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button type="button" onClick={reset} className="text-xs text-muted-foreground underline">
|
||||
{t('reset')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">{t('analyzing')}</p>}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{result && result.locked.length > 0 && (
|
||||
<div className="space-y-2 rounded-lg border border-status-warn/50 bg-status-warn/10 p-4">
|
||||
<p className="text-sm text-foreground">
|
||||
{t('locked', { files: result.locked.join(', ') })}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('lockedPassword')}
|
||||
aria-label={t('lockedPassword')}
|
||||
autoComplete="off"
|
||||
className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={!password || loading}
|
||||
onClick={() => void analyze(files, password)}
|
||||
>
|
||||
{t('unlock')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && result.items.length === 0 && !loading && (
|
||||
<p className="text-sm text-muted-foreground">{t('nothingFound')}</p>
|
||||
)}
|
||||
|
||||
{result && result.items.length > 0 && (
|
||||
<ul className="space-y-4">
|
||||
{result.items.map((item) => (
|
||||
<BundleItemCard key={item.id} item={item} byId={byId} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{result && result.ignored.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ignored', { files: result.ignored.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BundleItemCard({ item, byId }: { item: BundleItem; byId: Record<string, BundleItem> }) {
|
||||
const t = useTranslations('certManager.overview');
|
||||
const [pfxOpen, setPfxOpen] = useState(false);
|
||||
const [pfxPassword, setPfxPassword] = useState('');
|
||||
const [busy, setBusy] = useState<BundleExportFormat | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const match = item.matchId ? byId[item.matchId] : undefined;
|
||||
const chain = item.chainIds.map((id) => byId[id]).filter(Boolean);
|
||||
|
||||
async function download(format: BundleExportFormat, password?: string) {
|
||||
setBusy(format);
|
||||
setError(null);
|
||||
try {
|
||||
const file = await exportBundleItemAction({
|
||||
kind: item.kind,
|
||||
pem: item.pem,
|
||||
format,
|
||||
baseName: item.baseName,
|
||||
chain: item.kind === 'certificate' ? chain.map((c) => c.pem) : undefined,
|
||||
keyPem: item.kind === 'certificate' && match ? match.pem : undefined,
|
||||
password,
|
||||
});
|
||||
downloadBase64(file.filename, file.content, file.mimeType);
|
||||
if (format === 'pfx') {
|
||||
setPfxOpen(false);
|
||||
setPfxPassword('');
|
||||
}
|
||||
} catch {
|
||||
setError(t('exportError'));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const status =
|
||||
item.isExpired === null
|
||||
? null
|
||||
: item.isExpired
|
||||
? { cls: 'bg-status-down/15 text-status-down-fg', text: t('expired') }
|
||||
: (item.daysLeft ?? 0) <= 30
|
||||
? {
|
||||
cls: 'bg-status-warn/15 text-status-warn-fg',
|
||||
text: t('expiresSoon', { days: item.daysLeft ?? 0 }),
|
||||
}
|
||||
: { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('valid') };
|
||||
|
||||
return (
|
||||
<li className="rounded-lg border border-border p-4" data-testid="bundle-item">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[typeKey(item)]}`}>
|
||||
{t(`type.${typeKey(item)}`)}
|
||||
</span>
|
||||
<span className="font-medium break-all">{item.cn || item.baseName}</span>
|
||||
{status && (
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${status.cls}`}>
|
||||
{status.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t(`explain.${typeKey(item)}`)}</p>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
||||
{item.kind === 'certificate' && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('issuer')}</dt>
|
||||
<dd className="break-all">{item.issuerCn}</dd>
|
||||
<dt className="text-muted-foreground">{t('validity')}</dt>
|
||||
<dd>
|
||||
{formatDate(item.notBefore)} – {formatDate(item.notAfter)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{item.san.length > 0 && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('names')}</dt>
|
||||
<dd className="break-all">{item.san.join(', ')}</dd>
|
||||
</>
|
||||
)}
|
||||
{item.keyType && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('key')}</dt>
|
||||
<dd>{item.keyBits > 0 ? `${item.keyType} ${item.keyBits} Bit` : item.keyType}</dd>
|
||||
</>
|
||||
)}
|
||||
{match && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('belongsTo')}</dt>
|
||||
<dd className="break-all">
|
||||
{t(`type.${typeKey(match)}`)} {match.cn}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-muted-foreground">{t('source')}</dt>
|
||||
<dd className="break-all">{item.sources.join(', ')}</dd>
|
||||
</dl>
|
||||
|
||||
{item.kind === 'privateKey' && (
|
||||
<p className="mt-2 text-xs text-status-warn-fg">{t('keySecret')}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{item.formats.map((format) => (
|
||||
<button
|
||||
key={format}
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => (format === 'pfx' ? setPfxOpen((o) => !o) : void download(format))}
|
||||
className="rounded border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-secondary disabled:opacity-50"
|
||||
title={t(`formatHint.${format}`)}
|
||||
>
|
||||
{busy === format ? t('downloading') : t(`format.${format}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pfxOpen && (
|
||||
<div className="mt-3 space-y-2 rounded border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{match ? t('pfxWithKey') : t('pfxWithoutKey')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={pfxPassword}
|
||||
onChange={(e) => setPfxPassword(e.target.value)}
|
||||
placeholder={t('pfxPassword')}
|
||||
aria-label={t('pfxPassword')}
|
||||
autoComplete="new-password"
|
||||
className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!pfxPassword || busy !== null}
|
||||
onClick={() => void download('pfx', pfxPassword)}
|
||||
>
|
||||
{busy === 'pfx' ? t('downloading') : t('pfxDownload')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -9,10 +9,11 @@ import { InspectTab } from './components/InspectTab';
|
||||
import { SplitTab } from './components/SplitTab';
|
||||
import { MergeTab } from './components/MergeTab';
|
||||
import { ConvertTab } from './components/ConvertTab';
|
||||
import { OverviewTab } from './components/OverviewTab';
|
||||
|
||||
type TabId = 'inspect' | 'split' | 'merge' | 'convert';
|
||||
type TabId = 'overview' | 'inspect' | 'split' | 'merge' | 'convert';
|
||||
|
||||
const TABS: TabId[] = ['inspect', 'split', 'merge', 'convert'];
|
||||
const TABS: TabId[] = ['overview', 'inspect', 'split', 'merge', 'convert'];
|
||||
|
||||
/** Returns true if the selected file is a PFX/P12 (requires decryption password) */
|
||||
function isPfxFile(file: File | null): boolean {
|
||||
@@ -23,6 +24,7 @@ function isPfxFile(file: File | null): boolean {
|
||||
|
||||
/**
|
||||
* CertManagerPage — tab-based shell for certificate operations.
|
||||
* quick-261001-l4q: erster Reiter „Übersicht“ fuer ganze Zertifikatspakete.
|
||||
* Layout per UI-SPEC: max-w-4xl, shared input card, tab nav, tab content card.
|
||||
* T-09-02: password lives in local React state only; never logged or placed in URLs.
|
||||
* T-09-04: all API calls via postForm() which sends credentials:'include'.
|
||||
@@ -34,7 +36,7 @@ function isPfxFile(file: File | null): boolean {
|
||||
export default function CertManagerPage() {
|
||||
const t = useTranslations('certManager');
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>('inspect');
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [pemText, setPemText] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -75,17 +77,14 @@ export default function CertManagerPage() {
|
||||
|
||||
const renderActiveTab = () => {
|
||||
switch (activeTab) {
|
||||
case 'overview':
|
||||
return <OverviewTab />;
|
||||
case 'inspect':
|
||||
return <InspectTab file={file} pemText={pemText} password={password} />;
|
||||
case 'split':
|
||||
return <SplitTab file={file} pemText={pemText} password={password} />;
|
||||
case 'merge':
|
||||
return (
|
||||
<MergeTab
|
||||
password={password}
|
||||
onOutputFormatChange={setMergeOutputFormat}
|
||||
/>
|
||||
);
|
||||
return <MergeTab password={password} onOutputFormatChange={setMergeOutputFormat} />;
|
||||
case 'convert':
|
||||
return (
|
||||
<ConvertTab
|
||||
@@ -102,7 +101,27 @@ export default function CertManagerPage() {
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
|
||||
<PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
|
||||
|
||||
{/* Shared input card */}
|
||||
{/* Tab navigation */}
|
||||
<nav className="flex gap-6 overflow-x-auto border-b border-border">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => handleTabChange(tab)}
|
||||
className={`pb-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-primary-strong font-semibold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(`tabs.${tab}`)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Shared input card — nur fuer die Einzeldatei-Werkzeuge; die Uebersicht
|
||||
(quick-261001-l4q) hat ihre eigene Mehrfach-Ablage. */}
|
||||
{activeTab !== 'overview' && (
|
||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
|
||||
{/* DropZone */}
|
||||
<DropZone
|
||||
@@ -129,30 +148,9 @@ export default function CertManagerPage() {
|
||||
/>
|
||||
|
||||
{/* Conditional password field (T-09-02) */}
|
||||
<PasswordField
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
show={showPassword}
|
||||
/>
|
||||
<PasswordField value={password} onChange={setPassword} show={showPassword} />
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<nav className="border-b border-border flex gap-6">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => handleTabChange(tab)}
|
||||
className={`pb-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-primary-strong font-semibold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(`tabs.${tab}`)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Tab content card */}
|
||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getMessages } from 'next-intl/server';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';
|
||||
import { DesktopExternalLinks } from '@/components/desktop/desktop-external-links';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -10,11 +11,7 @@ export const metadata: Metadata = {
|
||||
description: 'Modulare Workflow-Plattform',
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
|
||||
@@ -29,6 +26,7 @@ export default async function RootLayout({
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<DesktopContextMenuGuard />
|
||||
<DesktopExternalLinks />
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { CalendarEvent } from '@/lib/calendar-api';
|
||||
|
||||
/**
|
||||
@@ -65,6 +65,16 @@ beforeEach(() => {
|
||||
]);
|
||||
});
|
||||
|
||||
// Das erste `await import('./calendar-widget')` uebersetzt die Komponente
|
||||
// samt Abhaengigkeiten. Auf dem ausgelasteten CI-Runner dauerte das ueber
|
||||
// 5 s: Test 1 lief in die Zeitgrenze, renderte nach dem Aufraeumen weiter,
|
||||
// und Test 2 fand 84 statt 42 Tageszellen (CI-Lauf 478, 01.10.2026).
|
||||
// Einmal vorab laden, mit grosszuegiger Grenze; die Tests treffen danach
|
||||
// den Modul-Zwischenspeicher.
|
||||
beforeAll(async () => {
|
||||
await import('./calendar-widget');
|
||||
}, 60_000);
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
|
||||
@@ -65,14 +65,23 @@ export function ReminderFormModal({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fokus nur EINMAL beim Oeffnen (quick-261001-g68): `onClose` kommt aus der
|
||||
// Kachel als neue Funktion bei jedem Neuzeichnen — die Kachel zeichnet alle
|
||||
// 10 s neu (NOW_TICK_MS). Hing der Fokus mit am `onClose`-Effekt, sprang der
|
||||
// Cursor beim Schreiben der Beschreibung immer wieder in den Titel.
|
||||
useEffect(() => {
|
||||
titleRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape') onCloseRef.current();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -349,6 +349,32 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReminderWidget — Formular behaelt den Fokus (quick-261001-g68)', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('der 10-s-Takt der Kachel holt den Cursor nicht aus der Beschreibung zurueck in den Titel', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-09-29T12:00:00.000Z'));
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
fireEvent.click(screen.getByText('reminder.add'));
|
||||
expect(document.activeElement).toBe(screen.getByLabelText('reminder.titleLabel'));
|
||||
|
||||
const description = screen.getByLabelText('reminder.descriptionLabel');
|
||||
description.focus();
|
||||
fireEvent.change(description, { target: { value: 'Unterlagen mitnehmen' } });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
expect(document.activeElement).toBe(description);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { act, cleanup, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DESKTOP_COOKIE_NAME } from '@/lib/desktop-client';
|
||||
import { DesktopExternalLinks } from './desktop-external-links';
|
||||
|
||||
/**
|
||||
* desktop-external-links.test (quick-261001-cxo) — im Desktop-Client gehen
|
||||
* Klicks auf Links mit `target="_blank"` ueber `window.open`; ohne Cookie,
|
||||
* bei verhinderten Klicks, ohne `_blank` und bei Nicht-http(s) bleibt alles
|
||||
* unberuehrt. `fire` gibt den `dispatchEvent`-Rueckgabewert zurueck:
|
||||
* `false` bedeutet, `preventDefault()` wurde aufgerufen.
|
||||
*/
|
||||
function setCookie() {
|
||||
document.cookie = `${DESKTOP_COOKIE_NAME}=1; path=/`;
|
||||
}
|
||||
|
||||
function clearCookie() {
|
||||
document.cookie = `${DESKTOP_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
}
|
||||
|
||||
function link(href: string, target?: string) {
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
if (target) a.target = target;
|
||||
const span = document.createElement('span');
|
||||
a.appendChild(span);
|
||||
document.body.appendChild(a);
|
||||
return span;
|
||||
}
|
||||
|
||||
function fire(el: Element, type: 'click' | 'auxclick' = 'click', button = 0) {
|
||||
return el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, button }));
|
||||
}
|
||||
|
||||
let openSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearCookie();
|
||||
openSpy.mockRestore();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('DesktopExternalLinks', () => {
|
||||
it('mit Cookie: Klick auf _blank-Link oeffnet per window.open', async () => {
|
||||
setCookie();
|
||||
render(<DesktopExternalLinks />);
|
||||
await act(async () => {});
|
||||
|
||||
const inner = link('https://chatgpt.com/', '_blank');
|
||||
expect(fire(inner)).toBe(false);
|
||||
expect(openSpy).toHaveBeenCalledWith('https://chatgpt.com/', '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
it('mit Cookie: kommt dem Link-Skript des Clients auf window zuvor', async () => {
|
||||
setCookie();
|
||||
render(<DesktopExternalLinks />);
|
||||
await act(async () => {});
|
||||
|
||||
// Nachbau von tauri-plugin-opener (init-iife.js): bricht ab, wenn der
|
||||
// Klick schon verhindert ist, sonst preventDefault + IPC-Aufruf.
|
||||
const openerSaw: boolean[] = [];
|
||||
const opener = (e: MouseEvent) => openerSaw.push(e.defaultPrevented);
|
||||
window.addEventListener('click', opener);
|
||||
try {
|
||||
fire(link('https://chatgpt.com/', '_blank'));
|
||||
} finally {
|
||||
window.removeEventListener('click', opener);
|
||||
}
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
expect(openerSaw).toEqual([true]);
|
||||
});
|
||||
|
||||
it('mit Cookie: Mittelklick auf _blank-Link oeffnet ebenfalls', async () => {
|
||||
setCookie();
|
||||
render(<DesktopExternalLinks />);
|
||||
await act(async () => {});
|
||||
|
||||
const inner = link('http://intranet.local/', '_blank');
|
||||
expect(fire(inner, 'auxclick', 1)).toBe(false);
|
||||
expect(openSpy).toHaveBeenCalledWith('http://intranet.local/', '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
it('mit Cookie: verhinderter Klick, Link ohne _blank und mailto bleiben unberuehrt', async () => {
|
||||
setCookie();
|
||||
render(<DesktopExternalLinks />);
|
||||
await act(async () => {});
|
||||
|
||||
const prevented = link('https://chatgpt.com/', '_blank');
|
||||
prevented.addEventListener('click', (e) => e.preventDefault());
|
||||
fire(prevented);
|
||||
|
||||
expect(fire(link('https://example.com/'))).toBe(true);
|
||||
expect(fire(link('mailto:a@example.com', '_blank'))).toBe(true);
|
||||
expect(fire(link('https://example.com/', '_blank'), 'click', 2)).toBe(true);
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne Cookie: keine Umleitung', async () => {
|
||||
render(<DesktopExternalLinks />);
|
||||
await act(async () => {});
|
||||
|
||||
expect(fire(link('https://chatgpt.com/', '_blank'))).toBe(true);
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useIsDesktopClient } from '@/lib/desktop-client';
|
||||
|
||||
/**
|
||||
* DesktopExternalLinks (quick-261001-cxo) — Links mit `target="_blank"`
|
||||
* (Favoriten, „In neuem Tab öffnen“ bei XFrame/eigenen Modulen, Quellen im
|
||||
* Ausschreibungs-Radar) taten im Desktop-Client beim Klick nichts, waehrend
|
||||
* `window.open` (Such-Widget) ueber `on_new_window` im System-Browser landet.
|
||||
*
|
||||
* Ursache (VM 8233, 01.10.2026, gemessen): tauri-plugin-opener schleust ein
|
||||
* Skript ein, das auf `window` solche Klicks abfaengt, `preventDefault()`
|
||||
* ruft und `plugin:opener|open_url` aufruft -- von der Server-Seite aus ist
|
||||
* dieser Aufruf nicht freigegeben, der Klick verpufft. Dieser Helfer kommt
|
||||
* ihm zuvor und oeffnet per `window.open`; das Opener-Skript sieht den Klick
|
||||
* dann als verhindert und tut nichts.
|
||||
*
|
||||
* Lauscht deshalb auf `document` in der Bubble-Phase: NACH Reacts Handlern
|
||||
* (Wurzel `document`, frueher registriert) -- ein Klick, den die Seite selbst
|
||||
* verhindert (Favoriten im Bearbeiten-Modus), bleibt verhindert --, aber VOR
|
||||
* dem Opener-Skript auf `window`. Nur http/https.
|
||||
*/
|
||||
function externalTarget(event: MouseEvent): HTMLAnchorElement | null {
|
||||
if (event.defaultPrevented) return null;
|
||||
// Linksklick und Mittelklick oeffnen beide ein neues Fenster.
|
||||
if (event.button !== 0 && event.button !== 1) return null;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return null;
|
||||
const anchor = target.closest('a[href]');
|
||||
if (!(anchor instanceof HTMLAnchorElement)) return null;
|
||||
if (anchor.target !== '_blank') return null;
|
||||
if (anchor.protocol !== 'http:' && anchor.protocol !== 'https:') return null;
|
||||
return anchor;
|
||||
}
|
||||
|
||||
export function DesktopExternalLinks() {
|
||||
const isDesktop = useIsDesktopClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (event.type === 'auxclick' && event.button !== 1) return;
|
||||
if (event.type === 'click' && event.button !== 0) return;
|
||||
const anchor = externalTarget(event);
|
||||
if (!anchor) return;
|
||||
event.preventDefault();
|
||||
window.open(anchor.href, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
document.addEventListener('click', handler);
|
||||
document.addEventListener('auxclick', handler);
|
||||
return () => {
|
||||
document.removeEventListener('click', handler);
|
||||
document.removeEventListener('auxclick', handler);
|
||||
};
|
||||
}, [isDesktop]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1182,8 +1182,9 @@
|
||||
},
|
||||
"certManager": {
|
||||
"title": "Zertifikat-Manager",
|
||||
"description": "Zertifikate analysieren, aufteilen, zusammenführen und konvertieren.",
|
||||
"description": "Zertifikatspakete prüfen und in jedes Format bringen; einzelne Zertifikate analysieren, aufteilen, zusammenführen und konvertieren.",
|
||||
"tabs": {
|
||||
"overview": "Übersicht",
|
||||
"inspect": "Analysieren",
|
||||
"split": "Aufteilen",
|
||||
"merge": "Zusammenführen",
|
||||
@@ -1217,18 +1218,85 @@
|
||||
},
|
||||
"emptyState": {
|
||||
"inspect": "Kein Zertifikat geladen.",
|
||||
"inspectBody": "Lade eine Datei hoch oder füge PEM-Text ein.",
|
||||
"inspectBody": "Laden Sie eine Datei hoch oder fügen Sie PEM-Text ein.",
|
||||
"split": "Keine Datei geladen.",
|
||||
"splitBody": "Lade eine Fullchain- oder P7B-Datei hoch.",
|
||||
"splitBody": "Laden Sie eine Fullchain- oder P7B-Datei hoch.",
|
||||
"merge": "Keine Zertifikate ausgewählt.",
|
||||
"mergeBody": "Lade mindestens zwei Dateien hoch.",
|
||||
"mergeBody": "Laden Sie mindestens zwei Dateien hoch.",
|
||||
"convert": "Keine Datei geladen.",
|
||||
"convertBody": "Lade eine Datei hoch und wähle ein Ausgabeformat."
|
||||
"convertBody": "Laden Sie eine Datei hoch und wählen Sie ein Ausgabeformat."
|
||||
},
|
||||
"error": {
|
||||
"generic": "Verarbeitung fehlgeschlagen. Prüfe das Dateiformat oder das Passwort.",
|
||||
"generic": "Verarbeitung fehlgeschlagen. Prüfen Sie das Dateiformat oder das Passwort.",
|
||||
"wrongPassword": "Falsches Passwort. PFX/P12-Datei konnte nicht entschlüsselt werden.",
|
||||
"unknownFormat": "Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden."
|
||||
},
|
||||
"overview": {
|
||||
"dropTitle": "Zertifikatsdateien oder ZIP hierher ziehen oder klicken",
|
||||
"dropHint": "Alles auf einmal, so wie es vom Aussteller kommt: .zip, .pem, .crt, .cer, .key, .csr, .pfx, .p12, .p7b",
|
||||
"removeFile": "{name} entfernen",
|
||||
"reset": "Alle entfernen",
|
||||
"analyzing": "Dateien werden geprüft …",
|
||||
"error": "Die Dateien konnten nicht geprüft werden. Bitte prüfen Sie, ob es Zertifikatsdateien sind.",
|
||||
"locked": "Geschützt: {files}. Geben Sie das Passwort ein, um auch diesen Inhalt zu lesen.",
|
||||
"lockedPassword": "Passwort der geschützten Datei",
|
||||
"unlock": "Entsperren",
|
||||
"nothingFound": "In den Dateien wurde kein Zertifikat, Schlüssel und keine Zertifikatsanfrage gefunden.",
|
||||
"ignored": "Nicht verwendet (kein Zertifikat erkannt): {files}",
|
||||
"type": {
|
||||
"end-entity": "Serverzertifikat",
|
||||
"intermediate": "Zwischenzertifikat",
|
||||
"root": "Stammzertifikat",
|
||||
"privateKey": "Privater Schlüssel",
|
||||
"csr": "Zertifikatsanfrage (CSR)"
|
||||
},
|
||||
"explain": {
|
||||
"end-entity": "Das eigentliche Zertifikat für Ihre Domain – das gehört auf den Webserver.",
|
||||
"intermediate": "Bestätigt Ihr Serverzertifikat gegenüber dem Browser. Wird zusammen mit dem Serverzertifikat eingespielt (Kette).",
|
||||
"root": "Oberste Zertifizierungsstelle. Ist in Browsern und Betriebssystemen meist schon vorhanden.",
|
||||
"privateKey": "Der geheime Schlüssel zum Serverzertifikat. Wird auf dem Server gebraucht, darf aber nie weitergegeben werden.",
|
||||
"csr": "Die Anfrage, mit der das Zertifikat beim Aussteller bestellt wurde. Wird nur für eine Neuausstellung gebraucht."
|
||||
},
|
||||
"issuer": "Ausgestellt von",
|
||||
"validity": "Gültig",
|
||||
"names": "Gilt für",
|
||||
"key": "Schlüssel",
|
||||
"belongsTo": "Gehört zu",
|
||||
"source": "Gefunden in",
|
||||
"valid": "Gültig",
|
||||
"expired": "Abgelaufen",
|
||||
"expiresSoon": "Läuft in {days} Tagen ab",
|
||||
"keySecret": "Geheim halten: Wer diesen Schlüssel hat, kann sich als Ihre Website ausgeben.",
|
||||
"downloading": "Wird erstellt …",
|
||||
"exportError": "Diese Datei konnte nicht erstellt werden.",
|
||||
"format": {
|
||||
"crt": "PEM (.crt)",
|
||||
"cer": "DER (.cer)",
|
||||
"fullchain": "Mit Kette (.pem)",
|
||||
"p7b": "PKCS#7 (.p7b)",
|
||||
"pfx": "PFX (.pfx)",
|
||||
"key": "PEM (.key)",
|
||||
"key-rsa": "RSA-PEM (.rsa.key)",
|
||||
"key-der": "DER (.key.der)",
|
||||
"csr": "PEM (.csr)",
|
||||
"csr-der": "DER (.csr.der)"
|
||||
},
|
||||
"formatHint": {
|
||||
"crt": "Textformat, z. B. für Apache, Nginx und die meisten Geräte",
|
||||
"cer": "Binärformat, z. B. für Windows und Java",
|
||||
"fullchain": "Zertifikat und Kette in einer Datei, z. B. für Nginx",
|
||||
"p7b": "Zertifikat und Kette ohne Schlüssel, z. B. für Windows/IIS",
|
||||
"pfx": "Zertifikat, Kette und Schlüssel in einer passwortgeschützten Datei, z. B. für Windows/IIS und Exchange",
|
||||
"key": "Schlüssel im Standardformat (PKCS#8)",
|
||||
"key-rsa": "Schlüssel im älteren RSA-Format (PKCS#1), für ältere Software",
|
||||
"key-der": "Schlüssel als Binärdatei",
|
||||
"csr": "Zertifikatsanfrage als Text",
|
||||
"csr-der": "Zertifikatsanfrage als Binärdatei"
|
||||
},
|
||||
"pfxWithKey": "Die PFX-Datei enthält Zertifikat, Kette und privaten Schlüssel. Legen Sie ein Passwort fest – es wird beim Einspielen abgefragt.",
|
||||
"pfxWithoutKey": "Zu diesem Zertifikat liegt kein Schlüssel vor – die PFX-Datei enthält nur Zertifikat und Kette. Legen Sie ein Passwort fest.",
|
||||
"pfxPassword": "Passwort für die PFX-Datei",
|
||||
"pfxDownload": "PFX herunterladen"
|
||||
}
|
||||
},
|
||||
"tenderRadar": {
|
||||
|
||||
@@ -1182,8 +1182,9 @@
|
||||
},
|
||||
"certManager": {
|
||||
"title": "Certificate Manager",
|
||||
"description": "Inspect, split, merge and convert certificates.",
|
||||
"description": "Check certificate bundles and download them in any format; inspect, split, merge and convert single certificates.",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"inspect": "Inspect",
|
||||
"split": "Split",
|
||||
"merge": "Merge",
|
||||
@@ -1229,6 +1230,73 @@
|
||||
"generic": "Processing failed. Check the file format or password.",
|
||||
"wrongPassword": "Wrong password. Could not decrypt the PFX/P12 file.",
|
||||
"unknownFormat": "Unknown format. The file could not be recognized as a certificate."
|
||||
},
|
||||
"overview": {
|
||||
"dropTitle": "Drop certificate files or a ZIP here, or click",
|
||||
"dropHint": "Everything at once, as delivered by the issuer: .zip, .pem, .crt, .cer, .key, .csr, .pfx, .p12, .p7b",
|
||||
"removeFile": "Remove {name}",
|
||||
"reset": "Remove all",
|
||||
"analyzing": "Checking files …",
|
||||
"error": "The files could not be checked. Please make sure they are certificate files.",
|
||||
"locked": "Protected: {files}. Enter the password to read this content as well.",
|
||||
"lockedPassword": "Password of the protected file",
|
||||
"unlock": "Unlock",
|
||||
"nothingFound": "No certificate, key or certificate request was found in the files.",
|
||||
"ignored": "Not used (no certificate detected): {files}",
|
||||
"type": {
|
||||
"end-entity": "Server certificate",
|
||||
"intermediate": "Intermediate certificate",
|
||||
"root": "Root certificate",
|
||||
"privateKey": "Private key",
|
||||
"csr": "Certificate request (CSR)"
|
||||
},
|
||||
"explain": {
|
||||
"end-entity": "The actual certificate for your domain – it goes on the web server.",
|
||||
"intermediate": "Vouches for your server certificate towards the browser. Install it together with the server certificate (chain).",
|
||||
"root": "Top-level certificate authority. Usually already present in browsers and operating systems.",
|
||||
"privateKey": "The secret key for the server certificate. Needed on the server, but must never be shared.",
|
||||
"csr": "The request used to order the certificate from the issuer. Only needed for a reissue."
|
||||
},
|
||||
"issuer": "Issued by",
|
||||
"validity": "Valid",
|
||||
"names": "Valid for",
|
||||
"key": "Key",
|
||||
"belongsTo": "Belongs to",
|
||||
"source": "Found in",
|
||||
"valid": "Valid",
|
||||
"expired": "Expired",
|
||||
"expiresSoon": "Expires in {days} days",
|
||||
"keySecret": "Keep secret: whoever has this key can impersonate your website.",
|
||||
"downloading": "Creating …",
|
||||
"exportError": "This file could not be created.",
|
||||
"format": {
|
||||
"crt": "PEM (.crt)",
|
||||
"cer": "DER (.cer)",
|
||||
"fullchain": "With chain (.pem)",
|
||||
"p7b": "PKCS#7 (.p7b)",
|
||||
"pfx": "PFX (.pfx)",
|
||||
"key": "PEM (.key)",
|
||||
"key-rsa": "RSA PEM (.rsa.key)",
|
||||
"key-der": "DER (.key.der)",
|
||||
"csr": "PEM (.csr)",
|
||||
"csr-der": "DER (.csr.der)"
|
||||
},
|
||||
"formatHint": {
|
||||
"crt": "Text format, e.g. for Apache, Nginx and most devices",
|
||||
"cer": "Binary format, e.g. for Windows and Java",
|
||||
"fullchain": "Certificate and chain in one file, e.g. for Nginx",
|
||||
"p7b": "Certificate and chain without key, e.g. for Windows/IIS",
|
||||
"pfx": "Certificate, chain and key in one password-protected file, e.g. for Windows/IIS and Exchange",
|
||||
"key": "Key in standard format (PKCS#8)",
|
||||
"key-rsa": "Key in older RSA format (PKCS#1), for older software",
|
||||
"key-der": "Key as binary file",
|
||||
"csr": "Certificate request as text",
|
||||
"csr-der": "Certificate request as binary file"
|
||||
},
|
||||
"pfxWithKey": "The PFX file contains certificate, chain and private key. Set a password – it is asked for when importing.",
|
||||
"pfxWithoutKey": "No key is available for this certificate – the PFX file contains only certificate and chain. Set a password.",
|
||||
"pfxPassword": "Password for the PFX file",
|
||||
"pfxDownload": "Download PFX"
|
||||
}
|
||||
},
|
||||
"tenderRadar": {
|
||||
|
||||
@@ -103,6 +103,13 @@ export const UMLAUT_REPLACEMENTS: Record<string, string> = {
|
||||
* and must never be touched by the replacement or flagged by the guard.
|
||||
*/
|
||||
export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
// quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch)
|
||||
'Aussteller',
|
||||
'Betriebssystemen',
|
||||
'Neuausstellung',
|
||||
'passwortgeschützten',
|
||||
'Schlüssel',
|
||||
'Zertifizierungsstelle',
|
||||
// quick-260929-if2: „lässt“ (Erinnerung „lässt sich nicht mehr bearbeiten“)
|
||||
'lässt',
|
||||
'manuell',
|
||||
|
||||
Reference in New Issue
Block a user